Zum Inhalt
← Seitenübergänge

Farbvorhang / Panel-Wipe in Markenfarbe

Ein Panel in Markenfarbe fährt über die Seite, zeigt den Seitennamen und gibt nach oben die neue Seite frei: einfach, doppelt oder in der Farbe des geklickten Links.

Beim Klick fährt ein vollflächiges Panel in Markenfarbe von unten (oder von der Seite) über die alte Seite. Sobald es deckt, wird der Inhalt getauscht. Im Panel können der Seitenname, ein Wort oder das Logo stehen. Dann fährt das Panel in derselben Richtung weiter und gibt die neue Seite frei, statt zurückzufahren. Die neue Seite setzt sich mit leichtem Versatz (y 40 bis 80 px, Scale 1.05 auf 1). Gemessen: Snellenberg 500 ms rein und 800 ms raus, darkroom 1100 ms power3.inOut.

Die Markenfarbe wird zur Interpunktion: Jeder Seitenwechsel ist ein kleiner Markenmoment. Das Panel verdeckt Ladezeit und Layoutsprünge. Läuft es rein und raus in dieselbe Richtung, wirkt der Wechsel wie ein durchlaufender Film.

Wann einsetzen

Bei Studio-, Agentur- und Markenseiten mit kräftiger Signalfarbe. Bei Seiten, deren neue Inhalte spürbar laden.

Wann nicht

Bei inhaltsgetriebenen Seiten mit vielen schnellen Wechseln, etwa Dokumentationen oder Shops mit Filtern. Spätestens ab der dritten Navigation nervt der Vorhang dort.

Varianten

  • Einfach: ein Panel fährt von unten rein und nach oben durch (Snellenberg: 500 ms Power4.easeIn rein, 800 ms Power3.easeInOut raus)
  • Doppel-Wipe: zwei Panels in zwei Farben, 80 bis 150 ms versetzt (Osmo „Double Wipe“)
  • Linkfarbe: Das Panel nimmt die Farbe des geklickten Links oder Projekts an (Gabriel Contassot)
  • Mit Seitenname oder Logo im Panel (siehe seitenname-typo-uebergang)
  • Mit gebogener Kante (siehe gebogener-wipe-shape)
  • Nativ als ::view-transition-new(root) mit clip-path inset (Piccalilli „Star-Wars-Wipe“)

Umsetzung

Ein fixiertes Overlay: position: fixed; inset: 0; hoher z-index; pointer-events: none; im Ruhezustand translateY(100%). Leave: Panel auf translateY(0) animieren und auf das Ende warten. Dann Inhalt tauschen und auf 0 scrollen. Enter: Panel auf translateY(-100%) weiterfahren, danach zurücksetzen. Mit clip-path statt Transform (inset(100% 0 0 0) auf inset(0), dann inset(0 0 100% 0)) steht das Panel still und nur die Kante wandert. Das ist kantenschärfer, der Panelinhalt fährt dann nicht mit. Mit Barba.js oder Taxi.js gehört das in leave() und enter(), jeweils als Promise. Nativ mit View Transitions: ::view-transition-old(root) { animation: none; } und den neuen Root per clip-path-Keyframe aufdecken, oder ein eigenes Vorhang-Element mit view-transition-name. Für echte Seitenwechsel (MPA) zusätzlich @view-transition { navigation: auto; } in beiden Seiten. Fallstricke: Erst tauschen, wenn das Panel voll deckt (auf onComplete warten). Bei Barba danach Lenis, ScrollTrigger und Webflow-Interaktionen neu initialisieren (Usecue).

Robust umsetzen

Telefon. Funktioniert unverändert. Auf dem Telefon die Gesamtdauer auf 700 bis 900 ms deckeln.

Ohne Bewegung. Das Panel nur ein- und ausblenden (opacity) statt fahren, oder direkt tauschen.

Barrierefrei. Overlay mit aria-hidden="true". Bei SPA-Routern nach dem Tausch den Fokus auf die H1 der neuen Seite setzen (tabindex="-1") oder auf den Seitenanfang. Den neuen Titel über eine Live Region ansagen und document.title mitziehen.

Code

<!DOCTYPE html>
<!--
  Farbvorhang / Panel-Wipe in Markenfarbe
  Fundus · Seitenübergänge · https://cecodesign.de/lab/fundus/seitenuebergaenge/vorhang-farbpanel-wipe/
  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>Farbvorhang / Panel-Wipe in Markenfarbe · 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: 'Big Shoulders'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/big-shoulders:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  --strom: #d6f03c;      /* Hausfarbe Festival Nachtstrom: Stromgelb */
  --nacht: #0b0b0b;
  --hell: #ebeae8;       /* Grund der Programmseiten, neutrales Off-White */
  /* Spartenfarben, jede mit weißer Schrift AA */
  --musik: #c4421a; --buehne: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b; --klub: #5b3fa6;
  --bg: var(--hell); --fg: var(--nacht);
  --linie: color-mix(in srgb, var(--fg) 22%, transparent);
  --leise: color-mix(in srgb, var(--fg) 64%, transparent);
  --rand: clamp(16px, 3.2vw, 48px);
  --kopf: 72px;
  --dock: 140px;         /* wird im JS aus der Höhe der Demo-Steuerung gesetzt */
  --display: 'Big Shoulders', 'Arial Narrow', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--bg); color: var(--fg); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; background: var(--bg); color: var(--fg); overflow-x: clip; }
a { color: inherit; text-decoration: none; }
svg { display: block; }
em { font-style: normal; }
.mono { font: 500 12px/1.45 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sparte { display: inline-flex; align-items: center; gap: .55em; }
.sparte::before { content: ''; width: .7em; height: .7em; background: var(--farbe); flex: none; }

/* ---------- Kopfzeile (bleibt stehen, der Vorhang fährt darüber) ---------- */
.kopf { position: fixed; z-index: 10; inset: 0 0 auto; height: var(--kopf); background: var(--bg); border-bottom: 1px solid var(--fg); display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: 0 var(--rand); color: var(--fg); }
.marke { display: inline-flex; align-items: center; gap: 12px; justify-self: start; }
.marke svg { width: 30px; height: 30px; flex: none; }
.marke b { font: 900 25px/1 var(--display); text-transform: uppercase; letter-spacing: .01em; }
.marke small { color: var(--leise); }
.nav { display: flex; gap: clamp(14px, 2.4vw, 36px); }
.nav a { position: relative; display: inline-flex; align-items: center; gap: .6em; padding: 8px 0; color: var(--fg); }
.nav a::after { content: ''; position: absolute; left: 0; right: 0; bottom: 3px; height: 2px; background: currentColor; transform: scaleX(0); transform-origin: right; transition: transform .6s cubic-bezier(0.22, 1, 0.36, 1); }
.nav a[aria-current='page']::after { transform: scaleX(1); transform-origin: left; }
.nav a i { display: none; width: 9px; height: 9px; background: var(--farbe); box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 30%, transparent); }
[data-modus='linkfarbe'] .nav a i { display: inline-block; }
.anfrage { justify-self: end; display: inline-flex; align-items: center; gap: .7em; height: 40px; padding: 0 18px; background: var(--strom); color: var(--nacht); }
.anfrage span { transition: translate .6s cubic-bezier(0.22, 1, 0.36, 1); }
@media (hover: hover) { .anfrage:hover span { translate: 4px 0; } }

/* ---------- Seiten ---------- */
#seite { min-height: 100svh; padding-top: var(--kopf); overflow: clip; }
.s { min-height: calc(100svh - var(--kopf)); padding: clamp(20px, 4vh, 44px) var(--rand) calc(var(--dock) + clamp(16px, 3vh, 32px)); }
h1:focus { outline: none; }
.plakat { overflow: hidden; background: var(--nacht); }
.plakat svg { width: 100%; height: 100%; }
.gross { font-family: var(--display); font-weight: 900; text-transform: uppercase; line-height: .84; letter-spacing: -.005em; }

/* Start: Nacht */
.s-start { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto 1fr; gap: clamp(12px, 2.4vh, 28px); container-type: inline-size; }
.start__label { display: flex; justify-content: space-between; gap: 16px; color: var(--leise); }
.riesig { font-size: 18.85cqi; overflow-wrap: anywhere; margin-left: -.035em; margin-right: -.06em; }   /* eine Zeile (die Box ist .06em breiter als nötig); bei Textabständen (1.4.12) bricht das Wort um statt abgeschnitten zu werden */
.riesig em { color: var(--strom); }
.start__unten { display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 5fr) minmax(0, 3fr); gap: 24px clamp(20px, 3vw, 48px); align-items: end; padding-top: clamp(14px, 2.4vh, 24px); border-top: 1px solid var(--linie); }
.start__links { display: grid; gap: 22px; align-self: stretch; align-content: space-between; }
.daten { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.daten b { font: 800 clamp(40px, 4.4vw, 68px)/.9 var(--display); color: var(--strom); letter-spacing: 0; }
.lead { font-size: clamp(18px, 1.35vw, 21px); line-height: 1.5; max-width: 32ch; }
.start__bild { display: grid; gap: 10px; }
.start__bild .plakat { aspect-ratio: 16 / 9; }
.start__bild svg { transition: scale .9s cubic-bezier(0.22, 1, 0.36, 1); }
.zeile { display: flex; justify-content: space-between; gap: 12px; color: var(--leise); }
.mehr { justify-self: stretch; align-self: stretch; display: grid; align-content: space-between; gap: 18px; }
.mehr__liste { list-style: none; display: grid; }
.mehr__liste li { display: grid; grid-template-columns: 4.6em 1fr; gap: 10px; padding: 8px 0; border-top: 1px solid var(--linie); }
.mehr__liste li:last-child { border-bottom: 1px solid var(--linie); }
.mehr__liste span { color: var(--leise); }
.mehr__link { display: grid; gap: 10px; justify-items: end; text-align: right; }
.mehr b { font: 900 clamp(34px, 3.4vw, 56px)/.9 var(--display); text-transform: uppercase; display: flex; gap: .2em; justify-content: space-between; width: 100%; padding-bottom: 8px; border-bottom: 2px solid currentColor; }
.mehr .mono { color: var(--leise); }
@media (hover: hover) { .start__bild:hover svg { scale: 1.04; } }

/* Programm */
.s-kopfzeile { display: flex; justify-content: space-between; align-items: end; gap: 16px; padding-bottom: clamp(14px, 2.4vh, 26px); border-bottom: 2px solid var(--fg); }
.titel { font-size: clamp(56px, 7.4vw, 124px); }
.titel sup { white-space: nowrap; font: 500 max(11px, .1em)/1 'IBM Plex Mono', monospace; letter-spacing: .1em; vertical-align: top; margin-left: .5em; color: var(--leise); }
.liste { list-style: none; }
.liste a { display: grid; grid-template-columns: 6.5em minmax(0, 1fr) 13em 10em 108px; align-items: center; gap: 18px; padding: clamp(10px, 1.7vh, 20px) 0; border-bottom: 1px solid var(--linie); }
.liste .wann b { display: block; font: 800 clamp(26px, 2.4vw, 38px)/.95 var(--display); letter-spacing: 0; }
.liste .name { display: grid; gap: 6px; }
.liste .name b { font: 900 clamp(30px, 3.4vw, 56px)/.9 var(--display); text-transform: uppercase; hyphens: manual; }
.liste .mono { color: var(--leise); }
.liste .sparte { color: var(--fg); }
.ticket { white-space: nowrap; justify-self: start; display: inline-flex; align-items: center; height: 34px; padding: 0 12px; border: 1px solid var(--fg); color: var(--fg); transition: background-color .15s linear, color .15s linear; }
.liste .daumen { width: 108px; aspect-ratio: 3 / 2; overflow: hidden; background: var(--nacht); }
.liste .daumen svg { width: 100%; height: 100%; transition: scale .9s cubic-bezier(0.22, 1, 0.36, 1); }
@media (hover: hover) {
  .liste a:hover .ticket { background: var(--fg); color: var(--bg); }
  .liste a:hover .daumen svg { scale: 1.08; }
}

/* Veranstaltung */
.s-projekt { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: clamp(24px, 4vw, 72px); align-items: start; }
.projekt__bild { position: sticky; top: calc(var(--kopf) + 20px); height: min(calc(100svh - var(--kopf) - var(--dock) - 52px), 860px); min-height: 340px; }
.projekt__text { container-type: inline-size; display: grid; gap: clamp(16px, 2.6vh, 28px); padding-top: clamp(4px, 1vh, 12px); }
.projekt__text .titel { font-size: max(44px, min(clamp(64px, 8.4vw, 148px), calc(100cqi / (var(--z, 10) * .56)))); }
.unter { font-size: clamp(21px, 1.9vw, 30px); line-height: 1.25; font-weight: 600; max-width: 26ch; }
.fakten div { display: grid; grid-template-columns: 9em 1fr; gap: 12px; padding: 9px 0; border-top: 1px solid var(--linie); }
.fakten dt { color: var(--leise); }
.fliess { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 32px; font-size: 18px; line-height: 1.6; }
.kaufen { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.kaufen span:first-child { display: inline-flex; align-items: center; height: 44px; padding: 0 18px; background: var(--fg); color: var(--bg); }
.weiter { display: grid; gap: 8px; padding-top: 16px; border-top: 2px solid var(--fg); }
.weiter b { font: 900 clamp(34px, 3.8vw, 62px)/.9 var(--display); text-transform: uppercase; }
.projekt .mono { color: var(--leise); }

/* Tickets */
.s-studio { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 4fr); gap: clamp(28px, 5vh, 56px) clamp(24px, 4vw, 72px); align-content: start; }
.aussage { font: 800 clamp(40px, 4.8vw, 82px)/.94 var(--display); text-transform: uppercase; text-wrap: balance; }
.aussage em { background: var(--strom); padding: 0 .08em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
.studio__text { display: grid; gap: 22px; align-content: start; font-size: 18px; line-height: 1.6; }
.studio__text p:not(.aussage) { max-width: 52ch; }
.studio__bild svg { height: 100%; overflow: visible; }
.studio__bild { height: min(44vh, 420px); background: none; overflow: visible; justify-self: end; aspect-ratio: 4 / 5; }
.zahlen { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding-top: 16px; border-top: 2px solid var(--fg); }
.zahlen b { display: block; font: 900 clamp(44px, 4.6vw, 80px)/.9 var(--display); margin-bottom: 10px; }
.zahlen .mono { color: var(--leise); }

/* Besuch */
.s-kontakt { display: grid; grid-template-rows: 1fr auto; gap: 40px; }
.s-kontakt .riesig { font-size: clamp(46px, 10.4vw, 176px); white-space: normal; align-self: center; line-height: .86; margin-right: 0; }
.s-kontakt .riesig em { display: inline-block; color: inherit; background: var(--strom); padding: .06em .06em 0; margin: .04em 0 0 -.06em; }
.adresse { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding-top: 16px; border-top: 2px solid var(--fg); font-size: 18px; line-height: 1.5; }
.adresse .mono { display: block; color: var(--leise); margin-bottom: 8px; }
.adresse a { border-bottom: 2px solid currentColor; }

/* ---------- Der Vorhang ---------- */
.vorhang { position: fixed; inset: 0; z-index: 30; pointer-events: none; visibility: hidden; overflow: hidden; }
.vorhang.an { visibility: visible; }
.vorhang--a { background: var(--nacht); }
.vorhang--b { background: var(--v-bg, var(--strom)); color: var(--v-fg, var(--nacht)); }
.vorhang__inhalt { position: absolute; inset: 0; display: grid; grid-template-rows: auto 1fr auto; padding: 24px var(--rand); }
.vorhang__oben, .vorhang__unten { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid currentColor; }
.vorhang__unten { padding: 12px 0 0; border: 0; border-top: 1px solid currentColor; }
.vorhang__mitte { align-self: center; display: grid; justify-items: center; gap: 14px; text-align: center; }
.vorhang__name { overflow: hidden; padding: .04em .04em 0; max-width: 100%; }
.vorhang__name span { display: block; font: 900 clamp(76px, 15vw, 260px)/.86 var(--display); text-transform: uppercase; letter-spacing: -.005em; overflow-wrap: anywhere; }

/* ---------- 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; color: var(--hell);
  background: rgba(11, 11, 11, .9); border: 1px solid rgba(235, 234, 232, .18); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
.dock__zeile { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.dock .mono { color: rgba(235, 234, 232, .62); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: rgba(235, 234, 232, .78); background: none; border: 1px solid rgba(235, 234, 232, .22); height: 30px; padding: 0 12px; cursor: pointer; transition: background-color .15s linear, color .15s linear; }
.modi button:hover { color: var(--hell); }
.modi button[aria-pressed='true'] { background: var(--strom); color: var(--nacht); border-color: var(--strom); }
.modi button:focus-visible { outline: 2px solid var(--strom); outline-offset: 2px; }
.werte { font-size: 10px; line-height: 1.6; letter-spacing: .04em; text-transform: none; min-height: 4.8em; }
.werte > span { display: block; white-space: nowrap; }
.werte b { color: var(--strom); font-weight: 500; }

/* 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; }
.zeiger.an { opacity: 1; }

/* Reduzierte Bewegung: Vorhang wird nur ein- und ausgeblendet (im JS) */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

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

@media (hover: none) {
  .modi button { height: 34px; }
}
@media (max-width: 1180px) {
  .liste a { grid-template-columns: 6em minmax(0, 1fr) 12em 80px; }
  .liste .kasse { display: none; }
  .liste .daumen { width: 80px; }
}
@media (max-width: 900px) {
  .start__unten { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .start__unten .mehr { grid-column: 1 / -1; }
  .mehr__liste { display: none; }
  .mehr__link { justify-items: start; text-align: left; }
  .s-projekt, .s-studio { grid-template-columns: minmax(0, 1fr); }
  .projekt__bild { position: static; height: auto; min-height: 0; aspect-ratio: 4 / 3; }
  .zahlen, .adresse { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .studio__bild { height: auto; max-width: 340px; width: 100%; justify-self: center; }
}
@media (max-width: 720px) {
  .werte > span { white-space: normal; }
}
@media (max-width: 600px) {
  :root { --kopf: 60px; }
  .kopf { grid-template-columns: auto 1fr; }
  .nav { justify-self: end; gap: 14px; }
  .nav .nav__start, .anfrage, .marke small { display: none; }
  .marke b { font-size: 20px; }
  .nav { font-size: 11px; letter-spacing: .06em; gap: 12px; }
  .s-kopfzeile { flex-wrap: wrap; }
  .s-kopfzeile > .mono { display: none; }
  .titel sup { display: block; margin: 10px 0 0; }
  .marke svg { width: 26px; height: 26px; }
  .start__label span:last-child { display: none; }
  .start__unten { grid-template-columns: minmax(0, 1fr); }
  .s-start { grid-template-rows: none; align-content: start; }
  .liste a { grid-template-columns: 4.6em minmax(0, 1fr) 64px; gap: 12px; }
  .liste .wo { display: none; }
  .liste .daumen { width: 64px; }
  .liste .name b { font-size: 32px; }
  .fliess { grid-template-columns: minmax(0, 1fr); }
  .fakten div { grid-template-columns: 7em 1fr; }
  .adresse, .zahlen { gap: 20px 16px; }
  .vorhang__oben span:last-child, .vorhang__unten span:last-child { display: none; }
  .dock__zeile > span.mono { display: none; }
  .dock { padding: 8px 10px; bottom: 12px; width: calc(100% - 24px); }
  .werte .lang { display: none; }
}
/* 200 % Zoom und 320 px: Kopfzeile passt in die Breite */
@media (min-width: 601px) and (max-width: 760px) { .marke small { display: none; } .nav { gap: 10px; } .kopf { gap: 8px; } .anfrage { padding: 0 10px; } }
@media (max-width: 380px) { .kopf { gap: 10px; } .marke b { font-size: 17px; } .nav { gap: 8px; letter-spacing: 0; } }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-modus="einfach">
<header class="kopf">
  <a class="marke" href="#start" data-ziel="start" aria-label="Festival Nachtstrom, Startseite">
    <svg viewBox="0 0 30 30" aria-hidden="true"><rect width="30" height="30" fill="#d6f03c"/><path d="M8 23V7l14 16V7" fill="none" stroke="#0b0b0b" stroke-width="3.4" stroke-linejoin="miter" stroke-linecap="square"/></svg>
    <b>Nachtstrom</b><small class="mono">Dresden</small>
  </a>
  <nav class="nav mono" aria-label="Hauptnavigation">
    <a class="nav__start" href="#start" data-ziel="start" style="--farbe:#0b0b0b"><i></i>Start</a>
    <a href="#programm" data-ziel="programm" style="--farbe:#c4421a"><i></i>Programm</a>
    <a href="#tickets" data-ziel="tickets" style="--farbe:#d6f03c"><i></i>Tickets</a>
    <a href="#besuch" data-ziel="besuch" style="--farbe:#3d5f9a"><i></i>Besuch</a>
  </nav>
  <a class="anfrage mono" href="#tickets" data-ziel="tickets" style="--farbe:#d6f03c">Festivalpass <span aria-hidden="true">→</span></a>
</header>

<main id="seite"></main>
<p class="sr" aria-live="polite" data-ansage></p>

<!-- Vorhang: A = Nacht für den Doppel-Wipe, B = Stromgelb mit Seitenname -->
<div class="vorhang vorhang--a" aria-hidden="true"></div>
<div class="vorhang vorhang--b" aria-hidden="true">
  <div class="vorhang__inhalt">
    <div class="vorhang__oben mono"><span>Festival Nachtstrom</span><span>Dresden · 21. bis 30. Januar 2027</span></div>
    <div class="vorhang__mitte">
      <span class="mono" data-v-label>02 / 04</span>
      <div class="vorhang__name"><span data-v-name>Programm</span></div>
    </div>
    <div class="vorhang__unten mono"><span data-v-nr>Seite wird geladen</span><span>7. Ausgabe</span></div>
  </div>
</div>

<aside class="dock" aria-label="Demo-Steuerung">
  <div class="dock__zeile">
    <span class="mono">Vorhang</span>
    <div class="modi mono" role="group" aria-label="Variante des Vorhangs">
      <button type="button" data-variante="einfach" aria-pressed="true">Einfach</button>
      <button type="button" data-variante="doppel" aria-pressed="false">Doppel</button>
      <button type="button" data-variante="linkfarbe" aria-pressed="false">Linkfarbe</button>
    </div>
  </div>
  <p class="werte mono" data-werte></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 A = document.querySelector('.vorhang--a');
  const B = document.querySelector('.vorhang--b');
  const bInhalt = B.querySelector('.vorhang__inhalt');
  const vName = B.querySelector('[data-v-name]');
  const vLabel = B.querySelector('[data-v-label]');
  const vNr = B.querySelector('[data-v-nr]');
  const ansage = document.querySelector('[data-ansage]');
  const werteFeld = document.querySelector('[data-werte]');
  const dock = document.querySelector('.dock');
  const zeiger = document.querySelector('.zeiger');

  /* ---------- Werte ----------
     Einfach: gemessen bei Dennis Snellenberg (rein .5s Power4.easeIn, raus .8s Power3.easeInOut, Seitenname .8s Power4.easeOut)
     Linkfarbe: gemessen bei darkroom.engineering (clipPath inset 1.1s power3.inOut, alter Inhalt y -50vh + Fade 1s)
     Doppel: zwei Panels, 120 ms versetzt (typisch 80–150) */
  const K = {
    p4in:  'cubic-bezier(0.64, 0, 0.78, 0)',    // power4.in
    p4out: 'cubic-bezier(0.22, 1, 0.36, 1)',    // power4.out
    p3io:  'cubic-bezier(0.76, 0, 0.24, 1)'     // power3.inOut
  };
  const NACHT = '#0b0b0b', HELL = '#ebeae8';
  const STROM = { bg: '#d6f03c', fg: NACHT };      // Hausfarbe mit Schrift im Panel
  const NEU = { y: 60, scale: 1.05, dauer: 1000 };    // neue Seite setzt sich
  const FADE = 200;                                  // reduzierte Bewegung
  const MOBIL_MAX = 900;                             // Gesamtdauer auf Telefonen

  const VARIANTEN = {
    einfach: { rein: 500, halten: 300, raus: 800, name: 800, versatz: 0 },
    doppel:  { rein: 500, halten: 300, raus: 800, name: 800, versatz: 120 },
    linkfarbe: { rein: 1100, halten: 150, raus: 800, name: 1100, versatz: 0 }
  };
  const gesamt = v => v.rein + v.versatz + v.halten + v.raus + v.versatz;
  const faktor = v => matchMedia('(max-width: 600px)').matches ? Math.min(1, MOBIL_MAX / gesamt(v)) : 1;
  let variante = 'einfach';

  const ms = n => Math.round(n) + ' ms';
  // Schriftfarbe im Vorhang nach Helligkeit der Vorhangfarbe
  const hell = hex => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * (n >> 8 & 255) + 0.114 * (n & 255)) > 150; };
  // Seiteninhalt endet über der Demo-Steuerung, damit sie nichts verdeckt
  const dockMessen = () => html.style.setProperty('--dock', Math.ceil(dock.getBoundingClientRect().height + 14) + 'px');
  function zeigeWerte() {
    const v = VARIANTEN[variante], f = faktor(v);
    const d = n => ms(n * f);
    let t;
    if (reduziert()) t = `<span><b>reduziert</b> Vorhang opacity 0 → 1 → 0 · je ${FADE} ms linear · Tausch dazwischen · nichts fährt</span>`;
    else if (variante === 'einfach') t =
      `<span><b>rein</b> translateY(100% → 0) · ${d(v.rein)} · ${K.p4in}</span>` +
      `<span><b>raus</b> translateY(0 → −100%) · ${d(v.raus)} · ${K.p3io}</span>` +
      `<span class="lang"><b>name</b> ${d(v.name)} ${K.p4out} · <b>alt</b> y −50vh, opacity 0 · <b>neu</b> y ${NEU.y}px → 0, Bild ${NEU.scale} → 1</span>`;
    else if (variante === 'doppel') t =
      `<span><b>A nacht, B stromgelb</b> clip-path inset(100% 0 0 0) → inset(0) → inset(0 0 100% 0)</span>` +
      `<span><b>rein</b> ${d(v.rein)} · <b>raus</b> ${d(v.raus)} · <b>versatz</b> ${d(v.versatz)} · ${K.p3io}</span>` +
      `<span class="lang"><b>raus</b> B vor A · <b>name</b> ${d(v.name)} ${K.p4out} · <b>neu</b> y ${NEU.y}px → 0, Bild ${NEU.scale} → 1</span>`;
    else t =
      `<span><b>farbe</b> Sparte des geklickten Links · inset(100% 0 0 0) → inset(0) · ${d(v.rein)} · ${K.p3io}</span>` +
      `<span><b>inhalt</b> translateY(100% → 0) ${d(v.name)} · <b>alt</b> y −50vh, opacity 0 · ${d(1000)}</span>` +
      `<span class="lang"><b>raus</b> inset(0 0 100% 0) · ${d(v.raus)} · gleiche Kurve · <b>neu</b> y ${NEU.y}px → 0</span>`;
    if (!reduziert() && f < 1) t += `<span><b>telefon</b> alle Dauern × ${f.toFixed(2)} → ${ms(gesamt(v) * f)} gesamt</span>`;
    werteFeld.innerHTML = t;
    dockMessen();
  }
  function setzeVariante(name) {
    variante = name;
    document.body.dataset.modus = name;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === name)));
    zeigeWerte();
  }

  /* ---------- Plakate: eigene SVG-Kompositionen je Veranstaltung ---------- */
  const rnd = s => () => (s = (s * 16807) % 2147483647) / 2147483647;   // fester Zufall, jedes Plakat gleich
  function plakat(art, farbe, w, h, wort) {
    const r = rnd(art.length * 7919 + w);
    let g = '';
    if (art === 'wellen') {            // Konzert: Schallringe, darüber das Wort im Überdruck
      const cx = w * .62, cy = h * .78;
      for (let i = 0; i < 22; i++) g += `<circle cx="${cx}" cy="${cy}" r="${18 + i * Math.max(w, h) * .052}" fill="none" stroke="${i % 3 ? farbe : HELL}" stroke-width="${i % 3 ? 2.2 : 6}" opacity="${1 - i * .028}"/>`;
    } else if (art === 'roehren') {    // Installation: Feld aus Leuchtröhren
      const sp = 26, zl = wort ? 3 : 5, bw = w / sp, top = wort ? h * .5 : h * .08, fh = h * .92 - top;
      for (let z = 0; z < zl; z++) for (let s = 0; s < sp; s++) {
        const an = r() > .42, x = s * bw + bw * .38, y = top + z * (fh / zl);
        g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${(bw * .24).toFixed(1)}" height="${(fh / zl * .8).toFixed(1)}" rx="${(bw * .12).toFixed(1)}" fill="${an ? '#e9f6c8' : farbe}" opacity="${an ? (.55 + r() * .45).toFixed(2) : .55}"/>`;
      }
    } else if (art === 'koerper') {    // Tanz: Bögen und Kreise im Überdruck
      g += `<rect width="${w}" height="${h}" fill="${HELL}"/>`;
      const u = Math.min(w, h);
      g += `<g style="mix-blend-mode:multiply">`
        + `<circle cx="${w * .36}" cy="${h * .66}" r="${u * .32}" fill="${farbe}"/>`
        + `<path d="M${w * .3} ${h} A${u * .42} ${u * .42} 0 0 1 ${w * .3 + u * .84} ${h}Z" fill="${NACHT}" opacity=".92"/>`
        + `<circle cx="${w * .8}" cy="${h * .6}" r="${u * .1}" fill="${NACHT}"/>`
        + `<rect x="${w * .62}" y="${h * .42}" width="${u * .08}" height="${h * .62}" fill="${farbe}" transform="rotate(-24 ${w * .66} ${h * .72})"/>`
        + `</g>`;
    } else if (art === 'film') {       // Kurzfilm: Filmstreifen mit Bildfeldern
      const zh = h / 3;
      for (let z = 0; z < 3; z++) {
        const y = z * zh + zh * .1, dx = (z % 2 ? -1 : 0) * w * .18;
        g += `<rect x="0" y="${y}" width="${w}" height="${zh * .8}" fill="#151515"/>`;
        for (let l = 0; l < 22; l++) { const x = dx + l * w * .07; g += `<rect x="${x}" y="${y + zh * .05}" width="${w * .03}" height="${zh * .07}" fill="${NACHT}"/><rect x="${x}" y="${y + zh * .68}" width="${w * .03}" height="${zh * .07}" fill="${NACHT}"/>`; }
        for (let b = 0; b < 5; b++) { const x = dx + b * w * .3 + w * .02; g += `<rect x="${x}" y="${y + zh * .16}" width="${w * .27}" height="${zh * .48}" fill="${farbe}" opacity="${(.35 + ((b + z) % 3) * .3).toFixed(2)}"/>`; }
      }
    } else {                           // Klubnacht: gestapelte Sinuswellen
      for (let i = 0; i < 16; i++) {
        const y0 = h * .1 + i * h * .052, a = h * (.02 + i * .004), lw = w / 3.2;
        let d = `M0 ${y0}`;
        for (let x = 0; x <= w; x += 8) d += ` L${x} ${(y0 + Math.sin(x / lw * Math.PI * 2 + i * .5) * a).toFixed(1)}`;
        g += `<path d="${d}" fill="none" stroke="${i === 9 ? STROM.bg : farbe}" stroke-width="${i === 9 ? 5 : 2.4}"/>`;
      }
    }
    if (wort) {
      const gr = Math.min(h * .3, w * .9 / (Math.max(wort[0].length, wort[1].length) * .56));
      const txtFarbe = art === 'koerper' ? NACHT : HELL;
      g += `<text x="${w * .05}" y="${h * .06 + gr * .82}" font-family="Big Shoulders, sans-serif" font-weight="900" font-size="${gr}" fill="${txtFarbe}" letter-spacing="-.5">${wort[0]}</text>`
        + `<text x="${w * .05}" y="${h * .06 + gr * 1.64}" font-family="Big Shoulders, sans-serif" font-weight="900" font-size="${gr}" fill="${txtFarbe}" letter-spacing="-.5">${wort[1]}</text>`;
    }
    return `<svg data-setzen viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMin slice" aria-hidden="true"><rect width="${w}" height="${h}" fill="${NACHT}"/>${g}</svg>`;
  }
  function ticketGrafik() {
    return `<svg data-setzen viewBox="0 0 400 500" aria-hidden="true">
      <g transform="rotate(-6 200 250)">
        <rect x="70" y="40" width="270" height="420" fill="${STROM.bg}"/>
        <path d="M70 330 h270" stroke="${NACHT}" stroke-width="2" stroke-dasharray="6 7"/>
        <circle cx="70" cy="330" r="14" fill="${HELL}"/><circle cx="340" cy="330" r="14" fill="${HELL}"/>
        <text x="96" y="92" font-family="IBM Plex Mono, monospace" font-weight="500" font-size="13" letter-spacing="1.3" fill="${NACHT}">FESTIVALPASS · 7. AUSGABE</text>
        <text x="94" y="196" font-family="Big Shoulders, sans-serif" font-weight="900" font-size="96" fill="${NACHT}">NACHT</text>
        <text x="94" y="282" font-family="Big Shoulders, sans-serif" font-weight="900" font-size="96" fill="${NACHT}">STROM</text>
        <text x="96" y="372" font-family="IBM Plex Mono, monospace" font-weight="500" font-size="13" letter-spacing="1.3" fill="${NACHT}">21.01. – 30.01.2027</text>
        <text x="96" y="396" font-family="IBM Plex Mono, monospace" font-size="13" letter-spacing="1.3" fill="${NACHT}">ALLE ORTE · 89 €</text>
        <g fill="${NACHT}">${Array.from({ length: 30 }, (_, i) => `<rect x="${96 + i * 7.6}" y="414" width="${[2, 4, 1.5, 3][i % 4]}" height="26"/>`).join('')}</g>
      </g>
      <g transform="rotate(4 200 250)" style="mix-blend-mode:multiply">
        <rect x="190" y="300" width="150" height="150" fill="none" stroke="${NACHT}" stroke-width="2"/>
      </g>
    </svg>`;
  }

  /* ---------- Inhalte: erfundenes Festival Nachtstrom, Dresden ---------- */
  const SEITEN = {
    start:    { nr: '01 / 04', name: 'Start',    titel: 'Festival Nachtstrom', farbe: NACHT, fg: HELL },
    programm: { nr: '02 / 04', name: 'Programm', titel: 'Programm', farbe: '#c4421a' },
    tickets:  { nr: '03 / 04', name: 'Tickets',  titel: 'Tickets', farbe: STROM.bg, fg: NACHT },
    besuch:   { nr: '04 / 04', name: 'Besuch',   titel: 'Besuch planen', farbe: '#3d5f9a' }
  };
  const PROJEKTE = [
    { id: 'kaltfront', name: 'Kalt\u00adfront', wort: ['KALT', 'FRONT'], art: 'Konzert', sparte: 'Musik', plakat: 'wellen', bg: '#c4421a', fg: '#ffffff',
      tag: 'Fr', datum: '22.01.', zeit: '21:30', ort: 'Turbinenhalle', preis: '28 €',
      unter: 'Zwölf Synthesizer und ein Streichquartett eröffnen das Festival.',
      fakten: [['Datum', 'Freitag, 22. Januar 2027'], ['Einlass', '20:45, Beginn 21:30'], ['Ort', 'Turbinenhalle, Saal 1'], ['Dauer', '95 Minuten, ohne Pause']],
      text: ['Das Quartett spielt im Halbkreis, um es herum stehen zwölf analoge Synthesizer. Jedes Instrument wird von einem Streicher angesteuert: Ein Bogenstrich öffnet einen Filter, ein Pizzicato setzt einen Impuls.', 'Das Stück ist für die Turbinenhalle geschrieben. Der Nachhall von fast sechs Sekunden ist Teil der Komposition. Sitzplätze auf der Galerie, Stehplätze im Saal.'] },
    { id: 'flutlicht', name: 'Flut\u00adlicht', wort: ['FLUT', 'LICHT'], art: 'Installation', sparte: 'Kunst', plakat: 'roehren', bg: '#2f7d4a', fg: '#ffffff',
      tag: 'täglich bis 30.01.', datum: '21.01.', zeit: '18–24 Uhr', ort: 'Wasserturm Nord', preis: 'frei',
      unter: '400 Leuchtröhren in einem leeren Wasserspeicher.',
      fakten: [['Zeitraum', '21. bis 30. Januar, täglich'], ['Geöffnet', '18 bis 24 Uhr, letzter Einlass 23:30'], ['Ort', 'Wasserturm Nord, Untergeschoss'], ['Eintritt', 'frei, Zeitfenster buchen']],
      text: ['Die Röhren hängen in fünf Reihen über dem trockenen Becken. Sie reagieren auf die Schritte der Besucher: Wer still steht, lässt den Raum langsam dunkel werden.', 'Der Speicher fasst 30 Personen gleichzeitig. Ein Zeitfenster dauert 20 Minuten. Warme Kleidung empfohlen, im Becken sind es acht Grad.'] },
    { id: 'schichtwechsel', name: 'Schicht\u00adwechsel', wort: ['SCHICHT', 'WECHSEL'], art: 'Tanz', sparte: 'Bühne', plakat: 'koerper', bg: '#3d5f9a', fg: '#ffffff',
      tag: 'Sa', datum: '23.01.', zeit: '19:00', ort: 'Spinnereihalle', preis: '22 €',
      unter: 'Vierzehn Tänzerinnen und Tänzer, eine Nacht in Echtzeit.',
      fakten: [['Datum', 'Samstag, 23. Januar 2027'], ['Beginn', '19:00, Kommen und Gehen erlaubt'], ['Ort', 'Spinnereihalle, Halle B'], ['Dauer', '6 Stunden, bis 1 Uhr']],
      text: ['Das Stück folgt einer Nachtschicht von 19 bis 1 Uhr. Alle zwei Stunden übergibt eine Gruppe an die nächste, die Bewegungen werden weitergereicht und verändern sich dabei.', 'Das Publikum sitzt auf Kissen rund um die Fläche und darf jederzeit gehen und wiederkommen. Die Halle hat eine Bar und einen Ruheraum.'] },
    { id: 'kurzschluss', name: 'Kurz\u00adschluss', wort: ['KURZ', 'SCHLUSS'], art: 'Kurzfilmnacht', sparte: 'Film', plakat: 'film', bg: '#b0186b', fg: '#ffffff',
      tag: 'Do', datum: '28.01.', zeit: '20:00', ort: 'Kino am Elbufer', preis: '12 €',
      unter: 'Neun Kurzfilme, live vertont von drei Musikerinnen.',
      fakten: [['Datum', 'Donnerstag, 28. Januar 2027'], ['Beginn', '20:00'], ['Ort', 'Kino am Elbufer, Saal 2'], ['Sprache', 'Originalfassungen mit deutschen Untertiteln']],
      text: ['Die Filme kommen aus Prag, Breslau, Riga und Dresden und sind alle kürzer als zwölf Minuten. Keiner hat eine eigene Tonspur. Drei Musikerinnen vertonen sie live an Cello, Schlagwerk und Elektronik.', 'Nach dem letzten Film sprechen die Regisseurinnen und Regisseure im Foyer über ihre Arbeit. Das Gespräch wird in Gebärdensprache übersetzt.'] },
    { id: 'gleichstrom', name: 'Gleich\u00adstrom', wort: ['GLEICH', 'STROM'], art: 'Klubnacht', sparte: 'Klub', plakat: 'sinus', bg: '#5b3fa6', fg: '#ffffff',
      tag: 'Sa', datum: '30.01.', zeit: '23:00', ort: 'Schaltwerk', preis: '18 €',
      unter: 'Die Abschlussnacht, bis die erste Straßenbahn fährt.',
      fakten: [['Datum', 'Samstag, 30. Januar 2027'], ['Beginn', '23:00, Ende 6:00'], ['Ort', 'Schaltwerk, beide Floors'], ['Einlass', 'ab 18 Jahren']],
      text: ['Zwei Floors, sechs Sets, ein durchgehender Bass. Im großen Saal laufen lange, ruhige Sets, im Keller wird es schneller und dunkler.', 'Das Awareness-Team ist die ganze Nacht ansprechbar und trägt gelbe Westen. Wasser gibt es an beiden Bars kostenlos.'] }
  ];
  const proj = id => PROJEKTE.find(p => p.id === id);

  function htmlStart() {
    const p = PROJEKTE[0];
    return `<section class="s s-start">
      <p class="start__label mono"><span>7. Ausgabe · Musik, Bühne, Kunst, Film, Klub</span><span>46 Veranstaltungen an 9 Orten</span></p>
      <h1 class="riesig gross" tabindex="-1">Nacht<em>strom</em></h1>
      <div class="start__unten">
        <div class="start__links">
          <p class="daten"><b>21.–30.01.2027</b><span class="mono" style="color:var(--leise)">Dresden</span></p>
          <p class="lead">Zehn Winternächte in Hallen, Kinos und einem leeren Wasserturm. Das Festival beginnt, wenn es dunkel wird.</p>
        </div>
        <a class="start__bild" href="#${p.id}" data-ziel="${p.id}" style="--farbe:${p.bg}">
          <span class="plakat">${plakat(p.plakat, p.bg, 640, 360, p.wort)}</span>
          <span class="zeile mono"><span class="sparte">Eröffnung · ${p.name}, ${p.art}</span><span>${p.tag} ${p.datum} · ${p.zeit}</span></span>
        </a>
        <div class="mehr">
          <ul class="mehr__liste mono" aria-label="Nächste Termine">${PROJEKTE.slice(1, 4).map(q => `<li><span>${q.datum}</span>${q.name.replace(/\u00ad/g, '')}</li>`).join('')}</ul>
          <a class="mehr__link" href="#programm" data-ziel="programm" style="--farbe:#c4421a"><span class="mono">Alle 46 Termine</span><b>Programm <span aria-hidden="true">→</span></b></a>
        </div>
      </div>
    </section>`;
  }
  function htmlArbeiten() {
    const zeilen = PROJEKTE.map(p => `
      <li><a href="#${p.id}" data-ziel="${p.id}" style="--farbe:${p.bg}">
        <span class="wann mono"><b>${p.datum}</b>${p.tag}</span>
        <span class="name"><b>${p.name}</b><span class="mono sparte">${p.sparte} · ${p.art}</span></span>
        <span class="mono wo">${p.zeit}<br>${p.ort}</span>
        <span class="kasse"><span class="ticket mono">Tickets ${p.preis}</span></span>
        <span class="daumen">${plakat(p.plakat, p.bg, 300, 200)}</span>
      </a></li>`).join('');
    return `<section class="s s-arbeiten">
      <div class="s-kopfzeile"><h1 class="titel gross" tabindex="-1">Programm<sup>Auswahl 5 / 46</sup></h1><span class="mono" style="color:var(--leise)">21. bis 30. Januar 2027</span></div>
      <ul class="liste">${zeilen}</ul>
    </section>`;
  }
  function htmlProjekt(p) {
    const i = PROJEKTE.indexOf(p), n = PROJEKTE[(i + 1) % PROJEKTE.length];
    return `<section class="s s-projekt projekt">
      <figure class="projekt__bild plakat">${plakat(p.plakat, p.bg, 500, 640, p.wort)}</figure>
      <div class="projekt__text">
        <p class="mono">${p.sparte} · ${p.art} · ${p.tag} ${p.datum} · ${p.ort}</p>
        <div><h1 class="titel gross" tabindex="-1" style="--z:${p.name.replace(/\u00ad/g, '').length}">${p.name}</h1><p class="unter" style="margin-top:14px">${p.unter}</p></div>
        <dl class="fakten mono">${p.fakten.map(([a, b]) => `<div><dt>${a}</dt><dd>${b}</dd></div>`).join('')}</dl>
        <div class="fliess">${p.text.map(t => `<p>${t}</p>`).join('')}</div>
        <p class="kaufen mono"><span>Tickets · ${p.preis}</span><span>Im Festivalpass enthalten</span></p>
        <a class="weiter" href="#${n.id}" data-ziel="${n.id}" style="--farbe:${n.bg}"><span class="mono">Danach im Programm · ${n.tag} ${n.datum}</span><b>${n.name} →</b></a>
      </div>
    </section>`;
  }
  function htmlStudio() {
    return `<section class="s s-studio">
      <h1 class="sr" tabindex="-1">Tickets</h1>
      <div class="studio__text">
        <p class="aussage">Ein Pass für <em>zehn Nächte.</em> Oder ein Ticket für die eine.</p>
        <p>Der Festivalpass gilt für alle 46 Veranstaltungen, solange Plätze frei sind. Für Konzerte und Tanz reservieren Sie mit dem Pass kostenlos einen Platz. Einzeltickets gibt es online und an der Abendkasse.</p>
      </div>
      <figure class="studio__bild plakat">${ticketGrafik()}</figure>
      <div class="zahlen">
        <div><b>89 €</b><span class="mono">Festivalpass, alle Orte</span></div>
        <div><b>ab 12 €</b><span class="mono">Einzelticket</span></div>
        <div><b>50 %</b><span class="mono">ermäßigt mit Dresden-Pass</span></div>
        <div><b>0 €</b><span class="mono">unter 18 Jahren</span></div>
      </div>
    </section>`;
  }
  function htmlKontakt() {
    return `<section class="s s-kontakt">
      <h1 class="riesig gross" tabindex="-1">Treffpunkt:<br><em>Turbinenhalle.</em></h1>
      <div class="adresse">
        <p><span class="mono">Festivalzentrum</span>Turbinenhalle<br>Elbuferstraße 12, 01067 Dresden</p>
        <p><span class="mono">Kasse und Info</span>Täglich ab 16 Uhr<br>bis Beginn der letzten Vorstellung</p>
        <p><span class="mono">Barrierefreiheit</span>Alle Orte stufenlos, Hörschleife in der Turbinenhalle, Programm in Leichter Sprache</p>
        <p><span class="mono">Vorher ansehen</span><a href="#programm" data-ziel="programm" style="--farbe:#c4421a">Alle Termine im Programm</a></p>
      </div>
    </section>`;
  }

  function info(ziel) {
    const p = proj(ziel);
    if (p) return { nr: `Programm 0${PROJEKTE.indexOf(p) + 1} / 05`, name: p.name, titel: p.name, bg: p.bg, fg: p.fg, seiteBg: p.bg, seiteFg: p.fg, nav: 'programm' };
    const s = SEITEN[ziel];
    const nacht = ziel === 'start';
    return { nr: s.nr, name: s.name, titel: s.titel, bg: s.farbe, fg: s.fg || '#ffffff', seiteBg: nacht ? NACHT : HELL, seiteFg: nacht ? HELL : NACHT, nav: ziel };
  }
  let aktuell = 'start';
  function zeige(ziel) {
    const p = proj(ziel), i = info(ziel);
    seite.innerHTML = p ? htmlProjekt(p) : ({ start: htmlStart, programm: htmlArbeiten, tickets: htmlStudio, besuch: htmlKontakt })[ziel]();
    html.style.setProperty('--bg', i.seiteBg);
    html.style.setProperty('--fg', i.seiteFg);
    document.querySelectorAll('.nav a').forEach(a => {
      if (a.dataset.ziel === i.nav) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
    });
    document.title = (ziel === 'start' ? 'Festival Nachtstrom' : i.titel.replace(/\u00ad/g, '') + ' · Festival Nachtstrom') + ' · Fundus-Demo';
    aktuell = ziel;
  }

  /* ---------- Der Übergang ---------- */
  let laeuft = false, lauf = 0, laufende = [];
  const warte = (n, t) => new Promise(r => setTimeout(() => r(t === lauf), n));
  const anim = (el, kf, o) => { const a = el.animate(kf, { fill: 'both', ...o }); laufende.push(a); return a; };
  const fertig = list => Promise.all(list.map(a => a.finished.catch(() => {})));

  function aufraeumen() {
    laufende.forEach(a => a.cancel()); laufende = [];
    A.classList.remove('an'); B.classList.remove('an');
    laeuft = false;
  }
  function abbrechen() { lauf++; aufraeumen(); }

  async function uebergang(ziel, quelle, mitFokus) {
    if (laeuft) return;
    laeuft = true;
    const t = ++lauf, v = VARIANTEN[variante], f = faktor(v), d = n => n * f, i = info(ziel);
    zeigeWerte();

    // Vorhang beschriften und einfärben (Linkfarbe: Spartenfarbe des geklickten Links)
    let farbe = STROM.bg, schrift = STROM.fg;
    if (variante === 'linkfarbe') {
      farbe = (quelle && getComputedStyle(quelle).getPropertyValue('--farbe').trim()) || i.bg;
      schrift = hell(farbe) ? NACHT : '#ffffff';
    }
    B.style.setProperty('--v-bg', farbe);
    B.style.setProperty('--v-fg', schrift);
    vName.textContent = i.name; vLabel.textContent = i.nr; vNr.textContent = 'Seite wird geladen';

    const neuSetzen = () => {
      anim(seite, { transform: [`translateY(${NEU.y}px)`, 'translateY(0)'] }, { duration: d(NEU.dauer), easing: K.p4out });
      seite.querySelectorAll('[data-setzen]').forEach(img => anim(img, { transform: [`scale(${NEU.scale})`, 'scale(1)'] }, { duration: d(NEU.dauer), easing: K.p4out }));
    };
    const tauschen = () => {
      laufende.filter(a => a.effect.target === seite).forEach(a => a.cancel());
      zeige(ziel);
      scrollTo(0, 0);
      ansage.textContent = 'Seite geladen: ' + i.titel.replace(/\u00ad/g, '');
    };

    if (reduziert()) {
      B.classList.add('an');
      await fertig([anim(B, { opacity: [0, 1] }, { duration: FADE, easing: 'linear' })]);
      if (t !== lauf) return;
      tauschen();
      await fertig([anim(B, { opacity: [1, 0] }, { duration: FADE, easing: 'linear' })]);
    } else if (variante === 'einfach') {
      B.classList.add('an');
      const rein = anim(B, { transform: ['translateY(100%)', 'translateY(0%)'] }, { duration: d(v.rein), easing: K.p4in });
      anim(seite, { transform: ['translateY(0)', 'translateY(-50vh)'], opacity: [1, 0] }, { duration: d(v.rein), easing: K.p4in });
      anim(vName, { transform: ['translateY(110%)', 'translateY(0%)'] }, { duration: d(v.name), delay: d(v.rein), easing: K.p4out });
      await fertig([rein]);
      if (t !== lauf) return;
      tauschen();
      if (!(await warte(d(v.halten), t))) return;
      const raus = anim(B, { transform: ['translateY(0%)', 'translateY(-100%)'] }, { duration: d(v.raus), easing: K.p3io });
      neuSetzen();
      await fertig([raus]);
    } else if (variante === 'doppel') {
      A.classList.add('an'); B.classList.add('an');
      const zu = 'inset(100% 0 0 0)', offen = 'inset(0% 0 0 0)', weg = 'inset(0 0 100% 0)';
      anim(A, { clipPath: [zu, offen] }, { duration: d(v.rein), easing: K.p3io });
      const rein = anim(B, { clipPath: [zu, offen] }, { duration: d(v.rein), delay: d(v.versatz), easing: K.p3io });
      anim(seite, { transform: ['translateY(0)', 'translateY(-50vh)'], opacity: [1, 0] }, { duration: d(v.rein), easing: K.p3io });
      anim(vName, { transform: ['translateY(110%)', 'translateY(0%)'] }, { duration: d(v.name), delay: d(v.rein + v.versatz), easing: K.p4out });
      await fertig([rein]);
      if (t !== lauf) return;
      tauschen();
      if (!(await warte(d(v.halten), t))) return;
      anim(B, { clipPath: [offen, weg] }, { duration: d(v.raus), easing: K.p3io });
      const raus = anim(A, { clipPath: [offen, weg] }, { duration: d(v.raus), delay: d(v.versatz), easing: K.p3io });
      if (!(await warte(d(v.versatz), t))) return;
      neuSetzen();
      await fertig([raus]);
    } else {
      B.classList.add('an');
      const rein = anim(B, { clipPath: ['inset(100% 0 0 0)', 'inset(0% 0 0 0)'] }, { duration: d(v.rein), easing: K.p3io });
      anim(bInhalt, { transform: ['translateY(100%)', 'translateY(0%)'] }, { duration: d(v.name), easing: K.p3io });
      anim(seite, { transform: ['translateY(0)', 'translateY(-50vh)'], opacity: [1, 0] }, { duration: d(1000), easing: K.p3io });
      await fertig([rein]);
      if (t !== lauf) return;
      tauschen();
      if (!(await warte(d(v.halten), t))) return;
      const raus = anim(B, { clipPath: ['inset(0% 0 0 0)', 'inset(0 0 100% 0)'] }, { duration: d(v.raus), easing: K.p3io });
      neuSetzen();
      await fertig([raus]);
    }
    if (t !== lauf) return;
    await fertig(laufende.filter(a => a.playState !== 'idle'));   // neue Seite fertig setzen lassen
    if (t !== lauf) return;
    aufraeumen();
    if (mitFokus) { const h = seite.querySelector('h1'); if (h) h.focus({ preventScroll: true }); }
  }

  // Alle Links mit data-ziel (Kopfzeile und Seiten)
  document.addEventListener('click', e => {
    const a = e.target.closest('[data-ziel]');
    if (!a) return;
    e.preventDefault();
    if (a.dataset.ziel === aktuell || laeuft) return;
    stoppeVorfuehrung();
    uebergang(a.dataset.ziel, a, true);
  });
  document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeVariante(b.dataset.variante); }));
  addEventListener('resize', zeigeWerte);

  /* ---------- Vorführung: gedachter Zeiger klickt sich durch ---------- */
  const PFAD = [
    ['.nav [data-ziel="programm"]', 'einfach'],
    ['.liste [data-ziel="kaltfront"]', 'doppel'],
    ['.nav [data-ziel="tickets"]', 'linkfarbe'],
    ['.nav [data-ziel="besuch"]', 'einfach'],
    ['.nav [data-ziel="programm"]', 'doppel'],
    ['.liste [data-ziel="schichtwechsel"]', 'linkfarbe'],
    ['.marke', 'einfach']
  ];
  let runde = 0, uhr = null, lief = false, schritt = 0, zx = 0, zy = 0;
  const pause_ = (n, r) => new Promise(res => { uhr = setTimeout(() => res(r === runde), n); });

  function zeigerZu(x, y, r) {
    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: K.p4out, fill: 'forwards' });
    return pause_(reduziert() ? 200 : 820, r);
  }
  async function spiel(r) {
    if (!(await pause_(700, r))) return;
    while (r === runde) {
      const [sel, v] = PFAD[schritt % PFAD.length];
      let el = document.querySelector(sel);
      if (!el || el.getClientRects().length === 0 || getComputedStyle(el).display === 'none') el = document.querySelector('.marke');
      const b = el.getBoundingClientRect();
      const x = Math.round(b.left + Math.min(b.width * 0.5, 160)), y = Math.round(b.top + b.height * 0.5);
      if (!(await zeigerZu(x, y, r))) return;
      zeiger.animate({ scale: [1, 0.7, 1] }, { duration: 260, easing: K.p4out });
      setzeVariante(v);
      if (!(await pause_(140, r))) return;
      const ziel = el.dataset.ziel;
      schritt++;
      if (ziel !== aktuell) {
        uebergang(ziel, el, false);
        const vv = VARIANTEN[v];
        if (!(await pause_(gesamt(vv) * faktor(vv) + 1300, r))) return;
      }
    }
  }
  let nachStart = null;
  function stoppeVorfuehrung() { runde++; clearTimeout(uhr); clearTimeout(nachStart); lief = false; zeiger.classList.remove('an'); }

  function neustart() {
    stoppeVorfuehrung();
    abbrechen();
    zeige('start'); scrollTo(0, 0);
    nachStart = setTimeout(() => uebergang('programm', document.querySelector('.nav [data-ziel="programm"]'), false), 500);
  }
  function vorfuehren() {
    stoppeVorfuehrung();
    abbrechen();
    zeige('start'); scrollTo(0, 0);
    schritt = 0; lief = true;
    zx = Math.round(innerWidth * 0.5); zy = Math.round(innerHeight * 0.42);
    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; laufende.forEach(a => a.pause()); }
  function weiter() {
    laufende.forEach(a => { if (a.playState === 'paused') a.play(); });
    if (lief) { const r = ++runde; zeiger.classList.add('an'); spiel(r); }
  }

  setzeVariante('einfach');
  zeige('start');
  addEventListener('load', zeigeWerte);
  if (document.fonts) document.fonts.ready.then(dockMessen);

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