Zum Inhalt
← Sektionen & Funktionen

Tourdaten und Termine mit Ticketstatus

Tourdaten als echte Liste: Datum, Stadt, Ort, Status als Wort und Ticketweg zum Anbieter, mit Verlegt, Abgesagt, Zusatzkonzert, .ics, Abo, Filter ab 15 Terminen und MusicEvent-Markup.

Band, Musiker:in, Chor mit Tourabschnitten und Festivals.

Die Termine eines Acts an vielen Orten als echte Liste, gruppiert nach Tour oder Reihe. Jede Zeile zeigt Datum mit Wochentag und Beginn, Stadt mit Länderkürzel, Ort als Link, Support, den Status als Wort und den Weg zum Ticket beim Anbieter. Ein Mehr-Menü je Termin legt eine .ics-Datei in den Kalender oder teilt den Termin; oben abonniert ein webcal-Feed alle Termine. Das Statusvokabular: Im Vorverkauf, Wenige Karten, Ausverkauft (Resale nur, wenn der Veranstalter ihn anbietet), Zusatzkonzert, Verlegt mit altem und neuem Datum, Abgesagt mit Hinweis zur Erstattung, Festival mit Link zum Festival. Vergangene Termine sind eingeklappt. Die Daten kommen aus einer JSON-Datei oder einem Import und werden selbst gesetzt; daraus entsteht auch das Event-Markup.

Fans kommen mit zwei Fragen: Spielt die Band in meiner Nähe, und gibt es noch Karten? Eine Zeile pro Termin mit Datum, Stadt und Status beantwortet beides am Telefon in Sekunden. Der Status als Wort ist eindeutig, auch ohne Farbe und für Screenreader. Verlegte Termine mit altem und neuem Datum sparen Nachfragen. Kalenderdatei und Abo machen aus Interesse einen festen Termin.

Wann einsetzen

Bands, Musiker:innen, DJs, Chöre und Ensembles mit Terminen an vielen Orten; Labels mit Terminen mehrerer Acts; Lesereisen und Comedy-Touren; bildende Künstler:innen mit Ausstellungen in verschiedenen Häusern. Ab etwa vier Terminen im Jahr, Filter ab etwa 15.

Wann nicht

Ein Haus mit vielen Terminen an einem Ort braucht programm-kalender (Rubriken, Monatsansicht). Wiederkehrende Wochentermine gehören in wochen-kursplan. Termin-Widgets von Bandsintown oder Songkick nicht als iframe einbinden: Sie laden Dritte vor der Einwilligung und brechen die Gestaltung. Daten importieren und selbst setzen.

Varianten

  • Tour: ein Act, nach Tour bzw. Reihe gruppiert (Tour, Festivals), vergangene Termine eingeklappt
  • Label: mehrere Acts, Künstler:in als erste Spalte, nach Monat gruppiert, Filter nach Künstler:in und Stadt
  • Ausstellungen: aktuell, kommend und Auswahl vergangen; Titel, Haus, Stadt, Zeitraum, Einzel- oder Gruppenausstellung, Link zum Haus statt Ticket
  • Teaser: die nächsten drei Termine im Hero mit Ticketlink und Link zur ganzen Liste

Umsetzung

Daten als JSON (id, gruppe, start, ende, stadt, land, ort, support, status, anbieter; bei Verlegung vorher und verfuegbar, bei Ausverkauf optional resale, bei Festival festival). Eine Render-Funktion setzt je Gruppe eine <section> mit Überschrift und <ol>, je Termin ein <li> mit <time datetime>. Kalenderrechnung in UTC, Anzeige mit Intl.DateTimeFormat('de-DE').

Zeile: Datum in Martian Mono mit tabular-nums, Stadt als Überschrift mit <abbr> für das Land, Ort als Link, Status als Text mit Form-Zeichen. Ticketlink <a target="_blank" rel="noopener">, sichtbar „Tickets / bei Anbieter“, versteckt „für Stadt am Datum“ und „öffnet in neuem Tab“. Ausverkauft ohne Ticketknopf; Resale-Link nur mit resale: true. Verlegt: <del> für das alte Datum mit „verlegt vom“, Satz zur Gültigkeit der Tickets als Platzhalter. Abgesagt: Datum durchgestrichen, Knopf „Erstattung bei Anbieter“.

Mehr-Menü: <button popovertarget> und <div popover role="menu">, Pfeiltasten, Escape schließt und gibt den Fokus zurück. .ics als Blob (VTIMEZONE Europe/Berlin, Faltung nach 75 Oktetten, CRLF). Teilen über navigator.share, sonst Link kopieren. Abo als webcal://-Link auf einen Feed, den der Server aus derselben JSON erzeugt.

Filter erst ab etwa 15 kommenden Terminen, Variante leiste von facettenfilter-ergebnisliste. „In meiner Nähe“ nur mit eingegebener Stadt oder PLZ und einer eigenen Ortstabelle (z. B. OpenGeoDB), nie mit dem Gerätestandort.

JSON-LD nur für kommende Termine, die ohne Klick sichtbar sind: MusicEvent mit eventStatus, previousStartDate, offers.availability. Importfehler: letzte gültige Liste mit Stand-Datum und „Neu laden“, nie leere Fläche. Leerzustand mit Newsletter (Double-Opt-in, Region freiwillig). In der Demo sind Anbieter, Domains und Links erfunden und öffnen nicht.

Robust umsetzen

Telefon. Jede Zeile wird zur Karte: Datum, Wochentag und Beginn oben in einer Zeile, Stadt groß, Ort und Support, Status, Hinweis, dann Ticketknopf in voller Breite (48 px) und daneben das Mehr-Menü. Filter als wischbare Leiste. Vergangene Termine ohne Aktionen.

Ohne Bewegung. Zeilen erscheinen nie animiert. Bei reduzierter Bewegung öffnen Menüs und vergangene Termine ohne Clip und Höhenänderung, die Kontur läuft nicht.

Barrierefrei. Je Gruppe <section> mit Überschrift und <ol>, Datum als <time>, Land als <abbr>. Status immer als Wort, Farbe und Form nur zusätzlich; verlegt mit <del> und „verlegt vom“. Ticketlinks 48 px hoch, ihr Name nennt Stadt, Datum, Anbieter und „öffnet in neuem Tab“. Mehr-Menü mit aria-expanded, role=menu, Pfeiltasten, Escape. Trefferzahl in aria-live, Filter-Chips als Knöpfe „… entfernen“. Eingeklappte vergangene Termine mit inert. Fehler und Leerzustand mit role=status. „In meiner Nähe“ nur über Eingabe, nie über den Standort. Kontrast: Meta 6,5:1, Abgesagt 6,8:1, Ära-Fläche mit Papier 4,6:1.

Code

<!DOCTYPE html>
<!--
  Tourdaten und Termine mit Ticketstatus
  Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/tourdaten-liste/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-variante="tour">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tourdaten und Termine mit Ticketstatus · 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: 'Science Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/science-gothic:vf@latest/latin-wght-normal.woff') format('woff'); font-weight: 100 900; font-stretch: 50% 200%; font-display: swap; }
@font-face { font-family: 'Redaction'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/redaction@latest/latin-400-normal.woff') format('woff'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Redaction'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/redaction@latest/latin-400-italic.woff') format('woff'); font-weight: 400; font-style: italic; font-display: swap; }
@font-face { font-family: 'Martian Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/martian-mono:vf@latest/latin-wght-normal.woff') format('woff'); font-weight: 100 800; font-stretch: 75% 112.5%; font-display: swap; }

/* Rezept musik-kuenstler-buehne: Tinte, Papier und ein Ära-Token aus dem Cover */
:root {
  --tinte: #111113; --papier: #eeebe3; --aera: #c2361f;          /* Zinnober: Papier darauf 4,6 : 1 */
  --meta-t: #9a9893; --meta-p: #5c5a55; --rot-t: #ff6b5e; --rot-p: #a8281c;
  --grund: var(--tinte); --text: var(--papier); --leise: var(--meta-t); --rot: var(--rot-t);
  --linie: rgba(238, 235, 227, .2); --linie-stark: var(--papier); --flaeche: rgba(238, 235, 227, .06);
  /* UI-Stil token-toggle-asymmetrisch: Öffnen expo.out, Schließen expo.in in etwa 65 % der Zeit */
  --auf: cubic-bezier(0.16, 1, 0.3, 1); --zu: cubic-bezier(0.7, 0, 0.84, 0);
  --t-auf: 300ms; --t-zu: 200ms; --t-menue-auf: 250ms; --t-menue-zu: 170ms;
  --kante: clamp(16px, 4vw, 56px);
  --aktion: calc(13.5rem + 56px);   /* feste Aktionsspalte, damit Spalten über alle Zeilen fluchten */
  --mono: 'Martian Mono', ui-monospace, monospace;
  --kopf: 'Science Gothic', 'Arial Narrow', sans-serif;
}
[data-variante="ausstellungen"] { --grund: var(--papier); --text: var(--tinte); --leise: var(--meta-p); --rot: var(--rot-p); --linie: rgba(17, 17, 19, .18); --linie-stark: var(--tinte); --flaeche: rgba(17, 17, 19, .05); }
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 1.1875rem/1.6 'Redaction', Georgia, serif; -webkit-font-smoothing: antialiased; }
:root:has(:focus-visible) { scroll-behavior: auto; }
body { min-height: 100svh; }
a { color: inherit; }
button, input, select { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; }
ol, ul { list-style: none; }
abbr { text-decoration: none; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.label { font: 500 .75rem/1.3 var(--mono); letter-spacing: .08em; text-transform: uppercase; }

/* ---------- Kopf der Künstlerseite ---------- */
.seite { display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 64px; padding: 0 var(--kante); border-bottom: 1px solid var(--linie); }
.wortmarke { font: 800 1.25rem/1 var(--kopf); font-stretch: var(--breite, 200%); text-transform: uppercase; letter-spacing: -.02em; text-decoration: none; white-space: nowrap; }
.seite nav ul { display: flex; gap: clamp(14px, 2.4vw, 32px); }
.seite nav a { font: 600 .8125rem/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; text-decoration: none; padding: 8px 0; border-bottom: 2px solid transparent; }
.seite nav a[aria-current] { border-color: var(--aera); }
.seite nav a:hover { border-color: var(--text); }

/* ---------- Sektion ---------- */
.live { max-width: 1320px; margin: 0 auto; padding: clamp(32px, 5vw, 72px) var(--kante) 140px; }
.live-kopf { display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(0, 30rem); gap: 20px 48px; align-items: end; padding-bottom: clamp(24px, 3vw, 40px); }
.live-kopf .label { color: var(--leise); margin-bottom: 14px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.live-kopf .label i { width: 10px; height: 10px; background: var(--aera); box-shadow: 0 0 0 1px var(--text); }
h2 { font: 700 clamp(2rem, 1.2rem + 3.5vw, 4rem)/.95 var(--kopf); font-stretch: 120%; text-transform: uppercase; letter-spacing: -.01em; text-wrap: balance; }
.vorspann { max-width: 38ch; text-wrap: pretty; }
.kopf-aktionen { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: center; margin-top: 22px; }
.stand { font: 500 .75rem/1.4 var(--mono); letter-spacing: .04em; color: var(--leise); }

/* Harte Taste (tiefen-taste-3d, Variante versatz-brutal) für die eine Haupthandlung */
.taste { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; padding: 0 20px; background: var(--aera); color: var(--papier); text-decoration: none;
  font: 600 .875rem/1.1 var(--mono); letter-spacing: .04em; text-transform: uppercase; text-align: center;
  box-shadow: inset 0 0 0 2px var(--text), 5px 5px 0 0 var(--text); margin: 0 5px 5px 0;
  transition: transform 160ms steps(2, end), box-shadow 160ms steps(2, end); }
.taste svg { width: 16px; height: 16px; flex: none; }
@media (hover: hover) { .taste:hover { transform: translate(-2px, -2px); box-shadow: inset 0 0 0 2px var(--text), 7px 7px 0 0 var(--text); } }
.taste.ist-hover { transform: translate(-2px, -2px); box-shadow: inset 0 0 0 2px var(--text), 7px 7px 0 0 var(--text); }
.taste:active, .taste.drueckt { transform: translate(5px, 5px); box-shadow: inset 0 0 0 2px var(--text), 0 0 0 0 var(--text); transition-duration: 0ms; }
.taste:focus-visible { outline: 2px solid var(--text); outline-offset: 6px; }

/* Kontur-Knopf (kontur-button-zeichnen, Variante laufend) für Ticketlinks und Nebenaktionen */
.kontur { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; padding: 6px 16px; color: var(--text); text-decoration: none; background: transparent; border: 1px solid transparent; transition: background-color 125ms linear; text-align: left; }
.kontur:active { background: var(--flaeche); }
.kontur svg.pfeil { width: 14px; height: 14px; flex: none; }
.kontur__svg { position: absolute; inset: -1px; width: calc(100% + 2px); height: calc(100% + 2px); overflow: visible; pointer-events: none; }
.kontur__svg rect { x: .75px; y: .75px; width: calc(100% - 1.5px); height: calc(100% - 1.5px); fill: none; stroke: var(--text); stroke-width: 1.25; stroke-dasharray: 7 5; }
@media (hover: hover) { .kontur:hover .kontur__svg rect { animation: laufen 1600ms linear 3; } .kontur:hover { background: var(--flaeche); } }
.kontur:focus-visible .kontur__svg rect, .kontur.ist-hover .kontur__svg rect { animation: laufen 1600ms linear 3; }
.kontur.ist-hover { background: var(--flaeche); }
@keyframes laufen { to { stroke-dashoffset: -48; } }
.knopf-text { display: grid; gap: 3px; }
.knopf-text b { font: 600 .875rem/1.1 var(--mono); letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
.knopf-text small { font: 500 .6875rem/1.2 var(--mono); letter-spacing: .02em; color: var(--leise); white-space: nowrap; }
.kontur.einzeilig { font: 600 .8125rem/1.1 var(--mono); letter-spacing: .04em; text-transform: uppercase; }

/* ---------- Filterleiste (facettenfilter-ergebnisliste, Variante leiste) ---------- */
.filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 14px 0; border-top: 2px solid var(--linie-stark); border-bottom: 1px solid var(--linie); }
.filter[hidden] { display: none; }
.filter > .label { color: var(--leise); margin-right: 4px; }
.facette { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border: 1px solid var(--linie-stark); font: 600 .8125rem/1 var(--mono); letter-spacing: .04em; text-transform: uppercase; }
.facette .anz { font-weight: 500; color: var(--leise); }
.facette svg { width: 12px; height: 12px; }
.facette:hover { background: var(--flaeche); }
.facette.aktiv { background: var(--text); color: var(--grund); }
.facette.aktiv .anz { color: inherit; }
.treffer { margin-left: auto; font: 500 .8125rem/1.3 var(--mono); color: var(--leise); text-align: right; }
.treffer b { color: var(--text); font-weight: 600; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; width: 100%; }
.chips:empty { display: none; }
.chip { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 6px 0 10px; background: var(--flaeche); border: 1px solid var(--linie); font: 500 .75rem/1 var(--mono); letter-spacing: .02em; }
.chip span { font-weight: 700; font-size: 1rem; line-height: 1; padding: 0 4px; }
.chip.zurueck { background: none; border-style: dashed; text-transform: uppercase; letter-spacing: .06em; padding: 0 10px; }

/* Popover: Menüs und Filter (öffnen 250 ms expo.out, schließen 170 ms expo.in) */
.pop { position: fixed; inset: auto; margin: 0; width: max-content; max-width: min(340px, calc(100vw - 24px)); padding: 8px; background: var(--grund); color: var(--text); border: 2px solid var(--linie-stark); box-shadow: 6px 6px 0 0 rgba(0, 0, 0, .45);
  clip-path: inset(0 0 100% 0); transition: clip-path var(--t-menue-zu) var(--zu), display var(--t-menue-zu) allow-discrete, overlay var(--t-menue-zu) allow-discrete; }
.pop:popover-open { clip-path: inset(0 -12px -12px 0); transition: clip-path var(--t-menue-auf) var(--auf), display var(--t-menue-auf) allow-discrete, overlay var(--t-menue-auf) allow-discrete; }
@starting-style { .pop:popover-open { clip-path: inset(0 0 100% 0); } }
.pop .label { color: var(--leise); padding: 8px 10px 6px; display: block; }
.menue [role=menuitem] { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 44px; padding: 0 12px; text-align: left; text-decoration: none; font: 500 .8125rem/1.2 var(--mono); letter-spacing: .02em; white-space: nowrap; }
.menue [role=menuitem]:hover, .menue [role=menuitem]:focus-visible { background: var(--text); color: var(--grund); outline: none; }
.menue svg { width: 16px; height: 16px; flex: none; }
.wahl { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 0 10px; cursor: pointer; font: 500 .8125rem/1.2 var(--mono); }
.wahl:hover { background: var(--flaeche); }
.wahl input { width: 18px; height: 18px; accent-color: var(--aera); flex: none; }
.wahl .n { margin-left: auto; color: var(--leise); padding-left: 16px; }
.wahl.leer { color: var(--leise); }
::placeholder { color: var(--leise); opacity: 1; }
.suche, .feld input, .feld select { width: 100%; min-height: 44px; padding: 0 12px; background: transparent; border: 1px solid var(--linie-stark); font: 500 .875rem/1 var(--mono); border-radius: 0; }
.suche { margin: 2px 0 6px; }
.feld { display: grid; gap: 6px; padding: 6px 10px; }
.feld label { font: 500 .75rem/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--leise); }
.feld select option { background: var(--grund); color: var(--text); }
.naehe-form { width: min(320px, calc(100vw - 48px)); display: grid; gap: 6px; padding-bottom: 6px; }
.naehe-form .hinweis { font: 400 .75rem/1.45 var(--mono); color: var(--leise); padding: 2px 10px 6px; }
.naehe-form .fehler { font: 500 .75rem/1.45 var(--mono); color: var(--rot); padding: 0 10px; }
.naehe-form .fehler:empty { display: none; }
.naehe-form .kontur { margin: 4px 10px 0; }

/* ---------- Gruppen und Zeilen ---------- */
.gruppe { margin-top: clamp(36px, 5vw, 64px); }
.gruppe:first-child { margin-top: 28px; }
.gruppe-kopf { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 24px; padding-bottom: 14px; border-bottom: 2px solid var(--linie-stark); }
.gruppe-kopf h3 { font: 700 clamp(1.375rem, 1rem + 1.4vw, 2rem)/1 var(--kopf); font-stretch: 120%; text-transform: uppercase; letter-spacing: -.005em; display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.gruppe-kopf h3 .nr { font: 500 .75rem/1 var(--mono); letter-spacing: .08em; color: var(--leise); }
.gruppe-kopf p { font: 500 .75rem/1.4 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }
.gruppe-kopf p em { font-style: normal; background: var(--aera); color: var(--papier); padding: 3px 7px; margin-right: 8px; box-shadow: 0 0 0 1px var(--text); }

.termin { display: grid; grid-template-columns: 9rem minmax(0, 1fr) 10.5rem var(--aktion); gap: 6px 28px; align-items: center; padding: 18px 0; border-bottom: 1px solid var(--linie); position: relative; }
[data-variante="label"] .termin { grid-template-columns: minmax(0, 12rem) 8.5rem minmax(0, 1fr) 9.5rem var(--aktion); }
[data-variante="label"] .stadt { font-size: 1.125rem; overflow-wrap: break-word; }
[data-variante="ausstellungen"] .termin { grid-template-columns: 12.5rem minmax(0, 1fr) 11rem var(--aktion); align-items: start; padding: 24px 0; }
.termin:has(.notiz) { align-items: start; }
.termin:has(.notiz) > .t-status, .termin:has(.notiz) > .t-aktion { padding-top: 4px; }
@media (hover: hover) { .termin:hover { background: linear-gradient(90deg, var(--flaeche), transparent 70%); } }
.t-datum { display: grid; gap: 2px; font: 500 .9375rem/1.3 var(--mono); font-stretch: 87.5%; font-variant-numeric: tabular-nums; }
.t-datum .wt { font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }
.t-datum time { font-weight: 600; white-space: nowrap; }
.t-datum .uhr { font-size: .8125rem; color: var(--leise); }
.t-datum .alt { font-size: .75rem; color: var(--leise); line-height: 1.35; margin-top: 4px; }
.t-datum .alt del { text-decoration-thickness: 1.5px; }
.t-kuenstler { font: 700 1.125rem/1.1 var(--kopf); font-stretch: 90%; text-transform: uppercase; overflow-wrap: break-word; }
.t-kuenstler a { text-decoration: none; }
.t-kuenstler a:hover { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 4px; }
.t-ort { min-width: 0; }
.stadt { font: 700 1.25rem/1.05 var(--kopf); font-stretch: 100%; text-transform: uppercase; letter-spacing: .005em; overflow-wrap: anywhere; }
.stadt abbr { display: inline-block; white-space: nowrap; font: 500 .8125rem/1 var(--mono); letter-spacing: .04em; color: var(--leise); margin-left: 6px; vertical-align: .14em; }
.titel { font: italic 400 clamp(1.5rem, 1.1rem + 1.2vw, 2rem)/1.12 'Redaction', Georgia, serif; text-wrap: balance; }
.ort { font-size: 1.0625rem; line-height: 1.45; margin-top: 4px; }
.ort a { text-decoration-thickness: 1px; text-underline-offset: 3px; }
.ort a:hover { text-decoration-thickness: 2px; }
.ort .support { color: var(--leise); }
.ort .dist { font: 500 .75rem/1 var(--mono); color: var(--text); background: var(--flaeche); border: 1px solid var(--linie); padding: 3px 6px; margin-left: 6px; white-space: nowrap; vertical-align: .1em; }
.notiz { font-size: .9375rem; line-height: 1.5; margin-top: 8px; max-width: 58ch; color: var(--leise); text-wrap: pretty; }
.platzhalter { display: block; margin-top: 6px; color: var(--text); border: 1px dashed var(--leise); padding: 6px 10px 8px; }
.platzhalter::before { content: 'Platzhalter · vom Kunden prüfen lassen'; display: block; font: 500 .625rem/1.6 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }

/* Status immer als Wort; Form und Farbe nur zusätzlich */
.status { display: inline-flex; align-items: center; gap: 8px; font: 600 .75rem/1.25 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.status::before { content: ''; width: 10px; height: 10px; flex: none; border: 1.5px solid currentColor; }
.status.tickets::before { background: currentColor; }
.status.knapp::before { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.status.aus { padding: 5px 8px; border: 1.5px solid currentColor; }
.status.aus::before { border: 0; width: 10px; background: linear-gradient(45deg, transparent 43%, currentColor 43% 57%, transparent 57%), linear-gradient(-45deg, transparent 43%, currentColor 43% 57%, transparent 57%); }
.status.zusatz { background: var(--aera); color: var(--papier); padding: 5px 8px; box-shadow: 0 0 0 1px var(--text); }
.status.zusatz::before { display: none; }
.status.zusatz i { font-style: normal; background: var(--papier); color: var(--tinte); padding: 1px 5px; margin-left: 2px; }
.status.verlegt::before { border-radius: 0; border-style: dashed; }
.status.abgesagt { color: var(--rot); }
.status.abgesagt::before { border: 0; background: linear-gradient(45deg, transparent 43%, currentColor 43% 57%, transparent 57%), linear-gradient(-45deg, transparent 43%, currentColor 43% 57%, transparent 57%); }
.status.festival::before { border-radius: 50%; }
.status.laeuft::before { background: var(--aera); border-color: var(--text); }
.status.kommend::before { border-style: dashed; }
.status.vorbei::before { display: none; }
.t-status { display: grid; gap: 6px; justify-items: start; }
.t-status small { font: 500 .75rem/1.35 var(--mono); color: var(--leise); }
.art { font: 500 .6875rem/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }

.t-aktion { display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
.t-aktion .kontur, .t-aktion .keine { flex: 1 1 auto; min-width: 0; justify-content: space-between; }
.keine { display: flex; align-items: center; min-height: 48px; padding: 0 14px; font: 500 .75rem/1.35 var(--mono); color: var(--leise); border: 1px solid var(--linie); background: repeating-linear-gradient(-45deg, transparent 0 6px, var(--flaeche) 6px 8px); }
.mehr { width: 48px; height: 48px; flex: none; display: grid; place-items: center; border: 1px solid var(--linie); }
.mehr svg { width: 20px; height: 20px; }
.mehr:hover, .mehr:popover-open, .mehr[aria-expanded="true"] { background: var(--text); color: var(--grund); border-color: var(--text); }

/* Abgesagt: Zeile bleibt lesbar, Datum durchgestrichen */
.termin[data-status="abgesagt"] .t-datum time { text-decoration: line-through; text-decoration-thickness: 1.5px; }
.termin[data-status="abgesagt"] .stadt { color: var(--leise); }

/* ---------- Vergangene Termine (eingeklappt) ---------- */
.vergangen { margin-top: clamp(36px, 5vw, 64px); border-top: 2px solid var(--linie-stark); }
.klapp { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; min-height: 64px; padding: 0; text-align: left; }
.klapp span { min-width: 0; font: 700 clamp(1.125rem, 1rem + .6vw, 1.5rem)/1.05 var(--kopf); font-stretch: 120%; text-transform: uppercase; overflow-wrap: break-word; }
.klapp i { position: relative; width: 44px; height: 44px; border: 1px solid var(--linie-stark); flex: none; }
.klapp i::before, .klapp i::after { content: ''; position: absolute; left: 50%; top: 50%; width: 16px; height: 2px; background: currentColor; translate: -50% -50%; }
.klapp i::after { rotate: 90deg; transition: rotate var(--t-zu) var(--zu); }
.klapp[aria-expanded="true"] i::after { rotate: 0deg; transition: rotate var(--t-auf) var(--auf); }
.klapp:hover i { background: var(--text); color: var(--grund); }
.klapp-inhalt { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--t-zu) var(--zu); }
.klapp-inhalt > div { overflow: hidden; min-height: 0; }
.vergangen.offen .klapp-inhalt { grid-template-rows: 1fr; transition: grid-template-rows var(--t-auf) var(--auf); }
.vergangen .termin { color: var(--leise); padding: 12px 0; }
.vergangen .stadt, .vergangen .titel { color: var(--leise); }
.vergangen .titel { font-size: 1.375rem; }
.vergangen .t-aktion { min-height: 0; }

/* ---------- Hinweise: Importfehler, Leerzustand, Filter leer ---------- */
.hinweis-fehler { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px 16px; align-items: center; margin-top: 20px; padding: 14px 16px; border: 2px solid var(--rot); }
.hinweis-fehler[hidden] { display: none; }
.hinweis-fehler > svg { width: 24px; height: 24px; color: var(--rot); }
.hinweis-fehler p { font-size: 1rem; line-height: 1.45; }
.hinweis-fehler b { font: 600 .8125rem/1.3 var(--mono); letter-spacing: .04em; text-transform: uppercase; color: var(--rot); display: block; }
.leer { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 26rem); gap: 24px 48px; align-items: end; margin-top: 28px; padding: clamp(28px, 4vw, 48px) 0; border-top: 2px solid var(--linie-stark); border-bottom: 1px solid var(--linie); }
.leer[hidden] { display: none; }
.leer h3 { font: 700 clamp(1.75rem, 1rem + 2.6vw, 3rem)/1 var(--kopf); font-stretch: 120%; text-transform: uppercase; }
.leer p { margin-top: 12px; max-width: 40ch; }
.brief { display: grid; gap: 10px; }
.brief label { font: 500 .75rem/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--leise); }
.brief input, .brief select { width: 100%; min-height: 48px; padding: 0 14px; background: transparent; border: 1px solid var(--linie-stark); border-radius: 0; font: 500 .9375rem/1 var(--mono); }
.brief select option { background: var(--grund); color: var(--text); }
.brief .klein { font: 400 .75rem/1.5 var(--mono); color: var(--leise); }
.brief .meldung-brief { font: 500 .8125rem/1.45 var(--mono); }
.brief .meldung-brief:empty { display: none; }
.brief .meldung-brief.fehler { color: var(--rot); }
.brief .taste { justify-self: start; margin-top: 4px; }
.filter-leer { padding: 40px 0; border-bottom: 1px solid var(--linie); display: grid; gap: 14px; justify-items: start; }
.filter-leer[hidden] { display: none; }
.filter-leer h3 { font: 700 1.5rem/1.05 var(--kopf); font-stretch: 120%; text-transform: uppercase; }

/* ---------- Meldung, Abo, JSON-LD ---------- */
.toast { position: fixed; left: 50%; bottom: 24px; z-index: 50; translate: -50% 12px; opacity: 0; visibility: hidden; width: min(480px, calc(100vw - 24px)); display: grid; gap: 4px; padding: 14px 18px; background: var(--papier); color: var(--tinte); box-shadow: 6px 6px 0 0 var(--aera); transition: opacity var(--t-menue-zu) var(--zu), translate var(--t-menue-zu) var(--zu), visibility 0s linear var(--t-menue-zu); }
.toast.an { opacity: 1; translate: -50% 0; visibility: visible; transition: opacity var(--t-menue-auf) var(--auf), translate var(--t-menue-auf) var(--auf), visibility 0s; }
.toast b { font: 600 .875rem/1.3 var(--mono); }
.toast small { font: 400 .75rem/1.45 var(--mono); color: #4a4843; overflow-wrap: anywhere; }
.abo { width: min(360px, calc(100vw - 24px)); max-width: none; padding: 16px; display: grid; gap: 12px; }
.abo p { font-size: 1rem; line-height: 1.5; }
.abo code { font: 500 .75rem/1.4 var(--mono); background: var(--flaeche); padding: 8px 10px; border: 1px solid var(--linie); overflow-wrap: anywhere; display: block; }
.abo .reihe { display: flex; flex-wrap: wrap; gap: 8px; }
.abo .reihe .kontur { flex: 1 1 9rem; }
dialog.ld { width: min(760px, calc(100vw - 24px)); max-height: 84svh; margin: auto; padding: 0; background: var(--tinte); color: var(--papier); border: 2px solid var(--papier); }
dialog.ld::backdrop { background: rgba(0, 0, 0, .6); }
dialog.ld header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 12px 12px 18px; border-bottom: 1px solid rgba(238, 235, 227, .2); position: sticky; top: 0; background: var(--tinte); }
dialog.ld header b { font: 600 .8125rem/1.3 var(--mono); letter-spacing: .04em; text-transform: uppercase; }
dialog.ld pre { padding: 16px 18px 24px; font: 400 .75rem/1.55 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: #d9d6ce; }
dialog.ld .zu { width: 44px; height: 44px; border: 1px solid rgba(238, 235, 227, .4); font: 600 1.25rem/1 var(--mono); }

/* ---------- Demo-Pult ---------- */
.pult { position: fixed; right: 14px; bottom: 14px; z-index: 40; display: grid; gap: 8px; padding: 12px 14px; max-width: 360px; background: rgba(17, 17, 19, .95); color: #eeebe3; border: 1px solid rgba(238, 235, 227, .35); font: 500 .6875rem/1.45 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.pult .zeile { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; min-height: 28px; padding: 0 9px; border: 1px solid rgba(238, 235, 227, .45); color: #eeebe3; }
.pult button[aria-pressed="true"] { background: #eeebe3; color: #111113; }
.pult .werte { color: #a9a7a1; text-transform: none; letter-spacing: .02em; }
.pult .werte b { color: #eeebe3; font-weight: 500; }

/* Gedachter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 24px; height: 24px; pointer-events: none; display: none; transition: transform 640ms var(--auf); }
.vorfuehrung .zeiger { display: block; }
.zeiger.klick { scale: .82; }

/* ---------- Reduzierte Bewegung ---------- */
.reduziert .pop, .reduziert .pop:popover-open { transition: none; }
.reduziert .klapp-inhalt, .reduziert .vergangen.offen .klapp-inhalt, .reduziert .klapp i::after { transition: none !important; }
.reduziert .kontur__svg rect { animation: none !important; }
.reduziert .taste { transition: none; }
.reduziert .toast, .reduziert .toast.an { translate: -50% 0; transition: opacity 120ms linear, visibility 0s linear 120ms; }
.reduziert .toast.an { transition: opacity 120ms linear; }
.reduziert .zeiger { transition: none; }

.facetten { display: flex; flex-wrap: wrap; gap: 8px; }

/* ---------- Touch ---------- */
@media (hover: none) {
  .facette:hover, .wahl:hover { background: none; }
  .facette.aktiv:hover { background: var(--text); }
  .klapp:hover i { background: none; color: inherit; }
  .mehr:hover { background: none; color: inherit; border-color: var(--linie); }
  .mehr:popover-open, .mehr[aria-expanded="true"] { background: var(--text); color: var(--grund); }
  .filter .facetten { flex: 1 1 100%; display: flex; flex-wrap: nowrap; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 calc(var(--kante) * -1); padding: 0 var(--kante); }
  .filter .facetten::-webkit-scrollbar { display: none; }
  .filter .facetten .facette { flex: none; }
}

/* ---------- Schmaler ---------- */
@media (max-width: 1100px) {
  .seite nav li:nth-child(n+4):not(.jetzt) { display: none; }
  [data-variante="ausstellungen"] .live-kopf { grid-template-columns: minmax(0, 1fr); }
  .termin { grid-template-columns: 8.5rem minmax(0, 1fr) var(--aktion); }
  .termin .t-status { grid-column: 2; grid-row: 2; }
  .termin .t-aktion { grid-column: 3; grid-row: 1 / span 2; }
  [data-variante="label"] .termin { grid-template-columns: 8.5rem minmax(0, 1fr) var(--aktion); }
  [data-variante="label"] .t-kuenstler { grid-column: 2; grid-row: 1; }
  [data-variante="label"] .t-datum { grid-column: 1; grid-row: 1 / span 3; align-self: start; }
  [data-variante="label"] .t-ort { grid-column: 2; grid-row: 2; }
  [data-variante="label"] .t-status { grid-column: 2; grid-row: 3; }
  [data-variante="label"] .t-aktion { grid-column: 3; grid-row: 1 / span 3; }
  [data-variante="ausstellungen"] .termin { grid-template-columns: 10.5rem minmax(0, 1fr) var(--aktion); }
}
@media (max-width: 760px) {
  .seite { height: auto; min-height: 56px; flex-wrap: wrap; column-gap: 16px; row-gap: 0; padding-block: 8px; }
  .wortmarke { font-stretch: var(--breite-s, 150%); }
  .seite nav li:not(.jetzt) { display: none; }
  .live { padding-top: 28px; }
  .live-kopf { grid-template-columns: minmax(0, 1fr); }
  .filter { gap: 10px; }
  .filter > .label { display: none; }
  .treffer { margin-left: 0; text-align: left; width: 100%; }
  /* Zeile wird Karte: Datum oben, Stadt groß, Ort und Status darunter, Knopf volle Breite */
  .termin, [data-variante="label"] .termin, [data-variante="ausstellungen"] .termin { grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 18px 0 20px; }
  .termin > *, [data-variante="label"] .termin > * { grid-column: 1 !important; grid-row: auto !important; }
  .t-datum { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; }
  .t-datum .alt { width: 100%; margin-top: 0; }
  [data-variante="label"] .t-datum { order: -1; }
  .stadt { font-size: 1.625rem; }
  .t-kuenstler { font-size: 1.5rem; }
  [data-variante="label"] .stadt { font-size: 1.0625rem; margin-top: 2px; }
  .t-ort { display: contents; }
  .t-status { margin-top: 4px; order: 1; }
  .notiz { order: 2; }
  .t-aktion { order: 3; }
  .t-aktion { justify-content: stretch; margin-top: 10px; }
  .t-aktion > :first-child { flex: 1 1 auto; min-width: 0; }
  .t-aktion .kontur, .keine { min-width: 0; }
  .vergangen .termin { gap: 2px; }
  .vergangen .t-aktion { display: none; }
  .leer { grid-template-columns: minmax(0, 1fr); }
  .hinweis-fehler { grid-template-columns: auto minmax(0, 1fr); }
  .hinweis-fehler .kontur { grid-column: 1 / -1; }
}
@media (max-width: 360px) {
  .wortmarke { font-stretch: var(--breite-xs, 150%); font-size: 1.125rem; }
  h2 { font-stretch: 105%; }
  .klapp span { font-stretch: 100%; }
  .wortmarke { font-size: var(--wm-xs, 1.125rem); }
  .knopf-text b, .knopf-text small { white-space: normal; }
}

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .live { padding-top: 26px; }
.vorschau .live-kopf { padding-bottom: 18px; }
.vorschau .vorspann { font-size: 1.0625rem; line-height: 1.45; }
.vorschau .kopf-aktionen { margin-top: 14px; }
.vorschau .termin { padding: 12px 0; }
.vorschau .notiz { display: none; }
.vorschau .gruppe:first-child { margin-top: 18px; }

@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
  .kontur, .taste { border: 1px solid LinkText !important; }
  .status::before { forced-color-adjust: none; background: CanvasText; }
}
</style>
</head>
<body>
<header class="seite">
  <a class="wortmarke" href="#live" id="wortmarke">Zinkweiß</a>
  <nav aria-label="Hauptnavigation"><ul id="nav"></ul></nav>
</header>

<main class="live" id="live">
  <div class="live-kopf">
    <div>
      <p class="label" id="kopf-label"><i aria-hidden="true"></i><span>Live · Kaltes Walzwerk</span></p>
      <h2 id="kopf-titel">Termine 2027</h2>
    </div>
    <div>
      <p class="vorspann" id="vorspann"></p>
      <div class="kopf-aktionen" id="kopf-aktionen"></div>
    </div>
  </div>

  <div class="hinweis-fehler" id="importfehler" role="status" hidden>
    <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 23 21H1z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 9v5M12 16.5v2" stroke="currentColor" stroke-width="2"/></svg>
    <p><b>Termine nicht aktuell</b><span id="fehler-text"></span></p>
    <button class="kontur einzeilig" type="button" id="neu-laden"><span>Neu laden</span><svg class="kontur__svg" aria-hidden="true" focusable="false"><rect width="100%" height="100%"/></svg></button>
  </div>

  <div class="filter" id="filter" role="search" aria-label="Termine filtern" hidden>
    <span class="label">Filtern</span>
    <div class="facetten" id="facetten"></div>
    <p class="treffer" id="treffer" aria-live="polite"></p>
    <div class="chips" id="chips"></div>
  </div>

  <div id="gruppen"></div>

  <div class="filter-leer" id="filter-leer" hidden>
    <h3>Kein Termin passt</h3>
    <p id="filter-leer-text"></p>
    <button class="kontur einzeilig" type="button" data-zuruecksetzen><span>Filter zurücksetzen</span><svg class="kontur__svg" aria-hidden="true" focusable="false"><rect width="100%" height="100%"/></svg></button>
  </div>

  <section class="leer" id="leer" aria-labelledby="leer-titel" hidden>
    <div>
      <h3 id="leer-titel">Gerade keine Termine.</h3>
      <p id="leer-text">Neue Termine zuerst im Newsletter: Vorverkauf eine Woche vor allen anderen, höchstens eine Mail im Monat.</p>
    </div>
    <form class="brief" id="brief" novalidate>
      <label for="brief-mail">E-Mail-Adresse</label>
      <input id="brief-mail" name="mail" type="email" autocomplete="email" required aria-describedby="brief-meldung">
      <label for="brief-region">Region für Termin-Hinweise (freiwillig)</label>
      <select id="brief-region" name="region">
        <option value="">Keine Angabe</option><option>Norden</option><option>Osten</option><option>Ruhrgebiet und Rheinland</option><option>Süden</option><option>Österreich und Schweiz</option>
      </select>
      <p class="klein">Du bekommst eine Mail mit Bestätigungslink (Double-Opt-in). Abmelden geht jederzeit. <a href="#datenschutz">Datenschutz</a></p>
      <p class="meldung-brief" id="brief-meldung" role="status"></p>
      <button class="taste" type="submit">Eintragen</button>
    </form>
  </section>

  <section class="vergangen" id="vergangen" aria-label="Vergangene Termine">
    <button class="klapp" type="button" aria-expanded="false" aria-controls="vergangen-inhalt" id="klapp"><span id="klapp-text">Vergangene Termine</span><i aria-hidden="true"></i></button>
    <div class="klapp-inhalt" id="vergangen-inhalt" inert><div><ol id="vergangen-liste"></ol></div></div>
  </section>
</main>

<div class="pop menue abo" id="abo" popover>
  <span class="label">Kalender abonnieren</span>
  <p id="abo-text"></p>
  <code id="abo-url"></code>
  <div class="reihe">
    <button class="kontur einzeilig" type="button" id="abo-kopieren"><span>Adresse kopieren</span><svg class="kontur__svg" aria-hidden="true" focusable="false"><rect width="100%" height="100%"/></svg></button>
    <button class="kontur einzeilig" type="button" id="abo-alle"><span>Alle als .ics</span><svg class="kontur__svg" aria-hidden="true" focusable="false"><rect width="100%" height="100%"/></svg></button>
  </div>
</div>
<div id="pops"></div>

<div class="toast" id="toast" role="status" aria-live="polite"><b></b><small></small></div>

<dialog class="ld" id="ld" aria-labelledby="ld-titel">
  <header><b id="ld-titel">MusicEvent-JSON-LD</b><button class="zu" type="button" id="ld-zu" aria-label="Schließen">×</button></header>
  <pre id="ld-text"></pre>
</dialog>

<div class="pult">
  <div class="zeile">Variante
    <button type="button" data-v="tour" aria-pressed="true">Tour</button>
    <button type="button" data-v="label" aria-pressed="false">Label</button>
    <button type="button" data-v="ausstellungen" aria-pressed="false">Ausstellungen</button>
  </div>
  <div class="zeile">Daten
    <button type="button" data-daten="ok" aria-pressed="true">Liste</button>
    <button type="button" data-daten="leer" aria-pressed="false">Leer</button>
    <button type="button" data-daten="fehler" aria-pressed="false">Importfehler</button>
    <button type="button" id="ld-auf">JSON-LD</button>
  </div>
  <div class="werte">Klappen <b>300 / 200 ms</b> · Menü <b>250 / 170 ms</b> · expo.out / expo.in · Taste <b>steps(2) 160 ms</b> · Stand <b>Mo 02.11.2026</b></div>
</div>
<svg class="zeiger" id="zeiger" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 2l16 9-7 1.8L8.4 21z" fill="#eeebe3" stroke="#111113" stroke-width="1.6" stroke-linejoin="round"/></svg>

<!-- Daten: im Einsatz eine Datei (termine.json) oder ein Import aus Bandsintown/Songkick, hier eingebettet -->
<script type="application/json" id="termine-json">
{
  "tour": {
    "act": "Zinkweiß", "domain": "zinkweiss.example", "stand": "2026-10-30T18:20",
    "label": "Live · Kaltes Walzwerk", "titel": "Termine 2027",
    "vorspann": "Kaltes Walzwerk live: zwölf Hallen im Frühjahr, vier Festivals im Sommer. Tickets gibt es nur bei den Anbietern, die hier stehen.",
    "gruppen": [
      { "id": "fruehjahr", "titel": "Tour Frühjahr 2027", "unter": "Mit Liska Tamm, im Ausland mit Doppelschicht", "aera": "Kaltes Walzwerk" },
      { "id": "festivals", "titel": "Festivals Sommer 2027", "unter": "Spielzeiten folgen mit dem Timetable" }
    ],
    "termine": [
      { "id": "oberhausen-2026", "gruppe": "vergangen", "start": "2026-09-05T20:00", "stadt": "Oberhausen", "land": "DE", "ort": "Turbinensaal", "status": "vorbei" },
      { "id": "bochum-2026", "gruppe": "vergangen", "start": "2026-09-12T20:00", "stadt": "Bochum", "land": "DE", "ort": "Club Unterwerk", "status": "vorbei" },
      { "id": "bielefeld-2026", "gruppe": "vergangen", "start": "2026-09-25T20:00", "stadt": "Bielefeld", "land": "DE", "ort": "Kesselraum", "status": "vorbei" },
      { "id": "kassel-2026", "gruppe": "vergangen", "start": "2026-10-03T20:00", "stadt": "Kassel", "land": "DE", "ort": "Halle Drei", "status": "vorbei" },
      { "id": "hannover-2026", "gruppe": "vergangen", "start": "2026-10-16T20:00", "stadt": "Hannover", "land": "DE", "ort": "Am Gleis 4", "status": "vorbei" },
      { "id": "rotterdam-2026", "gruppe": "vergangen", "start": "2026-10-24T20:00", "stadt": "Rotterdam", "land": "NL", "ort": "Werkplaats Noord", "status": "vorbei" },
      { "id": "bremen-2027", "gruppe": "fruehjahr", "start": "2027-02-18T20:00", "stadt": "Bremen", "land": "DE", "ort": "Zollkantine", "support": "Liska Tamm", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "hamburg-2027", "gruppe": "fruehjahr", "start": "2027-02-19T20:00", "stadt": "Hamburg", "land": "DE", "ort": "Kai Sieben", "support": "Liska Tamm", "status": "knapp", "anbieter": "Kartenhaus" },
      { "id": "berlin-2027", "gruppe": "fruehjahr", "start": "2027-02-20T20:00", "stadt": "Berlin", "land": "DE", "ort": "Spreespeicher Halle B", "support": "Liska Tamm", "status": "aus", "anbieter": "Kartenhaus", "resale": true },
      { "id": "berlin-2027-zusatz", "gruppe": "fruehjahr", "start": "2027-02-21T20:00", "stadt": "Berlin", "land": "DE", "ort": "Spreespeicher Halle B", "support": "Liska Tamm", "status": "zusatz", "anbieter": "Kartenhaus" },
      { "id": "leipzig-2027", "gruppe": "fruehjahr", "start": "2027-02-25T20:00", "vorher": "2027-02-11T20:00", "stadt": "Leipzig", "land": "DE", "ort": "Gleishalle West", "support": "Liska Tamm", "status": "verlegt", "verfuegbar": "tickets", "anbieter": "Billettwerk" },
      { "id": "dresden-2027", "gruppe": "fruehjahr", "start": "2027-02-26T20:00", "stadt": "Dresden", "land": "DE", "ort": "Elbkeller Neustadt", "support": "Liska Tamm", "status": "tickets", "anbieter": "Billettwerk" },
      { "id": "prag-2027", "gruppe": "fruehjahr", "start": "2027-02-27T20:00", "stadt": "Prag", "land": "CZ", "ort": "Klub Rozvodna", "support": "Doppelschicht", "status": "tickets", "anbieter": "Billettwerk" },
      { "id": "wien-2027", "gruppe": "fruehjahr", "start": "2027-03-04T20:00", "stadt": "Wien", "land": "AT", "ort": "Remise Simmering", "support": "Doppelschicht", "status": "tickets", "anbieter": "Tixmarkt" },
      { "id": "zuerich-2027", "gruppe": "fruehjahr", "start": "2027-03-05T20:30", "stadt": "Zürich", "land": "CH", "ort": "Kranhalle", "support": "Doppelschicht", "status": "knapp", "anbieter": "Tixmarkt" },
      { "id": "muenchen-2027", "gruppe": "fruehjahr", "start": "2027-03-06T20:00", "stadt": "München", "land": "DE", "ort": "Bahnwärterhalle", "support": "Liska Tamm", "status": "abgesagt", "anbieter": "Kartenhaus" },
      { "id": "koeln-2027", "gruppe": "fruehjahr", "start": "2027-03-12T20:00", "stadt": "Köln", "land": "DE", "ort": "Lagerhaus Deutz", "support": "Liska Tamm", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "duisburg-2027", "gruppe": "fruehjahr", "start": "2027-03-13T20:00", "stadt": "Duisburg", "land": "DE", "ort": "Gießhalle Ruhrort", "support": "Liska Tamm", "status": "aus", "anbieter": "Kartenhaus", "notiz": "Heimspiel. Der Veranstalter bietet keinen offiziellen Weiterverkauf an." },
      { "id": "haldenrauschen-2027", "gruppe": "festivals", "start": "2027-06-11", "ende": "2027-06-13", "stadt": "Bottrop", "land": "DE", "ort": "Haldenrauschen Festival", "status": "festival", "festival": "Haldenrauschen" },
      { "id": "wattklang-2027", "gruppe": "festivals", "start": "2027-07-03", "stadt": "Cuxhaven", "land": "DE", "ort": "Wattklang Open Air", "status": "festival", "festival": "Wattklang" },
      { "id": "grachtgeluid-2027", "gruppe": "festivals", "start": "2027-07-16", "ende": "2027-07-18", "stadt": "Utrecht", "land": "NL", "ort": "Grachtgeluid", "status": "festival", "festival": "Grachtgeluid" },
      { "id": "havnelyd-2027", "gruppe": "festivals", "start": "2027-08-07", "stadt": "Aarhus", "land": "DK", "ort": "Havnelyd Festival", "status": "festival", "festival": "Havnelyd" }
    ]
  },
  "label": {
    "act": "Ruhrort Tonträger", "domain": "ruhrort-tontraeger.example", "stand": "2026-10-30T18:20",
    "label": "Label · Duisburg-Ruhrort", "titel": "Termine aller Acts",
    "vorspann": "Wo Zinkweiß, Liska Tamm, Doppelschicht und Koksbach als Nächstes spielen. Filter nach Künstler:in oder Stadt, Tickets direkt beim Anbieter.",
    "gruppen": [],
    "termine": [
      { "id": "l-1", "gruppe": "vergangen", "kuenstler": "Liska Tamm", "start": "2026-10-09T20:00", "stadt": "Bonn", "land": "DE", "ort": "Kleiner Saal am Rhein", "status": "vorbei" },
      { "id": "l-2", "gruppe": "vergangen", "kuenstler": "Koksbach", "start": "2026-10-17T23:00", "stadt": "Dortmund", "land": "DE", "ort": "Halle am Hafen", "status": "vorbei" },
      { "id": "l-3", "gruppe": "vergangen", "kuenstler": "Doppelschicht", "start": "2026-10-23T21:00", "stadt": "Gent", "land": "BE", "ort": "Zaal Oost", "status": "vorbei" },
      { "id": "l-4", "gruppe": "vergangen", "kuenstler": "Zinkweiß", "start": "2026-10-24T20:00", "stadt": "Rotterdam", "land": "NL", "ort": "Werkplaats Noord", "status": "vorbei" },
      { "id": "l-5", "kuenstler": "Koksbach", "start": "2026-11-06T23:00", "stadt": "Essen", "land": "DE", "ort": "Zentrale Nord", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "l-6", "kuenstler": "Liska Tamm", "start": "2026-11-07T20:00", "stadt": "Münster", "land": "DE", "ort": "Speicherbühne", "status": "knapp", "anbieter": "Billettwerk" },
      { "id": "l-7", "kuenstler": "Doppelschicht", "start": "2026-11-12T21:00", "stadt": "Amsterdam", "land": "NL", "ort": "Studio Westerdok", "status": "tickets", "anbieter": "Tixmarkt" },
      { "id": "l-8", "kuenstler": "Koksbach", "start": "2026-11-13T23:30", "stadt": "Berlin", "land": "DE", "ort": "Kellerclub Ost", "status": "aus", "anbieter": "Kartenhaus" },
      { "id": "l-9", "kuenstler": "Liska Tamm", "start": "2026-11-14T20:00", "stadt": "Hamburg", "land": "DE", "ort": "Kai Sieben, Kajüte", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "l-10", "kuenstler": "Doppelschicht", "start": "2026-11-20T21:00", "stadt": "Köln", "land": "DE", "ort": "Lagerhaus Deutz", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "l-11", "kuenstler": "Koksbach", "start": "2026-11-21T23:00", "stadt": "Leipzig", "land": "DE", "ort": "Gleishalle West", "status": "tickets", "anbieter": "Billettwerk" },
      { "id": "l-12", "kuenstler": "Liska Tamm", "start": "2026-11-26T20:00", "stadt": "Wien", "land": "AT", "ort": "Café Nachtfalter", "status": "tickets", "anbieter": "Tixmarkt" },
      { "id": "l-13", "kuenstler": "Zinkweiß", "start": "2026-11-28T20:00", "stadt": "Duisburg", "land": "DE", "ort": "Gießhalle Ruhrort", "status": "aus", "anbieter": "Kartenhaus", "resale": true },
      { "id": "l-14", "kuenstler": "Doppelschicht", "start": "2026-12-04T21:00", "vorher": "2026-11-27T21:00", "stadt": "Brüssel", "land": "BE", "ort": "Atelier Midi", "status": "verlegt", "verfuegbar": "tickets", "anbieter": "Tixmarkt" },
      { "id": "l-15", "kuenstler": "Koksbach", "start": "2026-12-05T23:00", "stadt": "Köln", "land": "DE", "ort": "Club Achterdeck", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "l-16", "kuenstler": "Doppelschicht", "start": "2026-12-12T21:00", "stadt": "Berlin", "land": "DE", "ort": "Spreespeicher Halle B", "status": "abgesagt", "anbieter": "Kartenhaus" },
      { "id": "l-17", "kuenstler": "Zinkweiß", "start": "2026-12-19T20:00", "stadt": "Duisburg", "land": "DE", "ort": "Gießhalle Ruhrort", "status": "zusatz", "anbieter": "Kartenhaus" },
      { "id": "l-18", "kuenstler": "Koksbach", "start": "2027-01-15T23:00", "stadt": "Zürich", "land": "CH", "ort": "Kranhalle", "status": "tickets", "anbieter": "Tixmarkt" },
      { "id": "l-19", "kuenstler": "Liska Tamm", "start": "2027-01-16T20:00", "stadt": "Leipzig", "land": "DE", "ort": "Gleishalle West, Studio", "status": "knapp", "anbieter": "Billettwerk" },
      { "id": "l-20", "kuenstler": "Doppelschicht", "start": "2027-01-22T21:00", "stadt": "Hamburg", "land": "DE", "ort": "Kai Sieben", "status": "tickets", "anbieter": "Kartenhaus" },
      { "id": "l-21", "kuenstler": "Koksbach", "start": "2027-01-29", "ende": "2027-01-31", "stadt": "Rotterdam", "land": "NL", "ort": "Havenvuur Winterfestival", "status": "festival", "festival": "Havenvuur" },
      { "id": "l-22", "kuenstler": "Zinkweiß", "start": "2027-01-30T20:00", "stadt": "Münster", "land": "DE", "ort": "Speicherbühne", "status": "tickets", "anbieter": "Billettwerk" }
    ]
  },
  "ausstellungen": {
    "act": "Ida Rheinfeld", "domain": "idarheinfeld.example", "stand": "2026-10-30T18:20",
    "label": "Ausstellungen · Skulptur", "titel": "Ausstel\u00adlungen",
    "vorspann": "Ida Rheinfeld arbeitet in Duisburg mit Stahl, Gips und Gewicht. Laufende und kommende Ausstellungen, dazu eine Auswahl der vergangenen.",
    "gruppen": [
      { "id": "aktuell", "titel": "Aktuell", "unter": "Jetzt zu sehen" },
      { "id": "kommend", "titel": "Kommend", "unter": "Eröffnungen 2027" }
    ],
    "termine": [
      { "id": "a-1", "gruppe": "aktuell", "titel": "Gewicht der Luft", "art": "Einzelausstellung", "start": "2026-09-12", "ende": "2027-01-10", "haus": "Kunstverein Ruhrort", "stadt": "Duisburg", "land": "DE", "status": "laeuft" },
      { "id": "a-2", "gruppe": "aktuell", "titel": "Material und Mühe", "art": "Gruppenausstellung", "start": "2026-10-03", "ende": "2027-02-28", "vorher_ende": "2027-01-31", "haus": "Halle voor Heden", "stadt": "Gent", "land": "BE", "status": "laeuft" },
      { "id": "a-3", "gruppe": "kommend", "titel": "Fallhöhe", "art": "Einzelausstellung", "start": "2027-03-05", "ende": "2027-05-30", "eroeffnung": "2027-03-04T19:00", "haus": "Kunsthaus am Kanal", "stadt": "Basel", "land": "CH", "status": "kommend" },
      { "id": "a-4", "gruppe": "kommend", "titel": "Schwer, leicht", "art": "Gruppenausstellung", "start": "2027-06-18", "ende": "2027-09-26", "eroeffnung": "2027-06-17T19:00", "haus": "Seehalle", "stadt": "Konstanz", "land": "DE", "status": "kommend" },
      { "id": "a-5", "gruppe": "vergangen", "titel": "Lastenheft", "art": "Einzelausstellung", "start": "2026-03-14", "ende": "2026-06-21", "haus": "Städtische Galerie im Hafenamt", "stadt": "Bremerhaven", "land": "DE", "status": "vorbei" },
      { "id": "a-6", "gruppe": "vergangen", "titel": "Zwischen den Schichten", "art": "Gruppenausstellung", "start": "2025-11-08", "ende": "2026-02-15", "haus": "Kunsthalle Westkai", "stadt": "Rotterdam", "land": "NL", "status": "vorbei" },
      { "id": "a-7", "gruppe": "vergangen", "titel": "Gips, gegossen", "art": "Einzelausstellung", "start": "2025-05-10", "ende": "2025-08-03", "haus": "Raum für Plastik", "stadt": "Kassel", "land": "DE", "status": "vorbei" },
      { "id": "a-8", "gruppe": "vergangen", "titel": "Neue Härte", "art": "Gruppenausstellung", "start": "2024-09-20", "ende": "2025-01-12", "haus": "Forum Stahl", "stadt": "Linz", "land": "AT", "status": "vorbei" },
      { "id": "a-9", "gruppe": "vergangen", "titel": "Ballast", "art": "Einzelausstellung", "start": "2024-04-06", "ende": "2024-06-30", "haus": "Kunstverein Ruhrort", "stadt": "Duisburg", "land": "DE", "status": "vorbei" },
      { "id": "a-10", "gruppe": "vergangen", "titel": "Förderpreis Bildhauerei", "art": "Gruppenausstellung", "start": "2023-10-14", "ende": "2023-12-10", "haus": "Kunstsammlung am Markt", "stadt": "Mülheim an der Ruhr", "land": "DE", "status": "vorbei" }
    ]
  }
}
</script>
<script type="application/ld+json" id="jsonld">[]</script>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = (s, el = document) => el.querySelector(s), $$ = (s, el = document) => [...el.querySelectorAll(s)];
  const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const DATEN = JSON.parse($('#termine-json').textContent);
  // Demo-Stand fest, damit „vergangen“ stabil bleibt. Im Einsatz: new Date()
  const HEUTE = '2026-11-02';
  const VARIANTEN = ['tour', 'label', 'ausstellungen'];
  const FILTER_AB = 15;
  const LAENDER = { DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz', CZ: 'Tschechien', NL: 'Niederlande', BE: 'Belgien', DK: 'Dänemark' };
  const STATUS = { tickets: 'Im Vorverkauf', knapp: 'Wenige Karten', aus: 'Ausverkauft', zusatz: 'Zusatzkonzert', verlegt: 'Verlegt', abgesagt: 'Abgesagt', festival: 'Festival', vorbei: 'Vorbei', laeuft: 'Läuft', kommend: 'Kommend' };

  /* ---------- Datum: Kalenderrechnung in UTC, Anzeige mit Intl ---------- */
  const teil = s => { const [d, z = '00:00'] = s.split('T'); const [j, m, t] = d.split('-').map(Number); const [h, mi] = z.split(':').map(Number); return new Date(Date.UTC(j, m - 1, t, h, mi)); };
  const F = o => new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', ...o });
  const fWt = F({ weekday: 'short' }), fWtLang = F({ weekday: 'long' }), fDatum = F({ day: '2-digit', month: '2-digit', year: 'numeric' }), fLang = F({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), fMonat = F({ month: 'long', year: 'numeric' }), fKurz = F({ day: '2-digit', month: '2-digit' });
  const wt = d => fWt.format(d).replace('.', '');
  const datum = s => fDatum.format(teil(s));
  const uhr = s => s.includes('T') ? s.split('T')[1] : '';
  const monatId = s => s.slice(0, 7);
  const vorbei = t => (t.ende || t.start).slice(0, 10) < HEUTE;
  // Sommerzeit: letzter Sonntag im März bis letzter Sonntag im Oktober (für die Offsets im JSON-LD)
  const letzterSonntag = (j, m) => { const d = new Date(Date.UTC(j, m, 0)); return d.getUTCDate() - d.getUTCDay(); };
  const offset = s => { const d = teil(s), j = d.getUTCFullYear(), m = d.getUTCMonth() + 1, t = d.getUTCDate(); const sommer = (m > 3 && m < 10) || (m === 3 && t >= letzterSonntag(j, 3)) || (m === 10 && t < letzterSonntag(j, 10)); return sommer ? '+02:00' : '+01:00'; };

  /* ---------- Zustand ---------- */
  const q = new URLSearchParams(location.search);
  const zustand = { v: VARIANTEN.includes(q.get('v')) ? q.get('v') : 'tour', daten: 'ok', filter: { land: new Set(), monat: new Set(), kuenstler: new Set(), stadt: new Set(), naehe: null }, offen: false };
  const D = () => DATEN[zustand.v];
  const kommend = () => zustand.daten === 'leer' ? [] : D().termine.filter(t => !vorbei(t));
  const vergangene = () => D().termine.filter(vorbei);
  const urlVon = t => `https://${D().domain}/live/${t.id}`;
  const anbieterUrl = t => `https://${(t.anbieter || 'kartenhaus').toLowerCase().replace(/[^a-z]/g, '')}.example/event/${t.id}`;
  const festivalUrl = t => `https://${t.festival.toLowerCase().replace(/[^a-z]/g, '')}.example/`;
  const ortUrl = t => `https://${D().domain}/orte/${t.id}`;

  /* ---------- Entfernung für „In meiner Nähe“: nur eingegebene Stadt oder PLZ, nie Gerätestandort ---------- */
  const ORTE = { bremen: [53.08, 8.8], hamburg: [53.55, 9.99], berlin: [52.52, 13.4], leipzig: [51.34, 12.37], dresden: [51.05, 13.74], prag: [50.08, 14.44], wien: [48.21, 16.37], 'zürich': [47.38, 8.54], 'münchen': [48.14, 11.58], 'köln': [50.94, 6.96], duisburg: [51.43, 6.76], bottrop: [51.52, 6.92], cuxhaven: [53.87, 8.7], utrecht: [52.09, 5.12], aarhus: [56.16, 10.2], essen: [51.46, 7.01], dortmund: [51.51, 7.47], 'düsseldorf': [51.23, 6.78], bochum: [51.48, 7.22], 'münster': [51.96, 7.63], hannover: [52.37, 9.73], frankfurt: [50.11, 8.68], stuttgart: [48.78, 9.18], 'nürnberg': [49.45, 11.08], kassel: [51.31, 9.48], bielefeld: [52.03, 8.53], oberhausen: [51.47, 6.85], amsterdam: [52.37, 4.9], rotterdam: [51.92, 4.48], 'brüssel': [50.85, 4.35], kiel: [54.32, 10.13], rostock: [54.09, 12.1], erfurt: [50.98, 11.03], mannheim: [49.49, 8.47], freiburg: [47.99, 7.85], bonn: [50.74, 7.1], aachen: [50.78, 6.08], wuppertal: [51.26, 7.15], 'osnabrück': [52.28, 8.05], basel: [47.56, 7.59], bern: [46.95, 7.45], graz: [47.07, 15.44], linz: [48.31, 14.29], gent: [51.05, 3.72], kopenhagen: [55.68, 12.57] };
  // Postleitzahl: zwei Ziffern für große Regionen, sonst die Leitzone (grob, Hinweis „ungefähr“)
  const PLZ2 = { '01': 'dresden', '04': 'leipzig', '10': 'berlin', '12': 'berlin', '13': 'berlin', '20': 'hamburg', '22': 'hamburg', '28': 'bremen', '30': 'hannover', '40': 'düsseldorf', '44': 'dortmund', '45': 'essen', '46': 'oberhausen', '47': 'duisburg', '48': 'münster', '50': 'köln', '51': 'köln', '53': 'bonn', '60': 'frankfurt', '70': 'stuttgart', '80': 'münchen', '81': 'münchen', '90': 'nürnberg' };
  const PLZ1 = ['leipzig', 'berlin', 'hamburg', 'hannover', 'essen', 'köln', 'frankfurt', 'stuttgart', 'münchen', 'nürnberg'];
  function finde(eingabe) {
    const e = eingabe.trim().toLowerCase();
    if (/^\d{5}$/.test(e)) { const n = PLZ2[e.slice(0, 2)] || PLZ1[+e[0]]; return { name: 'PLZ ' + e, ort: ORTE[n], grob: true }; }
    const n = Object.keys(ORTE).find(k => k === e || k.replace(/ü/g, 'ue').replace(/ö/g, 'oe') === e);
    return n ? { name: n[0].toUpperCase() + n.slice(1), ort: ORTE[n] } : null;
  }
  const km = (a, b) => { const r = x => x * Math.PI / 180, dl = r(b[0] - a[0]), dn = r(b[1] - a[1]); const h = Math.sin(dl / 2) ** 2 + Math.cos(r(a[0])) * Math.cos(r(b[0])) * Math.sin(dn / 2) ** 2; return 6371 * 2 * Math.asin(Math.sqrt(h)); };
  const abstand = t => { const n = zustand.filter.naehe, o = ORTE[t.stadt.toLowerCase()]; return n && o ? km(n.ort, o) : null; };

  /* ---------- Filter ---------- */
  function passt(t, ohne) {
    const f = zustand.filter;
    if (ohne !== 'land' && f.land.size && !f.land.has(t.land)) return false;
    if (ohne !== 'monat' && f.monat.size && !f.monat.has(monatId(t.start))) return false;
    if (ohne !== 'kuenstler' && f.kuenstler.size && !f.kuenstler.has(t.kuenstler)) return false;
    if (ohne !== 'stadt' && f.stadt.size && !f.stadt.has(t.stadt)) return false;
    if (ohne !== 'naehe' && f.naehe) { const a = abstand(t); if (a == null || a > f.naehe.radius) return false; }
    return true;
  }
  const facettenFuer = () => zustand.v === 'label' ? ['kuenstler', 'stadt', 'naehe'] : ['land', 'monat', 'naehe'];
  const NAMEN = { land: 'Land', monat: 'Monat', kuenstler: 'Künstler:in', stadt: 'Stadt', naehe: 'In meiner Nähe' };
  const wert = (art, t) => art === 'monat' ? monatId(t.start) : t[art];
  const wertName = (art, w) => art === 'land' ? `${LAENDER[w]} (${w})` : art === 'monat' ? fMonat.format(teil(w + '-01')) : w;

  /* ---------- Bausteine ---------- */
  const PFEIL = '<svg class="pfeil" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 2h8v8M12 2 2 12" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
  const KONTUR = '<svg class="kontur__svg" aria-hidden="true" focusable="false"><rect width="100%" height="100%"/></svg>';
  const MEHR = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h.01M10 10h.01M16 10h.01" stroke="currentColor" stroke-width="3" stroke-linecap="square"/></svg>';
  const neuerTab = '<span class="sr">, öffnet in neuem Tab</span>';

  function datumZelle(t) {
    if (zustand.v === 'ausstellungen') {
      return `<div class="t-datum"><span class="wt">${wt(teil(t.start))} – ${wt(teil(t.ende))}</span>
        <span><time datetime="${t.start}">${datum(t.start)}</time> –</span><time datetime="${t.ende}">${datum(t.ende)}</time></div>`;
    }
    const d = teil(t.start), mehrtaegig = t.ende && t.ende !== t.start;
    let s = `<div class="t-datum"><span class="wt">${mehrtaegig ? wt(d) + '–' + wt(teil(t.ende)) : wt(d)}</span>`;
    s += mehrtaegig ? `<time datetime="${t.start}/${t.ende}">${fKurz.format(d)}.–${datum(t.ende)}</time>` : `<time datetime="${t.start}">${datum(t.start)}</time>`;
    if (uhr(t.start)) s += `<span class="uhr">Beginn ${uhr(t.start)}</span>`;
    if (t.vorher) s += `<span class="alt">verlegt vom <del><time datetime="${t.vorher}">${wt(teil(t.vorher))} ${datum(t.vorher)}</time></del></span>`;
    return s + '</div>';
  }
  function statusZelle(t) {
    if (zustand.v === 'ausstellungen') {
      let s = `<div class="t-status"><span class="status ${t.status}">${STATUS[t.status]}</span><span class="art">${t.art}</span>`;
      if (t.vorher_ende) s += `<small>verlängert, statt bis <del>${datum(t.vorher_ende)}</del></small>`;
      if (t.eroeffnung) s += `<small>Eröffnung ${wt(teil(t.eroeffnung))} ${datum(t.eroeffnung)}, ${uhr(t.eroeffnung)} Uhr</small>`;
      return s + '</div>';
    }
    let s = `<div class="t-status">`;
    if (t.status === 'zusatz') s += `<span class="status zusatz">Zusatzkonzert <i>neu</i></span>`;
    else s += `<span class="status ${t.status}">${STATUS[t.status]}</span>`;
    if (t.status === 'verlegt' && t.verfuegbar) s += `<small>Neues Datum, Tickets im Verkauf</small>`;
    return s + '</div>';
  }
  function ortZelle(t) {
    const a = abstand(t), dist = a != null ? `<span class="dist">ca. ${Math.max(10, Math.round(a / 10) * 10)} km</span>` : '';
    if (zustand.v === 'ausstellungen') {
      return `<div class="t-ort"><h4 class="titel">${esc(t.titel)}</h4>
        <p class="ort"><a href="${ortUrl(t)}" target="_blank" rel="noopener">${esc(t.haus)}${neuerTab}</a>, ${esc(t.stadt)} <abbr title="${LAENDER[t.land]}">(${t.land})</abbr></p></div>`;
    }
    const stadt = `<${zustand.v === 'label' ? 'p' : 'h4'} class="stadt">${esc(t.stadt)}<abbr title="${LAENDER[t.land]}">${t.land}</abbr></${zustand.v === 'label' ? 'p' : 'h4'}>`;
    let ort = t.status === 'festival' ? `<a href="${festivalUrl(t)}" target="_blank" rel="noopener">${esc(t.ort)}${neuerTab}</a>` : `<a href="${ortUrl(t)}" target="_blank" rel="noopener">${esc(t.ort)}${neuerTab}</a>`;
    if (t.support) ort += ` <span class="support">· mit ${esc(t.support)}</span>`;
    let notiz = '';
    if (t.status === 'verlegt') notiz = `<p class="notiz"><span class="platzhalter">Bereits gekaufte Tickets gelten für den neuen Termin. Wer nicht kommen kann, bekommt das Geld bei ${esc(t.anbieter)} zurück.</span></p>`;
    if (t.status === 'abgesagt') notiz = `<p class="notiz">Der Termin fällt aus. Das Geld für Tickets gibt es dort zurück, wo du gekauft hast (online: ${esc(t.anbieter)}). <span class="platzhalter">Frist und Ablauf der Erstattung nach Angabe des Veranstalters.</span></p>`;
    if (t.notiz) notiz = `<p class="notiz">${esc(t.notiz)}</p>`;
    return `<div class="t-ort">${stadt}<p class="ort">${ort}${dist}</p>${notiz}</div>`;
  }
  function aktionZelle(t) {
    const wann = `${t.stadt} am ${datum(t.start)}`;
    let haupt = '';
    if (zustand.v === 'ausstellungen') {
      haupt = `<a class="kontur" href="${ortUrl(t)}" target="_blank" rel="noopener"><span class="knopf-text"><b>Zum Haus</b><small>${t.status === 'kommend' ? 'Eröffnung und Anfahrt' : 'Öffnungszeiten'}</small></span><span class="sr"> ${esc(t.haus)}, ${esc(t.stadt)}</span>${neuerTab}${PFEIL}${KONTUR}</a>`;
    } else if (t.status === 'festival') {
      haupt = `<a class="kontur" href="${festivalUrl(t)}" target="_blank" rel="noopener"><span class="knopf-text"><b>Zum Festival</b><small>Line-up und Tickets</small></span><span class="sr"> ${esc(t.festival)} in ${esc(t.stadt)}</span>${neuerTab}${PFEIL}${KONTUR}</a>`;
    } else if (t.status === 'aus') {
      haupt = t.resale
        ? `<a class="kontur" href="${anbieterUrl(t)}/resale" target="_blank" rel="noopener"><span class="knopf-text"><b>Resale</b><span class="sr"> für ${esc(wann)}</span><small>offiziell · ${esc(t.anbieter)}</small></span>${neuerTab}${PFEIL}${KONTUR}</a>`
        : `<p class="keine">Keine Karten mehr</p>`;
    } else if (t.status === 'abgesagt') {
      haupt = `<a class="kontur" href="${anbieterUrl(t)}/erstattung" target="_blank" rel="noopener"><span class="knopf-text"><b>Erstattung</b><span class="sr"> für ${esc(wann)}</span><small>bei ${esc(t.anbieter)}</small></span>${neuerTab}${PFEIL}${KONTUR}</a>`;
    } else {
      haupt = `<a class="kontur" href="${anbieterUrl(t)}" target="_blank" rel="noopener"><span class="knopf-text"><b>Tickets</b><span class="sr"> für ${esc(wann)}</span><small>bei ${esc(t.anbieter)}</small></span>${neuerTab}${PFEIL}${KONTUR}</a>`;
    }
    const name = zustand.v === 'ausstellungen' ? `${t.titel}, ${t.stadt}` : `${t.kuenstler ? t.kuenstler + ', ' : ''}${t.stadt}, ${datum(t.start)}`;
    return `<div class="t-aktion">${haupt}<button class="mehr" type="button" popovertarget="m-${t.id}" aria-label="Mehr zu ${esc(name)}" aria-haspopup="menu">${MEHR}</button></div>`;
  }
  function menue(t) {
    const kalender = zustand.v === 'ausstellungen' ? (t.eroeffnung ? 'Eröffnung in den Kalender (.ics)' : 'Zeitraum in den Kalender (.ics)') : 'In den Kalender (.ics)';
    const ics = t.status !== 'abgesagt' ? `<button type="button" role="menuitem" data-ics="${t.id}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5h12V14H2zM2 6.5h12M5 1.5v3M11 1.5v3" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>${kalender}</button>` : '';
    return `<div class="pop menue" id="m-${t.id}" popover role="menu" aria-label="Aktionen">${ics}<button type="button" role="menuitem" data-teilen="${t.id}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 10V1.5M4.5 5 8 1.5 11.5 5M2.5 8v6h11V8" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>Teilen</button></div>`;
  }
  function zeile(t) {
    const k = zustand.v === 'label' ? `<p class="t-kuenstler"><a href="https://${D().domain}/acts/${t.kuenstler.toLowerCase().replace(/[^a-z]/g, '')}">${esc(t.kuenstler)}</a></p>` : '';
    return `<li class="termin" data-status="${t.status}" id="t-${t.id}">${k}${datumZelle(t)}${ortZelle(t)}${statusZelle(t)}${aktionZelle(t)}</li>`;
  }
  function vergangenZeile(t) {
    const k = zustand.v === 'label' ? `<p class="t-kuenstler">${esc(t.kuenstler)}</p>` : '';
    const ort = zustand.v === 'ausstellungen'
      ? `<div class="t-ort"><h4 class="titel">${esc(t.titel)}</h4><p class="ort">${esc(t.haus)}, ${esc(t.stadt)} (${t.land}) · ${t.art}</p></div>`
      : `<div class="t-ort"><h4 class="stadt">${esc(t.stadt)}<abbr title="${LAENDER[t.land]}">${t.land}</abbr></h4><p class="ort">${esc(t.ort)}</p></div>`;
    return `<li class="termin" data-status="vorbei">${k}${datumZelle(t)}${ort}<div class="t-status"><span class="status vorbei">Vorbei</span></div><div class="t-aktion"></div></li>`;
  }

  /* ---------- Rendern ---------- */
  function gruppenVon(liste) {
    if (zustand.v === 'label') {   // nach Monat gruppiert, wie bei Label-Seiten üblich
      const m = [...new Set(liste.map(t => monatId(t.start)))];
      return m.map((id, i) => ({ id, titel: fMonat.format(teil(id + '-01')), nr: i + 1, termine: liste.filter(t => monatId(t.start) === id) }));
    }
    return D().gruppen.map((g, i) => ({ ...g, nr: i + 1, termine: liste.filter(t => t.gruppe === g.id) })).filter(g => g.termine.length);
  }
  function render() {
    const d = D(), alle = kommend(), sicht = alle.filter(t => passt(t));
    html.dataset.variante = zustand.v;
    $('#wortmarke').textContent = d.act;
    $('#kopf-label span').textContent = d.label;
    $('#kopf-titel').textContent = d.titel;
    const NAV = { tour: ['Musik', 'Live', 'Videos', 'Presse', 'Shop'], label: ['Acts', 'Releases', 'Termine', 'Shop', 'Kontakt'], ausstellungen: ['Werke', 'Ausstellungen', 'Texte', 'CV', 'Kontakt'] }[zustand.v];
    const jetzt = { tour: 'Live', label: 'Termine', ausstellungen: 'Ausstellungen' }[zustand.v];
    $('#nav').innerHTML = NAV.map(n => `<li${n === jetzt ? ' class="jetzt"' : ''}><a href="#live"${n === jetzt ? ' aria-current="page"' : ''}>${n}</a></li>`).join('');
    const wm = $('#wortmarke').style, n = d.act.length;
    wm.setProperty('--breite', n <= 9 ? '200%' : n <= 13 ? '160%' : '125%');
    wm.setProperty('--breite-s', n <= 9 ? '200%' : n <= 13 ? '125%' : '100%');
    wm.setProperty('--breite-xs', n <= 9 ? '150%' : n <= 13 ? '112.5%' : '100%');
    wm.setProperty('--wm-xs', n > 13 ? '1rem' : '1.125rem');
    $('#vorspann').textContent = d.vorspann;
    // Kopf-Aktionen: Abo als Haupthandlung (harte Taste), Stand der Daten
    const st = teil(zustand.daten === 'fehler' ? d.stand : HEUTE + 'T09:00');
    $('#kopf-aktionen').innerHTML = (zustand.v === 'ausstellungen' || !alle.length ? '' : `<button class="taste" type="button" popovertarget="abo" aria-haspopup="dialog"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5h12V14H2zM2 6.5h12M5 1.5v3M11 1.5v3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 8.5v3.5M6.25 10.25h3.5" stroke="currentColor" stroke-width="1.6"/></svg>Alle Termine abonnieren</button>`)
      + `<p class="stand">Stand ${wt(st)} ${fDatum.format(st)}, ${String(st.getUTCHours()).padStart(2, '0')}:${String(st.getUTCMinutes()).padStart(2, '0')} Uhr</p>`;
    $('#abo-text').textContent = `Dein Kalender fragt, ob er „${d.act} live“ abonnieren soll. Neue, verlegte und abgesagte Termine kommen dann von selbst.`;
    $('#abo-url').textContent = `webcal://${d.domain}/termine.ics`;
    // Importfehler: letzte gültige Liste mit Stand statt leerer Fläche
    $('#importfehler').hidden = zustand.daten !== 'fehler';
    $('#fehler-text').textContent = ` Die Liste konnte gerade nicht neu geladen werden. Du siehst den Stand vom ${datum(d.stand)}, ${uhr(d.stand)} Uhr. Im Zweifel gilt die Angabe beim Ticketanbieter.`;
    // Leerzustand
    $('#leer').hidden = alle.length > 0;
    $('#leer-titel').textContent = zustand.v === 'ausstellungen' ? 'Gerade keine Ausstellung.' : 'Gerade keine Termine.';
    $('#leer-text').textContent = zustand.v === 'ausstellungen' ? 'Neue Ausstellungen und Eröffnungen zuerst im Newsletter, höchstens eine Mail im Monat.' : 'Neue Termine zuerst im Newsletter: Vorverkauf eine Woche vor allen anderen, höchstens eine Mail im Monat.';
    // Filter erst ab etwa 15 Terminen
    const mitFilter = alle.length >= FILTER_AB;
    $('#filter').hidden = !mitFilter;
    if (mitFilter) renderFilter(alle, sicht);
    else { zustand.filter.land.clear(); zustand.filter.monat.clear(); zustand.filter.kuenstler.clear(); zustand.filter.stadt.clear(); zustand.filter.naehe = null; }
    // Gruppen
    const gr = gruppenVon(mitFilter ? sicht : alle);
    $('#gruppen').innerHTML = gr.map(g => `<section class="gruppe" aria-labelledby="g-${g.id}">
      <div class="gruppe-kopf"><h3 id="g-${g.id}"><span class="nr" aria-hidden="true">${String(g.nr).padStart(2, '0')}</span>${esc(g.titel)}</h3>
      <p>${g.aera ? `<em>${esc(g.aera)}</em>` : ''}${g.unter ? esc(g.unter) + ' · ' : ''}${g.termine.length} ${zustand.v === 'ausstellungen' ? (g.termine.length === 1 ? 'Ausstellung' : 'Ausstellungen') : (g.termine.length === 1 ? 'Termin' : 'Termine')}</p></div>
      <ol>${g.termine.map(zeile).join('')}</ol></section>`).join('');
    $('#pops').innerHTML = (mitFilter ? sicht : alle).map(menue).join('');
    $('#filter-leer').hidden = !(mitFilter && alle.length && !sicht.length);
    if (mitFilter && alle.length && !sicht.length) $('#filter-leer-text').textContent = `Für diese Auswahl gibt es gerade keinen Termin. Neue Termine kommen zuerst im Newsletter.`;
    // Vergangene
    const v = vergangene();
    $('#klapp-text').textContent = zustand.v === 'ausstellungen' ? `Vergangene Ausstellungen, Auswahl (${v.length})` : `Vergangene Termine (${v.length})`;
    $('#vergangen-liste').innerHTML = v.slice().sort((a, b) => b.start.localeCompare(a.start)).map(vergangenZeile).join('');
    jsonld(alle);
  }

  function renderFilter(alle, sicht) {
    const f = zustand.filter;
    $('#facetten').innerHTML = facettenFuer().map(art => {
      const n = art === 'naehe' ? (f.naehe ? 1 : 0) : f[art].size;
      return `<button class="facette${n ? ' aktiv' : ''}" type="button" popovertarget="f-${art}" aria-haspopup="dialog">${NAMEN[art]}${n && art !== 'naehe' ? ` <span class="anz">${n}</span>` : ''}<svg viewBox="0 0 12 12" aria-hidden="true"><path d="m2 4 4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>`;
    }).join('');
    $('#treffer').innerHTML = sicht.length === alle.length ? `<b>${alle.length}</b> ${zustand.v === 'label' ? 'Termine' : 'kommende Termine'}` : `<b>${sicht.length}</b> von ${alle.length} Terminen`;
    const chips = [];
    ['land', 'monat', 'kuenstler', 'stadt'].forEach(art => f[art].forEach(w => chips.push(`<button class="chip" type="button" data-weg="${art}" data-wert="${esc(w)}">${esc(wertName(art, w))}<span aria-hidden="true">×</span><span class="sr"> entfernen</span></button>`)));
    if (f.naehe) chips.push(`<button class="chip" type="button" data-weg="naehe">${f.naehe.radius} km um ${esc(f.naehe.name)}${f.naehe.grob ? ' (ungefähr)' : ''}<span aria-hidden="true">×</span><span class="sr"> entfernen</span></button>`);
    if (chips.length > 1) chips.push(`<button class="chip zurueck" type="button" data-zuruecksetzen>Alle zurücksetzen</button>`);
    $('#chips').innerHTML = chips.join('');
    // Popover je Facette (Anzahl je Wert berücksichtigt die anderen Filter)
    const pops = facettenFuer().map(art => {
      if (art === 'naehe') return `<div class="pop naehe-pop" id="f-naehe" popover aria-label="In meiner Nähe">
        <form class="naehe-form" id="naehe-form" novalidate>
          <span class="label">In meiner Nähe</span>
          <div class="feld"><label for="naehe-ort">Stadt oder Postleitzahl</label><input id="naehe-ort" name="ort" autocomplete="off" value="${f.naehe ? esc(f.naehe.eingabe) : ''}" aria-describedby="naehe-fehler naehe-hinweis"></div>
          <div class="feld"><label for="naehe-radius">Umkreis</label><select id="naehe-radius" name="radius">${[100, 150, 300].map(r => `<option value="${r}"${(f.naehe ? f.naehe.radius : 150) === r ? ' selected' : ''}>${r} km</option>`).join('')}</select></div>
          <p class="fehler" id="naehe-fehler" role="alert"></p>
          <p class="hinweis" id="naehe-hinweis">Wir fragen nicht nach deinem Standort. Die Entfernung ist Luftlinie.</p>
          <button class="kontur einzeilig" type="submit"><span>Termine zeigen</span>${KONTUR}</button>
        </form></div>`;
      const werte = [...new Set(alle.map(t => wert(art, t)))];
      if (art === 'stadt' || art === 'kuenstler') werte.sort((a, b) => a.localeCompare(b, 'de'));
      const basis = alle.filter(t => passt(t, art));
      return `<div class="pop" id="f-${art}" popover aria-label="${NAMEN[art]}"><span class="label">${NAMEN[art]}</span>
        ${art === 'kuenstler' ? `<div class="feld"><label for="such-k" class="sr">Künstler:in suchen</label><input class="suche" id="such-k" type="search" placeholder="Suchen" autocomplete="off"></div>` : ''}
        <div class="wahlen">${werte.map(w => { const n = basis.filter(t => wert(art, t) === w).length; return `<label class="wahl${n ? '' : ' leer'}" data-name="${esc(String(w).toLowerCase())}"><input type="checkbox" data-art="${art}" value="${esc(w)}"${f[art].has(w) ? ' checked' : ''}>${esc(wertName(art, w))}<span class="n">${n}</span></label>`; }).join('')}</div></div>`;
    }).join('');
    // offene Facette merken, damit sie nach dem Neuaufbau offen bleibt
    const offen = $('#filter-pops .pop:popover-open');
    const offenId = offen && offen.id, fokusWert = document.activeElement && document.activeElement.matches('#filter-pops input[type=checkbox]') ? document.activeElement.value : null;
    let box = $('#filter-pops'); if (!box) { box = document.createElement('div'); box.id = 'filter-pops'; document.body.append(box); }
    box.innerHTML = pops;
    if (offenId) { const p = $('#' + offenId); const b = $(`[popovertarget="${offenId}"]`); p.dataset.keinFokus = '1'; try { p.showPopover({ source: b }); } catch (e) { p.showPopover(); } lege(p, b); if (fokusWert) { const i = $$('input[type=checkbox]', p).find(x => x.value === fokusWert); i && i.focus(); } }
  }

  /* ---------- JSON-LD: nur sichtbare, kommende Termine ---------- */
  function jsonld(liste) {
    const d = D(), ev = { tickets: 'EventScheduled', knapp: 'EventScheduled', aus: 'EventScheduled', zusatz: 'EventScheduled', festival: 'EventScheduled', verlegt: 'EventRescheduled', abgesagt: 'EventCancelled', laeuft: 'EventScheduled', kommend: 'EventScheduled' };
    const verf = { tickets: 'InStock', zusatz: 'InStock', verlegt: 'InStock', knapp: 'LimitedAvailability', aus: 'SoldOut' };
    const zeit = s => s.includes('T') ? s + ':00' + offset(s) : s;
    const out = liste.map(t => {
      if (zustand.v === 'ausstellungen') return { '@context': 'https://schema.org', '@type': 'ExhibitionEvent', name: t.titel, startDate: t.start, endDate: t.ende, eventStatus: 'https://schema.org/EventScheduled', eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode', location: { '@type': 'Place', name: t.haus, address: { '@type': 'PostalAddress', addressLocality: t.stadt, addressCountry: t.land } }, url: ortUrl(t), organizer: { '@type': 'Organization', name: t.haus } };
      const o = { '@context': 'https://schema.org', '@type': 'MusicEvent', name: `${t.kuenstler || d.act} live in ${t.stadt}`, startDate: zeit(t.start), eventStatus: 'https://schema.org/' + ev[t.status], eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
        location: { '@type': 'Place', name: t.ort, address: { '@type': 'PostalAddress', addressLocality: t.stadt, addressCountry: t.land } },
        performer: { '@type': 'MusicGroup', name: t.kuenstler || d.act } };
      if (t.ende) o.endDate = zeit(t.ende);
      if (t.vorher) o.previousStartDate = zeit(t.vorher);
      if (t.status === 'festival') o.url = festivalUrl(t);
      else if (t.status !== 'abgesagt') o.offers = { '@type': 'Offer', url: anbieterUrl(t), availability: 'https://schema.org/' + verf[t.verfuegbar || t.status] };
      return o;
    });
    $('#jsonld').textContent = JSON.stringify(out);
    $('#ld-text').textContent = JSON.stringify(out, null, 2);
    $('#ld-titel').textContent = `${zustand.v === 'ausstellungen' ? 'ExhibitionEvent' : 'MusicEvent'}-JSON-LD · ${out.length} sichtbare Termine`;
  }

  /* ---------- ICS (RFC 5545) ---------- */
  const enc = new TextEncoder();
  const icsText = s => String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
  const falte = z => { let a = '', l = 0; for (const c of z) { const b = enc.encode(c).length; if (l + b > 75) { a += '\r\n '; l = 1; } a += c; l += b; } return a; };
  const lokal = s => s.replace(/[-:]/g, '').padEnd(13, '0').slice(0, 13) + '00';
  const tagWert = s => s.slice(0, 10).replace(/-/g, '');
  const plusTag = s => { const d = teil(s); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); };
  const VTZ = ['BEGIN:VTIMEZONE', 'TZID:Europe/Berlin', 'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0100', 'TZOFFSETTO:+0200', 'TZNAME:CEST', 'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 'END:DAYLIGHT', 'BEGIN:STANDARD', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0100', 'TZNAME:CET', 'DTSTART:19701025T030000', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 'END:STANDARD', 'END:VTIMEZONE'];
  function ics(liste) {
    const d = D(), z = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:-//${d.act}//Termine//DE`, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText(d.act + ' live'), ...VTZ];
    const stempel = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
    liste.filter(t => t.status !== 'abgesagt').forEach(t => {
      z.push('BEGIN:VEVENT', `UID:${t.id}@${d.domain}`, 'DTSTAMP:' + stempel);
      if (zustand.v === 'ausstellungen') {
        if (t.eroeffnung) { const e = new Date(teil(t.eroeffnung).getTime() + 3 * 36e5).toISOString().slice(0, 16); z.push('DTSTART;TZID=Europe/Berlin:' + lokal(t.eroeffnung), 'DTEND;TZID=Europe/Berlin:' + lokal(e), 'SUMMARY:' + icsText(`Eröffnung: ${t.titel} (${d.act})`)); }
        else z.push('DTSTART;VALUE=DATE:' + tagWert(t.start), 'DTEND;VALUE=DATE:' + tagWert(plusTag(t.ende)), 'SUMMARY:' + icsText(`${t.titel} (${d.act})`));
        z.push('LOCATION:' + icsText(`${t.haus}, ${t.stadt}`), 'URL:' + ortUrl(t));
      } else {
        if (uhr(t.start)) { const e = new Date(teil(t.start).getTime() + 3 * 36e5).toISOString().slice(0, 16); z.push('DTSTART;TZID=Europe/Berlin:' + lokal(t.start), 'DTEND;TZID=Europe/Berlin:' + lokal(e)); }
        else z.push('DTSTART;VALUE=DATE:' + tagWert(t.start), 'DTEND;VALUE=DATE:' + tagWert(plusTag(t.ende || t.start)));   // DTEND exklusiv
        z.push('SUMMARY:' + icsText(`${t.kuenstler || d.act} · ${t.stadt}${t.status === 'festival' ? ' · ' + t.ort : ''}`), 'LOCATION:' + icsText(`${t.ort}, ${t.stadt} (${t.land})`),
          'DESCRIPTION:' + icsText((t.support ? `Support: ${t.support}\n` : '') + (t.status === 'festival' ? `Line-up und Tickets: ${festivalUrl(t)}` : `Tickets: ${anbieterUrl(t)}`) + (t.vorher ? `\nVerlegt vom ${datum(t.vorher)}` : '')),
          'URL:' + urlVon(t));
      }
      z.push('END:VEVENT');
    });
    z.push('END:VCALENDAR');
    return z.map(falte).join('\r\n') + '\r\n';
  }
  function laden(liste, name) {
    const blob = new Blob([ics(liste)], { type: 'text/calendar;charset=utf-8' });
    if (!html.classList.contains('vorfuehrung')) {   // in der Vorführung keine echte Datei
      const u = URL.createObjectURL(blob), a = document.createElement('a');
      a.href = u; a.download = name; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 4000);
    }
    melde(name, `${liste.length} ${liste.length === 1 ? 'Termin' : 'Termine'} · ${(blob.size / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB · öffnet in Kalender, Outlook oder Google Kalender`);
  }

  /* ---------- Meldung ---------- */
  let toastUhr;
  function melde(titel, info) { const t = $('#toast'); $('b', t).textContent = titel; $('small', t).textContent = info; t.classList.add('an'); clearTimeout(toastUhr); toastUhr = setTimeout(() => t.classList.remove('an'), 3400); }

  /* ---------- Popover an den auslösenden Knopf legen ---------- */
  function lege(pop, knopf) {
    if (!pop || !knopf) return;
    const r = knopf.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight;
    let x = pop.classList.contains('menue') ? r.right - w : r.left, y = r.bottom + 6;
    if (y + h > innerHeight - 8 && r.top - h - 6 > 8) y = r.top - h - 6;
    pop.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, x))) + 'px';
    pop.style.top = Math.round(Math.max(8, y)) + 'px';
  }
  let offenesPop = null;
  document.addEventListener('toggle', e => {
    const pop = e.target; if (!pop.matches || !pop.matches('.pop')) return;
    const knopf = $(`[popovertarget="${pop.id}"]`);
    if (e.newState === 'open') {
      offenesPop = pop; lege(pop, knopf); knopf && knopf.setAttribute('aria-expanded', 'true');
      const erstes = pop.matches('.menue') ? $('button, a', pop) : $('input[type=search], input:not([type]), input[type=checkbox]', pop);
      if (pop.dataset.keinFokus) delete pop.dataset.keinFokus; else if (erstes && !html.classList.contains('vorfuehrung')) erstes.focus();
    } else { knopf && knopf.setAttribute('aria-expanded', 'false'); if (offenesPop === pop) offenesPop = null; }
  }, true);
  addEventListener('scroll', () => offenesPop && lege(offenesPop, $(`[popovertarget="${offenesPop.id}"]`)), { passive: true });
  addEventListener('resize', () => offenesPop && lege(offenesPop, $(`[popovertarget="${offenesPop.id}"]`)));
  // Menü: Pfeiltasten zwischen den Einträgen
  document.addEventListener('keydown', e => {
    const m = e.target.closest && e.target.closest('.menue[role=menu]'); if (!m) return;
    const items = $$('[role=menuitem]', m), i = items.indexOf(e.target);
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus(); }
    if (e.key === 'Home') { e.preventDefault(); items[0].focus(); } if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
  });

  /* ---------- Ereignisse ---------- */
  const finde_t = id => D().termine.find(t => t.id === id);
  document.addEventListener('click', e => {
    const ziel = e.target.closest('button, a'); if (!ziel) return;
    if (ziel.dataset.ics) { const t = finde_t(ziel.dataset.ics); ziel.closest('.pop').hidePopover(); laden([t], `${D().domain.split('.')[0]}-${t.id}.ics`); return; }
    if (ziel.dataset.teilen) { teilen(finde_t(ziel.dataset.teilen)); ziel.closest('.pop').hidePopover(); return; }
    if (ziel.dataset.weg) { const art = ziel.dataset.weg; if (art === 'naehe') zustand.filter.naehe = null; else zustand.filter[art].delete(ziel.dataset.wert); render(); return; }
    if (ziel.hasAttribute('data-zuruecksetzen')) { zuruecksetzen(); return; }
    // Demo: Links zu erfundenen Anbietern nicht öffnen, sondern erklären (im Einsatz entfernen)
    if (ziel.matches('a[target=_blank]')) { e.preventDefault(); melde('Öffnet beim Anbieter in neuem Tab', ziel.href.replace('https://', '')); }
    if (ziel.matches('.seite a, .t-kuenstler a, a[href="#datenschutz"]')) e.preventDefault();
  });
  document.addEventListener('change', e => {
    const i = e.target; if (!i.matches('input[type=checkbox][data-art]')) return;
    const s = zustand.filter[i.dataset.art]; i.checked ? s.add(i.value) : s.delete(i.value); render();
  });
  document.addEventListener('input', e => {
    if (e.target.id !== 'such-k') return;
    const q = e.target.value.trim().toLowerCase();
    $$('#f-kuenstler .wahl').forEach(w => w.hidden = q && !w.dataset.name.includes(q));
  });
  document.addEventListener('submit', e => {
    if (e.target.id === 'naehe-form') {
      e.preventDefault();
      const eing = $('#naehe-ort').value, r = +$('#naehe-radius').value, o = finde(eing);
      if (!eing.trim()) { $('#naehe-fehler').textContent = 'Bitte eine Stadt oder Postleitzahl eingeben.'; return; }
      if (!o) { $('#naehe-fehler').textContent = `„${eing}“ kennen wir nicht. Versuch eine größere Stadt in der Nähe oder eine Postleitzahl.`; return; }
      zustand.filter.naehe = { ...o, eingabe: eing, radius: r };
      $('#f-naehe').hidePopover(); render();
    }
    if (e.target.id === 'brief') {
      e.preventDefault();
      const m = $('#brief-mail'), out = $('#brief-meldung');
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.value)) { out.className = 'meldung-brief fehler'; out.textContent = 'Bitte eine gültige E-Mail-Adresse eingeben, zum Beispiel name@beispiel.de.'; m.setAttribute('aria-invalid', 'true'); m.focus(); return; }
      m.removeAttribute('aria-invalid'); out.className = 'meldung-brief'; out.textContent = `Fast geschafft: Bitte bestätige den Link in der Mail an ${m.value}.`;
    }
  });
  function teilen(t) {
    const url = urlVon(t), text = zustand.v === 'ausstellungen' ? `${t.titel}, ${t.haus}, ${t.stadt}` : `${t.kuenstler || D().act} live in ${t.stadt}, ${datum(t.start)}`;
    if (navigator.share && !html.classList.contains('vorfuehrung')) { navigator.share({ title: text, url }).catch(() => {}); return; }
    (navigator.clipboard ? navigator.clipboard.writeText(url) : Promise.reject()).catch(() => {});
    melde('Link kopiert', url.replace('https://', ''));
  }
  function zuruecksetzen() { const f = zustand.filter; f.land.clear(); f.monat.clear(); f.kuenstler.clear(); f.stadt.clear(); f.naehe = null; render(); }
  $('#abo-kopieren').addEventListener('click', () => { const u = $('#abo-url').textContent; (navigator.clipboard ? navigator.clipboard.writeText(u) : Promise.reject()).catch(() => {}); melde('Adresse kopiert', u); });
  $('#abo-alle').addEventListener('click', () => { $('#abo').hidePopover(); laden(kommend(), `${D().domain.split('.')[0]}-alle-termine.ics`); });
  $('#neu-laden').addEventListener('click', () => {
    const k = $('#neu-laden span'); k.textContent = 'Lädt …';
    setTimeout(() => { k.textContent = 'Neu laden'; daten('ok'); melde('Termine aktualisiert', `Stand ${datum(HEUTE)}, 09:00 Uhr`); }, 900);
  });
  // Vergangene Termine auf- und zuklappen
  function klappe(auf) {
    zustand.offen = auf; const b = $('#klapp'), box = $('#vergangen-inhalt');
    b.setAttribute('aria-expanded', String(auf)); $('#vergangen').classList.toggle('offen', auf);
    box.inert = !auf;
  }
  $('#klapp').addEventListener('click', () => klappe(!zustand.offen));
  $('#ld-auf').addEventListener('click', () => $('#ld').showModal());
  $('#ld-zu').addEventListener('click', () => $('#ld').close());

  /* ---------- Variante und Datenzustand ---------- */
  function variante(v, schreiben = true) {
    zustand.v = VARIANTEN.includes(v) ? v : 'tour';
    $$('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === zustand.v)));
    const f = zustand.filter; f.land.clear(); f.monat.clear(); f.kuenstler.clear(); f.stadt.clear(); f.naehe = null;
    klappe(false); render();
    if (schreiben) { const p = new URLSearchParams(location.search); p.set('v', zustand.v); history.replaceState(null, '', '?' + p.toString().replace(/=(?=&|$)/g, '')); }
  }
  function daten(art) { zustand.daten = art; $$('[data-daten]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.daten === art))); render(); }
  $$('[data-v]').forEach(b => b.addEventListener('click', () => { stopp(); variante(b.dataset.v); }));
  $$('[data-daten]').forEach(b => b.addEventListener('click', () => { stopp(); daten(b.dataset.daten); }));

  /* ---------- Vorführung: gedachter Zeiger ---------- */
  const zeiger = $('#zeiger');
  let lauf = 0, uhren = [];
  const warte = ms => new Promise((ok, nein) => { const id = setTimeout(() => { uhren = uhren.filter(x => x !== id); ok(); }, ms); uhren.push(id); });
  async function hin(el, lauf_) {
    if (!el || lauf_ !== lauf) throw 0;
    const r = el.getBoundingClientRect();
    zeiger.style.transform = `translate(${Math.round(r.left + Math.min(r.width * .5, 60))}px, ${Math.round(r.top + r.height * .55)}px)`;
    await warte(reduziert() ? 200 : 720); if (lauf_ !== lauf) throw 0;
  }
  async function klick(el, l) { await hin(el, l); zeiger.classList.add('klick'); el.classList.add('ist-hover', 'drueckt'); await warte(140); zeiger.classList.remove('klick'); el.classList.remove('drueckt'); el.click(); await warte(80); el.classList.remove('ist-hover'); }
  async function tippe(input, text, l) { await hin(input, l); input.focus(); input.value = ''; for (const c of text) { input.value += c; input.dispatchEvent(new Event('input', { bubbles: true })); await warte(reduziert() ? 20 : 110); if (l !== lauf) throw 0; } }
  async function szene(l) {
    scrollTo(0, 0);
    zeiger.style.transform = `translate(${innerWidth * .62}px, ${innerHeight * .9}px)`;
    await warte(500);
    if (zustand.v === 'ausstellungen') {
      const m = $('#t-a-3 .mehr'); await klick(m, l); await warte(600);
      await klick($('#m-a-3 [data-ics]'), l); await warte(1800);
      await klick($('#klapp'), l); await warte(1900);
      await klick($('#klapp'), l); await warte(900);
      return;
    }
    if (zustand.v === 'label') {
      await klick($('[popovertarget="f-kuenstler"]'), l); await warte(400);
      await tippe($('#such-k'), 'dop', l); await warte(500);
      await klick($('#f-kuenstler input[value="Doppelschicht"]').closest('label'), l); await warte(1300);
      $('#f-kuenstler').hidePopover(); await warte(300);
      await klick($('[popovertarget="f-stadt"]'), l); await warte(400);
      await klick($('#f-stadt input[value="Hamburg"]').closest('label'), l); await warte(1500);
      $('#f-stadt').hidePopover(); await warte(1200);
      zuruecksetzen(); await warte(900);
      return;
    }
    await klick($('[popovertarget="f-naehe"]'), l); await warte(300);
    await tippe($('#naehe-ort'), 'Essen', l); await warte(300);
    await klick($('#naehe-form button[type=submit]'), l); await warte(2200);
    zuruecksetzen(); await warte(700);
    await klick($('#t-hamburg-2027 .mehr'), l); await warte(500);
    await klick($('#m-hamburg-2027 [data-ics]'), l); await warte(1700);
    await klick($('[popovertarget="f-land"]'), l); await warte(300);
    await klick($('#f-land input[value="AT"]').closest('label'), l); await warte(300);
    await klick($('#f-land input[value="CH"]').closest('label'), l); await warte(1200);
    $('#f-land').hidePopover(); await warte(1600);
    zuruecksetzen(); await warte(800);
  }
  function stopp() { lauf++; uhren.forEach(clearTimeout); uhren = []; html.classList.remove('vorfuehrung'); $$('.pop:popover-open').forEach(p => p.hidePopover()); }

  function neustart() { stopp(); zustand.daten = 'ok'; $$('[data-daten]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.daten === 'ok'))); variante(zustand.v, false); scrollTo(0, 0); }
  async function vorfuehren() {
    neustart(); html.classList.add('vorfuehrung'); const l = ++lauf;
    try { while (l === lauf) { await szene(l); } } catch (e) { /* abgebrochen */ }
  }
  function pause() { lauf++; uhren.forEach(clearTimeout); uhren = []; }
  function weiter() { if (html.classList.contains('vorfuehrung')) vorfuehren(); }

  variante(zustand.v, false);

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