Zum Inhalt
← Hover

Produktkarte mit Bildwechsel

Produktkarte, die bei Hover oder Fokus ein zweites Bild zeigt oder mit Bildzonen durch vier Bilder blättert; auf dem Telefon wischbar, Preis inkl. MwSt. und Zustand stehen fest und springen nie.

Mode, Optik, Laden, Möbel: genau ein zweites Bild (Detail oder Einsatz), ruhige Liste ohne Bedienelemente im Bild.

Eine Produktkarte zeigt in Ruhe immer denselben Bildtyp, meist den Freisteller. Bei Hover oder Tastaturfokus wechselt sie zu einem zweiten Bild: Seitenansicht, Detail oder Anwendung. Mit mehreren Bildern teilt sich die Bildfläche in unsichtbare Zonen, die Zeigerposition wählt das Bild, Punkte zeigen, wo man ist. Auf dem Telefon wischt man die Bilder in der Karte, ein Tippen führt zur Produktseite. Name, Preis inkl. MwSt. und Verfügbarkeit stehen immer sichtbar und springen nie.

Das zweite Bild beantwortet die nächste Frage, bevor man klickt: Wie sieht es von der Seite aus, wie groß ist es, wie wirkt es im Einsatz. Baymard empfiehlt für visuell geprägte Ware die Kombination aus Freisteller und Bild im Einsatz, mit einem einheitlichen Standardbild. Weil Preis und Name fest stehen, bleibt die Liste ruhig und vergleichbar.

Wann einsetzen

Sortimente, bei denen die Form zählt: Brillen und Hörgeräte, Mode, Schuhe, Möbel, Keramik, Fahrräder, Spielwaren, Bücher (Umschlag und Innenseite). Auf Kategorieseiten, Neuheiten-Reihen und Empfehlungen, auch ohne vollen Shop.

Wann nicht

Wenn es kein echtes zweites Bild gibt; ein gespiegeltes oder nur vergrößertes Bild verwirrt. Nicht für Dienstleistungen oder Texte. Wichtige Angaben (Preis, Farbe, Verfügbarkeit) nie nur im Hover-Zustand zeigen. Auf Touch nicht „erst tippen zeigt Bild 2, zweites Tippen öffnet“, das bricht die Erwartung.

Varianten

  • Zweitbild: ein zweites Bild bei Hover und Fokus, auf Touch nur das erste
  • Bildzonen: drei bis vier Bilder, die Zeigerposition wählt das Bild, Punkte als Tasten; auf Touch Wischreihe mit Scroll-Snap
  • Zustände ohne Sprung: neu, nur im Laden, letztes Stück, zurzeit nicht vorrätig

Umsetzung

Jede Karte ist ein <article> mit einem Link auf den Namen, der per ::after die ganze Karte abdeckt (wie karte-ganzflaechig-klickbar). Die Bildfläche hat ein festes aspect-ratio, alle Bilder liegen per grid-area übereinander, nur das aktive hat opacity 1. Zweitbild: :hover und :focus-within der Karte blenden Bild 2 ein (nur unter @media (hover: hover)), dazu scale 1.03 → 1. Bildzonen: pointermove rechnet aus der x-Position den Index (Math.floor(x / Breite × Anzahl)), pointerleave setzt auf Bild 1 zurück; die Punkte sind echte Knöpfe in einer Gruppe mit einem Tab-Stopp und Pfeiltasten, sie liegen per z-index über dem Karten-Link. Unter @media (hover: none) wird die Bildfläche zu einer waagerechten Reihe mit scroll-snap-type: x mandatory, die Punkte folgen per scroll-Ereignis. Zweite Bilder erst bei pointerenter oder im Sichtbereich laden (loading=lazy, decode()). Preis mit Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}), Zusatz „inkl. MwSt.“ und bei Versand „zzgl. Versand“ mit Link. Zustände (neu, nur im Laden, letztes Stück, nicht vorrätig) stehen in einer Zeile mit fester Höhe, damit nichts springt. Preisangaben vom Kunden prüfen lassen.

Robust umsetzen

Telefon. Zwei Karten nebeneinander, ab 360 px eine. Bildzonen werden zur Wischreihe mit Scroll-Snap und Punkten darunter; senkrechtes Scrollen der Seite bleibt frei (touch-action: pan-x pan-y). Ein Tippen auf die Karte öffnet die Produktseite. Im Zweitbild-Modus zeigt das Telefon nur das erste Bild.

Ohne Bewegung. Bilder wechseln sofort ohne Überblenden und ohne Skalierung. Die Wischreihe springt ohne weiches Scrollen.

Barrierefrei. Ein Link je Karte mit dem Produktnamen; Preis und Zustand stehen als Text in der Karte, nicht im Bild. Bild 1 hat einen Alternativtext, weitere Bilder sind aria-hidden, solange sie nicht aktiv sind, oder werden über die Punkte benannt („Bild 2 von 4: Seitenansicht“). Fokus auf der Karte zeigt dasselbe wie Hover. Punkte als Knöpfe mit aria-pressed, Trefferfläche mindestens 24 px.

Code

<!DOCTYPE html>
<!--
  Produktkarte mit Bildwechsel
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/produktkarte-bildwechsel/
  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>Produktkarte mit Bildwechsel · 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: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 400 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; 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; }

/* Rezept optik-hoerakustik-sehen-hoeren: Papierweiß, Schildpatt, Tinte, Tintenblau nur für die Handlung */
:root {
  --grund: #F4F5F3; --text: #121417; --flaeche: #E8DED3; --dunkel: #1C2026; --signal: #2442A8;
  --leise: #5A5F67; --linie: #D3D6D2; --bild: #ECEDEA;
  --ein: cubic-bezier(0.16, 1, 0.3, 1);   /* UI öffnen (token-toggle-asymmetrisch) */
  --aus: cubic-bezier(0.7, 0, 0.84, 0);   /* UI schließen */
  --t-ein: 300ms; --t-aus: 200ms; --t-ui: 150ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Mona Sans', system-ui, sans-serif; font-size: 100%; -webkit-text-size-adjust: 100%; scroll-padding-bottom: 88px; }
body { min-height: 100svh; font-size: 1.0625rem; line-height: 1.6; }
:focus-visible { outline: 2px solid var(--signal); outline-offset: 3px; }
:root:has(:focus-visible) { scroll-behavior: auto; }
a { color: inherit; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.mono { font-family: 'Fragment Mono', ui-monospace, monospace; font-size: .8125rem; letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.label { font: 400 .75rem/1 'Fragment Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }

/* Kopf des Ausschnitts */
.kopf { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 24px; padding: 20px clamp(16px, 4vw, 56px); border-bottom: 1px solid var(--linie); }
.marke { font: italic 500 1.5rem/1 'Bodoni Moda', serif; letter-spacing: -.01em; text-decoration: none; font-variation-settings: 'opsz' 28; }
.marke span { font-style: normal; font-family: 'Mona Sans', sans-serif; font-weight: 600; font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; font-stretch: 112%; margin-left: .5em; color: var(--leise); }
.kopf nav { display: flex; flex-wrap: wrap; gap: 4px 22px; font-size: .9375rem; }
.kopf nav a { text-decoration: none; padding: 4px 0; background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat; transition: background-size var(--t-aus) var(--aus); }
.kopf nav a:hover, .kopf nav a[aria-current] { background-size: 100% 1px; transition: background-size var(--t-ein) var(--ein); }

main { padding: clamp(40px, 6vw, 88px) clamp(16px, 4vw, 56px) clamp(48px, 7vw, 112px); max-width: 1440px; margin: 0 auto; }
.intro { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 16px 48px; align-items: end; margin-bottom: clamp(28px, 4vw, 48px); }
.intro h1 { font: 400 clamp(2.25rem, 1.4rem + 3.4vw, 4.25rem)/1 'Bodoni Moda', serif; letter-spacing: -.015em; font-variation-settings: 'opsz' 96; text-wrap: balance; }
.intro h1 em { font-style: italic; }
.intro p { max-width: 40ch; color: var(--text); text-wrap: pretty; }
.intro .label { display: block; margin-bottom: 18px; }

/* Raster */
.raster { list-style: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(28px, 3vw, 40px) clamp(14px, 2vw, 28px); }

/* Karte */
.karte { position: relative; container-type: inline-size; }
.karte:has(.name a:focus-visible) { outline: 2px solid var(--signal); outline-offset: 6px; }
.name a:focus-visible { outline: none; } /* Ersatz: Rahmen um die ganze Karte (Zeile oben) */
.bildflaeche { position: relative; aspect-ratio: 1; background: var(--bild); border: 1px solid var(--linie); overflow: hidden; }
.bilder { position: absolute; inset: 0; display: grid; }
.bilder > svg { grid-area: 1 / 1; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity var(--t-aus) var(--aus), scale var(--t-aus) var(--aus); }
.bilder > svg.ist { opacity: 1; transition: opacity var(--t-ein) var(--ein), scale var(--t-ein) var(--ein); z-index: 1; }
.bilder > svg:not(:first-child) { scale: 1.03; }
.bilder > svg.ist:not(:first-child) { scale: 1; }
/* Zustand oben links, feste Höhe */
.zustand { position: absolute; z-index: 3; top: 10px; left: 10px; font: 400 .6875rem/1 'Fragment Mono', monospace; letter-spacing: .08em; text-transform: uppercase; padding: 6px 8px 5px; background: var(--grund); color: var(--text); }
.zustand.leer { display: none; }
/* Bildname unten links (nur Zonen) */
.bildname { position: absolute; z-index: 3; left: 10px; bottom: 10px; font: 400 .6875rem/1 'Fragment Mono', monospace; letter-spacing: .06em; color: var(--text); background: var(--grund); padding: 6px 8px 5px; opacity: 0; translate: 0 4px; transition: opacity var(--t-aus) var(--aus), translate var(--t-aus) var(--aus); pointer-events: none; }
.karte.an .bildname { opacity: 1; translate: 0 0; transition: opacity var(--t-ein) var(--ein), translate var(--t-ein) var(--ein); }
/* Punkte */
.punkte { position: absolute; z-index: 3; right: 6px; bottom: 4px; display: flex; gap: 0; }
.punkte button, .punkte span { display: grid; place-items: center; width: 24px; height: 28px; border: 0; background: none; padding: 0; }
.punkte button { cursor: pointer; }
.punkte i { display: block; width: 6px; height: 6px; border-radius: 3px; background: var(--leise); opacity: .55; transition: width var(--t-ui) var(--ein), background-color var(--t-ui) var(--ein), opacity var(--t-ui) var(--ein); }
.punkte [aria-pressed="true"] i, .punkte .ist i { width: 16px; background: var(--text); opacity: 1; }
.punkte button:focus-visible { outline-offset: -2px; }

/* Text der Karte */
.text { padding-top: 14px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: baseline; }
.name { font: italic 400 1.5rem/1.1 'Bodoni Moda', serif; font-variation-settings: 'opsz' 28; letter-spacing: -.005em; }
.name a { text-decoration: none; }
.name a::after { content: ''; position: absolute; inset: 0; z-index: 2; }
.preis { font-weight: 600; font-variant-numeric: tabular-nums; font-size: 1.0625rem; text-align: right; white-space: nowrap; }
.spez { grid-column: 1 / -1; color: var(--leise); }
.fuss { grid-column: 1 / -1; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 10px; align-items: center; min-height: 1.6em; font-size: .8125rem; color: var(--leise); }
.farben { display: inline-flex; align-items: center; gap: 6px; }
.farben b { width: 11px; height: 11px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(18, 20, 23, .18); }
.karte:hover .name a, .karte:focus-within .name a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: .18em; }
.nicht-vorraetig .preis { color: var(--leise); }

@container (max-width: 230px) {
  .bildname { display: none; }
  .text { grid-template-columns: minmax(0, 1fr); }
  .preis { text-align: left; }
  .name { font-size: 1.3125rem; }
  .fuss { flex-direction: column; align-items: flex-start; }
}

.hinweis { margin-top: clamp(32px, 4vw, 48px); padding-top: 18px; border-top: 1px solid var(--linie); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; font-size: .875rem; color: var(--leise); }
.hinweis p { max-width: 70ch; }
.hinweis p a { color: var(--text); text-underline-offset: .2em; }
.termin { display: inline-flex; align-items: center; gap: 10px; padding: 12px 20px; border-radius: 999px; background: var(--signal); color: #fff; text-decoration: none; font-weight: 600; font-size: 1rem; white-space: nowrap; }
.termin:hover { background: #1b3486; }

/* Gedachter Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%; border: 1.5px solid var(--text); background: rgba(244, 245, 243, .5); pointer-events: none; z-index: 50; opacity: 0; transition: opacity 200ms; }
.vorfuehrung .zeiger { opacity: 1; }

/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 40; display: flex; align-items: center; gap: 4px; padding: 5px; background: rgba(28, 32, 38, .92); color: #f4f5f3; border-radius: 999px; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.pult span { padding: 0 8px 0 10px; opacity: .72; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; background: none; border: 0; border-radius: 999px; padding: 9px 12px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #f4f5f3; color: var(--dunkel); }
.pult button:focus-visible { outline: 2px solid #8fa4ff; outline-offset: 1px; }

/* Touch: Zweitbild zeigt nur Bild 1, Bildzonen werden zur Wischreihe */
@media (hover: none) {
  :root[data-v="zweitbild"] .punkte { display: none; }
  :root[data-v="zonen"] .bilder { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain; touch-action: pan-x pan-y; }
  :root[data-v="zonen"] .bilder::-webkit-scrollbar { display: none; }
  :root[data-v="zonen"] .bilder > svg { flex: 0 0 100%; opacity: 1; scale: 1; scroll-snap-align: start; transition: none; }
  :root[data-v="zonen"] .bildname { opacity: 1; translate: 0 0; }
  :root[data-v="zonen"] .name a::after { bottom: auto; top: calc(100cqw + 2px); height: 120px; } /* Bildreihe bleibt wischbar, Tippen auf Text öffnet */
  :root[data-v="zonen"] .bilder { cursor: pointer; }
}

@media (max-width: 1024px) { .raster { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .intro { grid-template-columns: minmax(0, 1fr); } .kopf nav { display: none; } }
@media (max-width: 360px) { .raster { grid-template-columns: minmax(0, 1fr); } }

/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .hinweis { display: none; }
.vorschau main { padding-top: 40px; }
.vorschau .raster { grid-template-columns: repeat(4, minmax(0, 1fr)); }

:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
:root.reduziert .bilder > svg { scale: 1 !important; }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
  .punkte [aria-pressed="true"] i { background: Highlight; }
  .karte:has(.name a:focus-visible) { outline-color: Highlight; }
}
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#">Feinsicht<span>Optik · Schwäbisch Gmünd</span></a>
  <nav aria-label="Hauptmenü">
    <a href="#" aria-current="page">Fassungen</a><a href="#">Sehtest</a><a href="#">Kontaktlinsen</a><a href="#">Hören</a><a href="#">Werkstatt</a>
  </nav>
</header>

<main>
  <div class="intro">
    <div>
      <span class="label">Neu im Laden · Herbst 2026</span>
      <h1>Vier Fassungen, <em>von nah gesehen</em></h1>
    </div>
    <p>Acetat aus Italien, Titan aus Japan, gefertigt in kleinen Serien. Alle Modelle zum Anprobieren bei uns im Laden, ohne Termin.</p>
  </div>

  <ul class="raster" data-raster aria-label="Neue Fassungen"></ul>

  <div class="hinweis">
    <p>Alle Preise sind Endpreise inkl. MwSt. und gelten für die Fassung ohne Brillengläser. Gläser nach Ihrer Sehstärke berechnen wir nach der Beratung. Reservieren und Abholen im Laden, kein Versand.</p>
    <a class="termin" href="#">Anprobe-Termin buchen</a>
  </div>
</main>

<div class="zeiger" aria-hidden="true"></div>

<div class="pult" role="group" aria-label="Variante der Demo">
  <span>Variante</span>
  <button type="button" data-wahl="zweitbild" aria-pressed="true">Zweitbild</button>
  <button type="button" data-wahl="zonen" aria-pressed="false">Bildzonen</button>
</div>

<!-- Gemeinsame Zeichenmittel der Fassungsbilder -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <defs>
    <filter id="havanna" x="-5%" y="-5%" width="110%" height="110%" color-interpolation-filters="sRGB">
      <feTurbulence type="fractalNoise" baseFrequency="0.022 0.055" numOctaves="3" seed="7" result="n"/>
      <feColorMatrix in="n" type="matrix" values="0 0 0 0 .19  0 0 0 0 .09  0 0 0 0 .03  0 0 0 -3.4 2.25" result="flecken"/>
      <feFlood flood-color="#B9772F" result="grund"/>
      <feComposite in="flecken" in2="grund" operator="over" result="mix"/>
      <feComposite in="mix" in2="SourceGraphic" operator="in"/>
    </filter>
    <filter id="havanna-dunkel" x="-5%" y="-5%" width="110%" height="110%" color-interpolation-filters="sRGB">
      <feTurbulence type="fractalNoise" baseFrequency="0.02 0.05" numOctaves="3" seed="3" result="n"/>
      <feColorMatrix in="n" type="matrix" values="0 0 0 0 .1  0 0 0 0 .05  0 0 0 0 .02  0 0 0 -3 2.3" result="flecken"/>
      <feFlood flood-color="#6B3E1C" result="grund"/>
      <feComposite in="flecken" in2="grund" operator="over" result="mix"/>
      <feComposite in="mix" in2="SourceGraphic" operator="in"/>
    </filter>
    <filter id="schatten" x="-20%" y="-200%" width="140%" height="500%"><feGaussianBlur stdDeviation="9"/></filter>
    <filter id="weich" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="8"/></filter>
    <linearGradient id="matt" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#34363b"/><stop offset=".45" stop-color="#17181b"/><stop offset="1" stop-color="#0d0e10"/></linearGradient>
    <linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#e9d3a1"/><stop offset=".4" stop-color="#b48a45"/><stop offset=".7" stop-color="#f1dfb4"/><stop offset="1" stop-color="#8e6a30"/></linearGradient>
    <linearGradient id="silber" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f2f3f4"/><stop offset=".45" stop-color="#9aa0a6"/><stop offset=".7" stop-color="#e7e9eb"/><stop offset="1" stop-color="#6e747a"/></linearGradient>
    <linearGradient id="rose" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f3d3c4"/><stop offset=".45" stop-color="#b57a66"/><stop offset=".7" stop-color="#f0cdbd"/><stop offset="1" stop-color="#8d5645"/></linearGradient>
    <radialGradient id="niet" cx=".35" cy=".35" r=".7"><stop offset="0" stop-color="#ffffff"/><stop offset=".5" stop-color="#b9bcc0"/><stop offset="1" stop-color="#6c7076"/></radialGradient>
    <linearGradient id="glas" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity=".2"/></linearGradient>
  </defs>
</svg>

<p class="vh" role="status" aria-live="polite" data-ansage></p>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
  const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
  const touch = matchMedia('(hover: none)');

  // Farben der Fassungen: Textur (Filter), Verlauf oder Fläche
  const FARBE = {
    havanna: { name: 'Havanna', farbe: '#9a5f2a', mal: { fill: '#fff', filter: 'url(#havanna)' } },
    honig: { name: 'Honig', farbe: '#d39a4c', mal: { fill: 'rgba(214,150,70,.82)', stroke: 'rgba(255,230,190,.55)' } },
    oliv: { name: 'Oliv', farbe: '#5f6a45', mal: { fill: '#5d6844' } },
    schwarz: { name: 'Schwarz matt', farbe: '#141517', mal: { fill: 'url(#matt)' } },
    dunkelhavanna: { name: 'Havanna dunkel', farbe: '#4e2d15', mal: { fill: '#fff', filter: 'url(#havanna-dunkel)' } },
    blaugrau: { name: 'Blaugrau', farbe: '#5b6878', mal: { fill: '#566373' } },
    rose: { name: 'Kristall Rosé', farbe: '#e6b3a8', mal: { fill: 'rgba(214,146,134,.55)', stroke: 'rgba(255,255,255,.7)' } },
    kristall: { name: 'Kristall', farbe: '#e9ecee', mal: { fill: 'rgba(200,208,214,.5)', stroke: 'rgba(255,255,255,.85)' } },
    bordeaux: { name: 'Bordeaux', farbe: '#6d1f2c', mal: { fill: '#6a1e2b' } },
    gold: { name: 'Gold', farbe: '#c49a55', mal: { metall: 'url(#gold)' } },
    silber: { name: 'Silber', farbe: '#b9bec3', mal: { metall: 'url(#silber)' } },
    rosegold: { name: 'Roségold', farbe: '#c99080', mal: { metall: 'url(#rose)' } }
  };

  const PRODUKTE = [
    { id: 'remstal', name: 'Remstal', form: 'panto', farben: ['havanna', 'honig', 'oliv'], material: 'Acetat', mass: '49□21 · 145', preis: 189, zustand: 'Neu', bildgrund: '#ECEDEA', detailgrund: '#E8DED3' },
    { id: 'rechberg', name: 'Rechberg', form: 'eckig', farben: ['schwarz', 'dunkelhavanna', 'blaugrau'], material: 'Acetat', mass: '52□18 · 145', preis: 169, zustand: '', bildgrund: '#ECEDEA', detailgrund: '#DCDFE0' },
    { id: 'staufen', name: 'Staufen', form: 'katze', farben: ['rose', 'kristall', 'bordeaux'], material: 'Acetat', mass: '51□19 · 140', preis: 199, zustand: 'Letztes Stück', bildgrund: '#ECEDEA', detailgrund: '#EADBD5' },
    { id: 'bargau', name: 'Bargau', form: 'rund', farben: ['gold', 'silber', 'rosegold'], material: 'Titan', mass: '47□22 · 145', preis: 249, zustand: 'Nur im Laden', bildgrund: '#ECEDEA', detailgrund: '#E4E1D8' }
  ];
  const BILDNAMEN = ['Ansicht von vorn', 'Seitenansicht', 'Bügel und Scharnier im Detail', 'Drei Farben'];
  const KURZ = ['Vorn', 'Seite', 'Detail', 'Farben'];

  // ---------- Zeichnung: Linsenform je Fassung ----------
  function linse(form, cx, cy, a, b) {
    if (form === 'rund') return `M${cx - a} ${cy}a${a} ${b} 0 1 0 ${2 * a} 0a${a} ${b} 0 1 0 ${-2 * a} 0Z`;
    if (form === 'eckig') { const r = a * .34; return `M${cx - a + r} ${cy - b}H${cx + a - r}Q${cx + a} ${cy - b} ${cx + a} ${cy - b + r}V${cy + b - r * 1.2}Q${cx + a} ${cy + b} ${cx + a - r * 1.3} ${cy + b}H${cx - a + r * 1.3}Q${cx - a} ${cy + b} ${cx - a} ${cy + b - r * 1.2}V${cy - b + r}Q${cx - a} ${cy - b} ${cx - a + r} ${cy - b}Z`; }
    if (form === 'katze') return `M${cx - a * 1.04} ${cy - b * 1.02}C${cx - a * .4} ${cy - b * 1.02} ${cx + a * .55} ${cy - b * .98} ${cx + a * .9} ${cy - b * .6}C${cx + a * 1.08} ${cy - b * .2} ${cx + a * .95} ${cy + b * .86} ${cx + a * .2} ${cy + b * .97}C${cx - a * .5} ${cy + b * 1.06} ${cx - a * .96} ${cy + b * .58} ${cx - a} ${cy - b * .1}C${cx - a * 1.02} ${cy - b * .5} ${cx - a * 1.12} ${cy - b * .92} ${cx - a * 1.04} ${cy - b * 1.02}Z`;
    // panto: oben flacher, unten rund
    return `M${cx - a} ${cy - b * .25}C${cx - a} ${cy - b * .95} ${cx - a * .55} ${cy - b} ${cx} ${cy - b}C${cx + a * .55} ${cy - b} ${cx + a} ${cy - b * .95} ${cx + a} ${cy - b * .25}C${cx + a} ${cy + b * .62} ${cx + a * .55} ${cy + b} ${cx} ${cy + b}C${cx - a * .55} ${cy + b} ${cx - a} ${cy + b * .62} ${cx - a} ${cy - b * .25}Z`;
  }
  const MASS = { panto: [68, 59], eckig: [74, 50], katze: [72, 55], rund: [61, 61] };

  // Vorderansicht: zwei Linsen, Steg, Backen; Acetat als dicke Kontur, Titan als feiner Draht
  function vorn(p, farbe, s = 1, ox = 200, oy = 200) {
    const f = FARBE[farbe].mal, [a, b] = MASS[p.form].map(v => v * s), abst = (p.form === 'rund' ? 30 : 24) * s;
    const lx = ox - abst / 2 - a, rx = ox + abst / 2 + a, metall = !!f.metall;
    const t = metall ? 3.2 * s : 10 * s;
    const L = linse(p.form, lx, oy, a, b);
    const R = `<g transform="translate(${2 * ox} 0) scale(-1 1)"><path d="${L}"/></g>`;
    const steg = p.form === 'panto' ? `M${lx + a * .82} ${oy - b * .58}C${ox - abst * .3} ${oy - b * .9} ${ox + abst * .3} ${oy - b * .9} ${rx - a * .82} ${oy - b * .58}` : `M${lx + a * .9} ${oy - b * .45}Q${ox} ${oy - b * .78} ${rx - a * .9} ${oy - b * .45}`;
    const backeL = `M${lx - a * .98} ${oy - b * .48}l${-16 * s} ${-3 * s}`, backeR = `M${rx + a * .98} ${oy - b * .48}l${16 * s} ${-3 * s}`;
    let rahmen;
    if (metall) {
      rahmen = `<g fill="none" stroke="${f.metall}" stroke-width="${t}" stroke-linecap="round"><path d="${L}"/>${R}<path d="${steg}" stroke-width="${t * .9}"/><path d="${backeL}" stroke-width="${t * 1.3}"/><path d="${backeR}" stroke-width="${t * 1.3}"/>
        <path d="M${lx + a * .78} ${oy - b * .66}Q${ox} ${oy - b * 1.05} ${rx - a * .78} ${oy - b * .66}" stroke-width="${t * .7}"/></g>
        <g fill="rgba(255,255,255,.55)" stroke="${f.metall}" stroke-width="${1.2 * s}"><ellipse cx="${ox - abst * .55}" cy="${oy + b * .05}" rx="${4.5 * s}" ry="${7 * s}"/><ellipse cx="${ox + abst * .55}" cy="${oy + b * .05}" rx="${4.5 * s}" ry="${7 * s}"/></g>`;
    } else {
      // Kontur als Maske, damit Textur oder Verlauf die ganze Fassung füllt
      const id = `m-${p.id}-${farbe}-${Math.round(s * 100)}-${ox}-${oy}`;
      rahmen = `<mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="400" height="400"><g fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="${t}"><path d="${L}"/>${R.replace('<path', `<path`)}<path d="${steg}" stroke-width="${t * 1.05}"/><path d="${backeL}" stroke-width="${t * 1.25}" stroke-linecap="round"/><path d="${backeR}" stroke-width="${t * 1.25}" stroke-linecap="round"/></g></mask>
        <g mask="url(#${id})"><rect x="0" y="0" width="400" height="400" fill="${f.fill}" ${f.filter ? `filter="${f.filter}"` : ''}/></g>
        ${f.stroke ? `<g fill="none" stroke="${f.stroke}" stroke-width="${1.1 * s}" opacity=".9"><path d="${linse(p.form, lx, oy, a - t * .32, b - t * .32)}"/><g transform="translate(${2 * ox} 0) scale(-1 1)"><path d="${linse(p.form, lx, oy, a - t * .32, b - t * .32)}"/></g></g>` : ''}
        <g fill="none" stroke="rgba(255,255,255,.28)" stroke-width="${1.3 * s}"><path d="${linse(p.form, lx, oy - t * .18, a + t * .28, b + t * .18)}" stroke-dasharray="${a * 1.1} ${a * 6}" stroke-dashoffset="${-a * .4}"/></g>`;
    }
    const glas = `<g fill="url(#glas)"><path d="${L}"/>${R}</g>`;
    return glas + rahmen;
  }

  // Seitenansicht: Fassung von der Kante, Bügel mit Endstück
  function seite(p, farbe) {
    const f = FARBE[farbe].mal, metall = !!f.metall, [, b] = MASS[p.form];
    const front = `M94 ${200 - b}C86 ${200 - b * .4} 86 ${200 + b * .5} 96 ${200 + b}L112 ${200 + b * .98}C104 ${200 + b * .4} 104 ${200 - b * .4} 112 ${200 - b * .98}Z`;
    const buegel = `M104 ${200 - b * .5}L300 ${200 - b * .35}C318 ${200 - b * .34} 326 ${200 - b * .2} 332 ${200 - b * .05}C344 ${200 + b * .3} 350 ${200 + b * .7} 346 ${200 + b * 1.05}`;
    if (metall) return `<path d="${front}" fill="none" stroke="${f.metall}" stroke-width="3"/><path d="${buegel}" fill="none" stroke="${f.metall}" stroke-width="3.4" stroke-linecap="round"/>
      <path d="M318 ${200 - b * .32}C334 ${200 - b * .25} 346 ${200 + b * .3} 344 ${200 + b * 1.02}" fill="none" stroke="#2b2b2b" stroke-width="7" stroke-linecap="round" opacity=".85"/>
      <rect x="100" y="${200 - b * .58}" width="16" height="9" rx="2" fill="${f.metall}"/><ellipse cx="114" cy="${200 + b * .1}" rx="4" ry="7" fill="rgba(255,255,255,.6)" stroke="${f.metall}"/>`;
    const id = `ms-${p.id}-${farbe}`;
    return `<mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="400" height="400"><path d="${front}" fill="#fff"/><path d="${buegel}" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/></mask>
      <g mask="url(#${id})"><rect width="400" height="400" fill="${f.fill}" ${f.filter ? `filter="${f.filter}"` : ''}/></g>
      <rect x="104" y="${200 - b * .58}" width="22" height="9" rx="1.5" fill="url(#silber)"/><circle cx="110" cy="${200 - b * .535}" r="2" fill="url(#niet)"/><circle cx="120" cy="${200 - b * .53}" r="2" fill="url(#niet)"/>
      <path d="M140 ${200 - b * .58}L290 ${200 - b * .46}" stroke="rgba(255,255,255,.3)" stroke-width="1.2"/>`;
  }

  // Detail: Bügel innen mit Gravur und Scharnier, nah fotografiert (Rand und Bügel laufen aus dem Bild)
  function detail(p, farbe) {
    const f = FARBE[farbe].mal, metall = !!f.metall;
    const gravur = `${p.name.toUpperCase()}  ${p.mass.replace(' · ', '  ')}`;
    const rand = 'M-70 30C80 20 168 110 166 214C164 318 86 392 -50 450';
    const buegel = 'M178 206L470 262';
    const winkel = Math.atan2(56, 292) * 180 / Math.PI;
    const formen = (dicke) => `<path d="${rand}" stroke-width="${dicke[0]}"/><path d="${buegel}" stroke-width="${dicke[1]}"/>`;
    if (metall) return `<g fill="none" stroke="rgba(18,20,23,.22)" stroke-linecap="round" transform="translate(10 22)" filter="url(#weich)">${formen([14, 16])}</g>
      <g fill="none" stroke="${f.metall}" stroke-linecap="round">${formen([12, 14])}</g>
      <g transform="translate(166 206) rotate(${winkel})"><rect x="-10" y="-17" width="70" height="34" rx="6" fill="${f.metall}"/><circle cx="8" cy="0" r="6" fill="url(#niet)"/><circle cx="40" cy="0" r="6" fill="url(#niet)"/>
      <text x="78" y="22" font-family="Fragment Mono, monospace" font-size="10.5" letter-spacing="1.6" fill="rgba(40,30,20,.62)">FEINSICHT · ${gravur}</text></g>`;
    const id = `md-${p.id}-${farbe}`;
    return `<mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="400" height="400"><g fill="none" stroke="#fff" stroke-linecap="round">${formen([40, 46])}</g><rect x="140" y="170" width="52" height="70" rx="10" fill="#fff"/></mask>
      <g fill="none" stroke="rgba(18,20,23,.24)" stroke-linecap="round" transform="translate(12 24)" filter="url(#weich)">${formen([40, 46])}</g>
      <g mask="url(#${id})"><rect width="400" height="400" fill="${f.fill}" ${f.filter ? `filter="${f.filter}"` : ''}/></g>
      <path d="M-60 18C90 8 182 104 180 214" fill="none" stroke="rgba(255,255,255,.32)" stroke-width="2"/>
      <g transform="translate(166 206) rotate(${winkel})"><path d="M20 -23H400" stroke="rgba(255,255,255,.34)" stroke-width="2"/>
      <rect x="2" y="-13" width="78" height="26" rx="3" fill="url(#silber)"/><circle cx="18" cy="0" r="5.5" fill="url(#niet)"/><circle cx="62" cy="0" r="5.5" fill="url(#niet)"/>
      <text x="94" y="6" font-family="Fragment Mono, monospace" font-size="10.5" letter-spacing="1.8" fill="rgba(255,248,236,.82)">FEINSICHT · ${gravur}</text></g>`;
  }

  function svg(grund, inhalt, label, hidden) {
    return `<svg viewBox="0 0 400 400" ${hidden ? 'aria-hidden="true"' : `role="img" aria-label="${label}"`} focusable="false"><rect width="400" height="400" fill="${grund}"/>${inhalt}</svg>`;
  }
  function bilder(p, v) {
    const f0 = p.farben[0];
    const schatten = `<ellipse cx="200" cy="${200 + MASS[p.form][1] + 44}" rx="160" ry="8" fill="rgba(18,20,23,.14)" filter="url(#schatten)"/>`;
    const b = [
      svg(p.bildgrund, schatten + vorn(p, f0), `${p.name} in ${FARBE[f0].name}, von vorn`),
      svg(p.detailgrund, detail(p, f0), '', true),
      svg('#EFEFEC', `<ellipse cx="225" cy="${200 + MASS[p.form][1] + 50}" rx="130" ry="8" fill="rgba(18,20,23,.14)" filter="url(#schatten)"/>` + seite(p, f0), '', true),
      svg('#F1F1EE', p.farben.map((fa, i) => vorn(p, fa, .46, 200, 92 + i * 108)).join('') + p.farben.map((fa, i) => `<text x="22" y="${96 + i * 108}" font-family="Fragment Mono, monospace" font-size="11" letter-spacing="1" fill="#5A5F67">${String(i + 1).padStart(2, '0')}</text>`).join(''), '', true)
    ];
    return v === 'zweitbild' ? [b[0], b[1]] : [b[0], b[2], b[1], b[3]];
  }
  const reihenfolge = v => v === 'zweitbild' ? [0, 2] : [0, 1, 2, 3];

  // ---------- Karten bauen ----------
  let v = 'zweitbild';
  const raster = $('[data-raster]');
  function bauen() {
    raster.innerHTML = PRODUKTE.map(p => {
      const nimm = reihenfolge(v), n = nimm.length;
      const punkte = v === 'zonen'
        ? `<div class="punkte" role="group" aria-label="Bilder von ${p.name}">${nimm.map((k, i) => `<button type="button" data-punkt="${i}" aria-pressed="${i === 0}" tabindex="${i === 0 ? 0 : -1}" aria-label="Bild ${i + 1} von ${n}: ${BILDNAMEN[k]}"><i></i></button>`).join('')}</div>`
        : `<div class="punkte" aria-hidden="true">${nimm.map((k, i) => `<span class="${i === 0 ? 'ist' : ''}"><i></i></span>`).join('')}</div>`;
      const nicht = p.zustand === 'Nur im Laden';
      return `<li class="karte${nicht ? '' : ''}" data-id="${p.id}" data-n="${n}">
        <div class="bildflaeche">
          <div class="bilder">${bilder(p, v).map((s, i) => i === 0 ? s.replace('<svg', '<svg class="ist"') : s).join('')}</div>
          <span class="zustand${p.zustand ? '' : ' leer'}">${p.zustand}</span>
          <span class="bildname" aria-hidden="true" data-bildname>01 · ${KURZ[nimm[0]]}</span>
          ${punkte}
        </div>
        <div class="text">
          <h2 class="name"><a href="#${p.id}">${p.name}</a></h2>
          <p class="preis"><span class="vh">Preis: </span>${euro.format(p.preis)}</p>
          <p class="spez mono">${p.material} · ${p.mass}</p>
          <p class="fuss"><span class="farben">${p.farben.map(f => `<b style="background:${FARBE[f].farbe}"></b>`).join('')}<span aria-hidden="true">${p.farben.length} Farben</span><span class="vh">${p.farben.length} Farben: ${p.farben.map(f => FARBE[f].name).join(', ')}</span></span><span>inkl. MwSt.</span></p>
        </div>
      </li>`;
    }).join('');
    $$('.karte', raster).forEach(verdrahten);
  }

  // Bild einer Karte setzen
  function setze(karte, i, quelle) {
    const svgs = $$('.bilder > svg', karte), n = svgs.length;
    i = Math.max(0, Math.min(n - 1, i));
    if (karte._i === i) return;
    karte._i = i;
    karte.classList.toggle('an', i > 0 || karte.classList.contains('drauf'));
    const nimm = reihenfolge(v);
    if (!(touch.matches && v === 'zonen')) svgs.forEach((s, k) => s.classList.toggle('ist', k === i));
    $$('.punkte > *', karte).forEach((d, k) => {
      d.classList.toggle('ist', k === i);
      if (d.tagName === 'BUTTON') { d.setAttribute('aria-pressed', k === i); d.tabIndex = k === i ? 0 : -1; }
    });
    const bn = $('[data-bildname]', karte); if (bn) bn.textContent = `${String(i + 1).padStart(2, '0')} · ${KURZ[nimm[i]]}`;
    if (quelle === 'punkt') $('[data-ansage]').textContent = `${karte.querySelector('.name').textContent}: Bild ${i + 1} von ${n}, ${BILDNAMEN[nimm[i]]}`;
    if (touch.matches && v === 'zonen' && quelle === 'punkt') {
      const b = $('.bilder', karte); b.scrollTo({ left: i * b.clientWidth, behavior: reduziert() ? 'auto' : 'smooth' });
    }
  }

  function verdrahten(karte) {
    karte._i = 0;
    const flaeche = $('.bildflaeche', karte), bilderEl = $('.bilder', karte);
    const n = () => +karte.dataset.n;
    // Hover: Zweitbild beim Betreten, Zonen nach Zeigerposition
    karte.addEventListener('pointerenter', e => { if (e.pointerType !== 'mouse' && e.pointerType !== 'pen') return; karte.classList.add('drauf'); if (v === 'zweitbild') setze(karte, 1); });
    karte.addEventListener('pointerleave', e => { if (e.pointerType !== 'mouse' && e.pointerType !== 'pen') return; karte.classList.remove('drauf'); if (!karte.contains(document.activeElement) || v === 'zonen') setze(karte, 0); karte.classList.remove('an'); });
    // Der Karten-Link liegt über allem: Zeigerposition an der Karte messen, nur über der Bildfläche auswerten
    karte.addEventListener('pointermove', e => {
      if (v !== 'zonen' || e.pointerType === 'touch') return;
      const r = flaeche.getBoundingClientRect();
      if (e.clientY > r.bottom) return;
      setze(karte, Math.floor((e.clientX - r.left) / r.width * n()));
    });
    // Tastatur: Fokus auf dem Link zeigt dasselbe wie Hover (Zweitbild)
    $('.name a', karte).addEventListener('focus', () => { if (v === 'zweitbild') setze(karte, 1); });
    $('.name a', karte).addEventListener('blur', () => { if (v === 'zweitbild' && !karte.classList.contains('drauf')) { setze(karte, 0); karte.classList.remove('an'); } });
    // Punkte: Klick und Pfeiltasten (ein Tab-Stopp je Karte)
    const gruppe = $('.punkte[role=group]', karte);
    if (gruppe) {
      gruppe.addEventListener('click', e => { const b = e.target.closest('[data-punkt]'); if (b) setze(karte, +b.dataset.punkt, 'punkt'); });
      gruppe.addEventListener('keydown', e => {
        const k = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
        let ziel = null;
        if (k) ziel = (karte._i + k + n()) % n(); else if (e.key === 'Home') ziel = 0; else if (e.key === 'End') ziel = n() - 1;
        if (ziel === null) return;
        e.preventDefault(); setze(karte, ziel, 'punkt'); $(`[data-punkt="${ziel}"]`, gruppe).focus();
      });
      gruppe.addEventListener('focusin', () => karte.classList.add('an'));
      gruppe.addEventListener('focusout', e => { if (!gruppe.contains(e.relatedTarget)) { setze(karte, 0); karte.classList.remove('an'); } });
    }
    // Touch: Wischreihe meldet das sichtbare Bild an die Punkte
    let raf = 0;
    bilderEl.addEventListener('scroll', () => {
      if (!(touch.matches && v === 'zonen')) return;
      cancelAnimationFrame(raf); raf = requestAnimationFrame(() => setze(karte, Math.round(bilderEl.scrollLeft / bilderEl.clientWidth)));
    }, { passive: true });
    // Tippen auf die Bildreihe öffnet die Produktseite (wie der Link)
    bilderEl.addEventListener('click', () => { if (touch.matches && v === 'zonen') $('.name a', karte).click(); });
  }

  // ---------- Variante ----------
  function variante(neu, url = true) {
    v = neu === 'zonen' ? 'zonen' : 'zweitbild';
    html.dataset.v = v;
    $$('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
    bauen();
    if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q + location.hash); }
  }
  $$('[data-wahl]').forEach(b => b.addEventListener('click', () => { const lief = !!lauf; pause(); variante(b.dataset.wahl); if (lief) vorfuehren(); }));
  touch.addEventListener?.('change', () => variante(v, false));
  raster.addEventListener('click', e => { const a = e.target.closest('.name a'); if (a) { e.preventDefault(); $('[data-ansage]').textContent = `Produktseite ${a.textContent} (in der Demo ohne Ziel).`; } });

  // ---------- Vorführung: gedachter Zeiger fährt über die Karten ----------
  const zeiger = $('.zeiger');
  let lauf = null, gen = 0;
  const warte = ms => new Promise(r => { lauf = setTimeout(r, ms); });
  function fahre(x, y, ms) {
    zeiger.style.transition = reduziert() ? 'opacity 200ms' : `opacity 200ms, translate ${ms}ms cubic-bezier(0.16, 1, 0.3, 1)`;
    zeiger.style.translate = `${x}px ${y}px`;
  }
  async function vorfuehren() {
    pause(); const g = ++gen; lauf = 1;
    html.classList.add('vorfuehrung');
    const karten = $$('.karte', raster);
    karten.forEach(k => { k.classList.remove('drauf', 'an'); k._i = -1; setze(k, 0); k.classList.remove('an'); });
    const r0 = karten[0].querySelector('.bildflaeche').getBoundingClientRect();
    fahre(r0.left - 40, r0.bottom + 60, 0); await warte(300); if (g !== gen) return;
    for (const k of karten) {
      const r = $('.bildflaeche', k).getBoundingClientRect();
      if (v === 'zweitbild') {
        fahre(r.left + r.width * .5, r.top + r.height * .55, 700); await warte(380); if (g !== gen) return;
        k.classList.add('drauf'); setze(k, 1); await warte(1500); if (g !== gen) return;
        k.classList.remove('drauf'); setze(k, 0); k.classList.remove('an');
      } else {
        const n = +k.dataset.n;
        fahre(r.left + r.width * .1, r.top + r.height * .55, 700); await warte(420); if (g !== gen) return;
        k.classList.add('drauf');
        for (let i = 0; i < n; i++) { fahre(r.left + r.width * (i + .5) / n, r.top + r.height * .55, 520); setze(k, i); await warte(620); if (g !== gen) return; }
        k.classList.remove('drauf'); setze(k, 0); k.classList.remove('an');
      }
    }
    if (g !== gen) return;
    lauf = setTimeout(vorfuehren, 400);
  }
  function neustart() { pause(); $$('.karte', raster).forEach(k => { k.classList.remove('drauf', 'an'); setze(k, 0); k.classList.remove('an'); }); }
  function pause() { gen++; clearTimeout(lauf); lauf = null; html.classList.remove('vorfuehrung'); }
  function weiter() { vorfuehren(); }

  variante(new URLSearchParams(location.search).get('v'), false);

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