Zum Inhalt
← Mikroanimationen

Drag-and-drop-Sortieren mit Platzhalter- und Drop-Animation

Gänge eines Menüs in die Wunschfolge ziehen: Das Original bleibt als Platzhalter, eine Linie oder ausweichende Nachbarn zeigen das Ziel, und ein kurzer Farb-Flash bestätigt das Ablegen.

  1. Ziehbare Elemente zeigen das vorab: Griff-Icon, cursor: grab und ein leichter Hintergrundwechsel beim Hover.
  2. Beim Ziehen bleibt das Original als gedimmter Platzhalter (opacity 0.4) stehen oder wird zur leeren Lücke. Eine Vorschau folgt dem Zeiger.
  3. Das Ziel wird sichtbar, bevor man loslässt: entweder als Drop-Linie mit Punkt am Anfang oder durch Nachbarn, die mit kurzer Transform-Animation ausweichen.
  4. Nach dem Ablegen gleitet das Element in seine Lücke, und die Fläche leuchtet einmal kurz in der Auswahlfarbe auf.
  5. Tastatur und Knöpfe („Nach oben“, „Nach unten“) erreichen dasselbe Ergebnis ohne Ziehen.

Direkte Manipulation fühlt sich physisch und schnell an. Linie oder Ausweichen zeigen die Folgen der Aktion, bevor man loslässt. Der Flash bestätigt, wo das Element gelandet ist.

Wann einsetzen

Kanban-Boards, Playlists, Prioritäten- und Wunschlisten, Menü- und Navigations-Editoren, Bildreihenfolge in Galerien, Formular- und Seitenbaukästen, Konfiguratoren.

Wann nicht

Als einzige Möglichkeit zum Umsortieren (Barrierefreiheit). Bei sehr langen, virtuellen Listen ohne Auto-Scroll. Wenn die Reihenfolge fachlich egal ist.

Varianten

  • Gedimmtes Original (opacity 0.4) mit Drop-Linie, Nachbarn bleiben stehen (Atlassian Pragmatic drag and drop)
  • Live-Umordnung: Nachbarn weichen während des Ziehens aus (dnd kit Sortable, SortableJS animation: 150)
  • Leere Lücke mit gestricheltem Rahmen in Elementgröße
  • Vorschau leicht gedreht und angehoben (Trello: 4°)
  • Drop-Flash in der Auswahlfarbe nach dem Ablegen
  • Native View Transitions für die Umordnung (siehe flip-zustandswechsel)

Umsetzung

Bibliothek wählen: Pragmatic drag and drop (nativ, sehr klein, framework-unabhängig), dnd kit (React, Vue, Svelte; Sortable-Preset, Tastatursensor) oder SortableJS (Vanilla). Ohne Bibliothek reichen Pointer Events, wenn alle Elemente gleich hoch sind.

Zustände als Klassen: .is-dragging { opacity: .4 } am Original. Drop-Indikator als absolut positionierte 2-px-Linie mit 8-px-Kreis, der 4 px nach links übersteht. Vorschau höchstens 280 × 280 px, 16/8 px vom Zeiger versetzt. Nachbarn: transition: transform 150ms cubic-bezier(0.25, 1, 0.5, 1) (dnd kit Standard: 250 ms). Nach dem Drop: element.animate([{ backgroundColor: 'var(--selected)' }, { backgroundColor: 'transparent' }], { duration: 700, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }).

Aktivierung: Maus ab 10 px Bewegung, Touch nach 250 ms Halten mit 5 px Toleranz, damit Scrollen möglich bleibt. Zusätzlich Knöpfe oder Menü „Verschieben nach …“ und Tastatur (Leertaste aufnehmen, Pfeiltasten, Leertaste ablegen, Escape abbrechen) mit aria-live-Ansagen.

Fallstricke: Nach dem Gleiten die Transforms und die DOM-Reihenfolge im selben Frame zurücksetzen, sonst springt die Liste. Den Klick nach einem Zug abfangen. Beim Verschieben per Tastatur den Fokus auf dem Griff halten.

Robust umsetzen

Telefon. Long-Press (etwa 250 ms) zum Aufnehmen, am Griff sofort. Auto-Scroll am Rand bei langen Listen. Griffe mindestens 24 × 24 px, besser 44 px. Natives HTML5-Drag-and-drop ist auf Touch eingeschränkt, deshalb Pointer Events oder eine Bibliothek mit Touch-Sensor nutzen.

Ohne Bewegung. Ausweich- und Gleitanimationen auf 0 ms. Drop-Linie und Flash bleiben, der Flash als reiner Farbwechsel ohne Bewegung.

Barrierefrei. Dasselbe Ergebnis ohne Ziehen ermöglichen (WCAG 2.5.7 Dragging Movements): Tastatursteuerung und Knöpfe oder Menüaktionen. Positionswechsel per aria-live ansagen („Position 2 von 5“). Der Griff ist ein Button mit Label, etwa „Saibling und Kren verschieben“.

Code

<!DOCTYPE html>
<!--
  Drag-and-drop-Sortieren mit Platzhalter- und Drop-Animation
  Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/drag-sortieren-platzhalter/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drag-and-drop-Sortieren mit Platzhalter- und Drop-Animation · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Commissioner'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/commissioner:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  /* Welt: „Lände“, Küche an der Donau, Linz-Urfahr. Hausfarbe Donaugrün, Akzent Senf. */
  --tisch: #24392f; --tisch-tief: #1c2e26; --rille: rgba(244, 238, 226, .12);
  --papier: #f4eee2; --teller: #fffcf5; --tinte: #2b2622; --tinte-leise: rgba(43, 38, 34, .70);
  --hell: #f4eee2; --hell-leise: rgba(244, 238, 226, .64);
  --senf: #e7bc63; --flash: #f6d88f; --rose: #e1a894;
  /* Werte aus dem Steckbrief (Atlassian Pragmatic DnD, dnd kit Sortable) */
  --nachbar: 150ms cubic-bezier(0.25, 1, 0.5, 1);   /* Ausweichen der Nachbarn */
  --hoehe: 78px; --abstand: 10px;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--tisch); color: var(--hell); font-family: 'Commissioner', system-ui, sans-serif; font-variation-settings: "FLAR" 0; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(20px, 4.5vw, 64px) clamp(16px, 5vw, 72px) 28px; display: grid; grid-template-rows: 1fr auto; gap: 28px; overflow-x: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Versal-Labels: Commissioner mit Flare als Brücke zur Serif (kein Mono in der Gastro-Welt) */
.rubrik { font: 600 12px/1.2 'Commissioner', sans-serif; font-variation-settings: "FLAR" 80; letter-spacing: .15em; text-transform: uppercase; }

.buehne { display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 6fr); gap: clamp(28px, 6vw, 96px); align-items: center; max-width: 1240px; width: 100%; margin: 0 auto; }

/* Linke Spalte: das Haus */
.haus { display: grid; gap: 22px; align-content: center; }
.marke { display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 14px; row-gap: 6px; color: var(--hell-leise); }
.marke svg { grid-row: span 2; }
.marke b { font: 500 26px/1 'Fraunces', serif; font-variation-settings: "SOFT" 100, "WONK" 0; letter-spacing: -.01em; color: var(--hell); }
.marke .rubrik { font-size: 11px; }
h1 { font: 480 clamp(2.5rem, 1.6rem + 3.2vw, 4.4rem)/1.02 'Fraunces', serif; font-variation-settings: "SOFT" 100, "WONK" 0; letter-spacing: -.01em; text-wrap: balance; }
h1 em { font-style: italic; font-weight: 400; color: var(--senf); }
h1 .zeile { white-space: nowrap; }
.haus p { font-size: 1.0625rem; line-height: 1.55; color: var(--hell-leise); max-width: 34ch; }
.eckdaten { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; padding-top: 18px; border-top: 1px solid var(--rille); font-size: 15px; font-variant-numeric: tabular-nums; }
.eckdaten dt { color: var(--hell-leise); }

/* Rechte Spalte: das Menü */
.menue { display: grid; gap: 16px; }
.menue-kopf { display: flex; justify-content: space-between; align-items: baseline; color: var(--hell-leise); padding-left: 96px; }
.menue-kopf .tipp { font-size: 14px; }
.tafel { position: relative; display: grid; grid-template-columns: 82px 1fr; column-gap: 14px; }

/* Die Zeitschiene bleibt stehen, die Gänge wandern zwischen den Zeiten */
.schiene { list-style: none; padding: 0; display: grid; gap: var(--abstand); }
.schiene li { height: var(--hoehe); display: grid; align-content: center; gap: 4px; padding-right: 14px; border-right: 1px solid var(--rille); text-align: right; transition: color 150ms cubic-bezier(0.25, 1, 0.5, 1); color: var(--hell-leise); }
.schiene .rubrik { font-size: 11px; }
.schiene time { font: 500 20px/1 'Commissioner', sans-serif; font-variant-numeric: tabular-nums; color: var(--hell); transition: color 150ms cubic-bezier(0.25, 1, 0.5, 1); }
.schiene li.ziel, .schiene li.ziel time { color: var(--senf); }

.gaenge { list-style: none; padding: 0; display: grid; gap: var(--abstand); position: relative; }
.gang {
  position: relative; height: var(--hoehe); display: grid; grid-template-columns: 34px 54px minmax(0, 1fr); align-items: center; gap: 14px;
  padding: 0 14px 0 6px; border-radius: 14px; background: var(--papier); color: var(--tinte);
  box-shadow: 0 1px 0 rgba(0, 0, 0, .18); cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none;
}
@media (hover: hover) { .gang:hover, .gang:focus-within { background: #f8f3ea; } .gang:hover .griff { color: var(--tinte); } }
.griff { width: 34px; height: 44px; display: grid; place-items: center; border-radius: 8px; color: rgba(43, 38, 34, .38); cursor: grab; touch-action: none; transition: color 150ms cubic-bezier(0.25, 1, 0.5, 1); }
.griff:focus-visible { outline: 2px solid var(--tinte); outline-offset: -2px; color: var(--tinte); }
.teller { width: 54px; height: 54px; display: block; }
.name { font: 520 19px/1.15 'Fraunces', serif; font-variation-settings: "SOFT" 80, "WONK" 0; letter-spacing: -.005em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.notiz { font-size: 14px; line-height: 1.35; color: var(--tinte-leise); overflow-wrap: anywhere; margin-top: 3px; }
.pfeile { position: absolute; right: 8px; top: 50%; translate: 0 -50%; display: flex; gap: 2px; opacity: 0; pointer-events: none; transition: opacity 150ms cubic-bezier(0.25, 1, 0.5, 1); }
.gang:hover .pfeile, .gang:focus-within .pfeile { pointer-events: auto; }
/* Text läuft unter den Pfeilen weich aus, egal welche Hintergrundfarbe die Karte gerade hat (Flash) */
.gang:hover > div, .gang:focus-within > div { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 96px), transparent calc(100% - 72px)); mask-image: linear-gradient(90deg, #000 calc(100% - 96px), transparent calc(100% - 72px)); }
.gang:hover .pfeile, .gang:focus-within .pfeile { opacity: 1; }
.pfeile button { width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; color: var(--tinte-leise); }
.pfeile button:hover { background: rgba(43, 38, 34, .07); color: var(--tinte); }
.pfeile button:focus-visible { outline: 2px solid var(--tinte); outline-offset: -2px; opacity: 1; }
.gang:first-child [data-hoch], .gang:last-child [data-runter] { visibility: hidden; }

/* Zustände während des Ziehens */
body.zieht, body.zieht * { cursor: grabbing !important; }
.gaenge.weichen .gang { transition: transform var(--nachbar); }
.gang.ist-quelle { opacity: .4; }                                   /* Linie: gedimmtes Original */
.gang.ist-gehoben { z-index: 3; transition: none !important; box-shadow: 0 18px 38px -8px rgba(8, 18, 14, .55), 0 2px 0 rgba(0, 0, 0, .1); }
.gang.ist-luecke { background: transparent; box-shadow: none; outline: 2px dashed rgba(244, 238, 226, .38); outline-offset: -2px; }
.gang.ist-luecke > * { visibility: hidden; }
.gang.ist-aufgenommen { outline: 2px solid var(--senf); outline-offset: 2px; box-shadow: 0 12px 28px -10px rgba(8, 18, 14, .6); }

/* Vorschau, die dem Zeiger folgt */
.fliegt { position: fixed; left: 0; top: 0; z-index: 20; pointer-events: none; margin: 0; list-style: none; }
.fliegt.kompakt { width: auto; max-width: 280px; height: 56px; grid-template-columns: 44px minmax(0, 1fr); padding: 0 16px 0 6px; gap: 10px; box-shadow: 0 10px 26px -6px rgba(8, 18, 14, .5); }
.fliegt.kompakt .griff, .fliegt.kompakt .notiz, .fliegt.kompakt .pfeile { display: none; }
.fliegt.kompakt .teller { width: 44px; height: 44px; }
.fliegt.kompakt .name { font-size: 17px; }
.fliegt.schraeg { box-shadow: 0 22px 44px -10px rgba(8, 18, 14, .6); transform-origin: 30% 50%; }
.fliegt .pfeile { opacity: 0 !important; }

/* Drop-Linie: 2 px mit 8-px-Ring, der 4 px links übersteht */
.linie { position: absolute; left: -4px; right: 0; height: 8px; pointer-events: none; display: none; }
.linie::before { content: ''; position: absolute; left: 6px; right: 0; top: 3px; height: 2px; background: var(--senf); border-radius: 2px; }
.linie::after { content: ''; position: absolute; left: 0; top: 0; width: 8px; height: 8px; border-radius: 50%; border: 2px solid var(--senf); box-sizing: border-box; background: var(--tisch); }
.linie.an { display: block; }

.aktion { display: flex; align-items: center; gap: 18px; padding-left: 96px; margin-top: 6px; flex-wrap: wrap; }
.senden { display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 22px; border-radius: 99px; background: var(--senf); color: var(--tinte); font-weight: 600; font-size: 15px; transition: background 150ms cubic-bezier(0.25, 1, 0.5, 1); }
.senden:hover { background: #efc97a; }
.senden:focus-visible { outline: 2px solid var(--hell); outline-offset: 3px; }
.rueckmeldung { font-size: 14px; color: var(--hell-leise); }

/* Fundus-Werkzeug: Variante und Werte, dezent */
.werkzeug { max-width: 1240px; width: 100%; margin: 0 auto; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px 24px; padding-top: 14px; border-top: 1px solid var(--rille); }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--hell-leise); }
.varianten { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.varianten button { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--hell-leise); padding: 8px 10px; border-radius: 99px; border: 1px solid transparent; transition: color 150ms cubic-bezier(0.25, 1, 0.5, 1), border-color 150ms cubic-bezier(0.25, 1, 0.5, 1); }
.varianten button[aria-pressed="true"] { color: var(--hell); border-color: rgba(244, 238, 226, .3); }
.varianten button:focus-visible { outline: 1px solid var(--hell); }
.werte { letter-spacing: .06em; text-transform: none; max-width: 92ch; }

/* Gezeichneter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 22px; height: 22px; transform-origin: 3px 2px; pointer-events: none; display: none; transition: scale 150ms cubic-bezier(0.25, 1, 0.5, 1); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { scale: .86; }

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; padding: 40px 48px 22px; gap: 18px; }
.vorschau .haus > p:not(.marke) { display: none; }
.vorschau .rueckmeldung { display: none; }

/* Reduzierte Bewegung: kein Ausweichen, kein Gleiten. Linie und Farb-Flash bleiben. */
.reduziert .gaenge.weichen .gang, .reduziert .schiene li, .reduziert .schiene time { transition: none; }
.reduziert .zeiger { display: none !important; }

/* Telefon und Touch */
@media (hover: none) {
  .gang { grid-template-columns: 34px 54px minmax(0, 1fr) auto; }
  .pfeile { position: static; translate: none; pointer-events: auto; opacity: 1; flex-direction: column; gap: 0; }
  .gang:hover > div, .gang:focus-within > div { -webkit-mask-image: none; mask-image: none; }
  .pfeile button { width: 34px; height: 30px; }
  .gang:first-child [data-hoch], .gang:last-child [data-runter] { visibility: visible; opacity: .25; pointer-events: none; }
}
@media (max-width: 900px) {
  .buehne { grid-template-columns: 1fr; align-items: start; }
  .haus p { max-width: 46ch; }
}
@media (max-width: 560px) {
  :root { --hoehe: 74px; --abstand: 8px; }
  .tafel { grid-template-columns: 56px 1fr; column-gap: 10px; }
  .schiene li { padding-right: 10px; }
  .schiene time { font-size: 16px; }
  .gang { grid-template-columns: 28px 42px minmax(0, 1fr) auto; gap: 8px; padding: 0 4px 0 2px; }
  .schiene .wort { display: none; }
  .schiene .rubrik { font-size: 12px; }
  .name { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.08; }
  .griff { width: 28px; }
  .teller { width: 42px; height: 42px; }
  .name { font-size: 16.5px; }
  .notiz { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .menue-kopf, .aktion { padding-left: 66px; }
  .menue-kopf .tipp { display: none; }
  .werkzeug { flex-direction: column; align-items: flex-start; }
}
/* Zugang: sehr schmal (320 px) brechen die Notizen um, die Karten werden dafür höher */
@media (max-width: 360px) {
  :root { --hoehe: 104px; }
  .notiz { white-space: normal; overflow: visible; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<main class="buehne">
  <section class="haus" aria-labelledby="titel">
    <p class="marke">
      <svg width="40" height="40" viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="14" fill="none" stroke="#e7bc63" stroke-width="1.4"/><path d="M5 17.5c3.2-2.4 6.8-2.4 10 0s6.8 2.4 10 0M5 21.5c3.2-2.4 6.8-2.4 10 0s6.8 2.4 10 0" fill="none" stroke="#e7bc63" stroke-width="1.4" stroke-linecap="round"/><path d="M10 13.5h10l-1.6-4h-6.8Z" fill="#e7bc63"/></svg>
      <b>Lände</b><span class="rubrik">Küche an der Donau · Linz-Urfahr</span>
    </p>
    <h1 id="titel"><span class="zeile">Fünf Gänge.</span> <span class="zeile"><em>Ihre</em> Folge.</span></h1>
    <p>Beim Herbstmenü „Freie Folge“ bestimmen Sie, was zuerst auf den Tisch kommt. Ziehen Sie die Gänge in Ihre Wunschfolge, die Küche richtet sich danach.</p>
    <dl class="eckdaten">
      <dt>Tisch</dt><dd>Freitag, 14. November · 2 Personen · 19:00 Uhr</dd>
      <dt>Menü</dt><dd>68 € pro Person · Weinbegleitung 42 €</dd>
      <dt>Haus</dt><dd>Mi bis So ab 17:30 · An der Lände 7, 4040 Linz</dd>
    </dl>
  </section>

  <section class="menue" aria-labelledby="menue-titel">
    <div class="menue-kopf"><h2 class="rubrik" id="menue-titel">Ihre Gangfolge</h2><span class="tipp">Am Griff ziehen oder Pfeile nutzen</span></div>
    <div class="tafel">
      <ol class="schiene" aria-hidden="true" data-schiene></ol>
      <ul class="gaenge" data-gaenge aria-describedby="anleitung">
        <li class="gang" data-id="saibling">
          <button class="griff" type="button" aria-label="Saibling und Kren verschieben"><svg width="12" height="18" viewBox="0 0 12 18" aria-hidden="true"><g fill="currentColor"><circle cx="3" cy="3" r="1.6"/><circle cx="9" cy="3" r="1.6"/><circle cx="3" cy="9" r="1.6"/><circle cx="9" cy="9" r="1.6"/><circle cx="3" cy="15" r="1.6"/><circle cx="9" cy="15" r="1.6"/></g></svg></button>
          <svg class="teller" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="28" fill="#fffcf5" stroke="#e3d8c3"/><circle cx="30" cy="30" r="20" fill="none" stroke="#efe6d4"/><path d="M15 33c4-9 18-12 29-6 2 1 2 4 0 5-9 6-23 7-29 1Z" fill="#e8977a"/><path d="M21 30l3 5M26 28l3 6M31 27l3 6M36 27l2.5 5.5" stroke="#f6c3ad" stroke-width="1.6" stroke-linecap="round"/><circle cx="20" cy="20" r="4.5" fill="#f6f2e8" stroke="#e6dcc8"/><circle cx="41" cy="39" r="2.6" fill="#8fae6b"/><circle cx="35" cy="42" r="2" fill="#a9c47f"/><circle cx="44" cy="33" r="1.6" fill="#7d9e5c"/></svg>
          <div><p class="name">Saibling &amp; Kren</p><p class="notiz">Mühlviertler Saibling geflämmt, Gurke, Dille</p></div>
          <span class="pfeile"><button type="button" data-hoch aria-label="Nach oben"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 8.5 7 4.5l4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button><button type="button" data-runter aria-label="Nach unten"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 5.5 7 9.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></span>
        </li>
        <li class="gang" data-id="ruebe">
          <button class="griff" type="button" aria-label="Rote Rübe und Ziege verschieben"><svg width="12" height="18" viewBox="0 0 12 18" aria-hidden="true"><g fill="currentColor"><circle cx="3" cy="3" r="1.6"/><circle cx="9" cy="3" r="1.6"/><circle cx="3" cy="9" r="1.6"/><circle cx="9" cy="9" r="1.6"/><circle cx="3" cy="15" r="1.6"/><circle cx="9" cy="15" r="1.6"/></g></svg></button>
          <svg class="teller" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="28" fill="#fffcf5" stroke="#e3d8c3"/><circle cx="30" cy="30" r="20" fill="none" stroke="#efe6d4"/><circle cx="24" cy="26" r="7.5" fill="#9b2f52"/><circle cx="36" cy="34" r="6" fill="#b23a61"/><circle cx="34" cy="22" r="4" fill="#c7577a"/><circle cx="23" cy="38" r="3.6" fill="#8a2848"/><circle cx="30" cy="30" r="3" fill="#f7f3ea"/><circle cx="41" cy="25" r="2.4" fill="#f7f3ea"/><circle cx="18" cy="31" r="2" fill="#f7f3ea"/><path d="M28 41l1.6-1M33 43l1.4.6M40 39l.8 1.4M17 24l1.2-.8" stroke="#a5743f" stroke-width="2" stroke-linecap="round"/></svg>
          <div><p class="name">Rote Rübe &amp; Ziege</p><p class="notiz">Ofenrübe, Ziegenfrischkäse, gebrannte Haselnuss</p></div>
          <span class="pfeile"><button type="button" data-hoch aria-label="Nach oben"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 8.5 7 4.5l4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button><button type="button" data-runter aria-label="Nach unten"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 5.5 7 9.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></span>
        </li>
        <li class="gang" data-id="erdaepfel">
          <button class="griff" type="button" aria-label="Erdäpfel und Schwammerl verschieben"><svg width="12" height="18" viewBox="0 0 12 18" aria-hidden="true"><g fill="currentColor"><circle cx="3" cy="3" r="1.6"/><circle cx="9" cy="3" r="1.6"/><circle cx="3" cy="9" r="1.6"/><circle cx="9" cy="9" r="1.6"/><circle cx="3" cy="15" r="1.6"/><circle cx="9" cy="15" r="1.6"/></g></svg></button>
          <svg class="teller" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="28" fill="#fffcf5" stroke="#e3d8c3"/><circle cx="30" cy="30" r="20" fill="none" stroke="#efe6d4"/><circle cx="25" cy="27" r="7" fill="#e9b85c"/><circle cx="35.5" cy="31" r="6.4" fill="#e2ab4c"/><path d="M22 25.5c2-1.6 4.5-1.6 6 0" stroke="#f5d79a" stroke-width="1.4" fill="none" stroke-linecap="round"/><path d="M33 29c1.8-1.4 4-1.4 5.4 0" stroke="#f2cf8b" stroke-width="1.4" fill="none" stroke-linecap="round"/><path d="M19 37c0-3 6-3 6 0Z" fill="#8a5a36"/><rect x="21.2" y="36.6" width="1.6" height="3" rx=".8" fill="#d9c09a"/><path d="M36 41c0-2.6 5-2.6 5 0Z" fill="#9c6a40"/><rect x="37.8" y="40.6" width="1.4" height="2.6" rx=".7" fill="#d9c09a"/><circle cx="30" cy="20" r="1.4" fill="#5f8a3e"/><circle cx="40" cy="23" r="1.2" fill="#6f9a48"/><circle cx="18" cy="30" r="1.2" fill="#5f8a3e"/><circle cx="29" cy="40" r="1.1" fill="#6f9a48"/></svg>
          <div><p class="name">Erdäpfel &amp; Schwammerl</p><p class="notiz">Erdäpfelknödel, Eierschwammerl, Petersilie</p></div>
          <span class="pfeile"><button type="button" data-hoch aria-label="Nach oben"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 8.5 7 4.5l4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button><button type="button" data-runter aria-label="Nach unten"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 5.5 7 9.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></span>
        </li>
        <li class="gang" data-id="reh">
          <button class="griff" type="button" aria-label="Reh und Sellerie verschieben"><svg width="12" height="18" viewBox="0 0 12 18" aria-hidden="true"><g fill="currentColor"><circle cx="3" cy="3" r="1.6"/><circle cx="9" cy="3" r="1.6"/><circle cx="3" cy="9" r="1.6"/><circle cx="9" cy="9" r="1.6"/><circle cx="3" cy="15" r="1.6"/><circle cx="9" cy="15" r="1.6"/></g></svg></button>
          <svg class="teller" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="28" fill="#fffcf5" stroke="#e3d8c3"/><circle cx="30" cy="30" r="20" fill="none" stroke="#efe6d4"/><path d="M16 36c6-8 22-12 30-8" stroke="#ece2cf" stroke-width="7" fill="none" stroke-linecap="round"/><path d="M19 27.5a6 6 0 0 1 11-3.5l-3 6.5Z" fill="#6e2a24"/><path d="M20.5 27.2a4.4 4.4 0 0 1 7.8-2.6l-2.2 4.6Z" fill="#b2493d"/><path d="M29 25.5a6 6 0 0 1 11 2l-6.6 3Z" fill="#6e2a24"/><path d="M30.6 25.9a4.4 4.4 0 0 1 7.9 1.6l-4.8 2.2Z" fill="#c1564a"/><circle cx="38" cy="38" r="2.4" fill="#c6283a"/><circle cx="42.5" cy="35" r="2" fill="#d63a4a"/><circle cx="34" cy="41" r="1.8" fill="#b81f33"/><path d="M21 39.5l3-1.2" stroke="#4f6e3a" stroke-width="1.6" stroke-linecap="round"/></svg>
          <div><p class="name">Reh &amp; Sellerie</p><p class="notiz">Rehrücken vom Kürnberg, Selleriepüree, Preiselbeere</p></div>
          <span class="pfeile"><button type="button" data-hoch aria-label="Nach oben"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 8.5 7 4.5l4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button><button type="button" data-runter aria-label="Nach unten"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 5.5 7 9.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></span>
        </li>
        <li class="gang" data-id="linzer">
          <button class="griff" type="button" aria-label="Linzer, neu gedacht verschieben"><svg width="12" height="18" viewBox="0 0 12 18" aria-hidden="true"><g fill="currentColor"><circle cx="3" cy="3" r="1.6"/><circle cx="9" cy="3" r="1.6"/><circle cx="3" cy="9" r="1.6"/><circle cx="9" cy="9" r="1.6"/><circle cx="3" cy="15" r="1.6"/><circle cx="9" cy="15" r="1.6"/></g></svg></button>
          <svg class="teller" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="28" fill="#fffcf5" stroke="#e3d8c3"/><circle cx="30" cy="30" r="20" fill="none" stroke="#efe6d4"/><path d="M17 38 33 18l8 20Z" fill="#c98a4b"/><path d="M19.6 36.4 33 19.8l6.6 16.6Z" fill="#9e2a35"/><path d="M24 31l12 0M21.5 34.2l17 0M27.5 26.5l7 0M26 36l5-12M32 36l2.4-11" stroke="#d9a062" stroke-width="1.6" stroke-linecap="round"/><ellipse cx="42" cy="24" rx="5" ry="3.4" fill="#fbf7ee" stroke="#e6dcc8"/><circle cx="20" cy="22" r="1.8" fill="#d6283f"/><circle cx="23" cy="19.5" r="1.5" fill="#e23a50"/><circle cx="44" cy="40" r="1.6" fill="#d6283f"/></svg>
          <div><p class="name">Linzer, neu gedacht</p><p class="notiz">Mandelmürbteig, Ribisel, Sauerrahmeis</p></div>
          <span class="pfeile"><button type="button" data-hoch aria-label="Nach oben"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 8.5 7 4.5l4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button><button type="button" data-runter aria-label="Nach unten"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 5.5 7 9.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></span>
        </li>
      </ul>
      <div class="linie" data-linie aria-hidden="true"></div>
    </div>
    <div class="aktion">
      <button class="senden" type="button" data-senden>Folge an die Küche senden <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
      <span class="rueckmeldung" data-rueckmeldung>Änderbar bis Freitag, 12 Uhr.</span>
    </div>
  </section>
</main>

<footer class="werkzeug">
  <div class="varianten" role="group" aria-label="Variante">
    <span class="mono">Variante</span>
    <button type="button" data-variante="linie" aria-pressed="true">Linie</button>
    <button type="button" data-variante="ausweichen" aria-pressed="false">Ausweichen</button>
    <button type="button" data-variante="luecke" aria-pressed="false">Lücke</button>
  </div>
  <p class="mono werte" data-werte></p>
</footer>

<p class="sr" id="anleitung">Griff mit Leertaste aufnehmen, mit Pfeiltasten verschieben, mit Leertaste ablegen, mit Escape abbrechen. Alternativ die Knöpfe Nach oben und Nach unten.</p>
<p class="sr" aria-live="assertive" data-ansage></p>
<svg class="zeiger" data-zeiger viewBox="0 0 22 22" aria-hidden="true"><path d="M3 2 18 11l-6.6 1.5-3.2 6.3Z" fill="#2b2622" stroke="#f4eee2" stroke-width="1.4" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const liste = document.querySelector('[data-gaenge]');
  const schiene = document.querySelector('[data-schiene]');
  const linie = document.querySelector('[data-linie]');
  const ansage = document.querySelector('[data-ansage]');
  const werteFeld = document.querySelector('[data-werte]');
  const zeiger = document.querySelector('[data-zeiger]');
  const ursprung = [...liste.children].map(k => k.dataset.id);

  // Werte (Quellen: Atlassian Pragmatic DnD Guidelines, dnd kit Sortable)
  const W = {
    quelleOpacity: 0.4,
    vorschauVersatz: [16, 8],          // px vom Zeiger
    vorschauMax: 280,                  // px
    nachbarMs: 150, nachbarKurve: 'cubic-bezier(0.25, 1, 0.5, 1)',
    gleitenMs: 250,                    // Ablegen: in die Lücke gleiten (gleiche Kurve)
    flashMs: 700, flashKurve: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
    flashVon: '#f6d88f', flashBis: '#f4eee2',
    gehobenScale: 1.02, kippenGrad: 4,
    mausSchwellePx: 10, touchHaltenMs: 250, touchToleranzPx: 5,
  };
  const ZEITEN = ['19:15', '19:40', '20:05', '20:30', '20:55'];
  const WERTE_TEXT = {
    linie: 'Original opacity .4 · Drop-Linie 2 px, Ring 8 px, −4 px · Vorschau max. 280 px, +16/+8 px · Flash 700 ms cubic-bezier(0.25, 0.1, 0.25, 1)',
    ausweichen: 'Nachbarn 150 ms cubic-bezier(0.25, 1, 0.5, 1) · gehoben scale 1.02 · Ablegen 250 ms · Flash 700 ms',
    luecke: 'Lücke gestrichelt · Vorschau 4° gekippt · Nachbarn 150 ms · Gleiten 250 ms cubic-bezier(0.25, 1, 0.5, 1) · Flash 700 ms',
  };

  ZEITEN.forEach((z, i) => {
    const li = document.createElement('li');
    li.innerHTML = `<span class="rubrik">${i + 1}.<span class="wort"> Gang</span></span><time>${z}</time>`;
    schiene.append(li);
  });
  const schienenPunkte = [...schiene.children];

  let variante = 'linie';
  function setzeVariante(v) {
    variante = v;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
    werteFeld.textContent = WERTE_TEXT[v];
  }
  document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { abbrechen(); setzeVariante(b.dataset.variante); }));
  setzeVariante('linie');

  const karten = () => [...liste.children];
  const nameVon = k => k.querySelector('.name').textContent;
  const sag = t => { ansage.textContent = ''; requestAnimationFrame(() => { ansage.textContent = t; }); };
  const zielMarkieren = i => schienenPunkte.forEach((p, j) => p.classList.toggle('ziel', j === i));

  function flash(k) {
    k.animate([{ backgroundColor: W.flashVon }, { backgroundColor: W.flashBis }], { duration: W.flashMs, easing: W.flashKurve });
  }
  // FLIP für Knopf, Tastatur und Vorführung ohne Zeiger
  function flip(aenderung, dauer) {
    const vorher = new Map(karten().map(k => [k, k.getBoundingClientRect().top]));
    aenderung();
    if (reduziert() || !dauer) return;
    karten().forEach(k => {
      const d = vorher.get(k) - k.getBoundingClientRect().top;
      if (d) k.animate([{ transform: `translateY(${d}px)` }, { transform: 'none' }], { duration: dauer, easing: W.nachbarKurve });
    });
  }
  function einsetzen(k, ziel) {
    const andere = karten().filter(x => x !== k);
    liste.insertBefore(k, andere[ziel] || null);
  }
  function verschieben(k, ziel, dauer = W.gleitenMs) {
    const von = karten().indexOf(k);
    ziel = Math.max(0, Math.min(karten().length - 1, ziel));
    if (ziel === von) return false;
    flip(() => einsetzen(k, ziel), dauer);
    return true;
  }
  const ortText = i => `Position ${i + 1} von ${karten().length}, ${i + 1}. Gang um ${ZEITEN[i]} Uhr`;

  /* ---------- Ziehen mit Zeiger ---------- */
  let zug = null;       // laufender Zug
  let landet = false;   // Drop-Animation läuft

  function aufnehmen(k, x, y) {
    const alle = karten();
    const r = k.getBoundingClientRect(), lr = liste.getBoundingClientRect();
    const abstand = alle[1].getBoundingClientRect().top - alle[0].getBoundingClientRect().top;
    zug = { k, von: alle.indexOf(k), nach: alle.indexOf(k), x0: x, y0: y, dx: x - r.left, dy: y - r.top, h: r.height, w: r.width, abstand, lr, n: alle.length, flieger: null, variante };
    document.body.classList.add('zieht');
    if (variante === 'linie') {
      const f = k.cloneNode(true); f.removeAttribute('data-id');
      f.classList.add('fliegt', 'kompakt'); f.setAttribute('aria-hidden', 'true');
      document.body.append(f); zug.flieger = f;
      k.classList.add('ist-quelle');
    } else {
      liste.classList.add('weichen');
      if (variante === 'ausweichen') k.classList.add('ist-gehoben');
      else {
        const f = k.cloneNode(true); f.removeAttribute('data-id');
        f.classList.add('fliegt', 'schraeg'); f.setAttribute('aria-hidden', 'true');
        f.style.width = r.width + 'px';
        document.body.append(f); zug.flieger = f;
        k.classList.add('ist-luecke');
      }
    }
    zielMarkieren(zug.von);
    sag(`${nameVon(k)} aufgenommen. ${ortText(zug.von)}.`);
    ziehen(x, y);
  }

  function nachbarnSchieben() {
    const { von, nach, abstand, k } = zug;
    karten().forEach((c, j) => {
      if (c === k) return;
      let s = 0;
      if (von < nach && j > von && j <= nach) s = -abstand;
      if (nach < von && j >= nach && j < von) s = abstand;
      c.style.transform = s ? `translateY(${s}px)` : '';
    });
  }

  function ziehen(x, y) {
    if (!zug) return;
    const z = zug;
    if (z.variante === 'linie') {
      z.flieger.style.transform = `translate(${x + W.vorschauVersatz[0]}px, ${y + W.vorschauVersatz[1]}px)`;
      const yRel = y - z.lr.top;
      let ein = 0;                                       // Einfügestelle 0…n
      for (let i = 0; i < z.n; i++) if (yRel > i * z.abstand + z.h / 2) ein = i + 1;
      if (ein === z.von || ein === z.von + 1) { linie.classList.remove('an'); z.nach = z.von; }
      else {
        const spalt = z.abstand - z.h;                   // Mitte des Spalts vor Karte „ein“
        linie.style.top = (liste.offsetTop + ein * z.abstand - spalt / 2 - 4) + 'px';
        linie.style.left = (liste.offsetLeft - 4) + 'px';
        linie.classList.add('an');
        z.nach = ein > z.von ? ein - 1 : ein;
      }
    } else {
      let mitte;
      if (z.variante === 'ausweichen') {
        const d = Math.max(-z.von * z.abstand - 14, Math.min((z.n - 1 - z.von) * z.abstand + 14, y - z.y0));
        z.k.style.transform = `translateY(${d}px) scale(${W.gehobenScale})`;
        mitte = z.von * z.abstand + d + z.h / 2;
      } else {
        const fx = x - z.dx, fy = y - z.dy;
        z.flieger.style.transform = `translate(${fx}px, ${fy}px) rotate(${W.kippenGrad}deg)`;
        mitte = fy - z.lr.top + z.h / 2;
      }
      const nach = Math.max(0, Math.min(z.n - 1, Math.floor((mitte + (z.abstand - z.h) / 2) / z.abstand)));
      if (nach !== z.nach) {
        z.nach = nach; nachbarnSchieben();
        if (z.variante === 'luecke') z.k.style.transform = `translateY(${(nach - z.von) * z.abstand}px)`;
        sag(ortText(nach) + '.');
      }
    }
    zielMarkieren(z.nach);
  }

  function aufraeumen(z) {
    document.body.classList.remove('zieht');
    liste.classList.remove('weichen');
    linie.classList.remove('an');
    karten().forEach(c => { c.style.transform = ''; c.classList.remove('ist-quelle', 'ist-gehoben', 'ist-luecke'); });
    if (z.flieger) z.flieger.remove();
    zielMarkieren(-1);
  }
  function abschliessen(z) {
    aufraeumen(z);
    if (z.nach !== z.von) einsetzen(z.k, z.nach);
    flash(z.k);
    sag(z.nach !== z.von ? `${nameVon(z.k)} abgelegt. ${ortText(z.nach)}.` : `${nameVon(z.k)} bleibt auf Position ${z.von + 1}.`);
  }

  function ablegen() {
    if (!zug) return Promise.resolve();
    const z = zug; zug = null;
    const dauer = reduziert() ? 0 : W.gleitenMs;
    if (z.variante === 'linie' || !dauer) { abschliessen(z); return Promise.resolve(); }
    landet = true;
    const zielY = (z.nach - z.von) * z.abstand;
    let a;
    if (z.variante === 'ausweichen') {
      a = z.k.animate([{ transform: z.k.style.transform }, { transform: `translateY(${zielY}px) scale(1)` }], { duration: dauer, easing: W.nachbarKurve, fill: 'forwards' });
    } else {
      const r = z.k.getBoundingClientRect();                    // die Lücke steht schon am Ziel (bzw. gleitet noch)
      const lr = liste.getBoundingClientRect();
      const zx = r.left, zy = lr.top + z.nach * z.abstand;
      a = z.flieger.animate([{ transform: z.flieger.style.transform }, { transform: `translate(${zx}px, ${zy}px) rotate(0deg)` }], { duration: dauer, easing: W.nachbarKurve, fill: 'forwards' });
    }
    return a.finished.catch(() => {}).then(() => { a.cancel(); abschliessen(z); landet = false; });
  }

  function abbrechen() {
    if (!zug) return;
    const z = zug; zug = null;
    z.nach = z.von; aufraeumen(z);
  }

  // Zeiger-Ereignisse: Maus ab 10 px Bewegung, Touch nach 250 ms Halten (Griff sofort)
  let wartet = null;
  liste.addEventListener('pointerdown', e => {
    if (e.button > 0 || zug || landet || html.classList.contains('vorfuehrung')) return;
    const k = e.target.closest('.gang'); if (!k || e.target.closest('.pfeile')) return;
    const amGriff = !!e.target.closest('.griff');
    wartet = { k, x: e.clientX, y: e.clientY, id: e.pointerId, typ: e.pointerType, uhr: null };
    if (e.pointerType !== 'mouse') {
      if (amGriff) { aufnehmen(k, e.clientX, e.clientY); wartet = null; }
      else wartet.uhr = setTimeout(() => { if (wartet) { aufnehmen(wartet.k, wartet.x, wartet.y); wartet = null; } }, W.touchHaltenMs);
    }
  });
  addEventListener('pointermove', e => {
    if (wartet) {
      const d = Math.hypot(e.clientX - wartet.x, e.clientY - wartet.y);
      if (wartet.typ === 'mouse' && d >= W.mausSchwellePx) { const w = wartet; wartet = null; aufnehmen(w.k, w.x, w.y); ziehen(e.clientX, e.clientY); }
      else if (wartet.typ !== 'mouse' && d > W.touchToleranzPx) { clearTimeout(wartet.uhr); wartet = null; }
      else if (wartet.typ !== 'mouse') { wartet.x = e.clientX; wartet.y = e.clientY; }
      return;
    }
    if (zug && !html.classList.contains('vorfuehrung')) ziehen(e.clientX, e.clientY);
  });
  const loslassen = () => { if (wartet) { clearTimeout(wartet.uhr); wartet = null; } if (zug && !html.classList.contains('vorfuehrung')) { klickSperre = true; ablegen(); } };
  addEventListener('pointerup', loslassen);
  addEventListener('pointercancel', () => { if (wartet) { clearTimeout(wartet.uhr); wartet = null; } if (zug && !html.classList.contains('vorfuehrung')) abbrechen(); });
  // Während eines Touch-Zugs nicht scrollen
  addEventListener('touchmove', e => { if (zug) e.preventDefault(); }, { passive: false });
  let klickSperre = false;
  liste.addEventListener('click', e => { if (klickSperre) { e.stopPropagation(); e.preventDefault(); klickSperre = false; } }, true);
  addEventListener('keydown', e => { if (e.key === 'Escape' && zug) abbrechen(); });

  /* ---------- Knöpfe und Tastatur (WCAG 2.5.7) ---------- */
  liste.addEventListener('click', e => {
    const b = e.target.closest('[data-hoch], [data-runter]'); if (!b) return;
    const k = b.closest('.gang'), von = karten().indexOf(k);
    const ziel = von + (b.hasAttribute('data-hoch') ? -1 : 1);
    if (verschieben(k, ziel)) {
      flash(k); sag(`${nameVon(k)}: ${ortText(ziel)}.`);
      const gleich = k.querySelector(b.hasAttribute('data-hoch') ? '[data-hoch]' : '[data-runter]');
      (getComputedStyle(gleich).visibility === 'hidden' ? k.querySelector('.griff') : gleich).focus();
    }
  });
  let tastZug = null;   // { k, von }
  liste.addEventListener('keydown', e => {
    const g = e.target.closest('.griff'); if (!g) return;
    const k = g.closest('.gang');
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      if (!tastZug) { tastZug = { k, von: karten().indexOf(k) }; k.classList.add('ist-aufgenommen'); sag(`${nameVon(k)} aufgenommen. ${ortText(tastZug.von)}. Pfeiltasten zum Verschieben, Leertaste zum Ablegen.`); }
      else { k.classList.remove('ist-aufgenommen'); flash(k); sag(`${nameVon(k)} abgelegt. ${ortText(karten().indexOf(k))}.`); tastZug = null; }
    } else if (tastZug && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
      e.preventDefault();
      const i = karten().indexOf(k) + (e.key === 'ArrowUp' ? -1 : 1);
      if (verschieben(k, i)) { g.focus(); sag(ortText(i) + '.'); }
    } else if (tastZug && e.key === 'Escape') {
      verschieben(k, tastZug.von); g.focus(); k.classList.remove('ist-aufgenommen');
      sag(`Abgebrochen. ${nameVon(k)} wieder auf Position ${tastZug.von + 1}.`); tastZug = null;
    }
  });
  liste.addEventListener('focusout', e => {
    if (tastZug && e.target.closest('.gang') === tastZug.k && !tastZug.k.contains(e.relatedTarget)) {
      setTimeout(() => { if (tastZug && !tastZug.k.contains(document.activeElement)) { tastZug.k.classList.remove('ist-aufgenommen'); tastZug = null; } }, 0);
    }
  });

  document.querySelector('[data-senden]').addEventListener('click', () => {
    const folge = karten().map(nameVon).join(', ');
    document.querySelector('[data-rueckmeldung]').textContent = 'Notiert. Die Küche schickt: ' + folge.split(', ')[0] + ' zuerst.';
    sag('Gangfolge gesendet: ' + folge + '.');
  });

  /* ---------- Vorführung ---------- */
  let gen = 0, runde = 0, zx = 0, zy = 0;
  const VARIANTEN = ['linie', 'ausweichen', 'luecke'];
  const ZUEGE = [[3, 0], [1, 3], [4, 1], [2, 4], [0, 2]];
  const lebt = g => g === gen;
  const warte = (ms, g) => new Promise(r => setTimeout(() => r(lebt(g)), ms));
  const io = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;     // power2.inOut für den gedachten Zeiger
  function tween(ms, g, fn) {
    return new Promise(r => {
      const s = performance.now();
      const f = t => {
        if (!lebt(g)) return r(false);
        const p = Math.min(1, (t - s) / ms); fn(io(p));
        p < 1 ? requestAnimationFrame(f) : r(true);
      };
      requestAnimationFrame(f);
    });
  }
  const zeigerSetzen = (x, y) => { zx = x; zy = y; zeiger.style.translate = `${x - 3}px ${y - 2}px`; };
  async function fahre(x, y, ms, g) { const ax = zx, ay = zy; return tween(ms, g, p => { zeigerSetzen(ax + (x - ax) * p, ay + (y - ay) * p); if (zug) ziehen(zx, zy); }); }

  async function runden(g) {
    const lr0 = liste.getBoundingClientRect();
    zeigerSetzen(lr0.right - 40, lr0.bottom + 30);
    while (lebt(g)) {
      const v = VARIANTEN[runde % 3]; setzeVariante(v);
      const [von, nach] = ZUEGE[runde % ZUEGE.length]; runde++;
      if (reduziert()) {                                // ohne Bewegung: springen, Flash bleibt
        const k = karten()[von]; zielMarkieren(nach);
        if (verschieben(k, nach, 0)) flash(k);
        if (!await warte(1400, g)) return; zielMarkieren(-1);
        if (!await warte(900, g)) return;
        continue;
      }
      const k = karten()[von], g1 = k.querySelector('.griff').getBoundingClientRect();
      const sx = g1.left + g1.width / 2, sy = g1.top + g1.height / 2;
      if (!await fahre(sx, sy, 650, g)) return;
      zeiger.classList.add('drueckt');
      if (!await warte(160, g)) return;
      aufnehmen(k, sx, sy);
      const lr = liste.getBoundingClientRect(), h = k.offsetHeight, ab = zug.abstand;
      const dy = sy - k.getBoundingClientRect().top;
      const ty = v === 'linie' ? lr.top + nach * ab + (nach < von ? h * .28 : h * .72) : lr.top + nach * ab + dy;
      if (!await fahre(sx + 34, (sy + ty) / 2, 520, g)) return;
      if (!await fahre(sx + 12, ty, 880, g)) return;
      if (!await warte(420, g)) return;
      zeiger.classList.remove('drueckt');
      await ablegen();
      if (!await warte(500, g)) return;
      if (!await fahre(lr.right - 60, Math.min(innerHeight - 30, lr.bottom + 26), 700, g)) return;
      if (!await warte(900, g)) return;
    }
  }

  function anhalten() { gen++; zeiger.classList.remove('drueckt'); }
  function neustart() {
    anhalten(); abbrechen(); if (landet) return setTimeout(neustart, W.gleitenMs + 20); html.classList.remove('vorfuehrung'); runde = 0;
    ursprung.forEach(id => liste.append(liste.querySelector(`[data-id="${id}"]`)));
    zielMarkieren(-1);
  }
  function vorfuehren() {
    anhalten(); abbrechen();
    html.classList.add('vorfuehrung');
    runden(gen);
  }
  function pause() { anhalten(); }
  function weiter() { if (html.classList.contains('vorfuehrung')) vorfuehren(); }

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