// The Miracle Wardrobe - Farbtypbestimmung/farbtool.jsx
// Build #1: Farb-Tool, 5 Schritte entlang Modul 2 Teil 2 des Kurses:
// 1 Fotos machen, 2 Kontrastlevel, 3 Unterton, 4 Chroma, 5 Farben testen
// (Farbtypen entdecken / Am Foto testen / Meine Palette als interne Tabs).
// React 18 + Babel Standalone im Browser (kein Bundler), wie lieblingsmarken.jsx.
// Nutzt window.MW.FarbtypConfig / window.MW.FarbtypModel (siehe Farbtypbestimmung/lib/)
// und window.MW.compressAndUploadImage (siehe lib/uploadImage.js, Build #0).

const { useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef } = React;
const Config = window.MW.FarbtypConfig;
const Model = window.MW.FarbtypModel;

const TOOL_SLUG = "paletten";

// ?profil=xyz in der URL oeffnet einen zweiten, komplett getrennten
// Datensatz unter derselben eingeloggten Session (Adina, 12.09.2026 -
// einmaliger Test-Durchlauf mit neuen Fotos, ohne den eigenen Stand zu
// ueberschreiben). Ohne den Parameter: 1:1 wie vorher, keine Aenderung.
// Serverseitig siehe resolveUserId() in api/_lib/userId.js.
const TEST_PROFIL = new URLSearchParams(window.location.search).get("profil") || "";
const PROFIL_QS = TEST_PROFIL ? `?profil=${encodeURIComponent(TEST_PROFIL)}` : "";
function withProfilQuery(url) {
  if (!TEST_PROFIL) return url;
  return url + (url.includes("?") ? "&" : "?") + `profil=${encodeURIComponent(TEST_PROFIL)}`;
}

function defaultToolData() {
  return {
    my_color_type: null,
    farbtyp_test: null,
    palettes: [],
    quiz_progress: {},
    photos: {
      gesicht_vorne: null,
      seitenprofil: null,
      haaransatz: null,
      augen: null,
      gesicht_zugeschnitten: null,
    },
  };
}

// ══════════════════════════════════════════════════════════
// Session-Gate: /Farbtypbestimmung/ hat noch keine eigene Login-Seite, das Cookie
// ist host-gebunden (siehe api/_lib/auth.js), miraclewardrobe.de-Besucherinnen
// können sich also bislang nirgends einloggen. Bis es eine eigene Login-Seite
// gibt, übernimmt diese Komponente den Login direkt hier (ruft nur die
// bestehenden Routen /api/login, /api/verify, /api/me auf - keine neue
// API-Route, kein zusätzlicher Function-Slot).
// ══════════════════════════════════════════════════════════

function useSession() {
  const [status, setStatus] = useState("checking"); // checking | anon | in
  const [session, setSession] = useState(null);

  const check = useCallback(() => {
    setStatus("checking");
    fetch("/api/me", { credentials: "same-origin" })
      .then((r) => r.json())
      .then((body) => {
        if (body.authenticated) {
          setSession(body);
          setStatus("in");
        } else {
          setStatus("anon");
        }
      })
      .catch(() => setStatus("anon"));
  }, []);

  useEffect(check, [check]);

  return { status, session, recheck: check };
}

function LoginGate({ onLoggedIn }) {
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [stage, setStage] = useState("email"); // email | code
  const [sending, setSending] = useState(false);
  const [err, setErr] = useState("");

  async function requestCode(e) {
    e.preventDefault();
    setErr("");
    setSending(true);
    try {
      const res = await fetch("/api/login", {
        method: "POST",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });
      if (!res.ok) throw new Error();
      // Test-Bypass (nur aktiv, wenn DEV_LOGIN_BYPASS_EMAIL serverseitig
      // gesetzt ist und exakt matcht, siehe api/login.js): Session steht
      // dann schon, Code-Eingabe entfällt.
      const body = await res.json().catch(() => null);
      if (body?.bypass) {
        onLoggedIn();
        return;
      }
      setStage("code");
    } catch {
      setErr("Der Code konnte nicht angefordert werden. Bitte versuch es gleich nochmal.");
    } finally {
      setSending(false);
    }
  }

  async function verifyCode(e) {
    e.preventDefault();
    setErr("");
    setSending(true);
    try {
      const res = await fetch("/api/verify", {
        method: "POST",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, code }),
      });
      const body = await res.json();
      if (!res.ok) throw new Error(body?.error || "");
      onLoggedIn();
    } catch (e2) {
      setErr(e2.message || "Der Code stimmt nicht oder ist abgelaufen. Bitte fordere einen neuen an.");
    } finally {
      setSending(false);
    }
  }

  return (
    <div className="login">
      <div className="login__card">
        <img
          src="/Farbtypbestimmung/assets/libelle.png"
          alt="Do-it-yourself Farbtypanalyse"
          className="logo"
          style={{ width: 46, height: 46 }}
        />
        {stage === "code" ? (
          <React.Fragment>
            <h1 className="login__h">Code eingeben.</h1>
            <p className="login__sub">
              Wir haben einen 6-stelligen Code an <strong>{email.trim()}</strong> geschickt.
              Gib ihn hier ein – er gilt 15 Minuten und nur in diesem Browser.
            </p>
            <form onSubmit={verifyCode} className="login__form" noValidate>
              <input
                className={"input" + (err ? " input--err" : "")}
                inputMode="numeric"
                autoComplete="one-time-code"
                pattern="[0-9]*"
                maxLength={6}
                value={code}
                onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
                placeholder="123456"
                style={{ textAlign: "center", fontSize: "1.4rem", letterSpacing: "0.4em", fontWeight: 500 }}
                autoFocus
              />
              {err ? <p className="field__err">{err}</p> : null}
              <button className={"btn btn--cta" + (sending ? " btn--off" : "")} type="submit" disabled={sending || code.length !== 6}>
                {sending ? "Prüfe …" : "Einloggen"}
              </button>
            </form>
            <button type="button" className="linklike" onClick={() => { setStage("email"); setErr(""); }}>
              Andere E-Mail verwenden
            </button>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <p className="eyebrow">Do-it-yourself Farbtypanalyse</p>
            <h1 className="login__h">Schön, dass du hier bist.</h1>
            <p className="login__sub">
              Gib deine E-Mail ein, wir schicken dir einen 6-stelligen Code (schau auch im Spam-Ordner).
            </p>
            <form onSubmit={requestCode} className="login__form" noValidate>
              <input
                className={"input" + (err ? " input--err" : "")}
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                placeholder="du@beispiel.de"
                autoComplete="email"
              />
              {err ? <p className="field__err">{err}</p> : null}
              <button className={"btn btn--cta btn--auto" + (sending ? " btn--off" : "")} type="submit" disabled={sending}>
                {sending ? "Code wird gesendet …" : "Code anfordern"}
              </button>
            </form>
            <p className="login__fine muted">
              Der Zugang gehört zu deinem Kurs. Verwende daher bitte deine Login-E-Mail von The Miracle Wardrobe.
            </p>
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

// ══════════════════════════════════════════════════════════
// Daten-Layer: GET/PUT /api/storage/data/paletten, Dirty-State + 409-Flow
// (Build-#0-Muster aus STORAGE.md). Jede Eingabe in den Schritten 1-4 wird
// sofort per `update()` übernommen (dirty=true) - es gibt keinen separaten
// "Speichern"-Klick pro Schritt mehr, die globale SaveBar deckt alles ab.
// ══════════════════════════════════════════════════════════

function useToolData(enabled) {
  const [data, setData] = useState(null);
  const [expectedUpdatedAt, setExpectedUpdatedAt] = useState(null);
  const [dirty, setDirty] = useState(false);
  const [loading, setLoading] = useState(true);
  const [loadError, setLoadError] = useState(null);
  const [saving, setSaving] = useState(false);
  const [saveError, setSaveError] = useState(null);
  const [conflict, setConflict] = useState(null);

  // `enabled` kommt vom Login-Status: ohne diese Bremse feuert der Fetch
  // schon beim allerersten Render (noch vor dem Login), scheitert am
  // fehlenden Session-Cookie und setzt `loadError` dauerhaft - der Effect
  // läuft nur einmal (Deps `[]`), ein späterer erfolgreicher Login ändert
  // daran nichts mehr, ohne einen vollen Seiten-Reload.
  useEffect(() => {
    if (!enabled) return;
    let cancelled = false;
    fetch("/api/storage/data/" + TOOL_SLUG + PROFIL_QS, { credentials: "same-origin" })
      .then(async (res) => {
        if (res.status === 404) return { envelope: null };
        if (!res.ok) throw new Error("load_failed_" + res.status);
        return { envelope: await res.json() };
      })
      .then(({ envelope }) => {
        if (cancelled) return;
        setData(envelope ? { ...defaultToolData(), ...envelope.data } : defaultToolData());
        setExpectedUpdatedAt(envelope ? envelope.updated_at : null);
        setLoading(false);
      })
      .catch((err) => {
        if (cancelled) return;
        setLoadError(err.message || "Laden fehlgeschlagen.");
        setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [enabled]);

  const update = useCallback((updater) => {
    setData((prev) => (typeof updater === "function" ? updater(prev) : updater));
    setDirty(true);
  }, []);

  const save = useCallback(async () => {
    setSaving(true);
    setSaveError(null);
    try {
      const res = await fetch("/api/storage/data/" + TOOL_SLUG + PROFIL_QS, {
        method: "PUT",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ expected_updated_at: expectedUpdatedAt, data }),
      });
      if (res.status === 409) {
        const body = await res.json();
        setConflict(body.server);
        setSaving(false);
        return { conflict: true };
      }
      if (!res.ok) throw new Error("save_failed_" + res.status);
      const envelope = await res.json();
      setExpectedUpdatedAt(envelope.updated_at);
      setDirty(false);
      setSaving(false);
      return { ok: true };
    } catch (err) {
      setSaveError(err.message || "Speichern fehlgeschlagen.");
      setSaving(false);
      return { error: true };
    }
  }, [data, expectedUpdatedAt]);

  // 409-Auflösung Weg 1: "Trotzdem meine Version speichern" - übernimmt den
  // updated_at-Stand vom Server, damit der nächste Speichern-Klick durchgeht,
  // OHNE die eigenen ungespeicherten Änderungen zu verwerfen.
  const keepMineAfterConflict = useCallback(() => {
    if (!conflict) return;
    setExpectedUpdatedAt(conflict.updated_at);
    setConflict(null);
  }, [conflict]);

  // 409-Auflösung Weg 2: "Anderen Stand übernehmen" - verwirft die eigenen
  // ungespeicherten Änderungen zugunsten des Server-Stands.
  const takeServerAfterConflict = useCallback(() => {
    if (!conflict) return;
    setData({ ...defaultToolData(), ...conflict.data });
    setExpectedUpdatedAt(conflict.updated_at);
    setDirty(false);
    setConflict(null);
  }, [conflict]);

  // Auto-Save: kurz nach der letzten Aenderung automatisch speichern, damit die
  // TN spaeter genau da weitermacht, wo sie aufgehoert hat - kein manueller
  // Speichern-Klick noetig. Bei Konflikt/Fehler pausiert der Auto-Save (der
  // Fehler-Fallback in der SaveBar bietet dann manuelles Erneut-Speichern).
  useEffect(() => {
    if (!enabled || !dirty || saving || conflict || saveError) return;
    const t = setTimeout(() => {
      save();
    }, 1200);
    return () => clearTimeout(t);
  }, [enabled, dirty, saving, conflict, saveError, data, save]);

  // beforeunload-Warnung, falls direkt nach einer Aenderung (vor dem Auto-Save)
  // geschlossen wird.
  useEffect(() => {
    function handler(e) {
      if (!dirty) return;
      e.preventDefault();
      e.returnValue = "";
    }
    window.addEventListener("beforeunload", handler);
    return () => window.removeEventListener("beforeunload", handler);
  }, [dirty]);

  return {
    data,
    update,
    loading,
    loadError,
    dirty,
    saving,
    saveError,
    save,
    conflict,
    keepMineAfterConflict,
    takeServerAfterConflict,
  };
}

// ══════════════════════════════════════════════════════════
// URL-Deep-Links: ?stufe=1..5, Sub-Parameter je Stufe bleiben lokaler
// Component-State (Konzept 5.1: "vor/zurück jederzeit möglich").
// ══════════════════════════════════════════════════════════

// stufe 0 = neuer Startscreen (Cover vor dem eigentlichen Flow), noch kein
// Kapitel - taucht deshalb nicht in ChapterProgress/CHAPTER_* auf.
function useStufeFromUrl() {
  const read = () => {
    const params = new URLSearchParams(window.location.search);
    const raw = parseInt(params.get("stufe"), 10);
    return raw >= 0 && raw <= 6 ? raw : 0;
  };
  const [stufe, setStufeState] = useState(read);

  const setStufe = useCallback((n) => {
    setStufeState(n);
    const params = new URLSearchParams(window.location.search);
    params.set("stufe", String(n));
    window.history.pushState({}, "", "?" + params.toString());
  }, []);

  useEffect(() => {
    function onPop() {
      setStufeState(read());
    }
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  return [stufe, setStufe];
}

// Persistierter Einzelschritt-Index innerhalb eines Kapitels (Stufe 1-6):
// liegt in toolData -> ueberlebt Refresh, kein eigener Persistenz-Code
// noetig (laeuft ueber den bestehenden Auto-Save von onUpdate).
// "Weiter" am Kapitelende geht IMMER auf die erste Seite des naechsten Kapitels
// (Adina, 18.09.2026: "nie springen") - sonst landet man wegen des gespeicherten
// Schritts (quiz_progress) mitten im Kapitel, sobald es schon einmal besucht
// wurde. Gesprungen wird nur ueber die Kapitel-Punkte oben oder "Zurueck".
function startChapterAtBeginning(onUpdate, key) {
  onUpdate((prev) => ({ ...prev, quiz_progress: { ...(prev.quiz_progress || {}), [key]: 0 } }));
}

// Gegenstueck fuer "Zurueck" am Kapitelanfang: das vorherige Kapitel startet auf
// seiner LETZTEN Seite (= die direkt vorherige Seite), nicht an einem
// beliebigen gespeicherten Schritt. Kapitel 5 ("Tests") hat dynamisch viele
// Seiten (Zusammenfassung, ggf. Plausi-Check + Duelle).
function chapterLastStep(n, toolData) {
  if (n === 5) {
    const ranking = computeRanking(toolData.farbtyp_test, { includeVergleiche: false });
    return ranking.complete ? vergleicheDots(toolData).total - 1 : 0;
  }
  return CHAPTER_SCREEN_COUNTS[n] - 1;
}
function startChapterAtEnd(onUpdate, n) {
  const key = n === 5 ? "vergleicheSub" : CHAPTER_KEYS[n];
  onUpdate((prev) => ({ ...prev, quiz_progress: { ...(prev.quiz_progress || {}), [key]: chapterLastStep(n, prev) } }));
}

function useChapterStep(toolData, onUpdate, key) {
  const step = toolData.quiz_progress?.[key] ?? 0;
  const setStep = (n) => onUpdate((prev) => ({ ...prev, quiz_progress: { ...(prev.quiz_progress || {}), [key]: n } }));
  return [step, setStep];
}

// ══════════════════════════════════════════════════════════
// Kleine UI-Bausteine
// ══════════════════════════════════════════════════════════

const STUFEN_LABELS = ["Fotos machen", "Kontrastlevel", "Unterton", "Chroma", "Tests", "Farbpalette"];

// Geplante Screen-Anzahl je Kapitel, fest eingetragen auch fuer noch nicht
// umgebaute Kapitel - damit sich die Gesamtzahl im "Schritt X / Y"-Zaehler
// nicht bei jedem weiteren Umbau verschiebt. CHAPTER_KEYS wird erweitert,
// sobald ein Kapitel wirklich auf useChapterStep umgestellt ist; bis dahin
// zeigt sein Fortschrittsbalken schlicht 0% (bzw. voll, wenn schon
// durchlaufen), da sein interner Fortschritt noch nicht getrackt wird.
const CHAPTER_SCREEN_COUNTS = { 1: 5, 2: 7, 3: 7, 4: 6, 5: 2, 6: 5 };
const CHAPTER_KEYS = { 1: "fotos", 2: "kontrast", 3: "unterton", 4: "chroma", 5: "ergebnis", 6: "farbenTesten" };
// Screen-Titel je Kapitel, fuer den Hover-Tooltip auf den Fortschritts-
// Punkten oben (zeigt den grossen Seitentitel des jeweiligen Screens, ohne
// erst hinklicken zu muessen). Nur fuer bereits umgebaute Kapitel gepflegt -
// muss beim Umbau weiterer Kapitel mit deren Screen-Titeln erweitert werden.
const CHAPTER_STEP_TITLES = {
  1: [
    "Bevor es losgeht",
    "Foto-Tipps",
    "Foto 1: Gesicht",
    "Foto 2: Haare",
    "Foto 3: Augen",
  ],
  2: [
    "Wie es funktioniert",
    "Kontrastlevel-Intro",
    "Hautton",
    "Haarlevel",
    "Augenfarbe",
    "Zusätzliche Merkmale",
    "Dein Kontrastlevel",
  ],
  3: [
    "Unterton-Intro",
    "Hautunterton 1/4",
    "Hautunterton 2/4",
    "Hautunterton 3/4",
    "Hautunterton 4/4",
    "Haarunterton",
    "Dein Unterton",
  ],
  4: [
    "Chroma-Intro",
    "Hautchroma 1/3",
    "Hautchroma 2/3",
    "Hautchroma 3/3",
    "Augenchroma",
    "Dein Chroma",
  ],
  5: ["Übersicht", "Letzte Vergleiche"],
  6: ["Dein Farbtyp", "Palettenauswahl", "Engere Farbauswahl", "Farbpalette zusammenstellen", "Meine Palette"],
};

function chapterProgress(stufe, toolData) {
  const key = CHAPTER_KEYS[stufe];
  const screenCount = CHAPTER_SCREEN_COUNTS[stufe] || 1;
  const screenIndex = key ? toolData.quiz_progress?.[key] ?? 0 : 0;
  return { screenIndex, screenCount };
}

// Kapitel 5 ("Letzte Tests") hat eine dynamische Punktzahl statt der fixen
// CHAPTER_SCREEN_COUNTS[5]: ein Punkt fuer die Uebersicht/Zusammenfassung
// zu Beginn, dann ein Punkt pro "letztem Vergleich" (Plausi-Check + je ein
// Close-Call-Duell, siehe computeVergleicheTopology). Der eigentliche
// Ergebnis-Reveal ist kein Punkt mehr hier, sondern der erste Screen von
// Kapitel 6 "Farbpalette" (Adina, 15.09.2026: "Das Farbtypergebnis soll
// schon der erste Screen sein von Farbpalette"). Ohne vollstaendiges
// Ranking (noch keine Duelle berechenbar) bleibt es bei Uebersicht +
// Plausi-Check.
function vergleicheDots(toolData) {
  const ranking = computeRanking(toolData.farbtyp_test, { includeVergleiche: false });
  const { duels } = computeVergleicheTopology(ranking.full);
  // Durchgehend "Vergleich N" (kein "Plausi-Check" - internes Wording, hat
  // beim TN nichts zu suchen; auch keine Achsen-Labels mehr in der Uebersicht,
  // Adina 15.09.2026), N zaehlt ab dem ersten Vergleiche-Screen (vormals
  // "Plausi-Check") durch.
  const titles = ["Übersicht", ...Array.from({ length: duels.length + 1 }, (_, i) => `Vergleich ${i + 1}`)];
  return { total: titles.length, titles, duelCount: duels.length };
}

// Einheitlicher Haken im Stil des "Foto hochladen"-Plus: duenne Strichlinie in
// currentColor, wird ueberall verwendet, wo etwas "erledigt/ausgewaehlt" ist.
function CheckIcon({ size = 14, strokeWidth = 2 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Duenner X-Strich in derselben Designsprache wie CheckIcon (gleiche
// strokeLinecap/-linejoin-Rezeptur), fuer die Dont-Checkbox in den Fotos-
// Regeln.
function CrossIcon({ size = 14, strokeWidth = 2 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Plus wie beim "Foto hochladen" (duenner Strich), plus Stift/Muelleimer fuer
// die Palette.
function PlusIcon({ size = 21 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 4V20M4 12H20" stroke="currentColor" strokeWidth="1" strokeLinecap="round" />
    </svg>
  );
}

function MinusIcon({ size = 21 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M4 12H20" stroke="currentColor" strokeWidth="1" strokeLinecap="round" />
    </svg>
  );
}

function PencilIcon({ size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M4 20h4L18.5 9.5a2.12 2.12 0 0 0-3-3L5 17v3z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
      <path d="M13.5 6.5l3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}

function TrashIcon({ size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 7h14M10 4h4M8 7l0.8 12.5A1 1 0 0 0 9.8 20.5h4.4a1 1 0 0 0 1-0.95L16 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Reiner Chevron ("<"), keine Pfeilstange - schlanker als ein voller Pfeil.
function ArrowLeftIcon({ size = 28, strokeWidth = 0.8 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M15 5l-7 7 7 7" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Spiegelbild von ArrowLeftIcon (">"), fuer den Weiter-Button auf reinen
// Leseblock-Screens - CheckIcon bleibt dem Weiter-Button auf Screens
// vorbehalten, auf denen die TN wirklich etwas auswaehlen/tun muss (Adina,
// 17.09.2026, To-Do #3).
function ArrowRightIcon({ size = 28, strokeWidth = 0.8 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Fuer den "Mehr"-Ausklapp-Trigger (InfoTip) - v-foermiger Chevron nach
// unten, dreht sich beim Ausklappen um 180deg (siehe .ft-infotip-toggle-open).
function ChevronDownIcon({ size = 9, strokeWidth = 1.8 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 9l7 7 7-7" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// "Foto ersetzen"-Icon im Tile: Kreisbogen + zweistrichige Pfeilspitze (kein
// gefuelltes Dreieck), damit die Spitze an denselben offenen Chevron wie
// ArrowLeftIcon erinnert (Adina-Wunsch), statt am generischen Unicode-↺.
function RedoIcon({ size = 17, strokeWidth = 1.6 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
      <path d="M1 4v6h6" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Kapitel-Leiste: jedes der 6 Kapitel zeigt Name + Fragenanzahl und einen
// eigenen Fortschrittsbalken (statt eines einzelnen globalen Balkens).
// Kapitel vor dem aktuellen gelten als komplett gefuellt, das aktive Kapitel
// fuellt sich anteilig am aktuellen Screen, kommende Kapitel bleiben leer.
function ChapterProgress({ stufe, setStufe, toolData, onUpdate }) {
  // Aktiver Kapitel-Kasten blinkt/springt/schwillt kurz an, sobald sich das
  // Kapitel wechselt (egal ob per Zurueck-/Weiter-Klick am Kapitelende oder
  // direkter Klick auf einen Kasten) - macht den Wechsel oben sichtbar,
  // nicht nur die neue Karte darunter. Klasse wird nach Animationsende
  // wieder entfernt (setTimeout, Dauer = CSS-Animationsdauer), damit sie
  // beim naechsten Kapitelwechsel erneut von "kein Klasse" auf "Klasse"
  // wechselt und die CSS-Animation zuverlaessig neu abspielt - nicht beim
  // allerersten Render (isFirstRender-Guard).
  const [pulsing, setPulsing] = useState(false);
  const prevStufeRef = useRef(stufe);
  const isFirstRender = useRef(true);
  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    if (prevStufeRef.current !== stufe) {
      prevStufeRef.current = stufe;
      setPulsing(true);
      const t = setTimeout(() => setPulsing(false), 400);
      return () => clearTimeout(t);
    }
  }, [stufe]);

  // Klick auf einen einzelnen Fortschritts-Punkt springt direkt zu diesem
  // Screen: Kapitel wechseln (falls noetig) + den Screen-Index des
  // Zielkapitels in quiz_progress setzen. Nur fuer Kapitel mit CHAPTER_KEYS-
  // Eintrag moeglich (deren interner Fortschritt ueberhaupt getrackt wird) -
  // bei noch nicht umgebauten Kapiteln verhaelt sich ein Punkt-Klick wie ein
  // Klick auf den ganzen Kasten (nur Kapitelwechsel, kein Sprung zum Screen).
  function goToStep(n, i) {
    // Kapitel 5 ("Letzte Tests"): alle dynamischen Punkte (Uebersicht +
    // Vergleiche) haengen nur noch an quiz_progress.vergleicheSub - der
    // Ergebnis-Reveal liegt seit dem Umbau nicht mehr in diesem Kapitel,
    // siehe Stufe5Ergebnis/SchrittErgebnis.
    if (n === 5) {
      if (onUpdate) {
        onUpdate((prev) => ({
          ...prev,
          quiz_progress: { ...(prev.quiz_progress || {}), vergleicheSub: i },
        }));
      }
      setStufe(n);
      return;
    }
    const key = CHAPTER_KEYS[n];
    if (key && onUpdate) {
      onUpdate((prev) => ({ ...prev, quiz_progress: { ...(prev.quiz_progress || {}), [key]: i } }));
    }
    setStufe(n);
  }

  return (
    <div className="ft-chapters">
      <nav className="ft-chapters-row" aria-label="Kapitel">
        {STUFEN_LABELS.map((label, i) => {
          const n = i + 1;
          const vd = n === 5 ? vergleicheDots(toolData) : null;
          const total = vd ? vd.total : CHAPTER_SCREEN_COUNTS[n] || 1;
          const titles = vd ? vd.titles : CHAPTER_STEP_TITLES[n];
          const isDone = n < stufe;
          let dotCurrent;
          if (isDone) dotCurrent = total;
          else if (n > stufe) dotCurrent = -1;
          else if (vd) {
            dotCurrent = Math.min(toolData.quiz_progress?.vergleicheSub ?? 0, total - 1);
          } else {
            dotCurrent = chapterProgress(stufe, toolData).screenIndex;
          }
          return (
            // div statt button: die einzelnen Fortschritts-Punkte sind jetzt
            // selbst <button>s (siehe QuizDots) - verschachtelte <button>s
            // waeren ungueltiges HTML. role="button"+tabIndex+onKeyDown
            // erhalten die Tastatur-/Screenreader-Bedienbarkeit des Kastens.
            <div
              key={n}
              role="button"
              tabIndex={0}
              className={
                "ft-chapter" +
                (n === stufe ? " is-active" : "") +
                (isDone ? " is-done" : "") +
                (n === stufe && pulsing ? " is-chapter-pulse" : "")
              }
              onClick={() => setStufe(n)}
              onKeyDown={(e) => {
                if (e.key === "Enter" || e.key === " ") {
                  e.preventDefault();
                  setStufe(n);
                }
              }}
            >
              <span className="ft-chapter-label">{label}</span>
              <QuizDots total={total} current={dotCurrent} titles={titles} onDotClick={(i) => goToStep(n, i)} />
            </div>
          );
        })}
      </nav>
    </div>
  );
}

// Kurze nummerierte Bullet-Anweisungen statt Fliesstext-Absatz (ADHS-
// freundlicher: klare Haeppchen statt eines Blocks). Items duerfen JSX sein,
// damit die wichtigste Stelle innerhalb einer Zeile fett hervorgehoben werden
// kann.
// Senf-Punkte statt Zahlen-Fortschritt ("Frage X von Y"): ein Punkt pro
// Screen des aktuellen Kapitels, gefuellt sobald der Screen erledigt ist
// (davor liegend), der aktuelle Screen bekommt nur einen Senf-Rand.
// onDotClick (optional): macht jeden Punkt einzeln anklickbar, springt direkt
// zu diesem Screen. titles (optional, Array parallel zu total): Screen-Titel
// je Punkt, geht als natives title-Attribut ins Hover-Tooltip - so sieht man
// vor dem Klick, welcher Screen sich dahinter verbirgt (Adina-Feedback).
function QuizDots({ total, current, titles, onDotClick }) {
  const label = current >= 0 ? `Schritt ${Math.min(current + 1, total)} von ${total}` : `noch nicht begonnen (${total} Schritte)`;
  return (
    <span className="ft-quiz-dots" aria-label={label}>
      {Array.from({ length: total }).map((_, i) => {
        const dotTitle = titles?.[i] || `Schritt ${i + 1} von ${total}`;
        return (
          <button
            key={i}
            type="button"
            className={"ft-quiz-dot" + (i < current ? " is-filled" : "") + (i === current ? " is-current" : "")}
            data-tooltip={dotTitle}
            aria-label={dotTitle}
            disabled={!onDotClick}
            onClick={(e) => {
              if (!onDotClick) return;
              e.stopPropagation();
              onDotClick(i);
            }}
          />
        );
      })}
    </span>
  );
}

// Rein dekorative Fortschritts-Anzeige rechts neben dem Titel der 3 Foto-
// Upload-Screens (Adina: "soll den ueberblick ueber die 3 zu machenden
// fotos nochmal visualisieren", "not-clickable") - Kreise im selben Look
// wie der runde Weiter-Button (CheckIcon), bis einschliesslich dem
// aktuellen Schritt gefuellt, danach nur zart umrandet. Icon:Kreis-
// Verhaeltnis identisch zum Weiter-Button (32px Icon in 48px Kreis = 2/3),
// nicht neu erfunden (Adina-Korrektur).
// done[i]: echter Fertigstellungsstatus (Foto hochgeladen + falls noetig
// zugeschnitten), nicht nur "Schritt schon erreicht" (Adina: "ersten haken
// erst ausfuellen, wenn bild hochgeladen und zuschnitt erstellt").
function PhotoStepDots({ done }) {
  return (
    <div className="ft-photostep-dots" aria-hidden="true">
      {done.map((isDone, i) => (
        <span key={i} className={"ft-photostep-dot" + (isDone ? " ft-photostep-dot-done" : "")}>
          <CheckIcon size={19} strokeWidth={1.2} />
        </span>
      ))}
    </div>
  );
}

function QuizTips({ items }) {
  if (!items || !items.length) return null;
  return (
    <ol className="ft-quiz-tips">
      {items.map((t, i) => (
        <li key={i}>
          <span className="ft-quiz-tips-num">{String(i + 1).padStart(2, "0")}</span>
          <span>{t}</span>
        </li>
      ))}
    </ol>
  );
}

// Vergroesserte Bildansicht per Portal direkt in document.body statt an der
// Aufrufstelle im Baum - QuizScreens Wisch-Animation setzt transform auf
// .ft-quiz-content, das erzeugt einen neuen Containing Block fuer
// position:fixed-Nachfahren. Ohne Portal waere die Lightbox NICHT mehr
// viewport-fixed gewesen, egal wo im Baum sie eingehaengt ist (siehe
// Munsell-Bug-Historie). Mit dem Portal ist das strukturell ausgeschlossen,
// auch fuer kuenftige Lightboxen an beliebiger Stelle.
function ImageLightbox({ src, alt, onClose }) {
  useEffect(() => {
    function onKey(e) {
      if (e.key === "Escape") onClose();
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  return ReactDOM.createPortal(
    <div className="ft-modal-backdrop" onClick={onClose}>
      <img className="ft-lightbox-img" src={src} alt={alt} onClick={(e) => e.stopPropagation()} />
      <button type="button" className="ft-lightbox-close" onClick={onClose} aria-label="Schließen">
        <CrossIcon size={16} strokeWidth={1.8} />
      </button>
    </div>,
    document.body
  );
}

// Wie QuizTips (01/02/03-Look), aber jeder Begriff hat sein EIGENES
// Ausklapp-Pfeilchen dahinter statt eines gemeinsamen "Mehr" am Ende der
// Liste (Adina: "hinter jedem Begriff... ohne das Wort 'mehr', sondern
// einfach nur dieses kleine Pfeilchen"). Jeder Eintrag klappt unabhaengig
// von den anderen auf/zu. Begriffs-Bilder (t.image) sind klickbar und
// oeffnen dieselbe Lightbox wie das Munsell-Bild (Adina: "genau gleiche
// Funktionsweise wie bei dem anderen Bild").
function ExpandableTermList({ terms }) {
  const [open, setOpen] = useState(() => new Set());
  const [lightboxImg, setLightboxImg] = useState(null);
  function toggle(i) {
    setOpen((prev) => {
      const next = new Set(prev);
      if (next.has(i)) next.delete(i);
      else next.add(i);
      return next;
    });
  }
  return (
    <React.Fragment>
      <ol className="ft-quiz-tips ft-quiz-tips-expandable">
        {terms.map((t, i) => {
          const isOpen = open.has(i);
          return (
            <li key={i}>
              <span className="ft-quiz-tips-num">{String(i + 1).padStart(2, "0")}</span>
              <div className="ft-term-body">
                <div className="ft-term-head">
                  <span>{t.label}</span>
                  <button
                    type="button"
                    className={"ft-term-toggle" + (isOpen ? " ft-term-toggle-open" : "")}
                    onClick={() => toggle(i)}
                    aria-label={(isOpen ? "Beschreibung zu " : "Mehr zu ") + t.label}
                    aria-expanded={isOpen}
                  >
                    <ChevronDownIcon size={9} strokeWidth={1.8} />
                  </button>
                </div>
                {isOpen && (
                  <React.Fragment>
                    <p className="ft-infotip-text">{t.desc}</p>
                    {(t.images || (t.image ? [t.image] : [])).map((im) => (
                      <div className="ft-term-image-wrap" key={im.src}>
                        {im.zoom === false ? (
                          <img className={"ft-term-image ft-term-image-" + (im.kind || "wheel")} src={im.src} alt={im.alt} />
                        ) : (
                          <button
                            type="button"
                            className="ft-term-image-btn"
                            onClick={() => setLightboxImg(im)}
                            aria-label={`${im.alt} vergrößert anzeigen`}
                          >
                            <img className={"ft-term-image ft-term-image-" + (im.kind || "wheel")} src={im.src} alt={im.alt} />
                          </button>
                        )}
                        {im.source && <span className="ft-munsell-source">Quelle: {im.source}</span>}
                      </div>
                    ))}
                    {t.descAfter && <p className="ft-infotip-text ft-term-desc-after">{t.descAfter}</p>}
                  </React.Fragment>
                )}
              </div>
            </li>
          );
        })}
      </ol>
      {lightboxImg && (
        <ImageLightbox src={lightboxImg.src} alt={lightboxImg.alt} onClose={() => setLightboxImg(null)} />
      )}
    </React.Fragment>
  );
}

// Ein Einzelschritt-Screen im Quiz-Format: Titel + kurze Bullet-Anweisungen
// oben, optional "Warum das zaehlt" zum Aufklappen, dann die eigentliche
// Aufgabe, dann zentrierte runde Zurueck/Weiter-Buttons. Ersetzt die alte
// "alle Fragen einer Stufe auf einer Seite"-Darstellung, ein Screen pro
// Frage/Interaktion (ADHS-freundlich: ein Fokuspunkt statt vieler auf einmal).
// Weiter-Klick wischt den Screen nach links raus, Zurueck-Klick nach rechts,
// bevor onBack/onNext den naechsten Screen einblendet (Sequentialitaet
// sichtbar machen). Aufrufer geben ihre Schrittnummer als screenKey-PROP mit
// (kein React-key mehr!) - die aeussere QuizScreen-Instanz (Karte + Buttons)
// bleibt dadurch ueber alle Screens hinweg EINE einzige, nie neu gemountete
// Komponente: Zurueck/Weiter verschwinden/hüpfen beim Navigieren nicht mehr
// kurz auf (frueher riss ein React-key={step} auf dem ganzen QuizScreen die
// komplette Karte samt Buttons bei jedem Schritt ab und neu auf). Nur der
// INNERE .ft-quiz-content-Wrapper bekommt key={screenKey}, damit genau er
// bei jedem Schritt frisch mountet und die CSS-Wisch-Animation abspielt.
let _ftQuizDirection = "forward";
function QuizScreen({ eyebrow, title, titleAfter, tips, why, children, onBack, onNext, nextDisabled = false, showBack = true, showNext = true, screenKey, centered = false, maxWidth, nextIcon = "check" }) {
  const [leaving, setLeaving] = useState(null); // null | "forward" | "back"
  const [popping, setPopping] = useState(false);
  const [enterDir, setEnterDir] = useState(_ftQuizDirection);
  // "State waehrend des Renders anpassen"-Pattern (offizielles React-Muster
  // fuer "auf Prop-Aenderung reagieren, BEVOR gemalt wird"): sobald sich
  // screenKey seit dem letzten Render geaendert hat, ist jede laufende
  // is-leaving-Animation vom VORHERIGEN Screen ueberholt - hier synchron
  // zuruecksetzen statt per useEffect, sonst wuerde der frisch gemountete
  // Content-Div fuer einen Frame die alte is-leaving-Klasse tragen und
  // kurz aufblitzen.
  const [prevScreenKey, setPrevScreenKey] = useState(screenKey);
  if (screenKey !== prevScreenKey) {
    setPrevScreenKey(screenKey);
    setLeaving(null);
    setPopping(false);
    setEnterDir(_ftQuizDirection);
  }

  function handleNext() {
    if (nextDisabled || leaving) return;
    _ftQuizDirection = "forward";
    setPopping(true);
    setLeaving("forward");
    setTimeout(onNext, 130);
  }

  function handleBack() {
    if (leaving) return;
    _ftQuizDirection = "back";
    setLeaving("back");
    setTimeout(onBack, 130);
  }

  return (
    // Aeussere Karte bleibt IMMER dieselbe DOM-Instanz (kein React-key mehr
    // auf QuizScreen selbst) und traegt kein Wisch-Transform - nur der
    // INNERE Content-Bereich bekommt die Wisch-Animation. So bleiben
    // Zurueck/Weiter (in .ft-quiz-actions, ausserhalb des Content-Bereichs)
    // beim Klick auf der Stelle stehen statt mitzuwischen, und verschwinden
    // nicht mehr kurz beim Schrittwechsel.
    <div className="ft-card ft-quiz-screen" style={maxWidth ? { maxWidth, marginLeft: "auto", marginRight: "auto" } : undefined}>
      <div key={screenKey} className={"ft-quiz-content ft-quiz-enter-" + enterDir + (leaving ? " is-leaving-" + leaving : "") + (centered ? " ft-quiz-content-centered" : "")}>
        {eyebrow && <span className="ft-eyebrow">{eyebrow}</span>}
        {title && (
          titleAfter ? (
            <div className="ft-quiz-title-row">
              <h2 className="ft-quiz-title">{title}</h2>
              {titleAfter}
            </div>
          ) : (
            <h2 className="ft-quiz-title">{title}</h2>
          )
        )}
        <QuizTips items={tips} />
        {why && (
          <Accordion title="Warum das zählt">
            <p>{why}</p>
          </Accordion>
        )}
        <div className="ft-quiz-body">{children}</div>
      </div>
      <div className="ft-quiz-actions">
        {showBack ? (
          <button type="button" className="ft-round-btn ft-round-btn-secondary" onClick={handleBack} aria-label="Zurück">
            <ArrowLeftIcon />
          </button>
        ) : (
          <span aria-hidden="true" />
        )}
        {showNext ? (
          <button
            type="button"
            className={"ft-round-btn ft-round-btn-primary" + (popping ? " is-popping" : "")}
            disabled={nextDisabled}
            onClick={handleNext}
            aria-label="Weiter"
          >
            {nextIcon === "arrow" ? <ArrowRightIcon size={28} strokeWidth={0.8} /> : <CheckIcon size={32} strokeWidth={0.8} />}
          </button>
        ) : (
          <span aria-hidden="true" />
        )}
      </div>
    </div>
  );
}

// Kein manueller Speichern-Klick mehr: der Auto-Save uebernimmt. Diese Leiste
// ist nur noch dezenter Status ("wird gespeichert") bzw. Fehler-Fallback.
function SaveBar({ dirty, saving, saveError, onSave }) {
  if (saveError) {
    return (
      <div className="ft-savebar ft-savebar-error">
        <span className="ft-savebar-text">{saveError}</span>
        <button type="button" className="ft-btn ft-btn-primary" onClick={onSave} disabled={saving}>
          {saving ? "Speichert …" : "Erneut speichern"}
        </button>
      </div>
    );
  }
  if (!dirty && !saving) return null;
  return (
    <div className="ft-savebar ft-savebar-status">
      <span className="ft-savebar-text">{saving ? "Speichert …" : "Änderungen werden gespeichert …"}</span>
    </div>
  );
}

function ConflictDialog({ conflict, onKeepMine, onTakeServer }) {
  if (!conflict) return null;
  return (
    <div className="ft-modal-backdrop">
      <div className="ft-modal">
        <h3>Auf einem anderen Gerät gespeichert</h3>
        <p className="ft-muted">
          Du hast (oder ein anderer Tab) zwischenzeitlich anderswo gespeichert. Wie möchtest du weitermachen?
        </p>
        <div className="ft-modal-actions">
          <button type="button" className="ft-btn ft-btn-secondary" onClick={onTakeServer}>
            Anderen Stand übernehmen
          </button>
          <button type="button" className="ft-btn ft-btn-primary" onClick={onKeepMine}>
            Trotzdem meine Version speichern
          </button>
        </div>
      </div>
    </div>
  );
}

// ══════════════════════════════════════════════════════════
// Generische Auswahl-Bausteine für die Test-Schritte
// ══════════════════════════════════════════════════════════

function Accordion({ title, children }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="ft-accordion">
      <button type="button" className="ft-accordion-head" onClick={() => setOpen((o) => !o)}>
        {open ? "▾" : "▸"} {title}
      </button>
      {open && <div className="ft-accordion-body">{children}</div>}
    </div>
  );
}

// Eigene eckige Box statt Browser-Default-Checkbox, im selben Look wie die
// senffarbenen Checkboxen weiter vorne im Flow (Fotos-Intro "Du brauchst:") -
// Adina: "vom design anpassen an die boxen, die wir weiter vorne schon
// haben". Natives <input> bleibt fuer Tastatur/Screenreader erhalten, nur
// visuell versteckt (.ft-checkbox-input) - die sichtbare Box daneben
// spiegelt den checked-Status ueber eine eigene Klasse statt CSS
// :checked-Selektoren, weil React den Status ohnehin schon kennt.
function Checkbox({ label, checked, onChange, accent = false, className = "" }) {
  return (
    <label className={"ft-checkbox" + (accent ? " ft-checkbox-accent" : "") + (className ? " " + className : "")}>
      <input
        type="checkbox"
        className="ft-checkbox-input"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
      />
      <span className={"ft-checkbox-toggle-box" + (checked ? " is-checked" : "")} aria-hidden="true">
        {checked && <CheckIcon size={11} strokeWidth={1.4} />}
      </span>
      {/* Label in eigenem <span> statt lose im Flex-<label>: sonst zaehlen
          Textknoten und <strong> im Label (falls JSX statt reinem String)
          als eigene Flex-Items und der gap rutscht mitten in den Text
          (bekannter Bug, siehe z. B. Foto-Regeln/Checkliste). */}
      <span>{label}</span>
    </label>
  );
}

// ══════════════════════════════════════════════════════════
// Farbtyp-Test: gemeinsame Konstanten, Antwort-Modell, Ranking
// (Konzept-Abschnitte 5.1, 5.4-5.8, 7 - jetzt über die Schritte 2-4 verteilt)
// ══════════════════════════════════════════════════════════

const AUGENFARBEN_LABELS = { braunschwarz: "Braunschwarz", braun: "Braun", gruenbraun: "Grünbraun", gruen: "Grün", blau: "Blau", grau: "Grau" };
const HAAR_WAERME_LABELS = { "keine-waerme": "(fast) keine Wärme", "moderate-waerme": "Moderate Wärme", "deutliche-waerme": "Deutliche Wärme" };
const UNTERTON_LABELS = { warm: "Warm", "warm-neutral": "Warm-neutral", "kuehl-neutral": "Kühl-neutral", kuehl: "Kühl" };
const CHROMA_LABELS = { soft: "Soft", mittel: "Mittel", klar: "Klar" };
const KONTRAST_LABELS = { niedrig: "Niedrig", mittel: "Mittel", hoch: "Hoch", "sehr-hoch": "Sehr hoch" };

// Dekliniertes Adjektiv fuer den "Passt zu deinem ... Unterton/Chroma/Kontrast"-
// Anker (Dativ nach "deinem").
const UNTERTON_ADJ = { warm: "warmen", "warm-neutral": "warm-neutralen", "kuehl-neutral": "kühl-neutralen", kuehl: "kühlen" };
const CHROMA_ADJ = { soft: "soften", mittel: "mittleren", klar: "klaren" };
const KONTRAST_ADJ = { niedrig: "niedrigen", mittel: "mittleren", hoch: "hohen", "sehr-hoch": "sehr hohen" };

function topOf(distribution) {
  let best = null;
  let bestP = -1;
  for (const [k, p] of Object.entries(distribution)) {
    if (p > bestP) {
      best = k;
      bestP = p;
    }
  }
  return best;
}

// Manuelles Override kann ein einzelnes Level (String) ODER ein Mischergebnis
// (Array benachbarter Level, z. B. ["niedrig","mittel"]) sein -> in eine gleich
// verteilte Wahrscheinlichkeitsverteilung umwandeln. Kein/leeres Override ->
// null (dann gilt die berechnete Verteilung).
function overrideToDist(ov) {
  if (!ov) return null;
  const arr = Array.isArray(ov) ? ov.filter(Boolean) : [ov];
  if (!arr.length) return null;
  const p = 1 / arr.length;
  const dist = {};
  for (const v of arr) dist[v] = (dist[v] || 0) + p;
  return dist;
}

function emptyAnswers() {
  return {
    hautton: [],
    haarlevel: [],
    // Exakte {x,y}-Position (in %) des freien 2D-Drag-Thumbs beim Haarlevel-
    // Regler (sprite.freeDrag, wie unterton_haar_pos) - stellt die Thumb-
    // Position bei Rueck-Navigation wieder her und bestimmt ueber y die
    // Bildreihe (oben/unten) fuer den ausgegebenen Haarfarben-Namen. Fliesst
    // NICHT ins Scoring ein (das nutzt nur haarlevel).
    haarlevel_pos: null,
    augenfarbe: [],
    kontrast_zusatz: { dunkler_irisring: false, prominente_brauen: false, auffaellige_wimpern: false, starkes_augenweiss: false },
    unterton_haut: [],
    unterton_haar: [],
    // Exakte {x,y}-Position (in %) des freien 2D-Drag-Thumbs beim
    // Haarunterton-Regler (sprite.freeDrag) - nur fuers Wiederherstellen der
    // Thumb-Position bei Rueck-Navigation gedacht, fliesst NICHT ins Scoring
    // ein (das nutzt weiterhin nur unterton_haar). Ohne dieses Feld sprang
    // der Thumb beim Zurueckkommen auf einen generischen Punkt der richtigen
    // Spalte statt an die zuletzt gezogene Stelle (Adina-Bugreport).
    unterton_haar_pos: null,
    // Digitales Draping als 4 Duelle (Optiker-Sehtest-Stil, Adina-Wunsch):
    // drape_rounds[i] = null (noch nicht beantwortet) | [key] (ein Sieger) |
    // [keyA, keyB] (Unentschieden - "beide gleich"). drape_picks ist die
    // flache Ableitung aller Runden (fliesst wie zuvor unveraendert in
    // Model.deriveUnterton/drapeToDist ein - siehe patchDrapeRound).
    drape_rounds: [null, null, null, null],
    drape_picks: [],
    chroma_teint: [],
    // 3 Chroma-Teint-Duelle, eine pro Value-Stufe (Hell/Mittel/Tief, Adina
    // 09.09.2026): null (unbeantwortet) oder Array mit 1-2 Favoriten aus
    // "soft"/"mittel"/"klar", z. B. ["soft"] oder ["soft","mittel"]. Ersetzt
    // den alten Einzel-Regler chroma_teint (bleibt als Altlast-Fallback
    // erhalten, siehe deriveChroma).
    chroma_teint_rows: [null, null, null],
    chroma_augen: [],
    // Braunschwarze Augen liefern kein Chroma-Indiz (Adina) - Checkbox
    // ueberspringt die Augen-Frage, dann zaehlt nur der Teint (siehe
    // deriveChroma-Fallback fuer leeres chroma_augen).
    chroma_augen_braunschwarz: false,
    // Exakte {x,y}-Position (in %) des freien 2D-Drag-Thumbs beim Chroma-
    // Augen-Regler (sprite.freeDrag, wie unterton_haar_pos oben) - nur fuers
    // Wiederherstellen der Thumb-Position bei Rueck-Navigation, fliesst
    // NICHT ins Scoring ein (das nutzt weiterhin nur chroma_augen).
    chroma_augen_pos: null,
    // "Ein paar letzte Vergleiche" (Stufe 5, Schritt 0): vergleiche_plausi
    // ist der eine Plausi-Check (Format wie drape_rounds-Eintrag: null |
    // ["top"|"bottom"] | ["gleich"] | ["gleich","top"|"bottom"]).
    // vergleiche_picks ist eine Map "typA|typB" -> derselbe Format
    // (Schluessel = die von computeVergleicheTopology() gebauten Duell-
    // Paare; per Typ-Paar statt Index, damit ein Duell auch bei leicht
    // verschobener Rangliste noch seiner vorherigen Antwort zugeordnet
    // wird). Fliesst ueber Model.deriveVergleicheMultipliers zusaetzlich
    // zu Kontrast/Unterton/Chroma als 4. Dimension in computeRanking ein.
    vergleiche_plausi: null,
    vergleiche_picks: {},
  };
}

function ensureFarbtypTest(prev) {
  return (
    prev.farbtyp_test || {
      answers: emptyAnswers(),
      overrides: { kontrast: null, unterton: null, chroma: null },
      ai_second_opinion: null,
    }
  );
}

// Welche Eingabe speist welche abgeleitete Dimension: aendert sich eine
// Eingabe weiter oben, wird ein evtl. manuell gesetztes Override dieser
// Dimension verworfen, damit das (End-)Ergebnis immer der letzten Aenderung
// folgt und wieder auf den berechneten Wert springt (Adinas Feedback:
// manuelle Aenderung darf das Ergebnis nicht einfrieren).
const ANSWER_OVERRIDE_DIM = {
  hautton: "kontrast",
  haarlevel: "kontrast",
  augenfarbe: "kontrast",
  unterton_haut: "unterton",
  unterton_haar: "unterton",
  drape_picks: "unterton",
  chroma_teint: "chroma",
  chroma_teint_rows: "chroma",
  chroma_augen: "chroma",
  chroma_augen_braunschwarz: "chroma",
};

const NO_OVERRIDES = { kontrast: null, unterton: null, chroma: null };

// Persistiert jede Eingabe sofort (kein separater Wizard-Save mehr nötig,
// weil Kontrastlevel/Unterton/Chroma jetzt einzeln anspringbare Top-Level-
// Schritte sind statt Sub-State einer gemeinsam gemounteten Komponente).
function farbtypActions(onUpdate) {
  return {
    patchAnswer(field, value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        const dim = ANSWER_OVERRIDE_DIM[field];
        const overrides = dim ? { ...(cur.overrides || NO_OVERRIDES), [dim]: null } : cur.overrides;
        return { ...prev, farbtyp_test: { ...cur, answers: { ...cur.answers, [field]: value }, overrides } };
      });
    },
    // Draping-Duell-Runde beantworten: schreibt drape_rounds[index] und leitet
    // drape_picks (flache Liste aller bisher gewaehlten/unentschiedenen Keys)
    // gleich mit ab, damit Model.deriveUnterton unveraendert bleibt.
    // Chroma-Teint-Duell (Value-Reihe) beantworten: schreibt
    // chroma_teint_rows[index], Model.deriveChroma liest das Array direkt
    // (siehe chromaTeintRowsToDist), keine Ableitung noetig wie bei drape_picks.
    patchChromaRow(index, value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        const rows = [...(cur.answers.chroma_teint_rows || [null, null, null])];
        rows[index] = value;
        return {
          ...prev,
          farbtyp_test: {
            ...cur,
            answers: { ...cur.answers, chroma_teint_rows: rows },
            overrides: { ...(cur.overrides || NO_OVERRIDES), chroma: null },
          },
        };
      });
    },
    patchDrapeRound(index, roundValue) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        const rounds = [...(cur.answers.drape_rounds || [null, null, null, null])];
        rounds[index] = roundValue;
        const picks = rounds.filter(Boolean).flat();
        return {
          ...prev,
          farbtyp_test: {
            ...cur,
            answers: { ...cur.answers, drape_rounds: rounds, drape_picks: picks },
            overrides: { ...(cur.overrides || NO_OVERRIDES), unterton: null },
          },
        };
      });
    },
    patchZusatz(field, value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        return {
          ...prev,
          farbtyp_test: {
            ...cur,
            answers: { ...cur.answers, kontrast_zusatz: { ...cur.answers.kontrast_zusatz, [field]: value } },
            overrides: { ...(cur.overrides || NO_OVERRIDES), kontrast: null },
          },
        };
      });
    },
    setOverride(dim, value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        return { ...prev, farbtyp_test: { ...cur, overrides: { ...cur.overrides, [dim]: value } } };
      });
    },
    // "Ein paar letzte Vergleiche": Plausi-Check (1x) und Close-Call-Duelle
    // (dynamisch viele, siehe computeVergleicheTopology). Beide im selben
    // Rundenformat wie patchDrapeRound (null | [key] | [TIE] | [TIE,key]),
    // wirken aber NICHT auf overrides.* - die Vergleiche sind eine eigene,
    // 4. Scoring-Dimension (Model.deriveVergleicheMultipliers), keine
    // Ableitung von Kontrast/Unterton/Chroma.
    patchVergleichePlausi(value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        return { ...prev, farbtyp_test: { ...cur, answers: { ...cur.answers, vergleiche_plausi: value } } };
      });
    },
    patchVergleichePick(key, value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        const picks = { ...(cur.answers.vergleiche_picks || {}), [key]: value };
        return { ...prev, farbtyp_test: { ...cur, answers: { ...cur.answers, vergleiche_picks: picks } } };
      });
    },
    setAiResult(value) {
      onUpdate((prev) => {
        const cur = ensureFarbtypTest(prev);
        return { ...prev, farbtyp_test: { ...cur, ai_second_opinion: value } };
      });
    },
  };
}

// Ranking wird nicht mehr persistiert (kein `result`/`derived`/`completed_at`
// mehr im Datenmodell), sondern hier live aus den aktuellen Antworten
// berechnet - sowohl am Ende von Schritt 4 als auch in Schritt 5.
function computeRanking(farbtypTest, opts) {
  const includeVergleiche = !(opts && opts.includeVergleiche === false);
  const answers = farbtypTest?.answers || emptyAnswers();
  const overrides = farbtypTest?.overrides || { kontrast: null, unterton: null, chroma: null };
  const P_K = Model.deriveKontrastlevel(answers);
  const P_U = Model.deriveUnterton(answers);
  const P_C = Model.deriveChroma(answers);
  const complete = Object.keys(P_K).length > 0 && Object.keys(P_U).length > 0 && Object.keys(P_C).length > 0;
  const effectiveP_K = overrideToDist(overrides.kontrast) || P_K;
  const effectiveP_U = overrideToDist(overrides.unterton) || P_U;
  const effectiveP_C = overrideToDist(overrides.chroma) || P_C;
  // "Ein paar letzte Vergleiche": vergleiche_picks ist eine Map
  // "typA|typB" -> Rundenwert (siehe emptyAnswers-Kommentar);
  // deriveVergleicheMultipliers erwartet ein Array aus {a,b,result}.
  // includeVergleiche:false (Adina-Bugreport 15.09.2026: Duell-Paarung
  // aenderte sich waehrend des Durchklickens) laesst die Multiplikatoren
  // bewusst weg - so bleibt die aus DIESEM Ranking abgeleitete Topologie
  // (computeVergleicheTopology) waehrend des gesamten Vergleiche-Ablaufs
  // "eingefroren" auf dem Stand VOR den Vergleichen, statt sich bei jeder
  // Antwort live zu verschieben. Fuer die tatsaechliche Anzeige (Ergebnis-
  // Screen, Fortschritts-Prozentzahlen) bleibt includeVergleiche:true
  // (Default) weiterhin aktiv.
  const vergleicheEntries = Object.entries(answers.vergleiche_picks || {}).map(([key, result]) => {
    const [a, b] = key.split("|");
    return { a, b, result };
  });
  const vergleicheMultipliers = includeVergleiche ? Model.deriveVergleicheMultipliers(vergleicheEntries) : {};
  const full = complete ? Model.computeFullRanking(effectiveP_K, effectiveP_U, effectiveP_C, vergleicheMultipliers) : [];
  const result = complete ? Model.toStoredResult(full) : [];
  return { P_K, P_U, P_C, effectiveP_K, effectiveP_U, effectiveP_C, complete, result, full };
}

// ══════════════════════════════════════════════════════════
// Schritt 1: Fotos machen - Anleitung (Haltung + 5 Foto-Typen, je mit
// Vorbild-/Gegenbeispiel-Foto) + persistenter Foto-Upload.
// ══════════════════════════════════════════════════════════

// Vier Aufnahmen. gesicht_zugeschnitten ist kein eigener Slot mehr, sondern
// entsteht direkt nach dem Frontal-Upload im Zuschnitt-Dialog (needsCrop).
const PHOTO_ORDINALS = ["erstes", "zweites", "drittes"];

const PHOTO_TYPES = [
  {
    key: "gesicht_vorne",
    label: "Gesicht (frontal)",
    guide: "gesicht-vorne-do",
    needsCrop: true,
    crop: {
      shape: "rect",
      photoNo: 1,
      example: "crop-gesicht-beispiel",
      title: "Zieh den Rahmen so um dein Gesicht, dass er oben die Stirnlinie, rechts und links deine Gesichtshaut und unten deine Kinnlinie berührt.",
    },
    tips: [
      <React.Fragment>Blicke <strong>gerade in die Kamera</strong></React.Fragment>,
      <React.Fragment>Möglichst <strong>kein Oberteil sichtbar</strong></React.Fragment>,
      <React.Fragment><strong>Indirektes</strong>, gleichmäßiges <strong>Tageslicht</strong></React.Fragment>,
      <React.Fragment>
        <strong>Pro-Tipp:</strong> Lade ein bereits freigestelltes Foto von dir hoch,
        <br />
        aber beachte, dass die Farbqualität dabei unverändert bleibt (
        <button type="button" className="ft-tip-magic-trigger">
          Beispiel
        </button>
        ).
      </React.Fragment>,
    ],
    // Adina, 17.09.2026: die iPhone-Funktion "Magic Markierung" veraendert
    // beim Freistellen leider die Farbe (weniger geeignet fuer die
    // Farbanalyse) - das Popup zeigt das jetzt als schlechtes Beispiel
    // (Farbverschiebung sichtbar im Vergleich zum Original), PowerPoints
    // Hintergrund-entfernen als gutes Beispiel (Farbe bleibt originalgetreu).
    freistellDemo: {
      title: "Foto freistellen",
      intro: (
        <>
          <strong>Vergleiche</strong> dein freigestelltes Foto <strong>unbedingt</strong> nochmal mit dem Original, um sicherzustellen, dass die{" "}
          <strong>Farbnuancen</strong> originalgetreu bleiben.
        </>
      ),
      original: "gesicht_frontal_2.jpeg",
      results: [
        { src: "Magic_Dummy.png", label: "Freigestellt mit iPhone", note: "(entfernt Sättigung)", verdict: "bad", scale: 0.95 },
        { src: "ppt_dummy.png", label: "Freigestellt mit PowerPoint", verdict: "good" },
      ],
    },
    calibNoun: "dein Teint",
  },
  {
    key: "haaransatz",
    label: "Haarfarbe",
    guide: "haaransatz-do",
    needsCrop: true,
    crop: {
      shape: "circle",
      photoNo: 2,
      example: "crop-haare-beispiel",
      intro: "Zieh den Kreis auf die Stelle, an der man deine Haarfarbe am Ansatz am klarsten sieht. Über die Ecken kannst du ihn größer oder kleiner ziehen.",
    },
    tips: [
      <React.Fragment>
        <strong>Nah</strong> am <strong>Ansatz (Oberkopf)</strong> fotografiert (hellere Spitzen und Strähnen
        vorne aussparen)
      </React.Fragment>,
      <React.Fragment>
        <strong>Haare gefärbt</strong>, gesträhnt oder grau? <strong>Lade</strong> ein <strong>altes Foto</strong>{" "}
        von deiner Naturhaarfarbe <strong>hoch</strong>.
      </React.Fragment>,
    ],
    calibNoun: "deine Haarfarbe",
  },
  {
    key: "augen",
    label: "Augenfarbe",
    guide: "augen-do",
    needsCrop: true,
    crop: {
      shape: "circle",
      photoNo: 3,
      example: "crop-augen-beispiel",
      intro: "Zieh den Kreis genau um deine Iris. Über die Ecken passt du die Größe an, in der Mitte verschiebst du ihn.",
    },
    tips: [
      <React.Fragment><strong>Nahaufnahme</strong> der <strong>Iris</strong></React.Fragment>,
      <strong>Kein Blitz</strong>,
      <React.Fragment>Möglichst <strong>wenig Spiegelung</strong></React.Fragment>,
    ],
    calibNoun: "deine Augenfarbe",
  },
];

const PHOTO_RULES = [
  {
    title: "Belichtung & Position",
    do: [<span>Bei <strong>Tageslicht</strong> mit <strong>1 Meter</strong> Abstand zum Fenster (frontal)</span>],
    dont: [
      "Keine direkte Sonne, keine Schatten im Gesicht",
      "Kein Kunstlicht, kein Mischlicht",
      "Keine Filter, keine Handy-Voreinstellungen oder Beauty-Modus",
    ],
  },
  {
    title: "Haut & Schminke",
    do: [<span>Haut natürlich und <strong>komplett ungeschminkt</strong></span>],
    dont: [
      "Keinerlei Make-up",
      "Möglichst keine frische Sonnenbräune (notfalls ein Foto deines ungebräunten Gesichts hochladen)",
      "Keine getönte Tagescreme, kein Selbstbräuner",
    ],
  },
  {
    title: "Kleidung und Accessoires",
    do: [<span><strong>Freier Hals</strong>, freie <strong>Schultern</strong> und freies <strong>Dekolleté</strong></span>],
    dont: [
      "Kein flächiges, farbiges Oberteil (jede Farbe beeinflusst die Reflektion in deinem Gesicht)",
      "Keinerlei Schmuck, keine Piercings, keine Brille",
      "Keine gefärbten Haare im Bild - binde sie zurück, wenn sie nicht ihre natürliche Farbe haben",
    ],
  },
];

const SCREEN_CALIBRATION_TIPS = [
  {
    title: "Mac",
    items: [
      "Night Shift ausschalten (Systemeinstellungen → Bildschirme)",
      "True Tone ausschalten (ebenfalls dort)",
      "f.lux oder ähnliche Apps kurz pausieren",
    ],
  },
  {
    title: "Windows",
    items: ["Nachtmodus ausschalten (Einstellungen → System → Anzeige)"],
  },
  {
    title: "iPhone / iPad",
    items: [
      "Night Shift ausschalten (Einstellungen → Anzeige & Helligkeit)",
      "True Tone ausschalten (ebenfalls dort)",
      "Keine Filter oder Stile aktiv, Stil: „Standard“ aktiv",
    ],
  },
  {
    title: "Android",
    items: [
      "Blaulichtfilter/Nachtmodus ausschalten (meist unter Einstellungen → Anzeige)",
      "Bildmodus von „Lebendig“ auf „Standard“ oder „Natürlich“ stellen",
    ],
  },
  {
    title: "Zusätzlich",
    items: [
      "Bei Tageslicht anschauen, nicht bei warmem Lampenlicht",
      "Automatische Helligkeit aus, Helligkeit auf mittel",
      "Bildschirm kurz sauber wischen",
    ],
  },
];

// Inline-Ausklapper statt Hover-/Tap-Popup (Adina: "i tooltip ersetzen durch
// ein 'Mehr' und v-Pfeilchen nach unten. bei klick darauf erscheint der
// text") - klappt den Inhalt direkt im Textfluss auf statt in einem
// schwebenden Panel, entsprechend kein Klick-ausserhalb-schliesst-Handling
// mehr noetig.
function InfoTip({ label = "Mehr erfahren", children }) {
  const [open, setOpen] = useState(false);
  return (
    <span className="ft-infotip">
      <button
        type="button"
        className={"ft-infotip-toggle" + (open ? " ft-infotip-toggle-open" : "")}
        aria-label={label}
        aria-expanded={open}
        onClick={() => setOpen((o) => !o)}
      >
        Mehr
        <ChevronDownIcon size={9} strokeWidth={1.8} />
      </button>
      {open && <div className="ft-infotip-body">{children}</div>}
    </span>
  );
}

// Kompakte "i"-Variante von InfoTip fuer knappe Platzverhaeltnisse direkt
// neben einer Ueberschrift (Adina, 16.09.2026: "ein kleines i, wenn man es
// anklickt kommt die Erklärung") - eigene, schlankere Optik statt "Mehr ⌄",
// selbes Ausklapp-Verhalten/dieselbe .ft-infotip-body-Textformatierung.
function InfoIconTip({ label = "Mehr erfahren", text }) {
  return (
    <span className="ft-infoicon-wrap">
      <span className="ft-infoicon" role="img" tabIndex={0} aria-label={`${label} ${text}`} data-tooltip={text}>
        i
      </span>
    </span>
  );
}

function ScreenCalibrationTip() {
  return (
    <InfoTip label="Wie stelle ich meinen Bildschirm richtig ein?">
      <p className="ft-infotip-intro">
        Zu warm (gelblich/orange)? Eventuell hast du einen Anti-Blaulicht-Filter an, oder deine Handykamera auf
        „warm“ eingestellt. Oder du hast nicht ausreichend Tageslicht oder Kunstlicht beim Fotografieren gehabt?
      </p>
      {SCREEN_CALIBRATION_TIPS.map((g) => (
        <div key={g.title} className="ft-infotip-group">
          <div className="ft-infotip-group-title">{g.title}</div>
          <ul className="ft-infotip-list">
            {g.items.map((t) => <li key={t}>{t}</li>)}
          </ul>
        </div>
      ))}
    </InfoTip>
  );
}

// Statische Entwickler-Assets (Beispielfotos, Sprites, Demo-GIFs) liegen seit
// 17.09.2026 in einem oeffentlichen Cloudflare-R2-Bucket statt im Git-Repo -
// jedes Vercel-Deployment schleppte sonst 180+ MB Bilder erneut mit, was das
// kostenlose "Deployment Storage"-Kontingent gesprengt hat (Adina-Report:
// "Exceeded free resources", 59GB/10GB). Nutzerinnen-Fotos bleiben weiterhin
// im separaten, privaten R2-Bucket (siehe api/_lib/storage.js) - dieser
// Bucket hier ist bewusst oeffentlich und enthaelt keine privaten Daten.
const R2_ASSETS_BASE = "https://pub-ced545f5c3824c7f8e4fcdfaf62fbfe4.r2.dev";

function assetPath(name) {
  return `${R2_ASSETS_BASE}/fotoanleitung/${name}.jpg`;
}

// Beispielfoto je Foto-Typ. Fehlt die Datei noch, zeigt onError einen klar
// beschrifteten Platzhalter statt eines kaputten Bild-Icons.
function GuidePhoto({ name, label }) {
  const [broken, setBroken] = useState(false);
  if (!name || broken) {
    return (
      <div className="ft-tile-guide ft-tile-guide-empty">
        <span className="ft-tile-guide-note">Beispiel folgt</span>
      </div>
    );
  }
  return (
    <img
      className="ft-tile-guide"
      src={assetPath(name)}
      alt={"Beispielfoto: " + label}
      onError={() => setBroken(true)}
    />
  );
}

// Vorher/Nachher-Beispielbild im "Foto freistellen"-Popup (siehe
// freistellDemo). Gleiches Fallback-Prinzip wie GuidePhoto (Platzhalter statt
// kaputtem Bild-Icon), falls ein Beispielbild noch nicht abgelegt ist.
function FreistellExampleImg({ src, alt, style }) {
  const [broken, setBroken] = useState(false);
  if (!src || broken) {
    return (
      <span className="ft-freistell-placeholder">Beispiel folgt</span>
    );
  }
  return <img src={src} alt={alt} style={style} onError={() => setBroken(true)} />;
}

function PhotoUploadSlot({ photo, cropped, cropConfig, guide, label, needsCrop, onUploaded, onRemoved, onCropRequest }) {
  const isCircleCrop = cropConfig?.shape === "circle" || cropConfig?.shape === "circle-fixed";
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const inputRef = useRef(null);

  // Adina-Korrektur zum vorherigen Fix: die Kachel soll IMMER das komplette
  // Originalfoto zeigen (object-fit:contain, siehe CSS), nichts wird
  // abgeschnitten - der gewaehlte Ausschnitt wird stattdessen als Rahmen
  // UEBER dem vollstaendigen Foto eingeblendet. cropFrac ist relativ zum
  // Original, das Original wird aber per contain evtl. oben/unten oder
  // links/rechts "letterboxed" im Tile - das hier auf den Rahmen umrechnen,
  // sonst laege er verschoben/verzogen.
  // Adina, 14.09.2026: der Auffuellrand (Cream-Balken oben/unten bei nicht
  // passendem Seitenverhaeltnis) gefiel ihr nicht - statt einer FESTEN 3:4-
  // Kachel jetzt dynamisch das tatsaechliche Seitenverhaeltnis des
  // Original-Fotos (cropped.origAspect, beim Crop gespeichert). Dadurch
  // deckt sich Ri (unten) IMMER mit TILE_ASPECT, sobald ein Zuschnitt
  // existiert - kein Letterboxing mehr moeglich, die Formel unten
  // vereinfacht sich in diesem Fall selbststaendig (leftOffset/topOffset=0).
  // Vor dem ersten Zuschnitt (cropped noch null) faellt es auf 3:4 zurueck,
  // bis der Crop-Dialog (der sofort nach dem Upload aufgeht) origAspect
  // liefert.
  const TILE_ASPECT = cropped?.origAspect || 3 / 4;
  const cropFrameStyle = React.useMemo(() => {
    if (!cropped?.cropFrac) return null;
    const { x: fx, y: fy, w: fw, h: fh } = cropped.cropFrac;
    const Ri = cropped.origAspect || TILE_ASPECT;
    let widthFrac, heightFrac, leftOffset, topOffset;
    if (Ri >= TILE_ASPECT) {
      heightFrac = TILE_ASPECT / Ri;
      topOffset = (1 - heightFrac) / 2;
      widthFrac = 1;
      leftOffset = 0;
    } else {
      widthFrac = Ri / TILE_ASPECT;
      leftOffset = (1 - widthFrac) / 2;
      heightFrac = 1;
      topOffset = 0;
    }
    if (isCircleCrop) {
      const cx = fx + fw / 2, cy = fy + fh / 2;
      return {
        left: (leftOffset + cx * widthFrac) * 100 + "%",
        top: (topOffset + cy * heightFrac) * 100 + "%",
        width: fw * widthFrac * 100 + "%",
      };
    }
    return {
      left: (leftOffset + fx * widthFrac) * 100 + "%",
      top: (topOffset + fy * heightFrac) * 100 + "%",
      width: fw * widthFrac * 100 + "%",
      height: fh * heightFrac * 100 + "%",
    };
  }, [cropped, isCircleCrop]);

  async function handleFile(e) {
    const file = e.target.files && e.target.files[0];
    e.target.value = "";
    if (!file) return;
    setErr("");
    setBusy(true);
    try {
      const result = await window.MW.compressAndUploadImage(file, { urlSuffix: PROFIL_QS });
      const saved = { id: result.id, url: result.url, uploaded_at: new Date().toISOString() };
      onUploaded(saved);
      // Crop-Quelle ist die lokale Datei (Object-URL), nicht die schon
      // hochgeladene R2-URL: Canvas-Export aus einem Cross-Origin-<img>
      // würde ohne R2-CORS-Konfiguration an "tainted canvas" scheitern.
      if (needsCrop && onCropRequest) onCropRequest({ localUrl: URL.createObjectURL(file) });
    } catch (e2) {
      setErr(e2.userMessage || e2.message || "Der Upload hat nicht geklappt, bitte versuch es gleich nochmal.");
    } finally {
      setBusy(false);
    }
  }

  // "Zuschneiden"-Button (Foto ist schon hochgeladen, aber noch nicht
  // zugeschnitten): Bug-Fix (Adina-Report) - oeffnete bisher versehentlich
  // den Datei-Auswaehler statt des Zuschnitt-Dialogs, weil kein eigener
  // Handler existierte. Die schon hochgeladene R2-URL kann nicht direkt als
  // Crop-Quelle dienen (tainted-canvas-Risiko ohne R2-CORS, siehe
  // handleFile) - deshalb per fetch()+Blob neu als lokale Object-URL laden,
  // das ist fuer Canvas-Export danach garantiert unproblematisch.
  async function reopenCrop() {
    if (!photo || busy || !onCropRequest) return;
    setErr("");
    setBusy(true);
    try {
      // &raw=1: Bytes direkt statt 302-Redirect auf die presignte R2-URL -
      // ein fetch() auf die redirectete Cross-Origin-URL scheiterte ohne
      // R2-CORS-Konfiguration (Adina-Report: Fehlermeldung statt Dialog).
      const res = await fetch(photo.url + "&raw=1", { credentials: "same-origin" });
      if (!res.ok) throw new Error("crop_reload_failed");
      const blob = await res.blob();
      // "existing" mitgeben, damit der Dialog den BEREITS gesetzten Rahmen
      // zeigt statt ihn zurueckzusetzen (Adina-Report: "Auswahl beim Oeffnen
      // wieder zurueckgesetzt... soll den Rahmen anzeigen, wie er aktuell
      // schon ist, damit ich aendern kann, nicht komplett neu setzen muss").
      onCropRequest({ localUrl: URL.createObjectURL(blob), existing: cropped });
    } catch {
      setErr("Der Zuschnitt-Dialog lässt sich gerade nicht öffnen, bitte versuch es gleich nochmal.");
    } finally {
      setBusy(false);
    }
  }

  async function handleRemove() {
    if (!photo) return;
    setBusy(true);
    try {
      await fetch(withProfilQuery(`/api/storage/images?id=${encodeURIComponent(photo.id)}`), {
        method: "DELETE",
        credentials: "same-origin",
      });
    } catch {
      // Soft-Delete-Fehler auf dem Server blockiert das lokale Entfernen nicht.
    } finally {
      onRemoved();
      setBusy(false);
    }
  }

  return (
    <div className="ft-upload-slot">
      <div className="ft-tile">
        <div className="ft-tile-frame" style={{ aspectRatio: TILE_ASPECT }}>
          {photo ? (
            <img className="ft-tile-photo" src={photo.url} alt={"Dein Foto: " + label} />
          ) : (
            <React.Fragment>
              <GuidePhoto name={guide} label={label} />
              <span className="ft-tile-badge">{guide ? "Beispiel" : "Beispiel folgt"}</span>
            </React.Fragment>
          )}

          {photo && (
            <div className="ft-tile-controls">
              <button
                type="button"
                className="ft-tile-icon-btn"
                disabled={busy}
                title={needsCrop ? "Ausschnitt wiederholen" : "Foto ersetzen"}
                aria-label={needsCrop ? "Ausschnitt wiederholen" : "Foto ersetzen"}
                onClick={needsCrop ? reopenCrop : () => inputRef.current.click()}
              >
                <RedoIcon size={15} strokeWidth={1.6} />
              </button>
              <button
                type="button"
                className="ft-tile-icon-btn"
                disabled={busy}
                title="Foto entfernen"
                aria-label="Foto entfernen"
                onClick={handleRemove}
              >
                <CrossIcon size={18} strokeWidth={1.3} />
              </button>
            </div>
          )}

          {needsCrop && photo && cropped && (
            <div
              className={"ft-tile-cropframe" + (isCircleCrop ? " ft-tile-cropframe-circle" : "")}
              style={cropFrameStyle}
              aria-hidden="true"
            />
          )}

          {needsCrop && photo && !cropped && (
            <button
              type="button"
              className="ft-tile-crop-cta"
              disabled={busy}
              onClick={reopenCrop}
            >
              Zuschneiden
            </button>
          )}
        </div>

        {photo ? (
          <div className="ft-tile-bar ft-tile-bar-done">
            {/* Gleiches Groessenverhaeltnis Icon:Kreis wie beim Weiter-Button
                (32px Icon in 48px Kreis = 2/3) statt vorher deutlich
                kleiner wirkend (18px in 34px = 0.53). 34 * 2/3 = 22.67 -> 23. */}
            <span className="ft-tile-check" aria-hidden="true">
              <CheckIcon size={23} strokeWidth={1} />
            </span>
            <span>Fertig</span>
          </div>
        ) : (
          <button type="button" className="ft-tile-bar ft-tile-upload" disabled={busy} onClick={() => inputRef.current.click()}>
            <span className="ft-tile-plus" aria-hidden="true">
              <svg width="21" height="21" viewBox="0 0 24 24" fill="none">
                <path d="M12 4V20M4 12H20" stroke="currentColor" strokeWidth="1" strokeLinecap="round" />
              </svg>
            </span>
            <span className="ft-tile-upload-label">{busy ? "Lädt hoch …" : "Foto hochladen"}</span>
          </button>
        )}
      </div>

      <input ref={inputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={handleFile} />
      {err && <div className="ft-error">{err}</div>}
    </div>
  );
}

const CROP_BOX = 360;
const CROP_MIN = 44;
// Durchmesser des festen Haar-Kreises als Anteil der kuerzeren Bildkante.
const CIRCLE_FIXED_FRAC = 0.28;

// Startrahmen je Form: Rechteck (Gesicht) eng, Kreise mittig zentriert.
function initialCropRect(shape, dw, dh) {
  if (shape === "circle" || shape === "circle-fixed") {
    const base = Math.min(dw, dh);
    const d = base * (shape === "circle-fixed" ? CIRCLE_FIXED_FRAC : 0.5);
    return { x: (dw - d) / 2, y: (dh - d) / 2, w: d, h: d };
  }
  return { x: dw * 0.17, y: dh * 0.17, w: dw * 0.66, h: dh * 0.66 };
}

function CropDialog({ source, config, onCancel, onSaved }) {
  const shape = config?.shape || "rect";
  const isCircle = shape === "circle" || shape === "circle-fixed";
  const canResize = shape !== "circle-fixed";
  const [img, setImg] = useState(null);
  const [rect, setRect] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  // Nur beim Gesicht-Rechteck-Zuschnitt (nicht bei den Kreis-Zuschnitten fuer
  // Haare/Augen): "Ausschnitt uebernehmen" fuehrt erst zu einer Test-Drapierung
  // in Senfgelb mit der Frage, ob die Farbe rundum die Gesichtshaut beruehrt -
  // "Rahmen korrigieren" fuehrt OHNE Neuladen zurueck zum selben Rahmen-Stand,
  // "Rahmen uebernehmen" loest den eigentlichen Upload aus (Adina, 17.09.2026).
  const [confirming, setConfirming] = useState(false);
  const confirmClip =
    confirming && img && rect
      ? faceClip({
          cropFrac: { x: rect.x / img.dw, y: rect.y / img.dh, w: rect.w / img.dw, h: rect.h / img.dh },
          origAspect: img.dw / img.dh,
        })
      : null;

  useEffect(() => {
    let alive = true;
    const im = new Image();
    im.onload = () => {
      if (!alive) return;
      const ar = im.naturalWidth / im.naturalHeight;
      const dw = ar >= 1 ? CROP_BOX : CROP_BOX * ar;
      const dh = ar >= 1 ? CROP_BOX / ar : CROP_BOX;
      setImg({ el: im, dw, dh });
      // Beim erneuten Oeffnen (Ausschnitt neu machen) den BEREITS gesetzten
      // Rahmen zeigen statt ihn zurueckzusetzen - source.existing.cropFrac
      // ist relativ zum Originalfoto (0-1) gespeichert, hier einfach auf die
      // aktuelle Anzeigegroesse zurueckrechnen (Adina-Report).
      const ex = source.existing?.cropFrac;
      setRect(ex ? { x: ex.x * dw, y: ex.y * dh, w: ex.w * dw, h: ex.h * dh } : initialCropRect(shape, dw, dh));
    };
    im.onerror = () => { if (alive) setErr("Das Foto lässt sich gerade nicht laden."); };
    im.src = source.localUrl;
    return () => { alive = false; };
  }, [source.localUrl, shape, source.existing]);

  function drag(mode) {
    return (ev) => {
      ev.preventDefault();
      ev.stopPropagation();
      if (!img || !rect) return;
      if (mode !== "move" && !canResize) return;
      const sx = ev.clientX;
      const sy = ev.clientY;
      const r = { ...rect };
      const move = (e2) => {
        const dx = e2.clientX - sx;
        const dy = e2.clientY - sy;
        const n = { ...r };
        if (mode === "move") {
          n.x = Math.min(Math.max(0, r.x + dx), img.dw - r.w);
          n.y = Math.min(Math.max(0, r.y + dy), img.dh - r.h);
        } else if (isCircle) {
          // Nur die gezogene Ecke bewegt sich, die gegenueberliegende Ecke
          // bleibt fix (quadratisch). Ankerpunkt = Gegenecke des Ziehgriffs.
          const ax = mode.includes("w") ? r.x + r.w : r.x;
          const ay = mode.includes("n") ? r.y + r.h : r.y;
          const px = (mode.includes("w") ? r.x : r.x + r.w) + dx;
          const py = (mode.includes("n") ? r.y : r.y + r.h) + dy;
          const maxSide = Math.min(
            mode.includes("w") ? ax : img.dw - ax,
            mode.includes("n") ? ay : img.dh - ay
          );
          const side = Math.max(CROP_MIN, Math.min(Math.max(Math.abs(px - ax), Math.abs(py - ay)), maxSide));
          n.x = mode.includes("w") ? ax - side : ax;
          n.y = mode.includes("n") ? ay - side : ay;
          n.w = side; n.h = side;
        } else {
          if (mode.includes("w")) { const nx = Math.min(Math.max(0, r.x + dx), r.x + r.w - CROP_MIN); n.x = nx; n.w = r.x + r.w - nx; }
          if (mode.includes("e")) { n.w = Math.min(Math.max(CROP_MIN, r.w + dx), img.dw - r.x); }
          if (mode.includes("n")) { const ny = Math.min(Math.max(0, r.y + dy), r.y + r.h - CROP_MIN); n.y = ny; n.h = r.y + r.h - ny; }
          if (mode.includes("s")) { n.h = Math.min(Math.max(CROP_MIN, r.h + dy), img.dh - r.y); }
        }
        setRect(n);
      };
      const up = () => {
        window.removeEventListener("pointermove", move);
        window.removeEventListener("pointerup", up);
      };
      window.addEventListener("pointermove", move);
      window.addEventListener("pointerup", up);
    };
  }

  function reset() {
    if (!img) return;
    setRect(initialCropRect(shape, img.dw, img.dh));
  }

  async function save() {
    if (!img || !rect || busy) return;
    setBusy(true);
    setErr("");
    try {
      const k = img.el.naturalWidth / img.dw;
      const cv = document.createElement("canvas");
      cv.width = Math.max(1, Math.round(rect.w * k));
      cv.height = Math.max(1, Math.round(rect.h * k));
      const ctx = cv.getContext("2d");
      ctx.fillStyle = "#fffef3";
      ctx.fillRect(0, 0, cv.width, cv.height);
      ctx.drawImage(img.el, rect.x * k, rect.y * k, rect.w * k, rect.h * k, 0, 0, cv.width, cv.height);
      // Kreise werden als quadratischer Ausschnitt gespeichert - die runde
      // Optik entsteht spaeter durch den runden Thumb im Vergleichs-Slider,
      // deshalb kein Alpha-Masking noetig (JPEG).
      const blob = await new Promise((res) => cv.toBlob(res, "image/jpeg", 0.92));
      const file = new File([blob], "zuschnitt.jpg", { type: "image/jpeg" });
      const result = await window.MW.compressAndUploadImage(file, { urlSuffix: PROFIL_QS });
      // Zuschnitt-Anteile relativ zum Originalfoto (0-1) + Original-Seiten-
      // verhaeltnis mitspeichern, damit der Rahmen im Tile (siehe
      // PhotoUploadSlot) den echten Ausschnitt exakt ueber dem vollstaendig
      // sichtbaren (object-fit:contain) Original anzeigt.
      onSaved({
        id: result.id,
        url: result.url,
        uploaded_at: new Date().toISOString(),
        shape,
        origAspect: img.dw / img.dh,
        cropFrac: { x: rect.x / img.dw, y: rect.y / img.dh, w: rect.w / img.dw, h: rect.h / img.dh },
      });
    } catch (e2) {
      setErr(e2.userMessage || e2.message || "Der Zuschnitt hat nicht geklappt, bitte versuch es nochmal.");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="ft-modal-backdrop" onClick={onCancel}>
      <div className="ft-modal ft-crop" onClick={(e) => e.stopPropagation()}>
        <div className="ft-eyebrow">Fotozuschnitt für Farbtest</div>

        {confirming ? (
          // Test-Drapierung: bestaetigt die TN den Rahmen NICHT sofort, sondern
          // sieht erst, ob die Farbe (Senfgelb, --accent) rundum die
          // Gesichtshaut beruehrt - derselbe faceClip()/ft-draping-*-Aufbau wie
          // beim digitalen Draping, nur mit dem LOKALEN Vorschaubild (noch kein
          // Upload) statt der schon hochgeladenen Foto-URL.
          <React.Fragment>
            <h3 className="ft-crop-heading" style={{ textAlign: "center" }}>Check:</h3>
            <p className="ft-muted ft-crop-title" style={{ textAlign: "center" }}>
              Berührt die Farbe oben, unten, rechts und links deine Gesichtshaut?
            </p>
            <div className="ft-crop-confirm">
              <span className="ft-draping-swatch" style={{ background: "var(--accent)" }}>
                <span className="ft-draping-face" style={{ aspectRatio: confirmClip.clipAspect }}>
                  <img src={source.localUrl} style={confirmClip.faceStyle} alt="" draggable={false} />
                </span>
              </span>
            </div>
            {err && <div className="ft-error">{err}</div>}
            <div className="ft-modal-actions">
              <button type="button" className="ft-btn ft-btn-primary" disabled={busy} onClick={save}>
                {busy ? "Speichert …" : "Rahmen übernehmen"}
              </button>
            </div>
            <div className="ft-crop-meta ft-crop-meta-end">
              <button type="button" className="ft-btn-ghost" onClick={() => setConfirming(false)}>
                Rahmen korrigieren
              </button>
            </div>
          </React.Fragment>
        ) : (
          <React.Fragment>
            {/* Titel steht jetzt IN der Flex-Zeile mit dem Beispielbild statt
                darueber, damit beides vertikal zueinander zentriert ist (Adina:
                "text ab zieh den rahmen vertikal alignen mit beispiel foto-
                zuschnitt rechts"). Gleiche Klasse wie der Haupttitel auf dem
                Slide davor (ft-quiz-title) statt Browser-Default-h3 (Adina:
                "wirkt zu gross und dunkel"). */}
            <div className="ft-crop-intro">
              <div className="ft-crop-intro-text">
                {/* Absatz-Formatierung (.ft-muted) statt Ueberschrift-Stil
                    (Adina: "an Absatz-Formatierung anpassen, nicht an
                    Ueberschrift formatierung") - ft-quiz-title wirkte zu
                    gross/dunkel fuer diesen Anleitungstext. */}
                {/* Kein genereller Fallback-Titel mehr (Adina: "Zieh den Rahmen
                    eng ums Motiv. - streichen") - Kreis-Zuschnitte haben ihren
                    eigenen intro-Text, der reicht als einzige Instruktion. */}
                {!isCircle && <h3 className="ft-crop-heading">Zuschnitt für Farbtests</h3>}
                {config?.title && <h3 className="ft-muted ft-crop-title">{config.title}</h3>}
                {config?.intro && <p className="ft-muted">{config.intro}</p>}
              </div>
              {config?.example && (
                <figure className="ft-crop-example">
                  <GuidePhoto name={config.example} label="So soll dein Ausschnitt aussehen" />
                  <figcaption>So sitzt der Rahmen richtig</figcaption>
                </figure>
              )}
            </div>

            <div className="ft-crop-stage">
              {img && rect && (
                <div className="ft-crop-img" style={{ width: img.dw, height: img.dh, backgroundImage: `url(${source.localUrl})` }}>
                  <div
                    className={"ft-crop-rect" + (isCircle ? " ft-crop-rect-circle" : "")}
                    style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
                    onPointerDown={drag("move")}
                  >
                    {!isCircle && <span className="ft-crop-thirds" aria-hidden="true" />}
                    {canResize && (
                      <React.Fragment>
                        <span className="ft-crop-h ft-crop-nw" onPointerDown={drag("nw")} />
                        <span className="ft-crop-h ft-crop-ne" onPointerDown={drag("ne")} />
                        <span className="ft-crop-h ft-crop-sw" onPointerDown={drag("sw")} />
                        <span className="ft-crop-h ft-crop-se" onPointerDown={drag("se")} />
                        {!isCircle && (
                          <React.Fragment>
                            <span className="ft-crop-h ft-crop-n" onPointerDown={drag("n")} />
                            <span className="ft-crop-h ft-crop-s" onPointerDown={drag("s")} />
                            <span className="ft-crop-h ft-crop-w" onPointerDown={drag("w")} />
                            <span className="ft-crop-h ft-crop-e" onPointerDown={drag("e")} />
                          </React.Fragment>
                        )}
                      </React.Fragment>
                    )}
                  </div>
                </div>
              )}
            </div>

            {err && <div className="ft-error">{err}</div>}
            {/* Reihenfolge getauscht (Adina): "Ausschnitt uebernehmen" zentriert
                oben, "Rahmen zuruecksetzen" zentriert direkt darunter - vorher
                stand Zuruecksetzen rechtsbuendig VOR dem primaeren Button. */}
            <div className="ft-modal-actions">
              {err && !img && (
                <button type="button" className="ft-btn ft-btn-text" onClick={onCancel}>Schließen</button>
              )}
              <button
                type="button"
                className="ft-btn ft-btn-primary"
                disabled={busy || !rect}
                onClick={() => (isCircle ? save() : setConfirming(true))}
              >
                {busy ? "Speichert …" : "Ausschnitt übernehmen"}
              </button>
            </div>
            <div className="ft-crop-meta ft-crop-meta-end">
              <button type="button" className="ft-btn-ghost" onClick={reset}>
                Rahmen zurücksetzen
              </button>
            </div>
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

// Auf QuizScreen-Einzelschritte umgebaut (analog Kontrastlevel/Unterton/
// Chroma): Screen 0 = Regel-Uebersicht, Screens 1-4 = je ein Foto-Upload
// (PHOTO_TYPES), mit neuer Pflicht-Validierung pro Foto (nextDisabled ohne
// Upload - vorher keine Sperre). CropDialog ist ein Modal-Overlay und wird
// deshalb als Geschwister von QuizScreen gerendert (nicht als dessen Kind),
// unabhaengig davon welcher der 4 Foto-Screens es ausgeloest hat.
function Stufe1Fotos({ toolData, onUpdate, onGotoStufe }) {
  const photos = toolData.photos || {};
  const [cropSource, setCropSource] = useState(null);
  const [step, setStep] = useChapterStep(toolData, onUpdate, "fotos");
  const [showMagicDemo, setShowMagicDemo] = useState(false);

  function setPhoto(key, value) {
    onUpdate((prev) => ({ ...prev, photos: { ...prev.photos, [key]: value } }));
  }

  const eyebrow = "Fotos machen";
  const goNext = () => setStep(step + 1);
  const cropDialog = cropSource && (
    <CropDialog
      source={cropSource}
      config={cropSource.config}
      onCancel={() => { URL.revokeObjectURL(cropSource.localUrl); setCropSource(null); }}
      onSaved={(p) => {
        URL.revokeObjectURL(cropSource.localUrl);
        setPhoto(cropSource.key + "_zugeschnitten", p);
        setCropSource(null);
      }}
    />
  );

  // Screen 0: kurzer Einstieg (kein Eyebrow, nur Titel + 3-Punkte-Uebersicht
  // + Ueberleitungssatz) - der eigentliche Regelkatalog + Beispielbild lebt
  // jetzt auf Screen 1, weil die kombinierte Seite "zu voll" war (Adina).
  if (step === 0) {
    return (
      <QuizScreen
        screenKey={step}
        title="Bevor es losgeht…"
        centered
        nextIcon="arrow"
        onBack={() => onGotoStufe(0)}
        onNext={goNext}
        nextDisabled={false}
      >
        {/* "Du brauchst:" + Checkliste teilen sich denselben linken Rand:
            gemeinsamer width:max-content-Block (zentriert als Ganzes), Text
            und Liste beide linksbuendig darin statt einzeln zentriert. */}
        <div className="ft-fotos-need-block">
          <p className="ft-muted" style={{ margin: "0 0 8px" }}>Du brauchst:</p>
          {/* Checkbox-Optik statt Textmarker-Haekchen (Adina: passt besser zur
              Design-Sprache) - derselbe duenne Haken wie im Weiter-Button
              (CheckIcon), nur in einem kleinen Senf-Quadrat statt im Kreis. */}
          <ul className="ft-checkbox-list">
            {/* Text in einem eigenen <span> statt lose als Textknoten +
                <strong> im Flex-<li>: sonst zaehlen Textknoten und <strong>
                als EIGENE Flex-Items und der li-gap (10px) rutscht mitten in
                den Satz statt nur vor den Text (Adina-Report "komische
                Leerzeichen" - derselbe Bug wie bei den Foto-Regeln). */}
            <li>
              <span className="ft-checkbox-box"><CheckIcon size={11} strokeWidth={1.4} /></span>
              <span>1x Foto von deinem <strong>Gesicht</strong></span>
            </li>
            <li>
              <span className="ft-checkbox-box"><CheckIcon size={11} strokeWidth={1.4} /></span>
              <span>1x Foto von deiner <strong>natürlichen Haarfarbe</strong></span>
            </li>
            <li>
              <span className="ft-checkbox-box"><CheckIcon size={11} strokeWidth={1.4} /></span>
              <span>1x Foto von deiner <strong>Augenfarbe</strong></span>
            </li>
          </ul>
        </div>
        <p className="ft-muted" style={{ textAlign: "center", maxWidth: 560, margin: "36px auto 0" }}>
          Damit du deinen Farbtyp im weiteren Verlauf richtig bestimmen kannst, <strong>beachte</strong> beim Fotos
          machen die <strong>Hinweise auf der nächsten Seite</strong>.
        </p>
      </QuizScreen>
    );
  }

  // Screen 1: Beispielbild + vollstaendiger Regelkatalog (frueher Teil von
  // Screen 0). Standard-Buttons (Zurueck links, Weiter rechts an der Karte) -
  // die zentrierte Variante war hier wieder raus (Adina-Wunsch).
  if (step === 1) {
    return (
      <QuizScreen screenKey={step} eyebrow={eyebrow} title="So gelingen deine Fotos" onBack={() => setStep(step - 1)} onNext={goNext} nextDisabled={false} nextIcon="arrow">
        {/* Zurueck zum linksbuendigen 2-Spalten-Layout (Text links, Bild
            rechts) statt der zentrierten Variante - Adina: "alles
            linksbündig... Foto rechts, Text links". */}
        <div className="ft-rules">
          <div className="ft-rules-list">
            {PHOTO_RULES.map((g) => (
              <div key={g.title} className="ft-rules-group">
                <div className="ft-rules-group-title">{g.title}</div>
                <ul className="ft-tip-list">
                  {g.do.map((t, i) => (
                    <li key={"do" + i} className="ft-tip-do">
                      <span className="ft-rules-icon ft-rules-icon-do"><CheckIcon size={10} strokeWidth={1.6} /></span>
                      {t}
                    </li>
                  ))}
                  {g.dont.map((t, i) => (
                    <li key={"dont" + i} className="ft-tip-dont">
                      <span className="ft-rules-icon ft-rules-icon-dont"><CrossIcon size={10} strokeWidth={1.6} /></span>
                      {t}
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </div>
          <div className="ft-rules-photo">
            <GuidePhoto name="haltung-do" label="So richtig" />
          </div>
        </div>
      </QuizScreen>
    );
  }

  const idx = step - 2;
  const t = PHOTO_TYPES[Math.min(idx, PHOTO_TYPES.length - 1)];
  const isLastPhoto = idx === PHOTO_TYPES.length - 1;
  return (
    <React.Fragment>
      <QuizScreen
        screenKey={step}
        eyebrow={eyebrow}
        title={`Foto ${idx + 1} von ${PHOTO_TYPES.length}: ${t.label}`}
        titleAfter={
          <PhotoStepDots
            done={PHOTO_TYPES.map((pt) => Boolean(photos[pt.key] && (!pt.needsCrop || photos[pt.key + "_zugeschnitten"])))}
          />
        }
        onBack={() => setStep(step - 1)}
        onNext={isLastPhoto ? () => { startChapterAtBeginning(onUpdate, "kontrast"); onGotoStufe(2); } : goNext}
        nextDisabled={!photos[t.key]}
      >
        {/* Eyebrow/Titel bleiben fix oben links (QuizScreens Standard-
            Kopfbereich, wie ueberall sonst). Einleitungssatz steht ausserhalb
            der zweispaltigen Reihe, damit "01" (nicht der Satz) oben buendig
            mit der Foto-Kachel ist (Adina) - align-items:start auf
            .ft-upload-layout. */}
        <p className="ft-muted" style={{ margin: "0 0 12px" }}>
          Lade nun dein {PHOTO_ORDINALS[idx]} Foto hoch.
        </p>
        <div className="ft-upload-layout">
          <div className="ft-upload-text">
            {/* Klick-Delegation statt eigenem onClick auf dem Trigger-Button:
                die Tipps in PHOTO_TYPES sind statisches, einmal am Modulstart
                gebautes JSX (kein Zugriff auf lokalen Komponenten-State) -
                .ft-tip-magic-trigger sitzt trotzdem in diesem Teilbaum und
                sein Click blubbert bis hierher hoch (Adina: Popup statt
                dauerhaft eingeblendetem GIF, "damit es nicht zu voll wird"). */}
            <div onClick={(e) => { if (e.target.closest(".ft-tip-magic-trigger")) setShowMagicDemo(true); }}>
              <QuizTips items={t.tips} />
            </div>
            <div className="ft-muted ft-calib-note">
              <strong>Überprüfe</strong> unbedingt bevor du weiter klickst, ob{" "}
              <strong>{t.calibNoun} originalgetreu wiedergegeben</strong> wird. Falls nicht: überprüfe deine Handy-
              und Monitoreinstellungen, bis dein Foto passt. Es ist die Grundlage für eine korrekte
              Farbtypbestimmung.
              <ScreenCalibrationTip />
            </div>
          </div>
          <PhotoUploadSlot
            photo={photos[t.key]}
            cropped={photos[t.key + "_zugeschnitten"]}
            cropConfig={t.crop}
            guide={t.guide}
            label={t.label}
            needsCrop={t.needsCrop}
            onUploaded={(p) => setPhoto(t.key, p)}
            onRemoved={() => { setPhoto(t.key, null); setPhoto(t.key + "_zugeschnitten", null); }}
            onCropRequest={(p) => setCropSource({ ...p, key: t.key, config: t.crop })}
          />
        </div>
      </QuizScreen>
      {cropDialog}
      {showMagicDemo && t.freistellDemo && (
        <div className="ft-modal-backdrop" onClick={() => setShowMagicDemo(false)}>
          <div className="ft-modal ft-magic-demo" onClick={(e) => e.stopPropagation()}>
            <div className="ft-family-head">
              <h3>{t.freistellDemo.title}</h3>
              <button type="button" className="ft-family-close" onClick={() => setShowMagicDemo(false)} aria-label="Schließen">
                ×
              </button>
            </div>
            <p className="ft-muted">{t.freistellDemo.intro}</p>
            <div className="ft-freistell-row">
              <figure className="ft-freistell-cell">
                <FreistellExampleImg src={`${R2_ASSETS_BASE}/${t.freistellDemo.original}`} alt="Original" />
                <figcaption>Original</figcaption>
              </figure>
              {t.freistellDemo.results.map((r) => (
                <figure className="ft-freistell-cell" key={r.src}>
                  <span className="ft-freistell-img-wrap">
                    <FreistellExampleImg
                      src={`${R2_ASSETS_BASE}/${r.src}`}
                      alt={r.label}
                      style={r.scale ? { transform: `scale(${r.scale})`, transformOrigin: "center bottom" } : undefined}
                    />
                    {/* Genau dieselbe Rundbutton-Optik wie Zurueck (X, sekundaer)
                        und Weiter (Haken, primaer) - 48px Kreis, dieselben
                        Icon-Groessen/Stroke-Staerke wie dort (Adina,
                        17.09.2026). */}
                    <span
                      className={"ft-freistell-corner" + (r.verdict === "good" ? " ft-freistell-corner-good" : " ft-freistell-corner-bad")}
                      aria-hidden="true"
                    >
                      {r.verdict === "good" ? <CheckIcon size={32} strokeWidth={0.8} /> : <CrossIcon size={28} strokeWidth={0.8} />}
                    </span>
                  </span>
                  <figcaption>
                    {r.label}
                    {r.note && (
                      <>
                        <br />
                        {r.note}
                      </>
                    )}
                  </figcaption>
                </figure>
              ))}
            </div>
          </div>
        </div>
      )}
    </React.Fragment>
  );
}

// ══════════════════════════════════════════════════════════
// Foto-Vergleichs-Slider: TN vergleicht ihr eigenes Foto visuell mit einer
// Referenz-Bildreihe (Sprite aus image_assets/) und wählt per Klick oder per
// Ziehen des eigenen Foto-Thumbnails 1-2 passende Stufen aus ("unsicher"
// erlaubt bis zu 2 gleichzeitig aktive Stufen, siehe toggle()).
// ══════════════════════════════════════════════════════════

function spritePath(name) {
  return `${R2_ASSETS_BASE}/${name}`;
}

// Gleichmäßig breite Stufen (die meisten Sprites sind saubere, gleich breite
// Spalten). Für Sprites mit ungleich breiten Spalten (z. B. Haarunterton.png)
// werden start/end direkt von Hand angegeben, siehe HAAR_WAERME_SPRITE.
function equalSegments(values, labelFn) {
  const n = values.length;
  return values.map((v, i) => ({ value: v, label: labelFn(v), start: i / n, end: (i + 1) / n }));
}

const HAUTTON_SPRITE = {
  // Drei deckungsgleiche, transparente Farbstufen (gleiche Leinwand/Positionen,
  // nur der Hautunterton der Beispielpersonen variiert: gelb=waermer,
  // mittel=neutral, rosa=rosiger). "mittel" dient als Basis fuer Aspect und
  // Spalten-Segmente; alle drei speisen den stufenlosen Unterton-Regler
  // (Graustufen -> Gelb -> Mittel -> Rosa), damit der TN die Beispielhaut an
  // seinen eigenen Unterton angleichen und so die Helligkeitsstufe leichter
  // treffen kann. Transparenter Hintergrund -> Gesichter schweben auf dem
  // Creme-Seitenhintergrund.
  // Matte-korrigierte Varianten (transparente Pixel auf Kartenfarbe #fffef3
  // gesetzt) - verhindert den gelbgruenen Kantensaum, der sonst beim
  // Herunterskalieren des senfgelb-transparenten PNGs entsteht.
  src: spritePath("Hautton_mittel_matte.png"),
  aspect: 5706 / 1191,
  segments: equalSegments(Config.HAUTTON_STUFEN, (v) => v.charAt(0).toUpperCase() + v.slice(1)),
  colorStops: {
    gelb: spritePath("Hautton_gelb_matte.png"),
    mittel: spritePath("Hautton_mittel_matte.png"),
    rosa: spritePath("Hautton_rosa_matte.png"),
  },
  // Von Adina per ?calib=1 von Hand kalibrierte Auswahlkaesten (statt der
  // automatischen Canvas-Gesichtserkennung, die hier leicht daneben lag) -
  // sprite.boxes hat in boxGeom immer Vorrang, autoBox laeuft dafuer nicht
  // mehr mit.
  boxes: [
    { cx: 10, w: 13, top: 3.5, bottom: 9.8 },
    { cx: 23.8, w: 13, top: 3.5, bottom: 9.8 },
    { cx: 37.6, w: 13, top: 3.5, bottom: 9.8 },
    { cx: 51, w: 13, top: 3.5, bottom: 9.8 },
    { cx: 64.3, w: 13, top: 3.5, bottom: 9.8 },
    { cx: 77.5, w: 13, top: 3.5, bottom: 9.8 },
    { cx: 90.7, w: 13, top: 3.5, bottom: 9.8 },
  ],
};

// y-Grenze (in % der Bildhoehe) zwischen oberer und unterer Bildreihe.
const HAARLEVEL_ROW_SPLIT_Y = 54;

// Zweireihiges Bild (Adina, 18.09.2026): oben Haarlevel 1-10 (Braun- bis
// Blondtoene), unten Haarlevel 4-10 (rotbraun bis kupferblond) - die untere
// Reihe entspricht der frueheren jeweils 2. (rechten) Spalte derselben Zahl.
// Freier 2D-Drag wie beim Haarunterton: der Thumb bleibt liegen, die Stufe(n)
// ergeben sich aus der x-Position (zwischen zwei Spalten -> 2 Stufen), die
// Reihe (oben/unten) aus der y-Position und bestimmt nur den ausgegebenen
// Haarfarben-Namen (siehe haarlevelName), nicht das Scoring. Auswahlkaesten
// per ?calib=1 kalibrierbar (Startwerte = Spaltenmitten).
const HAARLEVEL_SPRITE = {
  src: spritePath("Haarlevel_zweireihig_freigestellt.png"),
  aspect: 3441 / 1775,
  freeDrag: true,
  // Ziehbar nur ueber den Bildreihen (ohne Kopfzeile "dunkel/hell" + Zahlen).
  dragBand: { x0: 0.5, x1: 99.5, yTop0: 17.4, yTop1: 17.4, yBot0: 90.2, yBot1: 90.2 },
  // Ab Spalte 4 zweireihig: boxes = Rahmen der OBEREN Reihe (Zahl + Fotos,
  // Spalte 1-3 haben nur diese), boxes2 = Rahmen der UNTEREN Reihe (null bei
  // Spalte 1-3). Beide per ?calib=1 kalibrierbar.
  boxes: [4.95, 15, 25.1, 35.2, 45.3, 55.4, 65.5, 75.5, 85.6, 95.7].map((cx) => ({ cx, w: 9.6, top: 8.5, bottom: 46.5 })),
  // y-Grenze (%) zwischen oberer und unterer Reihe: je nachdem, wo das TN-Bild
  // liegt, gilt GENAU EINE Reihe (nur deren Rahmen, nur deren Spalten).
  rowSplitY: HAARLEVEL_ROW_SPLIT_Y,
  boxes2: [null, null, null, 35.2, 45.3, 55.4, 65.5, 75.5, 85.6, 95.7].map((cx) => (cx === null ? null : { cx, w: 9.6, top: 54.3, bottom: 0 })),
  segments: equalSegments([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], String),
};

// Wording je Haarlevel, obere Reihe (aus Haarlevel.png) - je Level wird das
// erstgenannte, kuehlere/neutralere Wort verwendet.
const HAARLEVEL_NAMES = {
  1: "Schwarz",
  2: "Schwarzbraun",
  3: "Sehr dunkles Braun",
  4: "Dunkelbraun",
  5: "Mittleres Braun",
  6: "Hellbraun",
  7: "Dunkelblond",
  8: "Mittleres Blond",
  9: "Hellblond",
  10: "Lichtblond",
};
// Untere Reihe (Rot-/Kupfer-Varianten, nur Level 4-10, Level 1-3 gibt es dort
// nicht -> haarlevelName faellt auf die obere Reihe zurueck).
const HAARLEVEL_NAMES_ROT = {
  4: "Dunkles Rotbraun",
  5: "Dunkles Kupferbraun",
  6: "Rot",
  7: "Kupfer",
  8: "Rotblond",
  9: "Helles Rot-Blond",
  10: "Sehr helles Rotblond",
};
function haarlevelName(v, pos) {
  const unten = !!pos && pos.y >= HAARLEVEL_ROW_SPLIT_Y;
  return (unten && HAARLEVEL_NAMES_ROT[v]) || HAARLEVEL_NAMES[v];
}

function haarlevelResultLabel(values, pos) {
  if (!values || !values.length) return null;
  // Aufsteigend sortiert statt in Klick-Reihenfolge (Adina: "immer zuerst
  // das niedrigere aufzaehlen, dann das hoehere") - bei "Unsicher" mit 2
  // Antworten war die Reihenfolge sonst von der Klick-Reihenfolge abhaengig.
  return [...values]
    .sort((a, b) => a - b)
    .map((v) => `${v} (${haarlevelName(v, pos)})`)
    .join(" bis ");
}

const AUGENFARBE_SPRITE = {
  src: spritePath("augenfarbe_freigestellt.png"),
  aspect: 4448 / 1666,
  segments: equalSegments(Config.AUGENFARBEN, (v) => AUGENFARBEN_LABELS[v]),
};

// Drei deckungsgleiche Farbstufen wie beim Hautton-Slider (Adina, 08.09.2026):
// "mittel" (hier: neutral) dient als Basis fuer Aspect/autoBox, alle drei
// speisen den stufenlosen Unterton-Regler - hier OHNE Graustufen-Phase
// (colorStopsNoGray: Adina "beim Unterton bitte nicht mit Graustufen
// arbeiten"), startet direkt bei voller Farbe (Gelb), Regler-Labels bleiben
// leer ("unbenannt lassen"). Matte-korrigierte Varianten (transparente
// Pixel auf Kartenfarbe #fffef3 alpha-geblendet) - verhindert den
// Kantensaum, der sonst beim Herunterskalieren des transparenten PNGs
// entsteht (gleicher Fix wie bei Hautton_*_matte.png).
const UNTERTON_HAUT_SPRITE = {
  src: spritePath("Unterton_neutral_matte.png"),
  aspect: 5709 / 1653,
  segments: equalSegments(Config.UNTERTON_LEVELS, (v) => UNTERTON_LABELS[v]),
  colorStops: {
    gelb: spritePath("Unterton_gelb_matte.png"),
    mittel: spritePath("Unterton_neutral_matte.png"),
    rosa: spritePath("Unterton_rosa_matte.png"),
  },
  colorStopsNoGray: true,
  // Fest hinterlegt: die Canvas-Erkennung (autoBox) scheitert am R2-Bild (kein
  // CORS) und fiel auf 3% Inset zurueck -> Rahmen sass zu tief.
  boxes: [
    { cx: 12.5, w: 21.2, top: 0, bottom: 0 },
    { cx: 36.8, w: 21.2, top: 0, bottom: 0 },
    { cx: 61.8, w: 21.2, top: 0, bottom: 0 },
    { cx: 86.1, w: 21.2, top: 0, bottom: 0 },
  ],
};

// Haarunterton.png hat 3 ungleich breite Spalten (die "Deutliche Wärme"-
// Gruppe ist deutlich breiter als "(fast) keine Wärme") - Grenzen per
// Bildanalyse an den Lücken zwischen den drei Spalten-Überschriften ermittelt
// (nicht gleich verteilt, sonst läge die Trennlinie mitten in einer Spalte).
const HAAR_WAERME_SPRITE = {
  src: spritePath("Haarunterton_freigestellt.png"),
  // Bild unten um den leeren Puffer (~1.5% Hoehe, nur Weissraum unter dem
  // letzten Beschreibungstext) beschnitten, der zusaetzlich zum Auswahlkasten-
  // Rahmen fuer sichtbar zu viel Freiraum unter dem Bild sorgte (Adina).
  aspect: 3566 / 2109,
  // Haarproben laufen diagonal: Level 1 oben links -> Level 10 unten rechts. Der
  // Foto-Thumb wandert deshalb auf einer Diagonalen (x/y in % der Flaeche); Werte
  // nach Adinas Auge justierbar.
  // Freier 2D-Drag; der Thumb bleibt beim Loslassen liegen. dragBand begrenzt den
  // Zug auf das diagonale Bilderband (y-Grenzen wandern mit x) -> kein Weissraum.
  freeDrag: true,
  dragBand: { x0: 9, x1: 96, yTop0: 8.1, yTop1: 40.6, yBot0: 44.7, yBot1: 81.2 },
  // Auswahlkasten je Spalte: umfasst Kopf- (oben) und Beschreibungstext (unten).
  // Werte in %, nach Adinas Auge justierbar. bottom: 0, da der Bild-Zuschnitt
  // jetzt selbst am unteren Textende endet.
  boxes: [
    { cx: 22.3, w: 28.7, top: 1.1, bottom: 0 },
    { cx: 51.5, w: 29.4, top: 1, bottom: 0 },
    { cx: 81.1, w: 29.4, top: 1.1, bottom: 0 },
  ],
  segments: [
    { value: "keine-waerme", label: HAAR_WAERME_LABELS["keine-waerme"], start: 0, end: 0.373 },
    { value: "moderate-waerme", label: HAAR_WAERME_LABELS["moderate-waerme"], start: 0.373, end: 0.683 },
    { value: "deutliche-waerme", label: HAAR_WAERME_LABELS["deutliche-waerme"], start: 0.683, end: 1 },
  ],
};

// Chroma-Teint als 3 Duelle statt Einzel-Regler (Adina, 09.09.2026): pro
// Value-Stufe (Hell/Mittel/Tief) eine Reihe mit den 3 Chroma-Farbfeldern
// dieser Zeile aus Adinas Referenz-Paletten (Hex-Werte per Skript aus
// Chroma_Farbpalette_rot.png/_pink.png ausgelesen). Palette haengt vom
// bereits bestimmten Unterton ab: warm/warm-neutral -> Rot, kuehl/
// kuehl-neutral -> Pink (siehe chromaPaletteForUnterton).
const CHROMA_ROW_META = [
  { key: "hell", label: "Hell" },
  { key: "mittel", label: "Mittel" },
  { key: "tief", label: "Tief" },
];
// Je Zeile [Soft, Mittel, Klar].
const CHROMA_PALETTE_ROT = [
  ["#EBCECC", "#F0A6A0", "#FF7162"],
  ["#B85C53", "#CE4C3A", "#E03602"],
  ["#823E38", "#913225", "#9E2300"],
];
const CHROMA_PALETTE_PINK = [
  ["#E2BAD8", "#F0A2DC", "#FD8AE1"],
  ["#CB7BB6", "#DC59B9", "#E836BA"],
  ["#904C7F", "#95307A", "#A10078"],
];
function chromaPaletteForUnterton(untertonTop) {
  return untertonTop === "kuehl" || untertonTop === "kuehl-neutral" ? CHROMA_PALETTE_PINK : CHROMA_PALETTE_ROT;
}

// Eine Chroma-Teint-Duell-Zeile: 3 Farbfelder (Soft/Mittel/Klar, mit dem
// eigenen Gesichtsfoto ueberlagert - identischer Aufbau wie
// DigitalDrapingRound, nur mit flachen Hex-Farben statt Metall-/Farb-
// Drape-Bildern). Kein separater "Zwischen"-Button (Adina, 09.09.2026):
// stattdessen
// direkt 1-2 Favoriten unter den 3 Farbfeldern anklicken - 2 gewaehlte
// Farben ENTSPRECHEN der vorherigen Zwischen-Option ("Soft bis Mittel" =
// Soft + Mittel beide ausgewaehlt). Nur benachbarte Kombinationen sind
// sinnvoll (Soft+Klar ohne Mittel dazwischen ergibt keinen Sinn) - waehlt
// man bei bereits 1 Favorit eine NICHT benachbarte zweite Farbe, ersetzt
// das die Auswahl statt sie zu ergaenzen. Bei bereits 2 Favoriten ersetzt
// ein neuer Klick die Auswahl komplett (faengt von vorne an).
const CHROMA_LEVEL_ORDER = ["soft", "mittel", "klar"];
function ChromaRowDuel({ photoUrl, framing, colors, rowValue, onPick }) {
  const [softHex, mittelHex, klarHex] = colors;
  const { clipAspect, faceStyle } = faceClip(framing);
  const selected = rowValue || [];

  function pick(key) {
    if (selected.includes(key)) {
      const rest = selected.filter((k) => k !== key);
      onPick(rest.length ? rest : null);
      return;
    }
    if (selected.length === 1) {
      const dist = Math.abs(CHROMA_LEVEL_ORDER.indexOf(selected[0]) - CHROMA_LEVEL_ORDER.indexOf(key));
      if (dist === 1) {
        onPick([...selected, key].sort((a, b) => CHROMA_LEVEL_ORDER.indexOf(a) - CHROMA_LEVEL_ORDER.indexOf(b)));
        return;
      }
    }
    onPick([key]);
  }

  function renderTile(key, hex, label) {
    const on = selected.includes(key);
    return (
      <button
        key={key}
        type="button"
        className={"ft-draping-tile ft-draping-duel-tile" + (on ? " is-selected" : "")}
        onClick={() => pick(key)}
        aria-pressed={on}
      >
        <span className="ft-draping-swatch" style={{ background: hex }}>
          <span className="ft-draping-face" style={{ aspectRatio: clipAspect }}>
            <img src={photoUrl} style={faceStyle} alt="" draggable={false} />
          </span>
        </span>
        <span className="ft-draping-label">{label}</span>
        <span className={"ft-draping-pick" + (on ? " ft-draping-pick-done" : "")} aria-hidden="true">
          {on && <CheckIcon size={19} strokeWidth={1.2} />}
        </span>
      </button>
    );
  }

  return (
    <div className="ft-draping">
      <ul className="ft-draping-fragen">
        <li>
          <strong className="ft-draping-fragen-head">Soft besser?</strong>
          <span className="ft-draping-fragen-body">
            Lässt dich die <strong>softe, gedeckte</strong> Variante <strong>edel, elegant</strong> und{" "}
            <strong>ebenmäßig</strong> wirken? <strong>Umrahmt</strong> sie dein Gesicht <strong>natürlich</strong>,
            ohne sich in den Vordergrund zu stellen? Und lassen die <strong>klare(re)n Farben</strong> dein Gesicht
            etwas <strong>fleckig</strong> wirken? Stechen sie zu sehr hervor und <strong>„überrollen"</strong> dein
            Gesicht? Als bräuchtest du Make-up, um gegen die Farbe anzukommen? Sieht man zuerst die Farbe und dann
            dein Gesicht?
          </span>
        </li>
        <li>
          <strong className="ft-draping-fragen-head">Klar besser?</strong>
          <span className="ft-draping-fragen-body">
            Lässt dich die <strong>klare</strong> Variante <strong>leuchten</strong> und{" "}
            <strong>strahlen</strong>? Wirken deine Augen <strong>klarer</strong> und <strong>präsent</strong>?
            Wirkt deine Haut <strong>frisch</strong> und <strong>lebendig</strong> durch die kräftige Farbe? Und
            lassen die <strong>softe(re)n Farben</strong> dein Gesicht eher <strong>verwaschen, müde</strong> und{" "}
            <strong>konturlos</strong> wirken? Siehst du damit eher <strong>ungesund</strong> und{" "}
            <strong>müde</strong> aus? Als könne die Farbe nicht „mit dir mithalten"?
          </span>
        </li>
      </ul>
      <div className="ft-chroma-row-duel">
        {renderTile("soft", softHex, "Soft")}
        {renderTile("mittel", mittelHex, "Mittel")}
        {renderTile("klar", klarHex, "Klar")}
      </div>
    </div>
  );
}

// Drei deckungsgleiche Farbstufen wie beim Unterton-Slider (Adina,
// 08.09.2026): "neutral" dient als Basis fuer Aspect/boxes, alle drei
// speisen den stufenlosen Regler - ohne Graustufen-Phase (colorStopsNoGray,
// "wie bei Ergebnisseite Unterton"). Matte-korrigierte Varianten
// (transparente Pixel auf Kartenfarbe #fffef3 alpha-geblendet) - gleicher
// Fix wie bei Hautton/Unterton.
const CHROMA_TEINT_SPRITE = {
  src: spritePath("Chroma_neutral_matte.png"),
  aspect: 2662 / 1606,
  segments: equalSegments(Config.CHROMA_LEVELS, (v) => CHROMA_LABELS[v]),
  colorStops: {
    gelb: spritePath("Chroma_gelb_matte.png"),
    mittel: spritePath("Chroma_neutral_matte.png"),
    rosa: spritePath("Chroma_rosa_matte.png"),
  },
  colorStopsNoGray: true,
  // Feinheiten einheitlich zu den Augen-Boxen (w/top/bottom); cx auf die
  // Gesichts-Spalten des Teint-Bildes. Im Kalibrier-Modus (?calib=1) justierbar.
  boxes: [
    { cx: 21, w: 33, top: -1, bottom: 2.7 },
    { cx: 50, w: 33, top: -1, bottom: 2.7 },
    { cx: 79, w: 33, top: -1, bottom: 2.7 },
  ],
};

// 4x3-Raster (4 Beispielaugen je Chroma-Stufe, urspruenglich 5, unterste
// Reihe mit grauen Augen am 13.09.2026 entfernt - Adina: "Grau bedeutet in
// seiner Natur einfach immer soft", die Reihe widersprach also der
// Spalten-Logik in soft/mittel/klar) statt vorher 3x3, plus ein
// Helligkeits-Regler links (Adina, 13.09.2026 - "um das noch ein bisschen
// genauer zu machen"): drei Bilder (hell/mittel/dunkel, "mittel" separat
// erstellt "damit das Ganze weiterhin stufenlos verstellbar ist" statt nur
// zwischen 2 Extremen zu kreuzblenden), die sich NUR in der Belichtung/
// Helligkeit unterscheiden, nicht im Farbton - siehe sprite.brightnessStops
// in ComparisonSlider (stufenloses Ueberblenden wie beim Unterton-Regler,
// nur ohne Graustufen-Phase). Alle drei Bilder haben dasselbe Seiten-
// verhaeltnis (~1.874 nach dem Zuschnitt der 5. Reihe, gecheckt) trotz
// unterschiedlicher Aufloesung.
// Anders als das alte freigestellt.png sind hier KEINE Spalten-Header
// eingebrannt (kein Cream-Rand/Text) - die "Soft/Mittel/Klar"-Ueberschriften
// kommen deshalb wieder aus .ft-chroma-augen-labels (siehe Stufe4Chroma).
const CHROMA_AUGEN_SPRITE = {
  brightnessStops: {
    hell: spritePath("Chroma_Augen_hell.png"),
    mittel: spritePath("Chroma_Augen_mittel.png"),
    dunkel: spritePath("Chroma_Augen_dunkel.png"),
  },
  aspect: 1226 / 652,
  // Freier 2D-Drag ueber dem GANZEN Bild statt Klick-Spalten (Adina,
  // 13.09.2026: "genauso wie beim Haarwärmebestimmer... auf dem gesamten
  // Bild") - kein dragBand noetig, die Bilder sind randlos (kein Cream-
  // Rand wie beim Haarunterton-Sprite), Standard-dragArea (0..100 x 0..100)
  // deckt also schon die komplette Flaeche ab.
  freeDrag: true,
  segments: equalSegments(Config.CHROMA_LEVELS, (v) => CHROMA_LABELS[v]),
  // Von Adina per ?calib=1 nachjustiert: top zieht die Kastenoberkante ueber
  // das Bild hinaus hoch, damit dort Platz fuer die Soft/Mittel/Klar-
  // Ueberschrift ist (.ft-chroma-augen-labels liest dieselben cx/w-Werte,
  // siehe dort) - die Ueberschriften sitzen dadurch IMMER exakt ueber ihrer
  // Box, unabhaengig von spaeteren Kalibrierungen. Urspruenglich -6.9 fuer
  // das 5-reihige Bild kalibriert - nach dem Entfernen der 5. (grauen) Reihe
  // ist das Bild bei gleicher Breite 20% niedriger (aspect 1.499 -> 1.874),
  // -6.9% davon waere ein kleinerer ABSOLUTER Puffer als vorher. Hochskaliert
  // auf -8.6 (-6.9 / 0.8), damit der Puffer in Pixeln gleich bleibt.
  boxes: [
    { cx: 16.8, w: 33.4, top: -8.6, bottom: 0 },
    { cx: 50, w: 33.4, top: -8.6, bottom: 0 },
    { cx: 83.2, w: 33.4, top: -8.6, bottom: 0 },
  ],
};

// Eindeutige Werte in Sprite-Reihenfolge (Haarlevel hat z. B. 2 Spalten pro
// Zahl) - Grundlage fuer den Nachbarschafts-Check beim Mehrfachauswaehlen.
function uniqueSpriteValues(sprite) {
  const seen = new Set();
  const out = [];
  for (const s of sprite.segments) {
    if (!seen.has(s.value)) {
      seen.add(s.value);
      out.push(s.value);
    }
  }
  return out;
}

function sliderResultLabel(sprite, values) {
  if (!values || !values.length) return null;
  const labels = values.map((v) => sprite.segments.find((s) => s.value === v)?.label).filter(Boolean);
  return labels.length ? labels.join(" bis ").toLowerCase() : null;
}

// Automatische Kasten-/Zentren-Regel: analysiert das Sprite einmalig per Canvas
// und leitet je Spalte das echte horizontale Zentrum (aus der Beschriftungszeile)
// sowie die gemeinsame vertikale Ausdehnung (oberste Content-Zeile inkl. Text bis
// unterste) ab. Dadurch sitzt der Auswahlkasten samt Foto-Thumb ohne manuelles
// Nachmessen exakt auf jedem Beispielbild - fuer jede transparente Skala und
// automatisch neu, sobald sich das Bild aendert. Faellt bei nicht analysierbaren
// (opaken) Bildern auf null zurueck.
function useAutoBoxes(src, segments, enabled) {
  const [boxes, setBoxes] = useState(null);
  useEffect(() => {
    if (!enabled || !src) {
      setBoxes(null);
      return;
    }
    let cancelled = false;
    const img = new Image();
    img.onload = () => {
      if (cancelled) return;
      try {
        const scale = Math.min(1, 400 / img.naturalWidth);
        const w = Math.max(8, Math.round(img.naturalWidth * scale));
        const h = Math.max(8, Math.round(img.naturalHeight * scale));
        const cv = document.createElement("canvas");
        cv.width = w;
        cv.height = h;
        const ctx = cv.getContext("2d");
        ctx.drawImage(img, 0, 0, w, h);
        const data = ctx.getImageData(0, 0, w, h).data;
        const op = (x, y) => data[(y * w + x) * 4 + 3] > 128;
        const rowThr = Math.max(1, w * 0.01);
        // Vertikale Content-Grenzen + oberstes Content-Band (= Beschriftung).
        const rowCov = new Array(h).fill(0);
        let cy0 = -1;
        let cy1 = -1;
        for (let y = 0; y < h; y++) {
          let c = 0;
          for (let x = 0; x < w; x++) if (op(x, y)) c++;
          rowCov[y] = c;
          if (c > rowThr) {
            if (cy0 < 0) cy0 = y;
            cy1 = y;
          }
        }
        if (cy0 < 0) {
          setBoxes(null);
          return;
        }
        let ly0 = -1;
        let ly1 = -1;
        let inrun = false;
        for (let y = 0; y < h; y++) {
          const on = rowCov[y] > rowThr;
          if (on && !inrun) {
            ly0 = y;
            ly1 = y;
            inrun = true;
          } else if (on && inrun) {
            ly1 = y;
          } else if (!on && inrun) {
            break;
          }
        }
        const topPct = (cy0 / h) * 100 - 3;
        const botPct = Math.max(0, (1 - cy1 / h) * 100 - 1);
        const result = segments.map((s) => {
          const xs = Math.round(s.start * w);
          const xe = Math.max(xs + 1, Math.round(s.end * w));
          let mn = -1;
          let mx = -1;
          for (let x = xs; x < xe; x++) {
            let hit = false;
            for (let y = ly0; y <= ly1; y++)
              if (op(x, y)) {
                hit = true;
                break;
              }
            if (hit) {
              if (mn < 0) mn = x;
              mx = x;
            }
          }
          const cx = mn < 0 ? ((s.start + s.end) / 2) * 100 : (((mn + mx) / 2) / w) * 100;
          return { cx, w: (s.end - s.start) * 100 * 0.85, top: topPct, bottom: botPct };
        });
        if (!cancelled) setBoxes(result);
      } catch (e) {
        if (!cancelled) setBoxes(null);
      }
    };
    img.onerror = () => {
      if (!cancelled) setBoxes(null);
    };
    img.src = src;
    return () => {
      cancelled = true;
    };
  }, [src, enabled, segments]);
  return boxes;
}

// boxInset (CSS-inset-Kurzform in %) -> {top,right,bottom,left} als Zahlen.
// Default = CSS .ft-cslider-seg-box (inset: 3% 5%), damit Slider ohne eigenen
// boxInset exakt wie bisher aussehen.
function parseInset(str) {
  const p = (str || "3% 5%").trim().split(/\s+/).map((v) => parseFloat(v) || 0);
  if (p.length === 1) return { top: p[0], right: p[0], bottom: p[0], left: p[0] };
  if (p.length === 2) return { top: p[0], right: p[1], bottom: p[0], left: p[1] };
  if (p.length === 3) return { top: p[0], right: p[1], bottom: p[2], left: p[1] };
  return { top: p[0], right: p[1], bottom: p[2], left: p[3] };
}

function ComparisonSlider({ sprite, photoUrl, value, onChange, max = 2, grayscaleToggle = false, grayscaleDefault = false, thumbSize = 72, framing = null, boxInset = null, locked = false, onLockedInteraction = null, maxWidth = null, posValue = null, onPosChange = null }) {
  const trackRef = useRef(null);
  const [previewFrac, setPreviewFrac] = useState(null);
  // Freier 2D-Drag (sprite.freeDrag): Thumb-Position {x,y} in %, bleibt beim
  // Loslassen liegen (kein Einrasten). Start aus posValue (falls vorhanden -
  // vom Aufrufer persistierte Position), sonst null = noch nicht gezogen.
  // Ohne posValue sprang der Thumb bei Rueck-Navigation auf einen
  // generischen Punkt der richtigen Spalte statt an die zuletzt gezogene
  // Stelle, weil freePos reines Komponenten-State war (Adina-Bugreport).
  const [freePos, setFreePos] = useState(() => posValue || null);
  const [freeDragging, setFreeDragging] = useState(false);
  const [grayscale, setGrayscale] = useState(grayscaleDefault);
  // Kalibrier-Modus (?calib=1): Boxen im Browser per Maus verschieben/skalieren.
  // Kalibrier-Modus jetzt auch fuer autoBox/faceCenters-Sprites (nicht mehr
  // nur sprite.boxes) - noetig, seit Hautton auf autoBox umgestellt ist,
  // die Erkennung aber nicht immer perfekt sitzt und von Hand nachjustiert
  // werden koennen soll.
  const calib =
    (!!sprite.boxes || !!sprite.autoBox || !!sprite.faceCenters) &&
    typeof window !== "undefined" &&
    new URLSearchParams(window.location.search).get("calib") === "1";
  const [calibBoxes, setCalibBoxes] = useState(() => sprite.boxes || []);
  // Zweite (untere) Rahmenreihe, nur fuer zweireihige Sprites (sprite.boxes2,
  // Eintraege null = Spalte hat keine untere Reihe).
  const [calibBoxes2, setCalibBoxes2] = useState(() => sprite.boxes2 || []);
  const calibDrag = useRef(null);
  const uniqueValues = useMemo(() => uniqueSpriteValues(sprite), [sprite]);

  // Stufenloser Unterton-Regler: sprite.colorStops liefert drei deckungsgleiche
  // transparente Farbstufen. blend laeuft 0..3 = Graustufen -> Gelb -> Mittel
  // -> Rosa. Start bei 0 (Graustufen) = nur Helligkeit vergleichen, damit der
  // Unterton die Helligkeitsstufen-Entscheidung nicht verfaelscht. Der blend
  // ist reine Vergleichshilfe und beeinflusst die Auswahl (value) NICHT.
  // sprite.colorStopsNoGray (Adina, Unterton-Ergebnisseite): keine
  // Graustufen-Phase - Regler startet direkt bei voller Farbe (Gelb) und
  // laeuft nur 0..2 (Gelb -> Mittel -> Rosa), Labels "Farbig"/"Graustufen"
  // bleiben leer ("Regler unbenannt lassen").
  const colorStops = sprite.colorStops || null;
  const noGray = !!(colorStops && sprite.colorStopsNoGray);
  // Helligkeits-Regler (Chroma-Augen, Adina 13.09.2026): dieselbe stufenlose
  // vertikale Pille wie beim Unterton-Regler, aber 3 Bilder (hell/mittel/
  // dunkel, kein Graustufen-Zwischenschritt noetig) - blend laeuft 0 (hell)
  // .. 1 (mittel) .. 2 (dunkel), identisches Kreuzblenden wie beim
  // colorStopsNoGray-Fall unten (gelb -> mittel -> rosa).
  const brightnessStops = sprite.brightnessStops || null;
  const maxBlend = brightnessStops ? 2 : noGray ? 2 : 3;
  const [blend, setBlend] = useState(0);
  // Graustufen-Anteil 0..1 fuer TN-Foto und (bei colorStops) die Gelb-Basis.
  const grayAmt = colorStops ? (noGray ? 0 : Math.max(0, Math.min(1, 1 - blend))) : grayscale ? 1 : 0;
  const photoFilter = grayAmt > 0 ? `grayscale(${Math.round(grayAmt * 100)}%)` : undefined;

  // Stufenloser vertikaler Pill-Regler (kein Einrasten, kein Text): unten
  // Grau, oben Rosa, der Verlauf im Track selbst zeigt den Farbwechsel,
  // der Thumb gleitet stufenlos mit. Eigenes Pointer-Handling (native
  // vertikale Range-Inputs sind nicht zuverlaessig ziehbar).
  const pillTrackRef = useRef(null);
  function blendFromY(clientY) {
    const r = pillTrackRef.current.getBoundingClientRect();
    const frac = r.height ? 1 - (clientY - r.top) / r.height : 0;
    return Math.max(0, Math.min(1, frac)) * maxBlend;
  }
  function pillDown(e) {
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setBlend(blendFromY(e.clientY));
  }
  function pillMove(e) {
    if (e.buttons !== 1) return;
    setBlend(blendFromY(e.clientY));
  }

  // Auswahlkasten optional von den Klick-Spalten entkoppeln und exakt auf die
  // gemessenen Gesichtszentren setzen (sprite.faceCenters), weil die
  // Beispielbilder nicht mittig in gleichmaessigen Spalten sitzen.
  const faceCenters = sprite.faceCenters || null;
  const faceBox = sprite.faceBox || { w: 12, top: 3, bottom: 3 };
  const [hoverIndex, setHoverIndex] = useState(null);
  // Kasten-/Zentren-Geometrie: automatisch (autoBox) bevorzugt, sonst manuelle
  // faceCenters, sonst null (dann klassischer Kasten in der Klick-Spalte).
  const autoBoxes = useAutoBoxes(sprite.src, sprite.segments, !!sprite.autoBox);
  const boxSpecs =
    autoBoxes ||
    (faceCenters ? faceCenters.map((cx) => ({ cx, w: faceBox.w, top: faceBox.top, bottom: faceBox.bottom })) : null);

  // Kalibrier-Boxen mit den aktuell ermittelten Werten vorbefuellen, sobald sie
  // da sind (autoBoxes kommt asynchron per Canvas-Analyse) - ohne sprite.boxes
  // waere calibBoxes sonst leer und man wuerde bei null anfangen muessen.
  useEffect(() => {
    if (calib && boxSpecs && calibBoxes.length === 0) setCalibBoxes(boxSpecs);
  }, [calib, boxSpecs]);

  // Einheitliche Kasten-Geometrie je Spalte ({cx,w,top,bottom} in %) fuer BEIDE
  // Pfade - so laufen boxSpecs-Slider (autoBox) und Klick-Spalten-Slider durch
  // dieselbe Merge-Logik und liefern bei Paaren EINEN durchgehenden Rahmen.
  // boxSpecs: echte Gesichtszentren. Sonst: aus Spaltengrenzen + boxInset
  // abgeleitet (reproduziert den bisherigen Einzelkasten exakt).
  const insetVals = useMemo(() => parseInset(boxInset || sprite.boxInset), [boxInset, sprite]);
  const boxGeom = useMemo(() => {
    // Explizite Boxen je Spalte (sprite.boxes: {cx,w,top,bottom} in %) haben
    // Vorrang - fuer Sprites, bei denen der Kasten von Hand gesetzt werden soll.
    if (sprite.boxes) return sprite.boxes;
    if (boxSpecs) {
      // Optionaler Extra-Abstand nach oben (sprite.boxTopExtra in %): zieht die
      // Kastenoberkante hoeher, damit ueber der Beschriftung mehr Luft ist.
      const extra = sprite.boxTopExtra || 0;
      return boxSpecs.map((b) => ({ ...b, top: extra ? b.top - extra : Math.max(0, b.top) }));
    }
    if (sprite.diagonal) {
      // Diagonaler Slider: kompakter Kasten je Spalte, zentriert auf dem
      // Diagonal-Punkt (dort, wo auch der Thumb sitzt) - nicht das ganze
      // Spalten-Rechteck. Groesse ueber sprite.diagonalBox (w/h in %), justierbar.
      const d = sprite.diagonal;
      const bw = (sprite.diagonalBox && sprite.diagonalBox.w) || 18;
      const bh = (sprite.diagonalBox && sprite.diagonalBox.h) || 28;
      return sprite.segments.map((s) => {
        const cx = ((s.start + s.end) / 2) * 100;
        const t = Math.max(0, Math.min(1, (cx - d.x0) / (d.x1 - d.x0)));
        const cy = d.y0 + t * (d.y1 - d.y0);
        return { cx, w: bw, top: cy - bh / 2, bottom: 100 - cy - bh / 2 };
      });
    }
    return sprite.segments.map((s) => {
      const startPct = s.start * 100;
      const endPct = s.end * 100;
      const colW = endPct - startPct;
      const leftEdge = startPct + (insetVals.left / 100) * colW;
      const rightEdge = endPct - (insetVals.right / 100) * colW;
      return { cx: (leftEdge + rightEdge) / 2, w: rightEdge - leftEdge, top: insetVals.top, bottom: insetVals.bottom };
    });
  }, [boxSpecs, sprite, insetVals]);

  // Snap-Positionen fuer den gezogenen Thumb: je Spalte ein Zentrum
  // (Einzelauswahl) plus je Luecke zwischen zwei benachbarten Spalten eine
  // Zwischenposition, die BEIDE Kategorien zugleich waehlt ("unsicher zwischen
  // zwei Stufen"). Position in % der Track-Breite - dieselbe Prioritaet wie
  // boxGeom (sprite.boxes > boxSpecs > gleichmaessige Spaltenmitten), sonst
  // laufen Auswahlbox (boxGeom) und Thumb-/Bahn-Position (centersPct)
  // auseinander: sprite.boxes wurde hier bisher NICHT beruecksichtigt, der
  // Thumb fiel bei kalibrierten Sprites (z. B. Hautton) faelschlich auf die
  // gleichmaessige Spaltenmitte statt auf die handkalibrierte Boxmitte
  // zurueck - dadurch hing das TN-Foto sichtbar links/rechts der Auswahlbox.
  const centersPct = useMemo(
    () =>
      sprite.segments.map((s, i) =>
        sprite.boxes ? sprite.boxes[i].cx : boxSpecs ? boxSpecs[i].cx : ((s.start + s.end) / 2) * 100
      ),
    [sprite, boxSpecs]
  );
  const stops = useMemo(() => {
    const segs = sprite.segments;
    const list = [];
    for (let i = 0; i < segs.length; i++) {
      list.push({ indices: [i], pos: centersPct[i] });
      // Zwischenposition (Paar) nur bei echten Nachbarn mit verschiedenen
      // Werten - Doppelspalten wie Haarlevel 4|4 ergeben kein sinnvolles Paar.
      if (max >= 2 && i < segs.length - 1 && segs[i].value !== segs[i + 1].value) {
        list.push({ indices: [i, i + 1], pos: (centersPct[i] + centersPct[i + 1]) / 2 });
      }
    }
    list.sort((a, b) => a.pos - b.pos);
    return list;
  }, [sprite, centersPct, max]);
  function nearestStop(frac) {
    const pct = frac * 100;
    let best = stops[0];
    let bestD = Infinity;
    for (const st of stops) {
      const d = Math.abs(st.pos - pct);
      if (d < bestD) {
        bestD = d;
        best = st;
      }
    }
    return best;
  }

  // Gesichts-Thumb: das Originalfoto wird so skaliert/positioniert, dass um das
  // in Schritt 1 gesetzte Crop-Rechteck ein perfekter Kreis passt, der dessen
  // Ecken beruehrt (Crop-Diagonale = Kreisdurchmesser). So ist der ganze Crop
  // drin (Stirn..Kinn) plus etwas Kontext in den Kreis-Ecken, mittig.
  const framedImgStyle = useMemo(() => {
    if (!framing?.cropFrac || !framing.origAspect) return null;
    const cf = framing.cropFrac;
    const Ri = framing.origAspect;
    const diag = Math.sqrt((cf.w * Ri) ** 2 + cf.h ** 2); // in Einheiten H_img=1
    // Minimal herauszoomen: gleichmaessiger Puffer rings um den Crop. Ohne
    // Faktor beruehrt der Kreis die Crop-Ecken exakt (BUFFER=1); <1 verkleinert
    // das Motiv einheitlich -> etwas Rand/Kontext. Mittig => Puffer ist rundum
    // gleich. Wert nach Adinas Auge feinjustierbar.
    const BUFFER = 1;
    const H = (thumbSize / diag) * BUFFER;
    const W = Ri * H;
    const cx = cf.x + cf.w / 2;
    const cy = cf.y + cf.h / 2;
    return { width: W, height: H, left: thumbSize / 2 - cx * W, top: thumbSize / 2 - cy * H };
  }, [framing, thumbSize]);

  // Manche Sprites haben mehrere Spalten mit demselben Wert (z. B. Haarlevel
  // 7 = "Dunkelblond" UND "Kupfer", kuehle vs. warme Variante). Fachlich soll
  // aber nur die tatsaechlich angeklickte Spalte hervorgehoben werden, nicht
  // automatisch auch die andere Spalte mit derselben Zahl (Adinas Feedback:
  // "jede Spalte soll einzeln fuer sich stehen"). Deshalb wird zusaetzlich
  // zu den Werten (fuer das Modell) separat gemerkt, welcher Spalten-Index
  // tatsaechlich gewaehlt wurde (fuers Hervorheben).
  const [pickedIndices, setPickedIndices] = useState(() =>
    value.map((v) => sprite.segments.findIndex((s) => s.value === v)).filter((i) => i >= 0)
  );
  useEffect(() => {
    setPickedIndices((prev) => {
      const stillValid = prev.filter((i) => value.includes(sprite.segments[i]?.value));
      const usedValues = stillValid.map((i) => sprite.segments[i].value);
      const missing = value.filter((v) => !usedValues.includes(v));
      const added = missing.map((v) => sprite.segments.findIndex((s) => s.value === v)).filter((i) => i >= 0);
      return [...stillValid, ...added];
    });
  }, [value, sprite]);

  function fracFromClientX(clientX) {
    const rect = trackRef.current.getBoundingClientRect();
    return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
  }

  // "Unsicher" erlaubt bis zu 2 gleichzeitige Stufen, aber nur direkt
  // benachbarte (z. B. hell+sehr hell) - fernliegende Kombinationen (sehr
  // dunkel+sehr hell) ergeben inhaltlich keinen Sinn, siehe Adinas Feedback.
  function toggle(v, idx) {
    const has = value.includes(v);
    if (has) {
      onChange(value.filter((x) => x !== v));
      setPickedIndices((prev) => prev.filter((i) => sprite.segments[i]?.value !== v));
      return;
    }
    if (!value.length) {
      onChange([v]);
      setPickedIndices([idx]);
      return;
    }
    const vi = uniqueValues.indexOf(v);
    const allAdjacent = value.every((existing) => Math.abs(uniqueValues.indexOf(existing) - vi) === 1);
    if (value.length < max && allAdjacent) {
      onChange([...value, v]);
      setPickedIndices((prev) => [...prev, idx]);
    } else {
      onChange([v]);
      setPickedIndices([idx]);
    }
  }

  // Auswahl direkt aus einem eingerasteten Stop setzen (Ziehen definiert die
  // Auswahl absolut, ersetzt also die bisherige): Einzelspalte -> ein Wert,
  // Zwischenposition -> beide Nachbarwerte.
  function selectStop(stop) {
    const idxs = stop.indices;
    const vals = [...new Set(idxs.map((i) => sprite.segments[i].value))];
    onChange(vals);
    setPickedIndices(idxs);
  }

  function onPointerDown(e) {
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setPreviewFrac(fracFromClientX(e.clientX));
  }
  function onPointerMove(e) {
    if (previewFrac === null) return;
    setPreviewFrac(fracFromClientX(e.clientX));
  }
  function onPointerUp() {
    if (previewFrac === null) return;
    selectStop(nearestStop(previewFrac));
    setPreviewFrac(null);
  }

  // Thumb-Ruheposition (% der Breite): waehrend des Ziehens auf dem
  // eingerasteten Stop; sonst mittig ueber der Auswahl - bei einem Paar also
  // genau auf der Zwischenposition zwischen beiden Spalten; ohne Auswahl neutral
  // in der Mitte. Nutzt centersPct (single source of truth, gleiche
  // sprite.boxes>boxSpecs>Spaltenmitte-Prioritaet wie boxGeom) statt die
  // Faellt-durch-Logik hier ein zweites Mal zu duplizieren - vorher fehlte
  // hier der sprite.boxes-Vorrang, wodurch der Thumb bei kalibrierten
  // Sprites (z. B. Hautton) sichtbar neben der Auswahlbox stand.
  const thumbCenterPct = (() => {
    // Stufenlos: waehrend des Ziehens folgt der Thumb der echten Zeigerposition
    // (previewFrac), statt beim Ueberqueren einer Spaltengrenze auf den naechsten
    // Stop zu springen. Eingerastet wird erst beim Loslassen (onPointerUp).
    if (previewFrac !== null) return previewFrac * 100;
    if (!pickedIndices.length) {
      const mid = Math.floor((sprite.segments.length - 1) / 2);
      return centersPct[mid];
    }
    const sorted = [...pickedIndices].sort((a, b) => a - b);
    const first = sorted[0];
    const last = sorted[sorted.length - 1];
    return (centersPct[first] + centersPct[last]) / 2;
  })();

  // Freier 2D-Drag (sprite.freeDrag): der Thumb ist ueber die gesamte
  // Beispielbild-Flaeche (sprite.dragArea) verschiebbar und bleibt beim Loslassen
  // liegen; die Auswahl (Waerme-Spalte) ergibt sich aus der x-Position.
  const free = !!sprite.freeDrag;
  const area = sprite.dragArea || { x0: 0, y0: 0, x1: 100, y1: 100 };
  const band = sprite.dragBand || null;
  // Begrenzt (x,y) auf die ziehbare Flaeche: entweder ein Rechteck (dragArea)
  // oder - fuer diagonale Bildbaender (dragBand) - eine Zone, deren y-Grenzen mit
  // x mitwandern, sodass nur ueber den Beispielbildern (kein Weissraum) gezogen wird.
  function clampFree(x, y) {
    if (band) {
      x = Math.max(band.x0, Math.min(band.x1, x));
      const t = (x - band.x0) / (band.x1 - band.x0);
      const yTop = band.yTop0 + t * (band.yTop1 - band.yTop0);
      const yBot = band.yBot0 + t * (band.yBot1 - band.yBot0);
      y = Math.max(yTop, Math.min(yBot, y));
    } else {
      x = Math.max(area.x0, Math.min(area.x1, x));
      y = Math.max(area.y0, Math.min(area.y1, y));
    }
    return { x, y };
  }
  function columnAtX(xPct) {
    const f = xPct / 100;
    const segs = sprite.segments;
    for (let i = 0; i < segs.length; i++) if (f < segs[i].end || i === segs.length - 1) return i;
    return segs.length - 1;
  }
  // Wird nahe der Grenze zweier Spalten (aeusserer Randbereich) abgelegt, kommt
  // ein Mischergebnis (beide Nachbarn) -> Rahmen um beide. EDGE = Anteil der
  // Spaltenbreite am Rand, ab dem gemischt wird.
  function columnsAtX(xPct) {
    const f = xPct / 100;
    const segs = sprite.segments;
    const i = columnAtX(xPct);
    const s = segs[i].start;
    const e = segs[i].end;
    const w = e - s;
    const EDGE = 0.3;
    if (i + 1 < segs.length && e - f < w * EDGE) return [i, i + 1];
    if (i - 1 >= 0 && f - s < w * EDGE) return [i - 1, i];
    return [i];
  }
  // Zweireihige Sprites (sprite.boxes2 + rowSplitY): je nach y-Position gilt
  // die obere ODER die untere Reihe, nie beide gleichzeitig. In der unteren
  // Reihe zaehlen nur Spalten, die dort ein Bild haben (Spalte 4-10); liegt
  // der Thumb unten ueber Spalte 1-3 (leer), gilt die obere Reihe.
  function freeColumns(x, y) {
    const idxs = columnsAtX(x);
    if (sprite.boxes2 && sprite.rowSplitY != null && y >= sprite.rowSplitY) {
      const lower = idxs.filter((i) => sprite.boxes2[i]);
      if (lower.length) return lower;
    }
    return idxs;
  }
  function freePosFromEvent(e) {
    const r = trackRef.current.getBoundingClientRect();
    return clampFree(((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100);
  }
  function freeDown(e) {
    if (locked) {
      e.preventDefault();
      if (onLockedInteraction) onLockedInteraction();
      return;
    }
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setFreeDragging(true);
    setFreePos(freePosFromEvent(e));
  }
  function freeMove(e) {
    if (freeDragging) setFreePos(freePosFromEvent(e));
  }
  function freeUp(e) {
    if (!freeDragging) return;
    setFreeDragging(false);
    const p = freePosFromEvent(e);
    setFreePos(p);
    if (onPosChange) onPosChange(p);
    const idxs = freeColumns(p.x, p.y);
    onChange(idxs.map((i) => sprite.segments[i].value));
    setPickedIndices(idxs);
  }
  const freeThumb = free
    ? freePos || clampFree(thumbCenterPct, band ? (band.yTop0 + band.yBot0) / 2 : (area.y0 + area.y1) / 2)
    : null;

  // Kalibrierung: Box i verschieben (move: cx + vertikaler Versatz) bzw.
  // skalieren (resize: rechte + untere Kante).
  function calibDown(e, i, mode, set = 1) {
    e.preventDefault();
    e.stopPropagation();
    e.currentTarget.setPointerCapture(e.pointerId);
    const r = trackRef.current.getBoundingClientRect();
    const src = set === 2 ? calibBoxes2 : calibBoxes;
    calibDrag.current = { i, mode, set, x: e.clientX, y: e.clientY, box: { ...src[i] }, w: r.width, h: r.height };
  }
  function calibMove(e) {
    const d = calibDrag.current;
    if (!d) return;
    const dx = ((e.clientX - d.x) / d.w) * 100;
    const dy = ((e.clientY - d.y) / d.h) * 100;
    const upd = (prev) =>
      prev.map((b, idx) => {
        if (idx !== d.i || !b) return b;
        if (d.mode === "move") return { ...b, cx: d.box.cx + dx, top: d.box.top + dy, bottom: d.box.bottom - dy };
        return { ...b, cx: d.box.cx + dx / 2, w: Math.max(4, d.box.w + dx), bottom: Math.max(0, d.box.bottom - dy) };
      });
    if (d.set === 2) setCalibBoxes2(upd);
    else setCalibBoxes(upd);
  }
  function calibUp() {
    calibDrag.current = null;
  }
  const round1 = (n) => Math.round(n * 10) / 10;

  // Diagonaler Thumb-Pfad (sprite.diagonal): Thumb liegt AUF dem Bild und wandert
  // entlang einer Diagonalen; y aus x abgeleitet, stufenlos beim Ziehen.
  const diag = sprite.diagonal || null;
  const thumbXpct = diag && previewFrac !== null ? Math.max(0, Math.min(100, previewFrac * 100)) : thumbCenterPct;
  const thumbTopPct = diag
    ? (() => {
        const t = Math.max(0, Math.min(1, (thumbXpct - diag.x0) / (diag.x1 - diag.x0)));
        return diag.y0 + t * (diag.y1 - diag.y0);
      })()
    : null;

  // Hervorhebung: bei freiem Drag die Spalte unter dem Thumb (waehrend des Zugs),
  // sonst die eingerastete Stop-Vorschau.
  // Rahmen folgt waehrend des Ziehens der echten (stufenlosen) Zeigerposition
  // statt des eingerasteten Stops - sonst laufen sichtbarer Foto-Thumb und
  // Auswahlrahmen auseinander, seit der Thumb selbst stufenlos ist.
  const previewIndices = free
    ? freeDragging
      ? freeColumns(freeThumb.x, freeThumb.y)
      : []
    : previewFrac !== null
    ? columnsAtX(previewFrac * 100)
    : [];

  // Gesperrter Slider (Ergebnis-Ansicht): Interaktion loest erst eine Rueckfrage
  // aus, statt direkt zu aendern. Nach Bestaetigung setzt der Parent locked=false.
  const guard = (fn) => (e) => {
    if (locked) {
      if (e && e.preventDefault) e.preventDefault();
      if (onLockedInteraction) onLockedInteraction();
      return;
    }
    fn(e);
  };

  // ft-cslider-hasrail (padding-bottom:0) galt bisher nur fuer "rail"-Slider
  // (!free && !diag) - freie 2D-Drag-Sprites (sprite.freeDrag, aktuell nur
  // Haarunterton) blieben dadurch auf den alten 68px Platzhalter-Padding
  // sitzen, obwohl ihr Thumb per dragBand IM Bild positioniert wird, nicht
  // darunter - sichtbar als zu viel Weissraum unter dem Bild (Adina). Nur
  // "diag" (aktuell nirgends gesetzt) braucht den reservierten Platz noch.
  return (
    <div
      className={"ft-cslider" + (locked ? " ft-cslider-locked" : "") + (!diag ? " ft-cslider-hasrail" : "")}
      style={maxWidth ? { maxWidth, marginLeft: "auto", marginRight: "auto" } : undefined}
    >
      {!colorStops && grayscaleToggle && (
        <label className="ft-cslider-gs-row">
          <span className="ft-cslider-gs-toggle-wrap">
            <input type="checkbox" checked={grayscale} onChange={(e) => setGrayscale(e.target.checked)} />
            <span className="ft-cslider-gs-track">
              <span className="ft-cslider-gs-knob" />
            </span>
          </span>
          Nur Helligkeit vergleichen (Graustufen)
        </label>
      )}
      <div className={"ft-cslider-body" + ((colorStops || brightnessStops) ? " ft-cslider-body-vctrl" : "")}>
        {(colorStops || brightnessStops) && (
          // Vertikaler Regler nach design_handoff_farbanalyse_slider/README.md:
          // Farbverlauf staendig sichtbar im Track (bei uns grau/gelb/mittel/
          // rosa bzw. hell/dunkel statt der Vorlagen-Brauntoene, siehe README
          // "Farbverlauf passt noch nicht"), Knopf gleitet stufenlos mit,
          // Labels ober-/unterhalb der Bahn.
          <div
            className={
              "ft-cslider-pill-wrap" +
              (noGray || brightnessStops ? " ft-cslider-pill-wrap-shift" : "") +
              (brightnessStops ? " ft-cslider-pill-wrap-brightness" : "")
            }
          >
            <span className="ft-cslider-pill-label">{brightnessStops || noGray ? "" : "Farbig"}</span>
            <div
              className="ft-cslider-pill-track"
              ref={pillTrackRef}
              onPointerDown={pillDown}
              onPointerMove={pillMove}
              role="slider"
              aria-label={
                brightnessStops
                  ? "Beispielaugen an die Helligkeit deines Fotos angleichen (unten hell, oben dunkel)"
                  : noGray
                  ? "Beispielhaut-Unterton stufenlos angleichen (unten gelb, oben rosa)"
                  : "Beispielhaut-Unterton stufenlos angleichen (unten grau, oben rosa)"
              }
              aria-valuemin={0}
              aria-valuemax={maxBlend}
              aria-valuenow={Math.round(blend * 10) / 10}
              tabIndex={0}
              onKeyDown={(e) => {
                if (e.key === "ArrowUp") setBlend((b) => Math.min(maxBlend, b + 0.1));
                if (e.key === "ArrowDown") setBlend((b) => Math.max(0, b - 0.1));
                if (e.key === "Home") setBlend(0);
                if (e.key === "End") setBlend(maxBlend);
              }}
            >
              {/* Farbverlauf + Maske liegen in einem eigenen geclippten Layer,
                  damit NUR der Verlauf beschnitten wird, nicht der Thumb (der
                  breiter ist als die duenne Bahn und sonst "verschwinden"
                  wuerde, wenn die Bahn selbst overflow:hidden traegt). */}
              {/* Bahn ist 20px breit = Knopfgroesse (Handoff-Massstab von
                  Adina auf 0,4x skaliert, Knopf sitzt IN der Bahn). Bei so
                  einem Knopf darf die Position NICHT rein prozentual
                  berechnet werden - bei 0%/100% wuerde die Kreismitte exakt
                  auf den Bahnrand fallen und der Knopf zur Haelfte
                  ueberstehen. Pixel+Prozent-Mischformel (10px = halbe
                  Knopfgroesse) haelt den Reiseweg innerhalb der Bahn, wie im
                  design_handoff_farbanalyse_slider/README.md fuer den
                  vertikalen Regler vorgegeben. Maske nutzt dieselbe Formel,
                  damit die Fuellgrenze optisch exakt am Knopf endet. */}
              <div className="ft-cslider-pill-fill">
                <span
                  className="ft-cslider-pill-mask"
                  style={{ height: `calc(100% - (10px + (100% - 20px) * ${blend / maxBlend}))` }}
                />
              </div>
              {/* Vertiefungsschatten UEBER Fuellung+Maske, s. CSS-Kommentar
                  .ft-cslider-pill-groove - sonst von der Maske verdeckt. */}
              <span className="ft-cslider-pill-groove" aria-hidden="true" />
              <span
                className="ft-cslider-pill-thumb"
                style={{ bottom: `calc(10px + (100% - 20px) * ${blend / maxBlend})` }}
              />
            </div>
            <span className="ft-cslider-pill-label">{brightnessStops || noGray ? "" : "Graustufen"}</span>
          </div>
        )}
        <div className="ft-cslider-frame" ref={trackRef} style={{ aspectRatio: sprite.aspect }}>
        {colorStops ? (
          // Drei deckungsgleiche Ebenen. Gelb liegt als opake Basis unten (mit
          // animiertem Graustufen-Filter im Bereich 0..1); Mittel und Rosa
          // blenden per Deckkraft darueber ein. Weil die untere Ebene immer
          // opak ist, bleibt das Gesicht beim Ueberblenden voll deckend (keine
          // Transparenz-Dellen), waehrend sich nur der Unterton weich aendert.
          <>
            <img
              src={colorStops.gelb}
              className="ft-cslider-sprite"
              alt=""
              draggable={false}
              style={{ filter: grayAmt > 0 ? `grayscale(${Math.round(grayAmt * 100)}%)` : undefined }}
            />
            <img
              src={colorStops.mittel}
              className="ft-cslider-sprite ft-cslider-sprite-layer"
              alt=""
              draggable={false}
              style={{ opacity: Math.max(0, Math.min(1, blend - (noGray ? 0 : 1))) }}
            />
            <img
              src={colorStops.rosa}
              className="ft-cslider-sprite ft-cslider-sprite-layer"
              alt=""
              draggable={false}
              style={{ opacity: Math.max(0, Math.min(1, blend - (noGray ? 1 : 2))) }}
            />
          </>
        ) : brightnessStops ? (
          // Drei deckungsgleiche Ebenen (nur Helligkeit unterscheidet sich,
          // siehe sprite.brightnessStops-Kommentar oben): hell liegt als
          // opake Basis unten, mittel blendet waehrend blend 0..1 ein, dunkel
          // waehrend blend 1..2 - identisches Muster wie colorStopsNoGray.
          <>
            <img src={brightnessStops.hell} className="ft-cslider-sprite" alt="" draggable={false} />
            <img
              src={brightnessStops.mittel}
              className="ft-cslider-sprite ft-cslider-sprite-layer"
              alt=""
              draggable={false}
              style={{ opacity: Math.max(0, Math.min(1, blend)) }}
            />
            <img
              src={brightnessStops.dunkel}
              className="ft-cslider-sprite ft-cslider-sprite-layer"
              alt=""
              draggable={false}
              style={{ opacity: Math.max(0, Math.min(1, blend - 1)) }}
            />
          </>
        ) : (
          <img
            src={sprite.src}
            className={"ft-cslider-sprite" + (grayscale ? " ft-cslider-grayscale" : "")}
            alt=""
            draggable={false}
          />
        )}
        {sprite.segments.map((s, i) => (
          <button
            key={`${s.value}-${i}`}
            type="button"
            className="ft-cslider-seg"
            style={{ left: `${s.start * 100}%`, width: `${(s.end - s.start) * 100}%` }}
            onClick={guard(() => toggle(s.value, i))}
            onMouseEnter={() => setHoverIndex(i)}
            onMouseLeave={() => setHoverIndex((h) => (h === i ? null : h))}
            aria-label={s.label}
            aria-pressed={pickedIndices.includes(i)}
          />
        ))}
        {/* Auswahlkasten auf der Kasten-Geometrie (boxGeom), entkoppelt von den
            Klick-Spalten. Aktive Spalten werden zu zusammenhaengenden Laeufen
            gebuendelt -> EIN durchgehender Rahmen pro Lauf (Paar = ein Kasten um
            beide). Gilt fuer boxSpecs- wie Klick-Spalten-Slider gleich. */}
        {!calib && (() => {
            // Aktive Indizes (Auswahl + Drag-Vorschau) zu zusammenhaengenden
            // Laeufen buendeln -> EIN durchgehender Rahmen pro Lauf. Ein Paar
            // benachbarter Kategorien ergibt so einen Kasten um beide.
            const activeIdx = [...new Set([...pickedIndices, ...previewIndices])].sort((a, b) => a - b);
            const runs = [];
            for (const i of activeIdx) {
              const last = runs[runs.length - 1];
              if (last && i === last[last.length - 1] + 1) last.push(i);
              else runs.push([i]);
            }
            const spanBox = (run, active) => {
              const a = boxGeom[run[0]];
              const b = boxGeom[run[run.length - 1]];
              // Echter Outer-Join der beteiligten Boxen (nicht die volle
              // Spaltenbreite) - bei fest kalibrierten Boxen (sprite.boxes)
              // sind Breite/Position schon von Hand passend gesetzt.
              const leftEdge = a.cx - a.w / 2;
              const rightEdge = b.cx + b.w / 2;
              return {
                key: (active ? "a" : "h") + run.join("-"),
                cx: (leftEdge + rightEdge) / 2,
                w: rightEdge - leftEdge,
                top: Math.min(...run.map((i) => boxGeom[i].top)),
                bottom: Math.min(...run.map((i) => boxGeom[i].bottom)),
                active,
              };
            };
            // Zweireihig: liegt der Thumb in der unteren Reihe (und die Auswahl
            // hat dort ein Bild), gibt es NUR untere Rahmen, sonst nur obere.
            const lowerRow =
              !!sprite.boxes2 &&
              free &&
              freeThumb &&
              sprite.rowSplitY != null &&
              freeThumb.y >= sprite.rowSplitY &&
              activeIdx.some((i) => sprite.boxes2[i]);
            const boxes = lowerRow ? [] : runs.map((run) => spanBox(run, true));
            // Hover-Kasten nur, wenn diese Spalte nicht ohnehin aktiv ist.
            if (hoverIndex !== null && !activeIdx.includes(hoverIndex)) {
              boxes.push(spanBox([hoverIndex], false));
            }
            // Zweireihige Sprites (sprite.boxes2): je aktivem Lauf zusaetzlich ein
            // Rahmen um die UNTERE Reihe - nur ueber den Spalten, die eine
            // untere Reihe haben (bei einem Paar 3+4 also nur um Spalte 4).
            if (lowerRow) {
              for (const run of runs) {
                const idx = run.filter((i) => sprite.boxes2[i]);
                if (!idx.length) continue;
                const a = sprite.boxes2[idx[0]];
                const b = sprite.boxes2[idx[idx.length - 1]];
                const leftEdge = a.cx - a.w / 2;
                const rightEdge = b.cx + b.w / 2;
                boxes.push({
                  key: "row2-" + idx.join("-"),
                  cx: (leftEdge + rightEdge) / 2,
                  w: rightEdge - leftEdge,
                  top: Math.min(...idx.map((i) => sprite.boxes2[i].top)),
                  bottom: Math.min(...idx.map((i) => sprite.boxes2[i].bottom)),
                  active: true,
                });
              }
            }
            return boxes.map((bx) => (
              <span
                key={bx.key}
                className={"ft-cslider-face-box" + (bx.active ? " is-active" : " is-hover")}
                style={{ left: `${bx.cx}%`, width: `${bx.w}%`, top: `${bx.top}%`, bottom: `${bx.bottom}%` }}
              />
            ));
          })()}
        {calib &&
          calibBoxes2.map((b, i) =>
            b ? (
              <div
                key={"b2-" + i}
                className="ft-calib-box"
                style={{ left: `${b.cx}%`, width: `${b.w}%`, top: `${b.top}%`, bottom: `${b.bottom}%` }}
                onPointerDown={(e) => calibDown(e, i, "move", 2)}
                onPointerMove={calibMove}
                onPointerUp={calibUp}
              >
                <span className="ft-calib-tag">{i + 1}b</span>
                <span
                  className="ft-calib-handle"
                  onPointerDown={(e) => calibDown(e, i, "resize", 2)}
                  onPointerMove={calibMove}
                  onPointerUp={calibUp}
                />
              </div>
            ) : null
          )}
        {calib &&
          calibBoxes.map((b, i) => (
            <div
              key={i}
              className="ft-calib-box"
              style={{ left: `${b.cx}%`, width: `${b.w}%`, top: `${b.top}%`, bottom: `${b.bottom}%` }}
              onPointerDown={(e) => calibDown(e, i, "move")}
              onPointerMove={calibMove}
              onPointerUp={calibUp}
            >
              <span className="ft-calib-tag">{i + 1}</span>
              <span
                className="ft-calib-handle"
                onPointerDown={(e) => calibDown(e, i, "resize")}
                onPointerMove={calibMove}
                onPointerUp={calibUp}
              />
            </div>
          ))}
        {/* Sliderbahn: optische Einbettung des Foto-Thumbs in eine Rille,
            nur bei der horizontalen Standard-Fuehrung sinnvoll (nicht bei
            freiem 2D-Drag oder der Diagonalen). */}
        {!free && !diag && (
          <div
            className="ft-cslider-rail"
            style={{
              height: thumbSize + 6,
              transform: "translateY(10px)",
              // Inset bis zur ECHTEN aeussersten Thumb-Position (centersPct[0]/
              // [letztes], aus der Gesichtserkennung - NICHT die gleichmaessige
              // Spaltenmitte, die bei autoBox-Sprites davon abweichen kann)
              // minus Thumb-Radius minus 3px, damit links/rechts genau so viel
              // Luft bleibt wie oben/unten (Bahnhoehe thumbSize+6 => 3px je Seite).
              left: `calc(${centersPct[0]}% - ${thumbSize / 2 + 3}px)`,
              right: `calc(${100 - centersPct[centersPct.length - 1]}% - ${thumbSize / 2 + 3}px)`,
            }}
          />
        )}
        {/* Eigener Foto-Thumb liegt direkt auf der Skala (unten), damit das
            eigene Gesicht/Auge/Haar unmittelbar neben dem Referenzbild sitzt.
            Ziehen waehlt beim Loslassen die Spalte an der Position. */}
        <div
          className={
            "ft-cslider-thumb" +
            (previewFrac !== null || freeDragging ? " ft-cslider-thumb-dragging" : "") +
            (diag || free ? " ft-cslider-thumb-diag" : "")
          }
          style={
            free
              ? { left: `${freeThumb.x}%`, top: `${freeThumb.y}%`, width: thumbSize, height: thumbSize }
              : diag
              ? { left: `${thumbXpct}%`, top: `${thumbTopPct}%`, width: thumbSize, height: thumbSize }
              : { left: `${thumbCenterPct}%`, width: thumbSize, height: thumbSize }
          }
          onPointerDown={free ? freeDown : guard(onPointerDown)}
          onPointerMove={free ? freeMove : onPointerMove}
          onPointerUp={free ? freeUp : onPointerUp}
        >
          <div className="ft-cslider-thumb-clip">
            {photoUrl ? (
              framedImgStyle ? (
                <img
                  className="ft-cslider-thumb-framed"
                  style={{ ...framedImgStyle, filter: photoFilter }}
                  src={photoUrl}
                  alt="Dein Foto"
                  draggable={false}
                />
              ) : (
                <img src={photoUrl} style={photoFilter ? { filter: photoFilter } : undefined} alt="Dein Foto" draggable={false} />
              )
            ) : (
              <span className="ft-cslider-thumb-fallback">Du</span>
            )}
          </div>
          {free && (
            <React.Fragment>
              <span className="ft-cslider-thumb-farrow ft-cslider-thumb-farrow-ne" aria-hidden="true">›</span>
              <span className="ft-cslider-thumb-farrow ft-cslider-thumb-farrow-se" aria-hidden="true">›</span>
              <span className="ft-cslider-thumb-farrow ft-cslider-thumb-farrow-sw" aria-hidden="true">‹</span>
              <span className="ft-cslider-thumb-farrow ft-cslider-thumb-farrow-nw" aria-hidden="true">‹</span>
            </React.Fragment>
          )}
          {/* Gerade Pfeilchen (l/r) nur noch bei der Diagonalen (kein Rail
              dort) - bei der Standard-Fuehrung macht die Sliderbahn selbst
              schon klar, dass sich der Thumb verschieben laesst. */}
          {diag && (
            <React.Fragment>
              <span className="ft-cslider-thumb-arrow ft-cslider-thumb-arrow-l" aria-hidden="true">‹</span>
              <span className="ft-cslider-thumb-arrow ft-cslider-thumb-arrow-r" aria-hidden="true">›</span>
            </React.Fragment>
          )}
        </div>
        </div>
      </div>
      {/* Platzhalter AUSSERHALB der gestreckten Reihe (sonst wuerde
          align-items:stretch den vertikalen Regler auf diese Hoehe mitziehen).
          Die Bahn haengt absolut positioniert thumbSize+16px unter dem Frame
          (10px Abstand + Bahnhoehe thumbSize+6); der sichtbare Freiraum NACH
          der Bahn ist (Platzhalter + Kartenpolster 32px) minus diese
          thumbSize+16px. Fuer 1,4x den oberen Freiraum (32*1.4=45px)
          Platzhalter = thumbSize+16 - (32-45) = thumbSize+29. Standardmass
          fuer alle Slider mit eigener Bahn, nicht nur diese Seite - bei
          Aenderung des 10px-Abstands oben (transform: translateY(...) am
          Rail) muss diese Formel entsprechend mitgezogen werden. */}
      {!free && !diag && <div style={{ height: thumbSize + 29 }} />}
      {calib && (
        <pre className="ft-calib-readout">
          {"boxes: [\n" +
            calibBoxes
              .map((b) => `  { cx: ${round1(b.cx)}, w: ${round1(b.w)}, top: ${round1(b.top)}, bottom: ${round1(b.bottom)} },`)
              .join("\n") +
            "\n]" +
            (sprite.boxes2
              ? "\nboxes2: [\n" +
                calibBoxes2
                  .map((b) => (b ? `  { cx: ${round1(b.cx)}, w: ${round1(b.w)}, top: ${round1(b.top)}, bottom: ${round1(b.bottom)} },` : "  null,"))
                  .join("\n") +
                "\n]"
              : "")}
        </pre>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 2: Kontrastlevel (Konzept-Abschnitt 5.4)
// ══════════════════════════════════════════════════════════

// Kontrast-Referenzbild als Vergleichs-Slider (freigestellt -> autoBox greift).
// Vier Spalten in Config-Reihenfolge niedrig..sehr-hoch.
const KONTRASTLEVEL_SPRITE = {
  src: spritePath("Kontrastlevel_freigestellt.png"),
  aspect: 5911 / 1834,
  // Bildreihenfolge links->rechts ist sehr-hoch, hoch, mittel, niedrig - also
  // umgekehrt zur Config-Reihenfolge (niedrig..sehr-hoch). Werte gespiegelt
  // zuweisen, damit der Ergebniskreis auf der richtigen Spalte sitzt.
  segments: equalSegments([...Config.KONTRAST_LEVELS].reverse(), (v) => KONTRAST_LABELS[v]),
  // Fest hinterlegt (s. Unterton-Sprite); Oberkante etwas ueber der Beschriftung.
  boxes: [
    { cx: 13.4, w: 21.2, top: -1.5, bottom: 5.5 },
    { cx: 37.6, w: 21.2, top: -1.5, bottom: 5.5 },
    { cx: 61.4, w: 21.2, top: -1.5, bottom: 5.5 },
    { cx: 86.2, w: 21.2, top: -1.5, bottom: 5.5 },
  ],
};

// Ergebnis-Level(s): der wahrscheinlichste Wert plus - bei echtem Gleichstand
// (>=25% und direkt benachbart) - ein zweites Level, sodass ein Mischergebnis
// wie "niedrig bis mittel" moeglich ist. Immer 1-2 benachbarte Level, aufsteigend.
// order = Reihenfolge der Level fuer die jeweilige Dimension (Kontrast/Unterton/
// Chroma), damit Nachbarschaft/Range korrekt bestimmt werden. Schwelle 04.09.2026
// von 30% auf 25% gesenkt (Adina), Werte zwischen 25-35% werden von rangeLabel()
// in Klammern gesetzt statt vollwertig angezeigt.
const SECONDARY_LEVEL_MIN = 0.25;
const SECONDARY_LEVEL_STRONG = 0.35;
function dominantLevels(dist, order) {
  const entries = Object.entries(dist || {}).filter(([, p]) => p > 0);
  if (!entries.length) return [];
  entries.sort((a, b) => b[1] - a[1]);
  const top = entries[0][0];
  const result = [top];
  for (const [lvl, p] of entries.slice(1)) {
    if (p >= SECONDARY_LEVEL_MIN && Math.abs(order.indexOf(lvl) - order.indexOf(top)) === 1) {
      result.push(lvl);
      break;
    }
  }
  return result.sort((a, b) => order.indexOf(a) - order.indexOf(b));
}

// Kleingeschriebenes Label fuer den Fliesstext ("niedrig", "niedrig bis
// mittel"). Optionaler dist-Parameter (die Rohverteilung, aus der `levels`
// via dominantLevels() kommt): liegt der schwaechere der beiden Werte
// zwischen 25% und 35%, wird er in Klammern gesetzt ("(niedrig bis) mittel"
// bzw. "hoch (bis sehr hoch)", je nachdem ob er vor oder nach dem
// Hauptwert liegt) statt vollwertig - Adina: "alles bis 30% Wahrschein-
// lichkeit in Klammern setzen". Ohne dist (z. B. bei rangeLabel() auf rohen
// Antwort-Arrays statt einer Verteilung) unveraendertes Verhalten.
function rangeLabel(levels, order, labels, dist) {
  if (!levels || !levels.length) return "";
  const sorted = [...levels].sort((a, b) => order.indexOf(a) - order.indexOf(b));
  if (sorted.length === 2 && dist) {
    const [a, b] = sorted;
    const pa = dist[a] || 0;
    const pb = dist[b] || 0;
    const weaker = pa <= pb ? a : b;
    const stronger = weaker === a ? b : a;
    if (Math.min(pa, pb) < SECONDARY_LEVEL_STRONG) {
      const weakLabel = (labels[weaker] || weaker).toLowerCase();
      const strongLabel = (labels[stronger] || stronger).toLowerCase();
      return order.indexOf(weaker) < order.indexOf(stronger)
        ? `(${weakLabel} bis) ${strongLabel}`
        : `${strongLabel} (bis ${weakLabel})`;
    }
  }
  return sorted.map((l) => (labels[l] || l).toLowerCase()).join(" bis ");
}

// Unterton-Bereichslabel in Adinas Wortreihenfolge: das "extremere" Level zuerst
// (kuehl bzw. warm vor den Neutralstufen), bei Gleichstand die kuehle Seite
// zuerst -> "kühl bis kühl-neutral", "kühl-neutral bis warm-neutral", "warm bis
// warm-neutral".
const UNTERTON_POS = { kuehl: 0, "kuehl-neutral": 1, "warm-neutral": 2, warm: 3 };
function untertonRangeLabel(levels, dist) {
  if (!levels || !levels.length) return "";
  if (dist && levels.length === 2) {
    const [a, b] = levels;
    const pa = dist[a] || 0;
    const pb = dist[b] || 0;
    if (Math.min(pa, pb) < SECONDARY_LEVEL_STRONG) {
      const [strong, weak] = pa >= pb ? [a, b] : [b, a];
      return `${(UNTERTON_LABELS[strong] || strong).toLowerCase()} (bis ${(UNTERTON_LABELS[weak] || weak).toLowerCase()})`;
    }
  }
  const sorted = [...levels].sort((a, b) => {
    const da = Math.abs(UNTERTON_POS[a] - 1.5);
    const db = Math.abs(UNTERTON_POS[b] - 1.5);
    if (db !== da) return db - da; // extremer (weiter von der Mitte) zuerst
    return UNTERTON_POS[a] - UNTERTON_POS[b]; // Gleichstand: kuehle Seite zuerst
  });
  return sorted.map((l) => (UNTERTON_LABELS[l] || l).toLowerCase()).join(" bis ");
}

// Deklinierte Adjektivform je Kontrastlevel, fuer "ein ___ Kontrastlevel"
// (Kontrastlevel ist sprachlich neutral, daher die -es-Endung). Nur fuer den
// Fall EINES eindeutigen Levels gedacht - bei einem Misch-/Bereichs-Ergebnis
// (z. B. "niedrig bis mittel") greift stattdessen die "Kontrastlevel von
// ..."-Formulierung weiter unten, die ohne Adjektiv-Deklination auskommt.
const KONTRAST_LEVEL_ADJ = { niedrig: "niedriges", mittel: "mittleres", hoch: "hohes", "sehr-hoch": "sehr hohes" };

// Wie stark sich die Helligkeit der Gesichtsmerkmale laut Kontrastlevel
// unterscheidet, als Adverb fuer den einheitlichen Erklaersatz (Adina:
// "Die Helligkeit deiner Gesichtsmerkmale ... unterscheidet sich ...").
const KONTRAST_LEVEL_ADVERB = { niedrig: "kaum", mittel: "moderat", hoch: "deutlich", "sehr-hoch": "stark" };

// Dynamischer Erklaertext: benennt die drei selbst gewaehlten Merkmale (+ ggf.
// Zusatzmerkmale) und begruendet, warum daraus das Kontrastlevel folgt.
// Berechnetes und manuelles Ergebnis bewusst getrennt dargestellt (Adina,
// 05.09.2026-Korrektur zum vorigen Fix): der Fliesstext zeigt IMMER die
// berechnete Begruendung (P_K), ein manueller Override wird als eigener
// Zusatzsatz angehaengt statt das berechnete Ergebnis zu ersetzen -
// dieselbe Trennung wie im Titel (siehe "Dein Kontrastlevel manuell
// gesetzt: ..." vs. "Dein Kontrastlevel ist wahrscheinlich ...").
function buildKontrastErklaerung(answers, P_K, overrideLevels) {
  const haut = sliderResultLabel(HAUTTON_SPRITE, answers.hautton);
  const haar = haarlevelResultLabel(answers.haarlevel, answers.haarlevel_pos);
  const augenWerte = (answers.augenfarbe || [])
    .map((v) => AUGENFARBE_SPRITE.segments.find((s) => s.value === v)?.label)
    .filter(Boolean)
    .map((l) => l.toLowerCase());
  // Bei zwei Augenfarb-Antworten ("Unsicher") "liegt zwischen X und Y" statt
  // "ist X bis Y" (Adina), bei einer Antwort weiterhin "ist X".
  const augenSatz =
    augenWerte.length === 2 ? (
      <>
        deine Augenfarbe liegt zwischen <strong>{augenWerte[0]}</strong> und <strong>{augenWerte[1]}</strong>
      </>
    ) : (
      <>
        deine Augenfarbe ist <strong>{augenWerte[0]}</strong>
      </>
    );

  const z = answers.kontrast_zusatz || {};
  const zusatz = [
    z.dunkler_irisring && "einen dunklen Ring um die Iris",
    z.prominente_brauen && "prominente Augenbrauen",
    z.auffaellige_wimpern && "auffällige Wimpern",
    z.starkes_augenweiss && "einen starken Kontrast zwischen Augenweiß und Haut",
  ].filter(Boolean);
  const zusatzSatz = zusatz.length
    ? ` Zusätzlich hast du ${listeMitUnd(zusatz)} angegeben, was den Kontrast anhebt.`
    : "";

  const kLevels = dominantLevels(P_K, Config.KONTRAST_LEVELS);
  const top = topOf(P_K);
  const adverb = KONTRAST_LEVEL_ADVERB[top] || "unterschiedlich";
  // Einheitlicher Satz statt 4 separater Formulierungen je Level (Adina:
  // "Die Helligkeit deiner Gesichtsmerkmale ... unterscheidet sich ...,
  // deshalb hast du wahrscheinlich ein xxx Kontrastlevel"). rangeLabel()
  // uebernimmt hier - mit KONTRAST_LEVEL_ADJ statt KONTRAST_LABELS - auch
  // die (schwach bis) stark-Klammerlogik, liefert dadurch direkt deklinierte
  // Adjektive statt der Woerter selbst: "mittleres" oder "(niedriges bis)
  // mittleres" statt vorher "ein Kontrastlevel von (niedrig bis) mittel"
  // (Adina: "klingt fluessiger" mit den deklinierten Formen).
  const kontrastAdj = rangeLabel(kLevels, Config.KONTRAST_LEVELS, KONTRAST_LEVEL_ADJ, P_K);
  // Override als eigener Satz angehaengt statt das berechnete Ergebnis zu
  // ersetzen (Adina: "deshalb hast du wahrscheinlich xxxx. Manuell
  // überschrieben zu xxx - yyy Kontrastlevel."). Kein dist noetig - eine
  // bewusste manuelle Wahl bekommt nie die (schwach bis)-Klammer.
  const overrideAdj = overrideLevels && rangeLabel(overrideLevels, Config.KONTRAST_LEVELS, KONTRAST_LEVEL_ADJ);
  const rationale = (
    <>
      Die Helligkeit deiner Gesichtsmerkmale (Teint, Haarfarbe, Augenfarbe etc.) unterscheidet sich {adverb},
      deshalb hast du wahrscheinlich ein <strong>{kontrastAdj}</strong> Kontrastlevel.
      {overrideAdj && (
        <>
          {" "}
          Manuell überschrieben: <strong>{overrideAdj}</strong> Kontrastlevel.
        </>
      )}
    </>
  );

  // JSX statt reinem String (Adina: "in der gesamten Box ist aktuell noch
  // nichts wirklich gefettet"), damit die Kernwerte hervorgehoben werden
  // koennen - einziger Aufrufer (.ft-result-explain) nimmt JSX-Children
  // genauso wie einen String entgegen.
  return (
    <>
      Deine Haut ist <strong>{haut}</strong>, dein Haarlevel liegt bei <strong>{haar}</strong> und {augenSatz}.
      {zusatzSatz} {rationale}
    </>
  );
}

// Warme Drape-Farben (untere Zeile) - fuer den Erklaertext.
const DRAPE_WARM_KEYS = new Set(["gold", "orange", "pfirsich", "camel"]);

// Dynamischer Erklaertext fuer den Unterton: bei digitalem Draping ueber die
// gewaehlten Farben, sonst (Fallback) ueber Haut-/Haar-Wahl.
// Haar-Waerme als Fliesstext: Adjektiv klein, 'Wärme' gross; bei zwei Werten
// als Bereich mit nur einem 'Wärme' am Ende ("moderate bis deutliche Wärme").
function haarWaermePhrase(values) {
  const labels = (values || []).map((v) => HAAR_WAERME_LABELS[v] || v);
  if (!labels.length) return "";
  const lc = labels.map((l) => l.toLowerCase().replace(/wärme/g, "Wärme"));
  if (lc.length === 1) return lc[0];
  return lc.map((l) => l.replace(/\s*Wärme$/, "")).join(" bis ") + " Wärme";
}

// Berechnetes und manuelles Ergebnis getrennt dargestellt, analog zu
// buildKontrastErklaerung (Adina: an die Kontrast-Ergebnisseite angleichen,
// "gilt jetzt auch fuer Chroma"): der Fliesstext zeigt IMMER die berechnete
// Begruendung (P_U), ein manueller Override wird als eigener Zusatzsatz
// angehaengt statt das berechnete Ergebnis zu ersetzen.
// Deklinierte Adjektivform je Unterton-Level, fuer "ein ___en Unterton"
// (Akkusativ) - regelmaessig aus dem Label ableitbar (+"en"), anders als bei
// Kontrastlevel keine eigene Wortliste noetig.
const UNTERTON_LEVEL_ADJ = { kuehl: "kühlen", "kuehl-neutral": "kühl-neutralen", "warm-neutral": "warm-neutralen", warm: "warmen" };

function buildUntertonErklaerung(answers, P_U, overrideLevels) {
  const topLevels = dominantLevels(P_U, Config.UNTERTON_LEVELS);
  const range = untertonRangeLabel(topLevels, P_U);
  const overrideRange = overrideLevels && untertonRangeLabel(overrideLevels);

  // Pro Runde zaehlen, ob eine klare Wahl ("eindeutig") oder eine Neutral+
  // Tendenz-Kombi vorlag (Model.DRAPE_TIE_KEY) - dafuer auf drape_rounds
  // zurueckgreifen statt auf das schon abgeflachte drape_picks, das die
  // Tendenz-Info nicht mehr unterscheidet (Adina: Aufschluesselung "1x
  // eindeutig und 2x neutral mit Tendenz zu kühl" im Fliesstext).
  const rounds = (answers.drape_rounds || []).filter(Boolean);
  let warmClear = 0,
    warmLean = 0,
    coolClear = 0,
    coolLean = 0;
  for (const round of rounds) {
    const hasTie = round.includes(Model.DRAPE_TIE_KEY);
    const metalKey = round.find((k) => k !== Model.DRAPE_TIE_KEY);
    if (!metalKey) continue; // reines "Neutral + Tendenz" ohne Metall kommt hier nicht vor (drapeRoundComplete)
    const warm = DRAPE_WARM_KEYS.has(metalKey);
    if (hasTie) warm ? warmLean++ : coolLean++;
    else warm ? warmClear++ : coolClear++;
  }
  // Klammer nur bei gemischten Runden (eindeutig + Tendenz) oder rein
  // Tendenz-Runden - bei ausschliesslich eindeutigen Runden faellt sie ganz
  // weg (Adina: "Klammer weglassen wenn eindeutig").
  function seitenTeil(clear, lean, adjDeklined, adjBasis) {
    const total = clear + lean;
    if (!total) return null;
    const noun = total === 1 ? "Farbe" : "Farben";
    let paren = "";
    if (clear > 0 && lean > 0) paren = ` (${clear}x eindeutig und ${lean}x neutral mit Tendenz zu ${adjBasis})`;
    else if (lean > 0) paren = ` (neutral mit Tendenz zu ${adjBasis})`;
    return `${total} ${adjDeklined} ${noun}${paren}`;
  }
  const coolTeil = seitenTeil(coolClear, coolLean, "kühlen", "kühl");
  const warmTeil = seitenTeil(warmClear, warmLean, "warmen", "warm");
  const farbTeil = [coolTeil, warmTeil].filter(Boolean).join(" und ");
  const farbSatz = farbTeil ? (
    <>
      Beim digitalen Testen hat dir dein Gesicht am besten in <strong>{farbTeil}</strong> gefallen.{" "}
    </>
  ) : null;

  // "eine" nur vor "moderate Wärme" (Adina: "bei den anderen Ausprägungen
  // braucht es das 'eine' nicht" - bei "(fast) keine Wärme"/"deutliche
  // Wärme" klingt der unbestimmte Artikel unnoetig bzw. falsch).
  const haarVals = answers.unterton_haar || [];
  const haarPrefix = haarVals.length === 1 && haarVals[0] === "moderate-waerme" ? "eine " : "";
  const haar = haarWaermePhrase(haarVals);

  const passendeFarbenSatz =
    topLevels.length === 1 ? (
      <>
        {" "}
        Dir werden Farben stehen, die ebenfalls einen <strong>{UNTERTON_LEVEL_ADJ[topLevels[0]]}</strong> Unterton
        haben.
      </>
    ) : (
      <>
        {" "}
        Dir werden Farben stehen, die ebenfalls einen Unterton im Bereich <strong>{range}</strong> haben.
      </>
    );

  return (
    <>
      {farbSatz}
      {haar ? (
        <>
          Deine Haare haben {haarPrefix}
          <strong>{haar}</strong>, daraus ergibt sich dein Gesamtunterton: <strong>{range}</strong>.
        </>
      ) : (
        <>
          Daraus ergibt sich dein Gesamtunterton: <strong>{range}</strong>.
        </>
      )}
      {overrideRange && (
        <>
          {" "}
          Manuell überschrieben: Unterton im Bereich <strong>{overrideRange}</strong>.
        </>
      )}
      {passendeFarbenSatz}
    </>
  );
}

function listeMitUnd(items) {
  if (items.length <= 1) return items.join("");
  return items.slice(0, -1).join(", ") + " und " + items[items.length - 1];
}

function Stufe2Kontrast({ toolData, onUpdate, onGotoStufe }) {
  const ft = toolData.farbtyp_test;
  const answers = ft?.answers || emptyAnswers();
  const overrides = ft?.overrides || { kontrast: null, unterton: null, chroma: null };
  const photos = toolData.photos || {};
  const { patchAnswer, patchZusatz, setOverride } = farbtypActions(onUpdate);
  const [step, setStep] = useChapterStep(toolData, onUpdate, "kontrast");
  const [showLimbal, setShowLimbal] = useState(false);

  const P_K = useMemo(() => Model.deriveKontrastlevel(answers), [answers]);
  const ready = answers.hautton.length > 0 && answers.haarlevel.length > 0 && answers.augenfarbe.length > 0;

  // Ergebnis-Level: aus der berechneten Verteilung abgeleitet, sofern die TN
  // nicht manuell ueberschrieben hat. Override kann Einzel- oder Mischergebnis
  // (Array) sein. shownLevels speist Headline, Erklaertext und den Ergebnis-Slider.
  const overrideLevels = overrides.kontrast ? (Array.isArray(overrides.kontrast) ? overrides.kontrast : [overrides.kontrast]) : null;
  const shownLevels = overrideLevels || dominantLevels(P_K, Config.KONTRAST_LEVELS);
  // Nur bei nicht-ueberschriebenem Ergebnis an rangeLabel() weiterreichen -
  // bei einem manuellen Override ist der Nebenwert eine bewusste Wahl der TN,
  // keine schwache Wahrscheinlichkeit, die in Klammern gehoert.
  const shownDist = overrideLevels ? undefined : P_K;
  // Ergebnis-Slider ist erst nach Ruecksprache editierbar (schuetzt das
  // errechnete Resultat vor versehentlichem Verstellen).
  const [editing, setEditing] = useState(false);
  const [showConfirm, setShowConfirm] = useState(false);

  const eyebrow = "Kontrastlevel bestimmen";
  const goBack = () => (step === 0 ? (startChapterAtEnd(onUpdate, 1), onGotoStufe(1)) : setStep(step - 1));
  const goNext = () => setStep(step + 1);

  // Neuer vorgeschalteter Erklaer-Screen (Adina-Wunsch): erst die generelle
  // 3-Parameter-Methode, dann speziell auf Kontrastlevel zugespitzt - EIN
  // Screen, kein Slider, "Weiter" immer aktiv.
  // Screen in zwei Screens gesplittet (Adina: "slide 2 teilen", "neue seite
  // nach 'werden dir gut stehen'") - die generelle 3-Parameter-Erklaerung
  // bleibt Screen 0, die Kontrastlevel-spezifische Vertiefung (Beispiele +
  // "Los geht's!") zieht auf einen neuen Screen 1 um, alle Folge-Screens
  // ruecken einen Index weiter.
  if (step === 0) {
    return (
      <QuizScreen screenKey={step} title="Wie wir deinen Farbtyp bestimmen" onBack={goBack} onNext={goNext} nextDisabled={false} nextIcon="arrow">
        <p className="ft-muted" style={{ marginBottom: 8 }}>
          <strong>Jede Farbe</strong> kann über <strong>folgende 3 Parameter</strong> eindeutig charakterisiert werden:
        </p>
        {/* Reihenfolge (Adina, 18.09.2026): Satz "Jede Farbe kann ..." -> Munsell-
            Bild mittig und gross (ohne Zoom-Popup) -> die drei Absaetze zu den
            Gesichtsmerkmalen -> darunter die ausklappbaren Begriffe (Helligkeit,
            Farbton, Chroma). */}
        <figure className="ft-munsell-figure">
          <img src={`${R2_ASSETS_BASE}/munsell_transparent.png`} alt="Munsell-Farbmodell" />
          <figcaption className="ft-munsell-source">
            Quelle:{" "}
            <a href="https://kunstunterricht.ch/e/farbdimensionen-hsb/" target="_blank" rel="noopener noreferrer">
              kunstunterricht.ch
            </a>
          </figcaption>
        </figure>
        <p className="ft-muted">
          <strong>Anhand derselben 3 Parameter</strong> können deine <strong>natürlichen Gesichtsmerkmale</strong> (Teint,
          Haarfarbe, Augenfarbe, Lippenfarbe, etc.) charakterisiert werden.
        </p>
        <p className="ft-muted">
          {/* "Merke:" in Senfgelb + fett (Adina: "Merke senfgelb schreiben und
              fett") statt der Standard-Ink-Farbe von <strong>. */}
          <strong style={{ color: "var(--accent)" }}>Merke:</strong> <strong>Jede Farbe</strong>, die in deinem{" "}
          <strong>Gesicht</strong> natürlicherweise <strong>vorkommt</strong>, <strong>steht dir automatisch</strong>{" "}
          (deshalb ist deine <strong>ideale Haarfarbe</strong> übrigens immer deine <strong>Naturhaarfarbe</strong>).
          <br />
          <br />
          <strong>Zusätzlich</strong> stehen dir alle Farben, die <strong>dieselben Eigenschaften</strong> wie deine{" "}
          <strong>Gesichtsmerkmale</strong> haben (Helligkeit, Temperatur und Sättigung), denn sie führen den{" "}
          <strong>Charakter</strong> deiner natürlichen Erscheinung fort und setzen sie dadurch automatisch in Szene.
          <br />
          <strong>Cool, oder?</strong> 😍
        </p>
        <p className="ft-muted">
          <strong>Um diese Farben zu finden</strong>, schauen wir uns gleich nacheinander an, wie{" "}
          <strong>kontrastreich</strong> deine Gesichtsmerkmale sind (Helligkeitsunterschied), welchen{" "}
          <strong>Unterton</strong> sie haben (Farbtemperatur) und wie <strong>gesättigt</strong> die Farben sind
          (Chroma).
        </p>
        {/* Jeder Begriff hat sein eigenes Ausklapp-Pfeilchen fuer eine Definition
            (ExpandableTermList statt QuizTips). */}
        <ExpandableTermList
          terms={[
            {
              label: "Helligkeit (für Kontrastlevelbestimmung)",
              desc: (
                <React.Fragment>
                  Die Helligkeit (engl. Value, Brightness) ist die{" "}
                  <strong>relative Helligkeit oder Dunkelheit der Farbe</strong>, meist angegeben als Prozentwert
                  zwischen <strong>0% (Schwarz, maximal dunkel)</strong> und{" "}
                  <strong>100% (Weiß, maximal hell)</strong>.
                </React.Fragment>
              ),
              images: [{ src: `${R2_ASSETS_BASE}/Helligkeit.png`, alt: "Helligkeit von 0% bis 100%", kind: "bar", zoom: false }],
              descAfter: (
                <React.Fragment>
                  Hast du sowohl <strong>sehr dunkle</strong> als auch <strong>sehr helle</strong> Gesichtsmerkmale (hoher
                  Kontrast), kannst du die <strong>gesamte Bandbreite</strong> an Farbhelligkeiten tragen. Hast du{" "}
                  <strong>keine</strong> sehr dunklen Gesichtsmerkmale (niedriges Kontrastlevel), gehören die sehr dunklen
                  Farben <strong>nicht zu deinen besten Farben</strong>.
                </React.Fragment>
              ),
            },
            {
              label: "Farbton (inkl. Farbtemperatur für Untertonbestimmung)",
              desc: (
                <React.Fragment>
                  Der Farbton oder Farbwert (engl. Hue) ist die <strong>Farbe (z. B. Rot, Orange, Blau etc.)</strong>, meist
                  angegeben als Gradzahl zwischen <strong>0° und 360°</strong> auf dem <strong>Standard-Farbkreis</strong>.
                  <br />
                  <br />
                  Der Farbton enthält auch die <strong>Farbtemperatur</strong>: Orange ist auf dem Farbkreis der{" "}
                  <strong>maximale Wärmepunkt</strong>, gegenüber ist Blau der <strong>maximale Kältepunkt</strong>.
                  <br />
                  <br />
                  Die <strong>Temperatur</strong> aller Farben lässt sich durch die <strong>relative Nähe oder Ferne</strong> zu diesen beiden
                  Punkten bestimmen (es gibt z. B. kein warmes Blau und kein kaltes Orange).
                </React.Fragment>
              ),
              descAfter: (
                <React.Fragment>
                  Unseren <strong>Unterton im Teint</strong> erhalten wir durch Pigmente, die auf dem Farbkreis zwischen{" "}
                  <strong>Gelb, über Orange bis Rot</strong> rangieren.
                  <br />
                  <br />
                  Je <strong>mehr orangene Pigmente</strong>, <strong>desto wärmer</strong> der Unterton.
                  <br />
                  <br />
                  Je <strong>weniger orangene Pigmente</strong> (also mehr rote/rosige oder gelbe/olivige), <strong>desto kühler</strong> der
                  Unterton.
                </React.Fragment>
              ),
              images: [
                {
                  src: `${R2_ASSETS_BASE}/colourwheel_beschriftet_transparent.png`,
                  alt: "Farbkreis mit maximaler Wärme und maximaler Kälte",
                  kind: "wheel",
                  source: "Anuschka Rees, Buch: Personal Color",
                },
              ],
            },
            {
              label: "Chroma (Sättigung)",
              desc: (
                <React.Fragment>
                  Chroma, auch Sättigung (engl. Saturation) genannt, ist die{" "}
                  <strong>Brillianz oder Reinheit der Farbe</strong>. Sie beschreibt, wie hoch der{" "}
                  <strong>Grauanteil</strong> im Verhältnis zum Farbton ist. Meist angegeben als Prozentwert
                  zwischen <strong>0% (=Grau)</strong> und <strong>100% (voll gesättigt)</strong>.
                </React.Fragment>
              ),
              images: [{ src: `${R2_ASSETS_BASE}/Chroma_Saettigung.png`, alt: "Sättigung von 0% bis 100%", kind: "bar", zoom: false }],
            },
          ]}
        />
      </QuizScreen>
    );
  }

  if (step === 1) {
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Kontrastlevel bestimmen"
        title="Dein natürliches Kontrastlevel"
        onBack={goBack}
        onNext={goNext}
        nextDisabled={false}
        nextIcon="arrow"
      >
        <p className="ft-muted">
          Als Erstes analysieren wir, wie hoch dein natürliches <strong>Kontrastlevel</strong> ist. Dazu schauen
          wir uns an, wie groß der <strong>Unterschied (Kontrast)</strong> zwischen deinen{" "}
          <strong>hellsten und dunkelsten</strong> Gesichtsmerkmalen (Hautton, Haarfarbe, Augenfarbe,
          Augenbrauen und Wimpern) ist. Uns stehen Farben am besten, die unser natürliches{" "}
          <strong>Kontrastlevel</strong> im Gesicht aufgreifen.
        </p>
        {/* "Beispiel 1"/"Beispiel 2" im selben Nummer-Badge-Look wie die
            01/02-Tips auf den vorherigen Seiten statt als reiner Textpraefix
            (Adina: "gleiche Schriftformatierung nutzen wie bei 01, 02") -
            "Beispiel N: " faellt dafuer aus dem Fliesstext raus, das Badge
            uebernimmt die Kennzeichnung. */}
        <div className="ft-example-row">
          <span className="ft-quiz-tips-num">Beispiel 1</span>
          <p className="ft-muted">
            Sind alle deine Gesichtsmerkmale <strong>eher gleichhell</strong>, hast du ein eher{" "}
            <strong>niedriges Kontrastlevel</strong> (wenig Helligkeitsunterschied). Du siehst in Farben am besten
            aus, die den <strong>eher niedrigen Kontrast</strong> in deinen Gesichtsmerkmalen aufgreifen, d. h.
            nicht deutlich heller oder dunkler als dein Hautton (Helligkeit) sind - diese Farben würden zu
            „scharf“ an dir aussehen.
          </p>
        </div>
        <div className="ft-example-row">
          <span className="ft-quiz-tips-num">Beispiel 2</span>
          <p className="ft-muted">
            Hast du <strong>sowohl sehr dunkle als auch sehr helle</strong> Gesichtsmerkmale, hast du ein eher{" "}
            <strong>hohes Kontrastlevel</strong> (z. B. sehr dunkle Augenbrauen bei hellem Hautton, oder sehr helles
            Augenweiß bei dunklem Hautton etc.). Du kannst auch Farben gut tragen, die{" "}
            <strong>deutlich heller oder dunkler</strong> sind als dein Hautton - sie werden dein natürliches{" "}
            <strong>hohes Kontrastlevel</strong> wunderbar in Szene setzen.
          </p>
        </div>
        <p className="ft-muted">
          Wir analysieren jetzt nacheinander die Helligkeit deiner Gesichtsmerkmale (Haut, Haare, Augen), um
          anschließend dein <strong>Kontrastlevel</strong> zu bestimmen.
        </p>
        <p className="ft-muted" style={{ marginTop: 18 }}>
          <strong>Los geht’s!</strong>
        </p>
      </QuizScreen>
    );
  }

  if (step === 2) {
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Kontrastlevel bestimmen: Hautton"
        title="Hautton: Wie hell bzw. dunkel ist deine Haut?"
        tips={[
          <>Zieh dein Foto auf die <strong>1-2 Hautton-Stufen</strong>, die deinem am ehesten entsprechen.</>,
          <>Trick: Wenn wir die Farbe aus Bildern entfernen (Graustufen), bleibt nur noch <strong>Helligkeit und Dunkelheit</strong> übrig - das erleichtert die korrekte Einordnung.</>,
        ]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={answers.hautton.length === 0}
      >
        <ComparisonSlider
          sprite={HAUTTON_SPRITE}
          photoUrl={photos.gesicht_vorne?.url}
          value={answers.hautton}
          onChange={(v) => patchAnswer("hautton", v)}
          thumbSize={60}
          framing={photos.gesicht_vorne_zugeschnitten}
        />
      </QuizScreen>
    );
  }

  if (step === 3) {
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Kontrastlevel bestimmen: Haarlevel"
        title="Haarlevel: Wie hell bzw. dunkel ist dein Haar?"
        tips={[
          <>Dein <strong>Haarlevel (1-10)</strong> ist die <strong>Helligkeit</strong> deiner Haare – unabhängig von Farbe oder Wärme. Die <strong>Helligkeit</strong> ist das, was übrig bleibt, wenn man auf <strong>Graustufen vergleicht</strong>.</>,
          <>Platziere das Bild mit deiner <strong>Haarfarbe</strong> dort, wo es am ehesten der <strong>Helligkeit</strong> (nicht unbedingt der Farbe/Wärme!) der Beispielbilder entspricht. Liegt es zwischen zwei Spalten, werden <strong>2 Haarlevel-Stufen</strong> gewählt.</>,
        ]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={answers.haarlevel.length === 0}
      >
        <ComparisonSlider
          sprite={HAARLEVEL_SPRITE}
          photoUrl={photos.haaransatz_zugeschnitten?.url || photos.haaransatz?.url}
          value={answers.haarlevel}
          onChange={(v) => patchAnswer("haarlevel", v)}
          posValue={answers.haarlevel_pos}
          onPosChange={(p) => patchAnswer("haarlevel_pos", p)}
          thumbSize={64}
          grayscaleToggle
          grayscaleDefault
        />
      </QuizScreen>
    );
  }

  if (step === 4) {
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Kontrastlevel bestimmen: Augen"
        title="Augen: Welche Augenfarbe hast du?"
        tips={[<>Wähle die <strong>1-2 Augenfarben</strong>, die deiner am ehesten entsprechen.</>]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={answers.augenfarbe.length === 0}
      >
        {/* "Du bist dir nicht ganz sicher?"-Hilfe (Braunschwarz/Braun etc.) auf
            Adinas Wunsch aus der Oberflaeche entfernt - soll stattdessen als
            Praeferenzregel in die Kontrastlevel-Berechnung wandern (Braunschwarz+
            Braun gleichzeitig gewaehlt -> Braun im Modell bevorzugen). Diese
            Backend-Logik ist noch NICHT umgesetzt, siehe Rueckfrage an Adina. */}
        <ComparisonSlider sprite={AUGENFARBE_SPRITE} photoUrl={photos.augen_zugeschnitten?.url || photos.augen?.url} value={answers.augenfarbe} onChange={(v) => patchAnswer("augenfarbe", v)} thumbSize={84} boxInset="-3% -1% 0% -1%" />
      </QuizScreen>
    );
  }

  if (step === 5) {
    // Live-Schaetzung VOR den Zusatzmerkmalen: dieselbe Berechnung wie P_K,
    // aber mit leerem kontrast_zusatz, damit die Upgrade-Regeln (Ring/Brauen/
    // Wimpern/Augenweiss) noch nicht greifen - zeigt der TN, wo sie aktuell
    // (nur aus Hautton/Haarlevel/Augenfarbe) steht, bevor sie diesen Screen
    // beantwortet (Adina-Wunsch, Punkt 5).
    const preZusatzDist = Model.deriveKontrastlevel({ ...answers, kontrast_zusatz: {} });
    const preZusatzRange = rangeLabel(
      dominantLevels(preZusatzDist, Config.KONTRAST_LEVELS),
      Config.KONTRAST_LEVELS,
      KONTRAST_LABELS,
      preZusatzDist
    );
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Kontrastlevel bestimmen: weitere Gesichtsmerkmale"
        title="Hast du außergewöhnlich dunkle Gesichtsmerkmale?"
        tips={[
          <>Auf Basis deiner letzten 3 Antworten ist dein Kontrastlevel voraussichtlich: <strong>{preZusatzRange}</strong>.</>,
          <>
            Folgende <strong>Gesichtsmerkmale</strong> können dein <strong>Kontrastlevel</strong> nochmal{" "}
            <strong>erhöhen</strong> – kreuze an, was zutrifft. Trifft nichts zu, klicke einfach weiter.
          </>,
        ]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={false}
      >
        <Checkbox
          className="ft-zusatz-checkbox"
          label={
            <>
              Augenfarbe: <strong>Dunkler Ring</strong> um die <strong>Iris</strong> (
              <button
                type="button"
                className="ft-tip-magic-trigger"
                onClick={(e) => {
                  e.preventDefault();
                  e.stopPropagation();
                  setShowLimbal(true);
                }}
              >
                Limbalring
              </button>
              )?
            </>
          }
          checked={answers.kontrast_zusatz.dunkler_irisring}
          onChange={(v) => patchZusatz("dunkler_irisring", v)}
        />
        <Checkbox
          className="ft-zusatz-checkbox"
          label={<><strong>Prominente Augenbrauen</strong> (deutlich dunkler als natürliche Haarfarbe od. sehr voluminös)?</>}
          checked={answers.kontrast_zusatz.prominente_brauen}
          onChange={(v) => patchZusatz("prominente_brauen", v)}
        />
        <Checkbox
          className="ft-zusatz-checkbox"
          label={<>Von Natur aus lange, <strong>auffällig dunkle Wimpern</strong>?</>}
          checked={answers.kontrast_zusatz.auffaellige_wimpern}
          onChange={(v) => patchZusatz("auffaellige_wimpern", v)}
        />
        <Checkbox
          className="ft-zusatz-checkbox"
          label={<>Starker Kontrast <strong>Augenweiß</strong> zu Haut (häufig bei dunkler Hautfarbe)?</>}
          checked={answers.kontrast_zusatz.starkes_augenweiss}
          onChange={(v) => patchZusatz("starkes_augenweiss", v)}
        />
        {showLimbal && (
          <div className="ft-modal-backdrop" onClick={() => setShowLimbal(false)}>
            <div className="ft-modal ft-limbal-demo" onClick={(e) => e.stopPropagation()}>
              <div className="ft-family-head">
                <h3>Limbalring</h3>
                <button type="button" className="ft-family-close" onClick={() => setShowLimbal(false)} aria-label="Schließen">
                  ×
                </button>
              </div>
              <img className="ft-limbal-img" src={`${R2_ASSETS_BASE}/Limbalring.png`} alt="Beispiel: Auge mit dunklem Ring um die Iris (Limbalring)" />
              <p className="ft-limbal-source">Quelle: KI-generiert, Stichwort „Limbalring“</p>
            </div>
          </div>
        )}
      </QuizScreen>
    );
  }

  // step === 6: Ergebnis-Box. Bewusst OHNE Eyebrow/Titel/Tips (Adina:
  // "dieser Screen soll nur aus der [Ergebnis-]box bestehen") - der
  // Erklaertext wandert stattdessen in die Box selbst, direkt vor den
  // manuellen Override-Bereich.
  return (
    <QuizScreen screenKey={step} onBack={goBack} onNext={() => { startChapterAtBeginning(onUpdate, "unterton"); onGotoStufe(3); }} nextDisabled={!ready}>
      {/* ft-result-standalone: dieser Screen hat (anders als Unterton/Chroma)
          kein Eyebrow/Titel/Tips mehr ueber der Box, das normale
          margin-top:26px von .ft-result war dafuer gedacht, sich von so
          einem Text abzusetzen. Ohne den Text obendrueber addierte sich das
          zum Karten-Padding und liess den Rand oben/unten deutlich groesser
          wirken als seitlich (Adina-Report). */}
      <section className="ft-result ft-result-standalone">
        <div className="ft-result-badge">Ergebnis</div>
        <p className="ft-result-headline">
          {overrideLevels ? (
            <>
              Dein Kontrastlevel manuell gesetzt:{" "}
              <strong>{rangeLabel(overrideLevels, Config.KONTRAST_LEVELS, KONTRAST_LABELS)}</strong>
            </>
          ) : (
            <>
              Dein Kontrastlevel ist wahrscheinlich{" "}
              <strong>{rangeLabel(shownLevels, Config.KONTRAST_LEVELS, KONTRAST_LABELS, shownDist)}</strong>.
            </>
          )}
        </p>
        <p className="ft-result-explain">{buildKontrastErklaerung(answers, P_K, overrideLevels)}</p>

        <div className="ft-result-edit">
          {/* .ft-muted-sm = die neue "kleine Schriftart" (Adina, 05.09.2026):
              ab jetzt Standardklasse fuer bewusst kleiner gesetzten
              Absatztext, Gegenstueck zu .ft-muted (14px, "grosse
              Schriftart"). */}
          <p className="ft-muted ft-muted-sm">
            Dieses Ergebnis basiert auf deiner Einordnung von <strong>Hautton, Haarlevel, Augenfarbe</strong> und
            weiteren <strong>Gesichtsmerkmalen</strong>. Dein Ergebnis ist nicht stimmig? Du kannst das Ergebnis
            nachfolgend <strong>manuell überschreiben</strong>, bevor du weiterklickst. Ziel: Schließe{" "}
            <strong>mindestens 2 der 4 Kontrastlevel</strong> aus.
          </p>
          <div className="ft-result-slider">
            <ComparisonSlider
              sprite={KONTRASTLEVEL_SPRITE}
              photoUrl={photos.gesicht_vorne?.url}
              framing={photos.gesicht_vorne_zugeschnitten}
              value={shownLevels}
              onChange={(v) => setOverride("kontrast", v.length ? v : null)}
              thumbSize={76}
              grayscaleDefault
              locked={!editing}
              onLockedInteraction={() => setShowConfirm(true)}
            />
            {showConfirm && !editing && (
              // Mini-Tooltip statt seitenfuellendem Modal: erscheint erst, wenn
              // die TN am gesperrten Ergebnis-Slider zu ziehen versucht.
              <div className="ft-result-tip" role="dialog">
                <p>
                  Die 4 Vergleichsbilder zeigen nur <strong>ein mögliches Beispiel</strong> pro Kontrastlevel. Die
                  Umrandung ist das <strong>Ergebnis deiner Angaben</strong> zu Hautton, Haarlevel, Augenfarbe etc.
                  Bist du sicher, dass du dieses Ergebnis trotzdem <strong>manuell überschreiben</strong> möchtest?
                </p>
                <div className="ft-result-tip-actions">
                  <button type="button" className="ft-btn ft-btn-text" onClick={() => setShowConfirm(false)}>
                    Abbrechen
                  </button>
                  <button
                    type="button"
                    className="ft-btn ft-btn-primary"
                    onClick={() => {
                      setEditing(true);
                      setShowConfirm(false);
                    }}
                  >
                    Ja, überschreiben.
                  </button>
                </div>
              </div>
            )}
          </div>
          {/* Reset-Link nur sichtbar, wenn tatsaechlich ueberschrieben wurde -
              vorher gab es keine erkennbare Aktion dafuer, nur das indirekte
              Abwaehlen aller Werte auf dem Slider (Adina-Frage). */}
          {overrideLevels && (
            <button
              type="button"
              className="ft-btn-text ft-result-reset"
              onClick={() => {
                setOverride("kontrast", null);
                setEditing(false);
              }}
            >
              Zurücksetzen (auf berechnetes Ergebnis)
            </button>
          )}
        </div>
      </section>
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 3: Unterton (Konzept-Abschnitt 5.5)
// ══════════════════════════════════════════════════════════

// Spielerische Fuehl-Hilfe fuer den Hautunterton: die TN legt Eiskristall- und
// Feuer-Emojis auf ihr eigenes Foto, verschiebt und skaliert sie frei. Rein
// visuell/haptisch - beeinflusst KEINE Auswahl, wird NICHT gespeichert
// (fluechtiger lokaler State, nach Neuladen leer). Adinas Vorgabe.
function EmojiPlayground({ photoUrl, framing }) {
  const stageRef = useRef(null);
  const dragRef = useRef(null);
  const [items, setItems] = useState([]); // {id, kind:'ice'|'fire', x%, y%, size px}
  const [selected, setSelected] = useState(null);

  if (!photoUrl) return null;

  // Zugeschnittenes Gesicht mit reichlich Pufferzone zeigen: das Original wird so
  // skaliert/positioniert, dass der Crop zentriert nur ~55% der Buehne fuellt -
  // ringsum bleibt Kontext zum Platzieren der Emojis. Ohne Crop-Daten Fallback
  // auf das volle Foto.
  const cf = framing?.cropFrac;
  const framed =
    cf && framing.origAspect
      ? (() => {
          // Gleichmaessiger Puffer in PIXELN auf allen vier Seiten: Puffer P als
          // Anteil der Crop-Breite, oben/unten und links/rechts identisch (die
          // Buehne bekommt dafuer das Seitenverhaeltnis Crop+2P statt Crop).
          const Ri = framing.origAspect;
          const cropAspect = (cf.w * Ri) / cf.h;
          const P = 0.35; // <-> Pufferbreite (Anteil der Crop-Breite), justierbar
          const Sw = 1 + 2 * P; // Buehnenbreite in Crop-Breiten
          const Sh = 1 / cropAspect + 2 * P; // Buehnenhoehe in Crop-Breiten
          const imgWunits = 1 / cf.w; // volle Bildbreite in Crop-Breiten
          const imgWpct = (imgWunits / Sw) * 100;
          const imgHpct = (imgWunits / Ri / Sh) * 100;
          return {
            stageAspect: Sw / Sh,
            imgStyle: {
              position: "absolute",
              width: imgWpct + "%",
              height: "auto",
              maxWidth: "none",
              left: 50 - (cf.x + cf.w / 2) * imgWpct + "%",
              top: 50 - (cf.y + cf.h / 2) * imgHpct + "%",
            },
          };
        })()
      : null;

  function addEmoji(kind) {
    const id = Date.now() + Math.random();
    setItems((prev) => [...prev, { id, kind, x: 50, y: 45, size: 46 }]);
    setSelected(id);
  }
  function onDown(e, id) {
    e.stopPropagation();
    if (e.target.classList.contains("ft-emoji-del")) return; // Loeschen laeuft ueber onClick
    e.currentTarget.setPointerCapture(e.pointerId);
    setSelected(id);
    const isHandle = e.target.classList.contains("ft-emoji-handle");
    const it = items.find((i) => i.id === id);
    dragRef.current = isHandle
      ? { mode: "scale", id, startX: e.clientX, startSize: it ? it.size : 46 }
      : { mode: "move", id };
  }
  function onMove(e) {
    const d = dragRef.current;
    if (!d) return;
    if (d.mode === "move") {
      const r = stageRef.current.getBoundingClientRect();
      const x = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100));
      const y = Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100));
      setItems((prev) => prev.map((i) => (i.id === d.id ? { ...i, x, y } : i)));
    } else {
      const size = Math.max(22, Math.min(160, d.startSize + (e.clientX - d.startX)));
      setItems((prev) => prev.map((i) => (i.id === d.id ? { ...i, size } : i)));
    }
  }
  function onUp() {
    dragRef.current = null;
  }
  function removeItem(id) {
    setItems((prev) => prev.filter((i) => i.id !== id));
    setSelected((s) => (s === id ? null : s));
  }

  return (
    <div className="ft-emoji">
      <div className="ft-emoji-palette">
        <span className="ft-emoji-hint">Tippe zum Hinzufügen, dann ziehen &amp; skalieren:</span>
        <button type="button" className="ft-emoji-add" onClick={() => addEmoji("ice")} aria-label="Eiskristall hinzufügen">❄️</button>
        <button type="button" className="ft-emoji-add" onClick={() => addEmoji("fire")} aria-label="Feuer hinzufügen">🔥</button>
      </div>
      <div
        className="ft-emoji-stage"
        ref={stageRef}
        style={framed ? { aspectRatio: framed.stageAspect } : undefined}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onClick={() => setSelected(null)}
      >
        <img src={photoUrl} style={framed ? framed.imgStyle : undefined} alt="Dein Foto" draggable={false} />
        {items.map((it) => (
          <div
            key={it.id}
            className={"ft-emoji-item" + (selected === it.id ? " is-selected" : "")}
            style={{ left: it.x + "%", top: it.y + "%", fontSize: it.size }}
            onPointerDown={(e) => onDown(e, it.id)}
            onPointerMove={onMove}
            onPointerUp={onUp}
            onClick={(e) => e.stopPropagation()}
          >
            <span className="ft-emoji-glyph">{it.kind === "ice" ? "❄️" : "🔥"}</span>
            {selected === it.id && (
              <React.Fragment>
                <button
                  type="button"
                  className="ft-emoji-del"
                  onClick={(e) => {
                    e.stopPropagation();
                    removeItem(it.id);
                  }}
                >
                  ×
                </button>
                <span className="ft-emoji-handle" />
              </React.Fragment>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

// Digitales Draping als 4 Duelle im "Optiker-Sehtest"-Stil (Adina-Wunsch,
// ersetzt die fruehere freie 8-aus-8-Auswahl): das gecroppte Gesicht wird
// paarweise in je einer kuehlen und einer warmen Drape-Farbe gezeigt, die TN
// entscheidet sich fuer eine Seite oder "Beide gleich". Die Paarung folgt
// Adinas Vorgabe (Gold/Silber, Pink/Orange) und setzt sich fuer die
// restlichen zwei Paare in derselben Reihenfolge fort.
const DIGITAL_DRAPES = [
  { key: "silber", label: "Silber", img: "silber.jpg", dot: "#C8C8C8" },
  { key: "pink", label: "Pink", color: "#FF0090" },
  { key: "rosa", label: "Rosa", color: "#FEBFE5" },
  { key: "flieder", label: "Lilie", color: "#D4A7C3" },
  { key: "gold", label: "Gold", img: "gold.jpg", dot: "#E9B639" },
  { key: "orange", label: "Orange", color: "#FF5F00" },
  { key: "pfirsich", label: "Pfirsich", color: "#FFD7BE" },
  { key: "camel", label: "Kaschmir", color: "#E4BDA4" },
];

// Originaler Crop, RECHTECKIG und ohne Pufferzugabe: das Original wird so
// positioniert/skaliert, dass exakt das Crop-Rechteck den (crop-formatigen)
// Rahmen fuellt. clipAspect = Seitenverhaeltnis des Crops. Wird beim digitalen
// Draping (Seite 3) und beim Foto-Test (Stufe 6 B) verwendet.
function faceClip(framing) {
  const cf = framing?.cropFrac;
  const hasCrop = cf && framing.origAspect;
  const clipAspect = hasCrop ? (cf.w * framing.origAspect) / cf.h : 1;
  const faceStyle = hasCrop
    ? {
        position: "absolute",
        maxWidth: "none",
        height: "auto",
        width: 100 / cf.w + "%",
        left: -(cf.x / cf.w) * 100 + "%",
        top: -(cf.y / cf.h) * 100 + "%",
      }
    : { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" };
  return { clipAspect, faceStyle };
}

// Ein Paar pro Duell-Screen (kuehl vs. warm), in Adinas Reihenfolge:
// Gold/Silber, Pink/Orange, dann dieselbe Logik fuer die restlichen zwei
// Paare fortgesetzt.
const DRAPE_PAIRS = [
  ["gold", "silber"],
  ["orange", "pink"],
  ["pfirsich", "rosa"],
  ["camel", "flieder"],
];

// "Neutral" allein (TIE ohne Metall-Tendenz) ist keine vollstaendige Antwort
// (Adina, 08.09.2026: man muss zusaetzlich Silber oder Gold ankreuzen) -
// Weiter erst freigeben, wenn die Runde eine echte Metall-Wahl enthaelt.
function drapeRoundComplete(round) {
  return !!round && round.some((k) => k !== Model.DRAPE_TIE_KEY);
}

// Ein Duell-Screen: zwei grosse Kacheln nebeneinander (gleiche Swatch-/Face-
// Optik wie frueher die 8er-Auswahl, nur groesser), darunter "Beide gleich".
// roundValue (bereits gespeicherte Antwort dieser Runde, falls man zurueck-
// navigiert) markiert die vorherige Wahl. Klick entscheidet UND blaettert
// sofort weiter (Optiker-Sehtest-Gefuehl), onNext kommt vom Aufrufer.
function DigitalDrapingRound({ photoUrl, framing, pairIndex, roundValue, onPick }) {
  const [keyA, keyB] = DRAPE_PAIRS[pairIndex];
  const dA = DIGITAL_DRAPES.find((x) => x.key === keyA);
  const dB = DIGITAL_DRAPES.find((x) => x.key === keyB);
  const { clipAspect, faceStyle } = faceClip(framing);
  // EIN einzelner "Neutral + Tendenz"-Button unter den beiden Metall-
  // Kacheln, kombinierbar mit Silber ODER Gold (Adina, 08.09.2026: kein
  // Popup mehr - stattdessen pulsieren die Auswahl-Kreise der beiden
  // Metall-Kacheln kurz, wenn Neutral OHNE Tendenz angeklickt wird, siehe
  // is-nudge/ft-round-btn-pulse). roundValue: [metallKey] (klare Wahl), [TIE]
  // (Neutral ohne Tendenz - kein vollstaendiges Ergebnis, siehe
  // drapeRoundComplete) oder [TIE, metallKey] (Neutral MIT Tendenz).
  const TIE = Model.DRAPE_TIE_KEY;
  const isTie = !!roundValue && roundValue.includes(TIE);
  const metalKey = roundValue ? roundValue.find((k) => k !== TIE) : null;
  const [nudge, setNudge] = useState(false);

  function pickMetal(key) {
    const nextMetal = metalKey === key ? null : key;
    setNudge(false);
    onPick(isTie ? (nextMetal ? [TIE, nextMetal] : [TIE]) : nextMetal ? [nextMetal] : null);
  }

  function toggleTie() {
    if (isTie) {
      setNudge(false);
      onPick(metalKey ? [metalKey] : null);
    } else {
      onPick(metalKey ? [TIE, metalKey] : [TIE]);
      // Kein Metall dazu gewaehlt -> Silber/Gold pulsieren lassen, bis
      // tatsaechlich eines der beiden zusaetzlich gewaehlt wird (Adina) -
      // Abschalten passiert in pickMetal(), nicht per Timeout.
      if (!metalKey) setNudge(true);
    }
  }

  function renderTile(d) {
    const on = metalKey === d.key;
    return (
      <button
        key={d.key}
        type="button"
        className={"ft-draping-tile ft-draping-duel-tile" + (on ? " is-selected" : "") + (nudge ? " is-nudge" : "")}
        onClick={() => pickMetal(d.key)}
        aria-pressed={on}
      >
        <span
          className="ft-draping-swatch"
          style={d.img ? { backgroundImage: `url(${spritePath(d.img)})` } : { background: d.color }}
        >
          <span className="ft-draping-face" style={{ aspectRatio: clipAspect }}>
            <img src={photoUrl} style={faceStyle} alt="" draggable={false} />
          </span>
        </span>
        <span className="ft-draping-label">{d.label}</span>
        {/* Rundes Auswahl-Icon unter jeder Kachel, im selben Look wie der
            Weiter-Button (Adina: "damit man weiss, man kann den Kreis
            anklicken und dann kommt der Haken") - unausgewaehlt nur zart
            umrandet, ausgewaehlt senf-gefuellt mit Haken (gleiches Muster
            wie PhotoStepDots). */}
        <span className={"ft-draping-pick" + (on ? " ft-draping-pick-done" : "")} aria-hidden="true">
          {on && <CheckIcon size={19} strokeWidth={1.2} />}
        </span>
      </button>
    );
  }

  // Jedes der 4 Duell-Paare hat eigene, konkret benannte Fragen (analog zum
  // Gold/Silber-Paar gespiegelt, siehe jeweilige Commits) statt des
  // generischen Fallback-Textes.
  const isGoldSilberPaar = pairIndex === 0;
  const isPinkOrangePaar = pairIndex === 1;
  const isPfirsichRosaPaar = pairIndex === 2;
  const isKaschmirLiliePaar = pairIndex === 3;

  return (
    <div className="ft-draping">
      <ul className="ft-draping-fragen">
        {isGoldSilberPaar ? (
          <>
            <li>
              <strong className="ft-draping-fragen-head">Gold besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>wärmer, lebendiger</strong> und <strong>gesünder</strong> aussehen? Lässt das{" "}
                <strong>Silber</strong> deine Haut eher <strong>blass</strong>, <strong>fahl</strong>,{" "}
                <strong>grau</strong> oder <strong>müde</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Silber besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>klar</strong> wirken und <strong>strahlen</strong>? Lässt das{" "}
                <strong>Gold</strong> dein Gesicht eher <strong>gelb, unruhig, klobig</strong>,{" "}
                <strong>aufdringlich</strong>, <strong>fleckig</strong> oder <strong>rötlicher</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Beides ähnlich gut?</strong>
              <span className="ft-draping-fragen-body">
                Wenn <strong>keines</strong> der beiden Metalle deutlich <strong>besser oder schlechter</strong>{" "}
                aussieht, entscheide dich für <strong>Neutral mit Tendenz Silber</strong> oder{" "}
                <strong>Gold</strong>.
              </span>
            </li>
          </>
        ) : isPinkOrangePaar ? (
          <>
            <li>
              <strong className="ft-draping-fragen-head">Orange besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>wärmer, lebendiger</strong> und <strong>gesünder</strong> aussehen? Lässt das{" "}
                <strong>Pink</strong> deine Haut eher <strong>blass</strong>, <strong>fahl</strong>,{" "}
                <strong>grau</strong> oder <strong>müde</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Pink besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>klar</strong> wirken und <strong>strahlen</strong>? Lässt das{" "}
                <strong>Orange</strong> dein Gesicht eher <strong>gelb, unruhig, klobig</strong>,{" "}
                <strong>aufdringlich</strong>, <strong>fleckig</strong> oder <strong>rötlicher</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Beides ähnlich gut?</strong>
              <span className="ft-draping-fragen-body">
                Wenn <strong>keine</strong> der beiden Farben deutlich <strong>besser oder schlechter</strong>{" "}
                aussieht, entscheide dich für <strong>Neutral mit Tendenz Pink</strong> oder{" "}
                <strong>Orange</strong>.
              </span>
            </li>
          </>
        ) : isPfirsichRosaPaar ? (
          <>
            <li>
              <strong className="ft-draping-fragen-head">Pfirsich besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>wärmer, lebendiger</strong> und <strong>gesünder</strong> aussehen? Lässt das{" "}
                <strong>Rosa</strong> deine Haut eher <strong>blass</strong>, <strong>fahl</strong>,{" "}
                <strong>grau</strong> oder <strong>müde</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Rosa besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>klar</strong> wirken und <strong>strahlen</strong>, vielleicht etwas{" "}
                <strong>sonnengebräunter</strong> aussehen? Lässt das <strong>Pfirsich</strong> dein Gesicht eher{" "}
                <strong>gelblich</strong>, <strong>fleckiger</strong>, <strong>rötlicher</strong>,{" "}
                <strong>verwaschen</strong> oder <strong>undefinierter</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Beides ähnlich gut?</strong>
              <span className="ft-draping-fragen-body">
                Wenn <strong>keine</strong> der beiden Farben deutlich <strong>besser oder schlechter</strong>{" "}
                aussieht, entscheide dich für <strong>Neutral mit Tendenz Rosa</strong> oder{" "}
                <strong>Pfirsich</strong>.
              </span>
            </li>
          </>
        ) : isKaschmirLiliePaar ? (
          <>
            <li>
              <strong className="ft-draping-fragen-head">Kaschmir besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>wärmer, lebendiger</strong> und <strong>gesünder</strong> aussehen? Lässt das{" "}
                <strong>Lilie</strong> deine Haut eher <strong>blass</strong>, <strong>fahl</strong>,{" "}
                <strong>grau</strong> oder <strong>müde</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Lilie besser?</strong>
              <span className="ft-draping-fragen-body">
                Lässt es dich <strong>klar</strong> wirken und <strong>strahlen</strong>? Lässt das{" "}
                <strong>Kaschmir</strong> dein Gesicht eher <strong>gelb, unruhig, klobig</strong>,{" "}
                <strong>aufdringlich</strong>, <strong>fleckig</strong> oder <strong>rötlicher</strong> wirken?
              </span>
            </li>
            <li>
              <strong className="ft-draping-fragen-head">Beides ähnlich gut?</strong>
              <span className="ft-draping-fragen-body">
                Wenn <strong>keine</strong> der beiden Farben deutlich <strong>besser oder schlechter</strong>{" "}
                aussieht, entscheide dich für <strong>Neutral mit Tendenz Lilie</strong> oder{" "}
                <strong>Kaschmir</strong>.
              </span>
            </li>
          </>
        ) : (
          <li>Wo strahlt dein Gesicht mehr?</li>
        )}
      </ul>
      {/* Die beiden Metall-Kacheln wieder direkt nebeneinander (naeher
          beieinander), "Neutral + Tendenz" darunter versetzt und mittig
          zentriert (Adina, 08.09.2026). */}
      <div className="ft-draping-duel-row">
        {renderTile(dA)}
        {renderTile(dB)}
      </div>
      <button
        type="button"
        className={"ft-draping-tie-tile" + (isTie ? " is-selected" : "")}
        onClick={toggleTie}
        aria-pressed={isTie}
      >
        <span className="ft-draping-label">
          Neutral
          <br />+ Tendenz
        </span>
        <span className={"ft-draping-pick" + (isTie ? " ft-draping-pick-done" : "")} aria-hidden="true">
          {isTie && <CheckIcon size={19} strokeWidth={1.2} />}
        </span>
      </button>
    </div>
  );
}

// Auf QuizScreen-Einzelschritte umgebaut (analog Stufe2Kontrast): 1. Digitales
// Draping, 2. Haarunterton, 3. Ergebnis. Emoji-Playground/Haut-Slider bleiben
// bewusst auskommentiert (siehe DigitalDraping/EmojiPlayground oben).
function Stufe3Unterton({ toolData, onUpdate, onGotoStufe }) {
  const ft = toolData.farbtyp_test;
  // Mit Defaults mergen, damit neue Felder (z. B. drape_picks) auch bei aelteren
  // gespeicherten Analysen vorhanden sind (sonst Crash -> leere Seite).
  const answers = { ...emptyAnswers(), ...(ft?.answers || {}) };
  const overrides = ft?.overrides || { kontrast: null, unterton: null, chroma: null };
  const photos = toolData.photos || {};
  const { patchAnswer, patchDrapeRound, setOverride } = farbtypActions(onUpdate);
  const [step, setStep] = useChapterStep(toolData, onUpdate, "unterton");

  const P_U = useMemo(() => Model.deriveUnterton(answers), [answers]);
  const ready = answers.drape_picks.length > 0;

  // "x-Zelle" (Adina, Buch-Tabelle): Hautunterton eindeutig kuehl + Haarunterton
  // enthaelt deutliche Waerme hat laut Vorlage KEIN definiertes Ergebnis
  // ("Etwas stimmt nicht. Ueberpruefe deine Ergebnisse noch einmal.") - das
  // Modell faellt intern auf kuehl-neutral zurueck (siehe UNTERTON_MATRIX-
  // Kommentar), zeigt hier aber stattdessen den Warnhinweis statt eines
  // scheinbar sicheren Ergebnisses. Nur bei EINDEUTIG kuehl (Verteilung
  // ausschliesslich {kuehl:1}), nicht bei jeder Mischung, in der kuehl nur
  // anteilig vorkommt.
  const hautDist = Model.drapeToDist(answers.drape_rounds);
  const isHautKuehlEindeutig = !!hautDist && Object.keys(hautDist).length === 1 && hautDist.kuehl === 1;
  const haarEnthaeltDeutlich = (answers.unterton_haar || []).includes("deutliche-waerme");
  const showXZelleWarnung = isHautKuehlEindeutig && haarEnthaeltDeutlich;

  const overrideLevels = overrides.unterton ? (Array.isArray(overrides.unterton) ? overrides.unterton : [overrides.unterton]) : null;
  const shownLevels = overrideLevels || dominantLevels(P_U, Config.UNTERTON_LEVELS);
  const [editing, setEditing] = useState(false);
  const [showConfirm, setShowConfirm] = useState(false);

  const eyebrow = "Unterton bestimmen";
  const goBack = () => (step === 0 ? (startChapterAtEnd(onUpdate, 2), onGotoStufe(2)) : setStep(step - 1));
  const goNext = () => setStep(step + 1);

  if (step === 0) {
    return (
      <QuizScreen screenKey={step} eyebrow={eyebrow} title="Als Nächstes: dein Unterton" onBack={goBack} onNext={goNext} nextDisabled={false} nextIcon="arrow">
        <p className="ft-muted">
          Nachdem wir dein Kontrastlevel bestimmt haben, analysieren wir als Zweites den Unterton deiner{" "}
          <strong>Haut</strong> und deiner <strong>Haare</strong>. Dieser kann entweder <strong>warm</strong>,{" "}
          <strong>kühl</strong> oder neutral mit einer Tendenz zu warm oder kühl sein (
          <strong>warm-neutral</strong>, <strong>kühl-neutral</strong>).
        </p>
        <p className="ft-muted">
          Ist in deinem Teint und deinen Haaren <strong>Wärme</strong> (Orange) zu finden, hast du einen eher
          warmen Unterton. Dir werden Farben stehen, die ebenfalls einen eher warmen Unterton haben.
        </p>
        <p className="ft-muted">
          Ist in deinem Teint und deinen Haaren <strong>keine Wärme</strong> (keine Spur von Orange, Teint
          schimmert eher alabasterfarben oder oliv) zu finden, hast du einen eher{" "}
          <strong>kühlen Unterton</strong>. Kühle Farben werden dir besser stehen.
        </p>
        <p className="ft-muted">
          Den Hautunterton zu erkennen ist für ungeübte Augen gar nicht so leicht. Deshalb nähern wir uns Schritt
          für Schritt.
        </p>
        <p className="ft-muted" style={{ marginTop: 18 }}>
          <strong>Los geht’s!</strong>
        </p>
      </QuizScreen>
    );
  }

  // Steps 1-4: die 4 Draping-Duelle (Optiker-Sehtest-Stil). Klick auf eine
  // Kachel ODER "Beide gleich" speichert nur die Auswahl, blaettert aber
  // NICHT automatisch weiter (Adina: "man soll auswaehlen koennen und dann
  // weiterklicken") - der Weiter-Button im QuizScreen (per nextDisabled an
  // drape_rounds[pairIndex] gekoppelt) ist der einzige Weg zur naechsten Runde.
  if (step >= 1 && step <= 4) {
    const pairIndex = step - 1;
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Unterton bestimmen: Teint"
        title={`Hautunterton: Vergleich ${step} von 4`}
        tips={
          pairIndex === 0
            ? [
                <>Wähle das <strong>Metall</strong>, das mit deinem Teint intuitiv <strong>besser harmoniert</strong>.</>,
                <>
                  Stelle sicher, dass du in Schritt 3 (Fotos machen) dein Foto so zugeschnitten hast, dass das{" "}
                  <strong>Metall oben, unten, rechts</strong> und <strong>links</strong> deine Gesichtshaut{" "}
                  <strong>berührt</strong>.
                </>,
              ]
            : [
                <>Wähle die <strong>Farbe</strong>, die mit deinem Teint intuitiv <strong>besser harmoniert</strong>.</>,
              ]
        }
        onBack={goBack}
        onNext={goNext}
        nextDisabled={!drapeRoundComplete(answers.drape_rounds[pairIndex])}
      >
        <DigitalDrapingRound
          photoUrl={photos.gesicht_vorne?.url}
          framing={photos.gesicht_vorne_zugeschnitten}
          pairIndex={pairIndex}
          roundValue={answers.drape_rounds[pairIndex]}
          onPick={(v) => patchDrapeRound(pairIndex, v)}
        />
      </QuizScreen>
    );
  }

  if (step === 5) {
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Unterton bestimmen: Haare"
        title="Haarunterton: Wie viel Wärme hat dein Haar?"
        tips={[
          <>
            Platziere das Bild mit deiner <strong>Haarfarbe</strong> (aus Schritt 4) dort, wo es am ehesten mit dem
            oder den <strong>Beispielbild(ern)</strong> in der unmittelbaren Umgebung übereinstimmt.
          </>,
        ]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={answers.unterton_haar.length === 0}
      >
        <ComparisonSlider
          sprite={HAAR_WAERME_SPRITE}
          photoUrl={photos.haaransatz_zugeschnitten?.url || photos.haaransatz?.url}
          value={answers.unterton_haar}
          onChange={(v) => patchAnswer("unterton_haar", v)}
          posValue={answers.unterton_haar_pos}
          onPosChange={(p) => patchAnswer("unterton_haar_pos", p)}
          thumbSize={64}
        />
      </QuizScreen>
    );
  }

  // step === 6: Ergebnis-Box. Bewusst OHNE Eyebrow/Titel/Tips, analog zur
  // Kontrastlevel-Ergebnisseite (Adina: "anpassen an die Ergebnisseite vom
  // Kontrast") - der Erklaertext wandert stattdessen in die Box selbst,
  // direkt vor den manuellen Override-Bereich.
  // x-Zelle-Warnung ersetzt die Ergebnis-Box komplett statt nur einen Satz
  // anzuhaengen (Adina) - ein scheinbar sicheres Ergebnis waere hier
  // irrefuehrend, da die Buch-Tabelle fuer diese Kombination keine Antwort
  // kennt. Ein bereits gesetzter manueller Override unterdrueckt die
  // Warnung (die Nutzerin hat den Widerspruch dann schon selbst aufgeloest).
  if (showXZelleWarnung && !overrideLevels) {
    return (
      <QuizScreen screenKey={step} onBack={goBack} onNext={() => { startChapterAtBeginning(onUpdate, "chroma"); onGotoStufe(4); }} nextDisabled>
        <section className="ft-result ft-result-standalone ft-result-conflict">
          <div className="ft-result-badge">Hinweis</div>
          <p className="ft-result-headline">
            Wir konnten deinen Haut- und Unterton nicht eindeutig bestimmen.
          </p>
          <p className="ft-result-explain">
            Navigiere noch mal zurück und überprüfe, ob du deinen <strong>Hautunterton</strong> (digitales Draping)
            und deinen <strong>Haarunterton</strong> korrekt bestimmt hast.
          </p>
        </section>
      </QuizScreen>
    );
  }

  return (
    <QuizScreen screenKey={step} onBack={goBack} onNext={() => { startChapterAtBeginning(onUpdate, "chroma"); onGotoStufe(4); }} nextDisabled={!ready}>
      <section className="ft-result ft-result-standalone">
        <div className="ft-result-badge">Ergebnis</div>
        <p className="ft-result-headline">
          {overrideLevels ? (
            <>
              Dein Unterton manuell gesetzt: <strong>{untertonRangeLabel(overrideLevels)}</strong>
            </>
          ) : (
            <>
              Dein Unterton ist wahrscheinlich{" "}
              <strong>{untertonRangeLabel(dominantLevels(P_U, Config.UNTERTON_LEVELS), P_U)}</strong>.
            </>
          )}
        </p>
        <p className="ft-result-explain">{buildUntertonErklaerung(answers, P_U, overrideLevels)}</p>

        <div className="ft-result-edit">
          <p className="ft-muted ft-muted-sm">
            Dieses Ergebnis basiert auf deinem <strong>digitalen Farbtest</strong> (warme vs. kühle Drapierfarben)
            und deiner <strong>Haarunterton-Einschätzung</strong>. Dein Ergebnis ist nicht stimmig? Du kannst das
            Ergebnis nachfolgend <strong>manuell überschreiben</strong>, bevor du weiterklickst. Ziel: Schließe{" "}
            <strong>mindestens 2 der 4 Untertonkategorien</strong> aus.
          </p>
          <div className="ft-result-slider">
            <ComparisonSlider
              sprite={UNTERTON_HAUT_SPRITE}
              photoUrl={photos.gesicht_vorne?.url}
              framing={photos.gesicht_vorne_zugeschnitten}
              value={shownLevels}
              onChange={(v) => setOverride("unterton", v.length ? v : null)}
              thumbSize={76}
              locked={!editing}
              onLockedInteraction={() => setShowConfirm(true)}
            />
            {showConfirm && !editing && (
              <div className="ft-result-tip" role="dialog">
                <p>
                  Die 4 Vergleichsbilder zeigen nur <strong>ein mögliches Beispiel</strong> pro Unterton. Die
                  Umrandung ist das <strong>Ergebnis deiner Angaben</strong> zu deinem digitalen Farbtest und deiner
                  Haarunterton-Bestimmung. Bist du sicher, dass du dieses Ergebnis trotzdem{" "}
                  <strong>manuell überschreiben</strong> möchtest?
                </p>
                <div className="ft-result-tip-actions">
                  <button type="button" className="ft-btn ft-btn-text" onClick={() => setShowConfirm(false)}>
                    Abbrechen
                  </button>
                  <button
                    type="button"
                    className="ft-btn ft-btn-primary"
                    onClick={() => {
                      setEditing(true);
                      setShowConfirm(false);
                    }}
                  >
                    Ja, überschreiben.
                  </button>
                </div>
              </div>
            )}
          </div>
          {overrideLevels && (
            <button
              type="button"
              className="ft-btn-text ft-result-reset"
              onClick={() => {
                setOverride("unterton", null);
                setEditing(false);
              }}
            >
              Zurücksetzen (auf berechnetes Ergebnis)
            </button>
          )}
        </div>
      </section>
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// KI-Zweitmeinung (Konzept 5.9): optional, erst am Ende von Schritt 4
// verfügbar (Anti-Anker), Fotos werden nie hochgeladen/gespeichert - nur
// lokal komprimiert und direkt an /api/ai/vision-check gesendet, das sie
// nach der Analyse sofort verwirft. Bewusst unabhängig von den Fotos aus
// Schritt 1 (die dauerhaft gespeichert werden) - zwei verschiedene Zwecke.
// ══════════════════════════════════════════════════════════

function fileToBase64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const result = String(reader.result || "");
      const idx = result.indexOf(",");
      resolve(idx >= 0 ? result.slice(idx + 1) : result);
    };
    reader.onerror = () => reject(new Error("read_failed"));
    reader.readAsDataURL(blob);
  });
}

const AI_DIM_LABELS = { hautton: "Hautton", haarlevel: "Haarlevel", augenfarbe: "Augenfarbe", unterton: "Unterton", chroma: "Chroma" };

function AiZweitmeinung({ ownValues, aiResult, setAiResult, onAdoptAnswer, onAdoptDerived }) {
  const [consent, setConsent] = useState(false);
  const [files, setFiles] = useState([]);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  function handleFiles(e) {
    setFiles(Array.from(e.target.files || []).slice(0, 4));
  }

  async function requestOpinion() {
    setErr("");
    setBusy(true);
    try {
      const compressed = [];
      for (const file of files) {
        const blob = await window.MW.compressImage(file);
        const base64 = await fileToBase64(blob);
        compressed.push({ data: base64, mime: blob.type || "image/webp" });
      }
      const res = await fetch("/api/ai/vision-check" + PROFIL_QS, {
        method: "POST",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ context: "farbtyp", consent: true, photos: compressed }),
      });
      const body = await res.json();
      if (!res.ok) throw new Error(body?.message || "Die KI-Zweitmeinung ist gerade nicht verfügbar.");
      setAiResult({ requested_at: new Date().toISOString(), estimates: body.estimates });
    } catch (e2) {
      setErr(e2.userMessage || e2.message || "Unbekannter Fehler.");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="ft-ai-section">
      <h3>KI-Zweitmeinung einholen (optional)</h3>
      {!aiResult && (
        <React.Fragment>
          <p className="ft-muted">
            Lass deine Fotos zusätzlich von einer KI einschätzen.
          </p>
          <label className="ft-checkbox">
            <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
            Meine Fotos werden einmalig zur Analyse an Anthropic (USA) übertragen und weder dort noch auf der
            Plattform gespeichert.
          </label>
          <div className="ft-photo-picker">
            <input type="file" accept="image/*" multiple onChange={handleFiles} />
            {files.length > 0 && <p className="ft-muted">{files.length} Foto(s) ausgewählt.</p>}
          </div>
          <div className="ft-actions">
            <button
              type="button"
              className="ft-btn ft-btn-secondary"
              disabled={!consent || !files.length || busy}
              onClick={requestOpinion}
            >
              {busy ? "Analysiert …" : "KI-Zweitmeinung anfordern"}
            </button>
          </div>
          {err && <div className="ft-error">{err}</div>}
        </React.Fragment>
      )}

      {aiResult && (
        <React.Fragment>
          <div className="ft-ai-compare">
            {Object.entries(aiResult.estimates).map(([dim, est]) => {
              const own = ownValues[dim];
              const ownDisplay = Array.isArray(own) ? own.join(" / ") : String(own ?? "-");
              const diff = Array.isArray(own) ? !own.map(String).includes(String(est.value)) : String(own) !== String(est.value);
              return (
                <div key={dim} className={"ft-ai-row" + (diff ? " ft-ai-row-diff" : "")}>
                  <span>
                    <strong>{AI_DIM_LABELS[dim]}:</strong> du sagst „{ownDisplay}“, KI schätzt „{String(est.value)}“
                    {est.confidence === "unsicher" ? " (unsicher)" : ""}.
                  </span>
                  {diff && (
                    <button
                      type="button"
                      className="ft-btn ft-btn-text"
                      onClick={() => {
                        if (dim === "unterton" || dim === "chroma") onAdoptDerived(dim, est.value);
                        else onAdoptAnswer(dim, est.value);
                      }}
                    >
                      Antwort übernehmen
                    </button>
                  )}
                </div>
              );
            })}
          </div>
          <p className="ft-ai-disclaimer">Achtung: nach unserer Erfahrung irrt sich die KI häufig, besonders bei Mischtypen. Deine eigene Einschätzung zählt im Zweifelsfall.</p>
          <button type="button" className="ft-btn ft-btn-text" onClick={() => setAiResult(null)}>
            Zweitmeinung entfernen
          </button>
        </React.Fragment>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 4: Chroma (Konzept-Abschnitt 5.6) + Ergebnis-Ranking + KI-Zweitmeinung
// ══════════════════════════════════════════════════════════

// Dynamischer Erklaertext fuer das Chroma: benennt Teint- und Augen-Wahl und
// begruendet das resultierende Chroma (Bereich, ggf. Mischergebnis).
// Berechnetes und manuelles Ergebnis getrennt dargestellt, analog zu
// buildKontrastErklaerung/buildUntertonErklaerung (Adina: an die Kontrast-
// Ergebnisseite angleichen, "gilt jetzt auch fuer Chroma").
// Deklinierte Adjektivform je Chroma-Stufe, fuer "X softe(n) Farbe(n)".
// Dativ nach "in" -> Adjektiv braucht die -n-Endung ("in 2 soften Farben"),
// egal ob Singular oder Plural.
const CHROMA_LEVEL_ADJ = { soft: "soften", mittel: "mittleren", klar: "klaren" };
// Fuer "ein ___es Chroma" (Chroma ist sprachlich neutral, Nominativ/Akkusativ
// nach "ein" -> -es-Endung), separate Wortliste statt CHROMA_LEVEL_ADJ.
const CHROMA_LEVEL_ADJ_ES = { soft: "softes", mittel: "mittleres", klar: "klares" };

function buildChromaErklaerung(answers, P_C, overrideLevels) {
  const rangeAdj = rangeLabel(dominantLevels(P_C, Config.CHROMA_LEVELS), Config.CHROMA_LEVELS, CHROMA_LEVEL_ADJ_ES, P_C);
  const overrideRange = overrideLevels && rangeLabel(overrideLevels, Config.CHROMA_LEVELS, CHROMA_LABELS);

  // Farbduell-Zusammenfassung analog zu buildUntertonErklaerung (Adina: "so
  // wie der Text auf der Unterton-Seite") - zaehlt, wie oft jede Chroma-
  // Stufe unter den Favoriten der 3 Value-Duelle war (eine 2er-Auswahl
  // zaehlt fuer beide gewaehlten Stufen).
  const rows = (answers.chroma_teint_rows || []).filter((r) => Array.isArray(r) && r.length);
  let teintSatz = null;
  if (rows.length) {
    const counts = { soft: 0, mittel: 0, klar: 0 };
    for (const r of rows) for (const level of r) counts[level]++;
    const levels = Config.CHROMA_LEVELS.filter((l) => counts[l] > 0);
    const totalCount = levels.reduce((sum, l) => sum + counts[l], 0);
    // Nur EIN "Farbe(n)" am Ende statt nach jeder Stufe (Adina: "3 soften
    // und 2 mittleren Farben" statt "3 soften Farben und 2 mittleren Farben").
    const parts = levels.map((l) => `${counts[l]} ${CHROMA_LEVEL_ADJ[l]}`);
    const farbTeil = listeMitUnd(parts) + (totalCount === 1 ? " Farbe" : " Farben");
    teintSatz = (
      <>
        Bei den Farbduellen hat dir dein Gesicht am besten in <strong>{farbTeil}</strong> gefallen.{" "}
      </>
    );
  }
  const augen = rangeLabel(answers.chroma_augen, Config.CHROMA_LEVELS, CHROMA_LABELS);
  return (
    <>
      {teintSatz}
      {augen && (
        <>
          Du hast das Chroma deiner Augenfarbe als <strong>{augen}</strong> eingestuft.{" "}
        </>
      )}
      In Summe ergibt sich daraus ein <strong>{rangeAdj}</strong> Chroma.
      {answers.chroma_augen_braunschwarz && (
        <>
          {" "}
          Da deine Augen <strong>braunschwarz</strong> sind, liefern sie kein Chroma-Indiz – es zählt nur dein
          Teint.
        </>
      )}
      {overrideRange && (
        <>
          {" "}
          Manuell überschrieben: Chroma im Bereich <strong>{overrideRange}</strong>.
        </>
      )}
    </>
  );
}

// Auf QuizScreen-Einzelschritte umgebaut (analog Stufe2Kontrast/Stufe3Unterton):
// Steps 1-3 Chroma-Teint-Duelle (Hell/Mittel/Tief), Step 4 Chroma
// Augenfarbe, Step 5 Ergebnis.
function Stufe4Chroma({ toolData, onUpdate, onGotoStufe }) {
  const ft = toolData.farbtyp_test;
  const answers = { ...emptyAnswers(), ...(ft?.answers || {}) };
  const overrides = ft?.overrides || { kontrast: null, unterton: null, chroma: null };
  const photos = toolData.photos || {};
  const { patchAnswer, patchChromaRow, setOverride } = farbtypActions(onUpdate);
  const [step, setStep] = useChapterStep(toolData, onUpdate, "chroma");

  // Chroma-Teint-Palette haengt vom bereits bestimmten Unterton ab (Adina):
  // manueller Override hat Vorrang vor dem berechneten Unterton, sonst
  // wuerde die Palette nicht mitziehen, wenn die TN ihr Unterton-Ergebnis
  // von Hand ueberschrieben hat.
  const P_U_forPalette = useMemo(() => Model.deriveUnterton(answers), [answers]);
  const untertonOverride = overrides.unterton ? (Array.isArray(overrides.unterton) ? overrides.unterton : [overrides.unterton]) : null;
  const untertonTop = untertonOverride ? untertonOverride[0] : topOf(P_U_forPalette);
  const chromaPalette = chromaPaletteForUnterton(untertonTop);

  const P_C = useMemo(() => Model.deriveChroma(answers), [answers]);
  const chromaRowsReady = (answers.chroma_teint_rows || []).every((r) => Array.isArray(r) && r.length > 0);
  const ready = chromaRowsReady && answers.chroma_augen.length > 0;
  const overrideLevels = overrides.chroma ? (Array.isArray(overrides.chroma) ? overrides.chroma : [overrides.chroma]) : null;
  const shownLevels = overrideLevels || dominantLevels(P_C, Config.CHROMA_LEVELS);
  const shownDist = overrideLevels ? undefined : P_C;
  const [editing, setEditing] = useState(false);
  const [showConfirm, setShowConfirm] = useState(false);

  const eyebrow = "Chroma bestimmen";
  const goBack = () => (step === 0 ? (startChapterAtEnd(onUpdate, 3), onGotoStufe(3)) : setStep(step - 1));
  const goNext = () => setStep(step + 1);

  if (step === 0) {
    return (
      <QuizScreen screenKey={step} eyebrow={eyebrow} title="Als Nächstes: dein Chroma" onBack={goBack} onNext={goNext} nextDisabled={false} nextIcon="arrow">
        <p className="ft-muted">
          Nachdem wir jetzt sowohl dein <strong>Kontrastlevel</strong> als auch deinen <strong>Unterton</strong>{" "}
          bestimmt haben, bestimmen wir als Letztes dein <strong>Chroma</strong>. Das Chroma steht für die{" "}
          <strong>Brillanz der Farben</strong> in deinem Gesicht.
        </p>
        <p className="ft-muted">
          Sind die Farben deines Teints, deiner Lippen und deiner Augen <strong>klar und leuchtend</strong> – ohne
          einen Hauch von Grau – hast du ein <strong>klares Chroma</strong> (hohe Sättigung). Passend zu deinem
          Gesicht werden dir <strong>leuchtende, pure Farben</strong> stehen.
        </p>
        <p className="ft-muted">
          Wirken die Farben deiner Gesichtsmerkmale eher, als wären sie etwas mit einem{" "}
          <strong>Aschepuder mattiert</strong>, hast du ein <strong>softes Chroma</strong> (niedrige Sättigung).
          Knallige Farben würden deine Erscheinung <strong>überwältigen</strong> anstatt sie hervorzuheben.
          Dafür siehst du in <strong>gedeckten, pudrigen Tönen</strong> – die dein Chroma aufgreifen – edel aus.
        </p>
        <p className="ft-muted">
          Das ist der <strong>letzte Schritt</strong>, bevor wir deinen wahrscheinlichsten{" "}
          <strong>Farbtypen</strong> erhalten.
        </p>
        <p className="ft-muted" style={{ marginTop: 18 }}>
          <strong>Los geht’s!</strong>
        </p>
      </QuizScreen>
    );
  }

  // Steps 1-3: die 3 Chroma-Teint-Duelle (Value-Stufen Hell/Mittel/Tief),
  // analog zu den Draping-Duellen bei Unterton (Adina, 09.09.2026).
  if (step >= 1 && step <= 3) {
    const rowIndex = step - 1;
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Chroma bestimmen: Teint"
        title={`Teint: Vergleich ${step} von 3`}
        tips={[
          <>
            Wähle die <strong>1-2 Farben</strong>, die mit deinem Teint intuitiv <strong>am besten harmonieren</strong>.
          </>,
        ]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={!(answers.chroma_teint_rows[rowIndex] || []).length}
      >
        <ChromaRowDuel
          photoUrl={photos.gesicht_vorne?.url}
          framing={photos.gesicht_vorne_zugeschnitten}
          colors={chromaPalette[rowIndex]}
          rowValue={answers.chroma_teint_rows[rowIndex]}
          onPick={(v) => patchChromaRow(rowIndex, v)}
        />
      </QuizScreen>
    );
  }

  if (step === 4) {
    return (
      <QuizScreen
        screenKey={step}
        eyebrow="Chroma bestimmen: Augenfarbe"
        title="Augenfarbe: Wie viel Grau enthält deine Augenfarbe?"
        tips={[
          <>
            Platziere das Bild mit deiner <strong>Augenfarbe</strong> dort, wo es am ehesten mit dem oder den{" "}
            <strong>Beispielbild(ern)</strong> in der unmittelbaren Umgebung übereinstimmt.
          </>,
          <>
            <strong>Soft</strong> bedeutet, dass die Augenfarbe einen <strong>höheren Grauanteil</strong> hat.{" "}
            <strong>Klar</strong> bedeutet, dass <strong>keine Spur von Grau</strong> enthalten ist.
          </>,
          <>
            Wenn deine Augen <strong>braunschwarz</strong> sind, klicke hier und dann weiter.
          </>,
        ]}
        onBack={goBack}
        onNext={goNext}
        nextDisabled={!answers.chroma_augen_braunschwarz && answers.chroma_augen.length === 0}
      >
        {/* Braunschwarze Augen liefern kein Chroma-Indiz (Adina) - Checkbox
            ueberspringt die Frage, dann zaehlt nur der Teint (siehe
            deriveChroma-Fallback fuer leeres chroma_augen). Setzt eine
            evtl. schon gewaehlte Slider-Antwort gleichzeitig zurueck, damit
            sie nicht widerspruechlich stehen bleibt. Direkt unter Tipp 03
            und ueber dem Auswahlbild (Adina). */}
        <Checkbox
          label="Meine Augen sind braunschwarz."
          checked={answers.chroma_augen_braunschwarz}
          onChange={(v) => {
            patchAnswer("chroma_augen_braunschwarz", v);
            if (v) patchAnswer("chroma_augen", []);
          }}
          accent
          className="ft-chroma-augen-checkbox"
        />
        {/* Statt Hilfe-Accordion (Adina-Wunsch): die Entscheidungshilfe steht
            jetzt direkt als Spalten-Beschriftung ueber dem Vergleichsbild,
            eine Spalte pro Chroma-Stufe (CHROMA_LEVELS-Reihenfolge: soft,
            mittel, klar - identisch mit CHROMA_AUGEN_SPRITE.segments). Nur
            noch die Ueberschrift (Soft/Mittel/Klar) - die Augenfarben-
            beschreibungen darunter braucht es nicht mehr (Adina, 13.09.2026):
            durch das 4x3-Raster + Helligkeits-Regler zeigen die Beispielbilder
            selbst schon genug Varianten, Text war redundant.
            Jede Ueberschrift ist absolut auf cx/w der ZUGEHOERIGEN
            CHROMA_AUGEN_SPRITE-Box positioniert (dieselben Werte, die auch
            den Auswahlkasten auf dem Bild darunter setzen - Adina: "in diese
            Box zentrieren") statt einer unabhaengigen Grid-Spalte, die bei
            Kalibrierungen sonst wieder auseinanderlaufen wuerde. */}
        <div className="ft-chroma-augen-labels">
          {CHROMA_AUGEN_SPRITE.boxes.map((b, i) => (
            <strong key={CHROMA_AUGEN_SPRITE.segments[i].value} style={{ left: `${b.cx}%`, width: `${b.w}%` }}>
              {CHROMA_AUGEN_SPRITE.segments[i].label}
            </strong>
          ))}
        </div>
        <div className={"ft-chroma-augen-slider" + (answers.chroma_augen_braunschwarz ? " is-dimmed" : "")}>
          <ComparisonSlider
            sprite={CHROMA_AUGEN_SPRITE}
            photoUrl={photos.augen_zugeschnitten?.url || photos.augen?.url}
            value={answers.chroma_augen}
            onChange={(v) => !answers.chroma_augen_braunschwarz && patchAnswer("chroma_augen", v)}
            posValue={answers.chroma_augen_pos}
            onPosChange={(p) => patchAnswer("chroma_augen_pos", p)}
            thumbSize={76}
          />
        </div>
      </QuizScreen>
    );
  }

  // step === 5: Ergebnis-Box. Bewusst OHNE Eyebrow/Titel/Tips, analog zur
  // Kontrastlevel-Ergebnisseite (Adina: "anpassen an die Ergebnisseite vom
  // Kontrast", gilt jetzt auch fuer Chroma). Letzter Schritt des Kapitels -
  // das fruehere "noch ein paar letzte Vergleiche"-Platzhalter ist jetzt Teil
  // des Ergebnis-KAPITELS (Stufe5Ergebnis), nicht mehr hier (Adina,
  // 14.09.2026: "diese Seiten bitte in das Kapitel Ergebnis rutschen").
  return (
    <QuizScreen screenKey={step} onBack={goBack} onNext={() => { startChapterAtBeginning(onUpdate, "vergleicheSub"); onGotoStufe(5); }} nextDisabled={!ready}>
      <section className="ft-result ft-result-standalone">
        <div className="ft-result-badge">Ergebnis</div>
        <p className="ft-result-headline">
          {overrideLevels ? (
            <>
              Dein Chroma manuell gesetzt: <strong>{rangeLabel(overrideLevels, Config.CHROMA_LEVELS, CHROMA_LABELS)}</strong>
            </>
          ) : (
            <>
              Dein Chroma ist wahrscheinlich{" "}
              <strong>{rangeLabel(shownLevels, Config.CHROMA_LEVELS, CHROMA_LABELS, shownDist)}</strong>.
            </>
          )}
        </p>
        <p className="ft-result-explain">{buildChromaErklaerung(answers, P_C, overrideLevels)}</p>
        <div className="ft-result-edit">
          <p className="ft-muted ft-muted-sm">
            Dieses Ergebnis basiert auf deiner Einordnung von <strong>Teint und Augenfarbe</strong>. Dein Ergebnis
            ist nicht stimmig? Du kannst das Ergebnis nachfolgend <strong>manuell überschreiben</strong>, bevor du
            weiterklickst.
          </p>
          <div className="ft-result-slider">
            <ComparisonSlider
              sprite={CHROMA_TEINT_SPRITE}
              photoUrl={photos.gesicht_vorne?.url}
              framing={photos.gesicht_vorne_zugeschnitten}
              value={shownLevels}
              onChange={(v) => setOverride("chroma", v.length ? v : null)}
              thumbSize={76}
              maxWidth={460}
              locked={!editing}
              onLockedInteraction={() => setShowConfirm(true)}
            />
            {showConfirm && !editing && (
              <div className="ft-result-tip" role="dialog">
                <p>
                  Die Vergleichsbilder zeigen nur <strong>ein mögliches Beispiel</strong> pro Chroma-Stufe. Die
                  Umrandung ist das <strong>Ergebnis deiner Angaben</strong> zu Teint und Augenfarbe. Bist du sicher,
                  dass du dieses Ergebnis trotzdem <strong>manuell überschreiben</strong> möchtest?
                </p>
                <div className="ft-result-tip-actions">
                  <button type="button" className="ft-btn ft-btn-text" onClick={() => setShowConfirm(false)}>
                    Abbrechen
                  </button>
                  <button
                    type="button"
                    className="ft-btn ft-btn-primary"
                    onClick={() => {
                      setEditing(true);
                      setShowConfirm(false);
                    }}
                  >
                    Ja, überschreiben.
                  </button>
                </div>
              </div>
            )}
          </div>
          {overrideLevels && (
            <button
              type="button"
              className="ft-btn-text ft-result-reset"
              onClick={() => {
                setOverride("chroma", null);
                setEditing(false);
              }}
            >
              Zurücksetzen (auf berechnetes Ergebnis)
            </button>
          )}
        </div>
      </section>
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 5: Ergebnis - Zusammenfassung der drei Dimensionen + Farbtyp-Ranking
// + KI-Zweitmeinung (vorher am Ende von Schritt 4/Chroma).
// ══════════════════════════════════════════════════════════

// Positionen der 12 Farbtypen auf dem Farbkreis-Bild (% der Flaeche). Im
// Kalibrier-Modus (?calib=1) per Maus verschiebbar, Werte-Readout darunter.
const FARBKREIS_POS = {
  "heller-fruehling": { x: 15.8, y: 7 },
  "klarer-fruehling": { x: 0.2, y: 22.6 },
  "warmer-fruehling": { x: -3.1, y: 40.6 },
  "heller-sommer": { x: 87.1, y: 6.7 },
  "softer-sommer": { x: 94.5, y: 22 },
  "kuehler-sommer": { x: 102.9, y: 38 },
  "softer-herbst": { x: 7.9, y: 76 },
  "warmer-herbst": { x: -3.1, y: 59.4 },
  "tiefer-herbst": { x: 15.8, y: 91.8 },
  "klarer-winter": { x: 96.7, y: 72.9 },
  "kuehler-winter": { x: 99.8, y: 57 },
  "tiefer-winter": { x: 83.6, y: 91.7 },
};

// Adjektive fuer den Ergebnistext "Dir stehen vor allem ... Farben"
// (FarbkreisErgebnis) - eigene Wortwahl/Grammatik (Plural vor "Farben"),
// bewusst getrennt von KONTRAST_LEVEL_ADJ/UNTERTON_LEVEL_ADJ/
// CHROMA_LEVEL_ADJ_ES weiter unten (andere Satzbauten/Genus). Schluessel
// kommen NICHT aus den rohen K/U/C-Werten der TN, sondern aus dem Modus
// (einfache Mehrheit) der Typ-Koordinaten aller Typen ab 10% der finalen,
// vergleiche-gewichteten Rangliste - siehe modeAxis() in FarbkreisErgebnis
// (Adina, 16.09.2026: "was aktuell noch fehlt... die aktuelle Prio
// beachten, nicht das was vor den Vergleichen rauskam"; "absolute Mehrheit
// finde ich gerade gut" statt einer echten Dominanzerkennung aus den
// Testantworten - das waere "einen Schritt zu weit" fuer den aktuellen
// Stand). Kontrast hat nur 3 Adjektive fuer 4 Stufen: hoch UND sehr-hoch
// -> "tiefe".
const FARBEN_CHROMA_ADJ = { soft: "sanfte", mittel: "zart leuchtende", klar: "leuchtende" };
const FARBEN_UNTERTON_ADJ = { kuehl: "kühle", "kuehl-neutral": "kühl-neutrale", "warm-neutral": "warm-neutrale", warm: "warme" };
const FARBEN_KONTRAST_ADJ = { niedrig: "helle", mittel: "mitteltiefe", hoch: "tiefe", "sehr-hoch": "tiefe" };

// Kein Berechnungs-Spinner mehr (Adina, 16.09.2026: "keine Berechnungs-
// sekunden mehr"): der Kreis ist von Anfang an sichtbar, der Button sitzt
// darueber. Hochzaehlen + Plop-Animation sind aber wie vor dem Umbau
// geblieben ("Optik sonst genau wie vorher"), nur die Prio-Logik dahinter
// hat sich geaendert: senfgelbe Umrandung (is-top-Klasse), Plop und
// Haekchen bekommt jetzt jeder Typ ab 10% Score statt vormals nur der
// Spitzenreiter - siehe defaultTestSelection fuer dieselbe 10%-Schwelle
// bei der Vorauswahl.
function FarbkreisErgebnis({ ranking, selected, onToggle, topType, candidates, alreadyRevealed, onReveal }) {
  const calib = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("calib") === "1";
  // Kein Berechnungs-Spinner mehr vor dem Hochzaehlen (Adina, 16.09.2026:
  // "keine Berechnungssekunden mehr") - Hochzaehlen bleibt wie zuvor. Der
  // Plop einer Box laeuft aber nicht mehr erst NACH dem Hochzaehlen ALLER
  // Boxen in einer separaten Staffel-Sequenz, sondern direkt in dem Frame,
  // in dem die jeweilige Box ihr eigenes Ziel erreicht - auch waehrend
  // andere (hoehere) Boxen noch weiterzaehlen (Adina, 16.09.2026: "nicht am
  // Ende nachdem alle hochgezaehlt sind, sondern nachdem eine Box fertig
  // ist schon ploppen").
  // Kam man schon einmal (mit unveraendertem Ranking) hierher, startet die
  // Karte direkt in "done" - kein erneutes Klicken/keine Animation noetig
  // (Adina, 16.09.2026: "Button geklickt bleiben, Ergebnis sichtbar, damit
  // ich nicht immer neu klicken muss").
  const [phase, setPhase] = useState(calib || alreadyRevealed ? "done" : "idle"); // idle -> reveal (Countup, Plop pro Box) -> done
  const [elapsed, setElapsed] = useState(0);
  const [pos, setPos] = useState(FARBKREIS_POS);
  // Kurzes "Schuetteln" auf der angeklickten Box, wenn eine 7. Palette
  // ausgewaehlt werden soll (Adina, 16.09.2026: "Auswahl nicht moeglich"-
  // Feedback statt stillem No-Op).
  const [shakeId, setShakeId] = useState(null);
  const stageRef = useRef(null);
  const dragRef = useRef(null);
  const rafRef = useRef(null);
  const shakeTimeoutRef = useRef(null);

  const PER_PCT_MS = 45; // minimal schneller als zuvor (Adina, 16.09.2026)

  const probById = {};
  (ranking.full || []).forEach((r) => (probById[r.type] = r.probability));
  const maxPct = Math.max(...Config.FARBTYPEN.map((t) => Math.round((probById[t.id] || 0) * 100)), 1);
  // Anzahl der Typen ab 10% Score - fuer den Schlussabsatz ("Deine X besten
  // Farbpaletten haben wir schon vorausgewaehlt") statt selected.length, da
  // sich die manuelle Auswahl davon loesen kann (Adina, 16.09.2026: "Anzahl
  // = Anzahl oben mind. 10% habende Typen").
  const qualifyingCount = (ranking.result || []).filter((r) => r.probability >= 0.1).length;
  // "Dir stehen vor allem ... Farben": einfache Mehrheit (Modus) je Achse
  // ueber die Typ-Koordinaten aller Typen ab 10% (finale, vergleiche-
  // gewichtete Rangliste - NICHT die rohen K/U/C-Werte von vor den
  // Vergleichen) statt eines echten "dominanten Merkmals" aus den
  // Testantworten selbst (Adina, 16.09.2026: "das ist wohl einen Schritt zu
  // weit gerade fuer das was wir haben... deshalb finde ich absolute
  // Mehrheit gerade gut"). Bei Gleichstand gewinnt der Wert des staerkeren
  // Typs (ranking.result ist nach Wahrscheinlichkeit sortiert).
  const qualifyingTypes = (ranking.result || [])
    .filter((r) => r.probability >= 0.1)
    .map((r) => Config.FARBTYPEN_BY_ID[r.type])
    .filter(Boolean);
  // getValues liefert je Typ ein Array (nicht nur einen Einzelwert) - bei
  // Chroma/Unterton hat jeder Typ genau einen Koordinatenwert, bei Kontrast
  // BEIDE Werte aus dem Zweier-Array (z.B. Softer Sommer "hoch" UND
  // "mittel" zaehlen beide mit), damit die komplette Bandbreite jedes Typs
  // in die Mehrheitswahl einfliesst statt nur des Primaerwerts (Adina,
  // 16.09.2026: "nicht nur Primaerwert, sondern die komplette
  // Beschreibung").
  function modeAxis(getValues) {
    const counts = new Map();
    qualifyingTypes.forEach((t) => {
      getValues(t).forEach((v) => counts.set(v, (counts.get(v) || 0) + 1));
    });
    let best = null;
    let bestCount = -1;
    qualifyingTypes.forEach((t) => {
      getValues(t).forEach((v) => {
        const c = counts.get(v);
        if (c > bestCount) {
          bestCount = c;
          best = v;
        }
      });
    });
    return best;
  }
  const chromaMode = modeAxis((t) => [t.koordinaten.chroma]);
  const untertonMode = modeAxis((t) => [t.koordinaten.unterton]);
  const kontrastMode = modeAxis((t) => t.koordinaten.kontrast);

  function startCountUp() {
    const t0 = performance.now();
    const total = maxPct * PER_PCT_MS;
    const tick = () => {
      const e = performance.now() - t0;
      setElapsed(e);
      if (e < total) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        setElapsed(total);
        setPhase("done");
      }
    };
    rafRef.current = requestAnimationFrame(tick);
  }
  function startReveal() {
    if (onReveal) onReveal();
    setPhase("reveal");
    startCountUp();
  }
  useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
  useEffect(() => () => clearTimeout(shakeTimeoutRef.current), []);

  function tryToggle(id, isSel) {
    if (!isSel && selected.length >= MAX_TEST_SELECTION) {
      setShakeId(id);
      clearTimeout(shakeTimeoutRef.current);
      shakeTimeoutRef.current = setTimeout(() => setShakeId((cur) => (cur === id ? null : cur)), 400);
      return;
    }
    onToggle(id);
  }

  function calibDown(e, id) {
    e.preventDefault();
    e.stopPropagation();
    e.currentTarget.setPointerCapture(e.pointerId);
    dragRef.current = { id };
  }
  function calibMove(e) {
    if (!dragRef.current) return;
    const r = stageRef.current.getBoundingClientRect();
    const x = Math.max(-14, Math.min(114, ((e.clientX - r.left) / r.width) * 100));
    const y = Math.max(-6, Math.min(106, ((e.clientY - r.top) / r.height) * 100));
    setPos((prev) => ({ ...prev, [dragRef.current.id]: { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 } }));
  }
  function calibUp() {
    dragRef.current = null;
  }

  const showBadges = calib || phase === "reveal" || phase === "done";
  const done = phase === "done";

  return (
    <div className="ft-kreis">
      <div className="ft-kreis-actions">
        <button type="button" className="ft-btn ft-btn-primary" onClick={startReveal} disabled={phase !== "idle"}>
          Farbtyp anzeigen
        </button>
      </div>
      <div className="ft-kreis-stage" ref={stageRef}>
        <img src={spritePath("Farbkreis_freigestellt_web.webp")} alt="Farbkreis der 12 Farbtypen" decoding="async" draggable={false} />
        {showBadges &&
          Config.FARBTYPEN.map((typ) => {
            const p = pos[typ.id];
            if (!p) return null;
            const target = Math.round((probById[typ.id] || 0) * 100);
            // phase "done" ohne durchlaufene Animation (alreadyRevealed) soll
            // sofort den Zielwert zeigen, nicht von einem nie gestarteten
            // elapsed-Timer abhaengen.
            const cur = calib || phase === "done" ? target : Math.min(target, Math.floor(elapsed / PER_PCT_MS));
            // Fette Umrandung + Plop haengen NUR an der 10%-Schwelle, nie am
            // (separaten) Auswahl-Status - Adina-Bugreport 16.09.2026: mit
            // is-selected verdrahtet hatte ein Klick frueher gesehene,
            // veraltet gespeicherte test_selection-Eintraege weiterhin
            // sichtbar hervorgehoben, obwohl ihr Score laengst unter 10% lag.
            // Manuelles An-/Abwaehlen (onToggle) bekommt aber eine eigene,
            // DUENNE Umrandung + Haekchen zurueck (Adina, 16.09.2026: "das
            // Anklicken weiterer Boxen fuehrt aktuell nicht zum Umranden und
            // Haken setzen") - is-selected bleibt optisch klar schwaecher
            // als is-top (kein Box-Shadow-Ring).
            const qualifies = (probById[typ.id] || 0) >= 0.1;
            const isSel = selected.includes(typ.id);
            // Ploppt genau ab dem Frame, in dem cur sein eigenes Ziel
            // erreicht (statt einer nachgelagerten Staffel-Sequenz) - die
            // Klasse bleibt danach unveraendert bestehen, die CSS-Animation
            // spielt dadurch nur einmal ab, nicht bei jedem weiteren Tick.
            const popped = qualifies && cur >= target && target > 0;
            return (
              <button
                key={typ.id}
                type="button"
                className={
                  "ft-kreis-badge" +
                  (qualifies ? " is-top" : "") +
                  (isSel ? " is-selected" : "") +
                  (target === 0 ? " is-zero" : "") +
                  (calib ? " is-calib" : "") +
                  (popped ? " is-pop" : "") +
                  (shakeId === typ.id ? " is-shake" : "")
                }
                style={{ left: p.x + "%", top: p.y + "%" }}
                onClick={() => (calib || !done ? null : tryToggle(typ.id, isSel))}
                onPointerDown={calib ? (e) => calibDown(e, typ.id) : undefined}
                onPointerMove={calib ? calibMove : undefined}
                onPointerUp={calib ? calibUp : undefined}
                title={typ.name}
              >
                <span className="ft-kreis-badge-pct">{cur}%</span>
                <span className="ft-kreis-badge-bar">
                  <span style={{ width: Math.round((cur / maxPct) * 100) + "%" }} />
                </span>
                {(qualifies || isSel) && <span className="ft-kreis-badge-check"><CheckIcon size={10} /></span>}
              </button>
            );
          })}
      </div>

      {done && (
        <div className="ft-kreis-result">
          <h3 className="ft-result-heading">Ergebnis:</h3>
          <p className="ft-muted">
            Du bist wahrscheinlich ein{" "}
            <strong>
              {topType ? topType.name : "unbekannter Typ"} Typ
              {ranking.result[0] ? ` (${Math.round(ranking.result[0].probability * 100)}%)` : ""}
            </strong>
            .
            {candidates.length > 1 && (
              <React.Fragment>
                <br />
                Auch möglich:{" "}
                {candidates
                  .slice(1)
                  .map((r) => Config.FARBTYPEN_BY_ID[r.type]?.name)
                  .filter(Boolean)
                  .map((name, i, arr) => (
                    <React.Fragment key={name}>
                      <strong>{name}</strong>
                      {i < arr.length - 1 ? " oder " : "."}
                    </React.Fragment>
                  ))}
              </React.Fragment>
            )}
          </p>
          <p className="ft-muted" style={{ marginTop: 12 }}>
            Dir stehen vor allem {FARBEN_CHROMA_ADJ[chromaMode]}, {FARBEN_UNTERTON_ADJ[untertonMode]},{" "}
            {FARBEN_KONTRAST_ADJ[kontrastMode]} Farben.
          </p>
          <p className="ft-muted" style={{ marginTop: 12 }}>
            Im nächsten Schritt kannst du die <strong>Farbpaletten</strong> verschiedener Farbtypen{" "}
            <strong>an dir testen</strong>. Deine <strong>{qualifyingCount} besten Farbpaletten</strong> haben wir
            schon <strong>vorausgewählt</strong>.
          </p>
          <p className="ft-muted" style={{ marginTop: 12 }}>
            Möchtest du <strong>weitere ausprobieren</strong>, <strong>klicke</strong> einfach auf die entsprechende{" "}
            <strong>Box neben dem Farbtyp</strong>.
          </p>
        </div>
      )}

      {calib && (
        <pre className="ft-calib-readout">
          {"FARBKREIS_POS = " + JSON.stringify(Object.fromEntries(Config.FARBTYPEN.map((t) => [t.id, pos[t.id]])), null, 1)}
        </pre>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════
// "Ein paar letzte Vergleiche" (Stufe 5, Schritt 0)
// ══════════════════════════════════════════════════════════
// Digitalisierte Buch-Daten (Farbtest 2 gut/2 schwierig/1 nicht so gut,
// entgegengesetzte Farben, "beste Farben"-Zeilen inkl. Hautton-Hinweis) -
// siehe Farbtypbestimmung/lib/farbtyp-vergleiche-data.js.
const VergleicheData = window.MW.FarbtypVergleicheData ? window.MW.FarbtypVergleicheData.VERGLEICHE_DATA : {};

// Zwischentypen: zusaetzliche Farbpaletten zwischen zwei der 12 Haupttypen
// (Adina, 16.09.2026) - siehe Farbtypbestimmung/lib/farbtyp-zwischentypen-data.js.
// Werden NICHT ins Ranking-Modell aufgenommen (Config.FARBTYPEN bleibt exakt
// 12), sondern nur zusaetzlich zur Auswahl angeboten, wenn ihr baseTypeId im
// "Top-Set" (Score >= 10%) auftaucht - siehe zwischentypenForTopSet() und
// SchrittAuswahl. resolveTyp() ist der einzige Ort, an dem eine test_selection-
// Id sowohl einen der 12 Haupttypen als auch einen Zwischentyp treffen kann;
// alle Downstream-Schritte (Foto-Test, Vergleich, Palette), die eine
// test_selection-Id in ein Typ-Objekt aufloesen, nutzen deshalb resolveTyp()
// statt direkt Config.FARBTYPEN_BY_ID.
const ZwischentypenData = window.MW.FarbtypZwischentypenData ? window.MW.FarbtypZwischentypenData.ZWISCHENTYPEN_BY_ID : {};
const ZWISCHENTYPEN = window.MW.FarbtypZwischentypenData ? window.MW.FarbtypZwischentypenData.ZWISCHENTYPEN : [];
function resolveTyp(id) {
  return Config.FARBTYPEN_BY_ID[id] || ZwischentypenData[id];
}
function zwischentypenForTopSet(topSetIds) {
  return ZWISCHENTYPEN.filter((zt) => topSetIds.includes(zt.baseTypeId));
}

// Kurzwort je Modifikator-Praefix aus dem Dateinamen (z.B. "Heller_softer_
// sommer" -> Modifikator "heller") + welche Achse er betrifft, fuer den
// Zwischentyp-Kartentext (Adina, 16.09.2026: "sanfte, kuehl-neutrale Toene,
// die etwas heller sind als der klassische Soft Summer - passend zu deinem
// Kontrastlevel").
const ZWISCHENTYP_MODIFIER = {
  heller: { adj: "heller", achse: "Kontrastlevel" },
  klarer: { adj: "leuchtender", achse: "Chroma" },
  softer: { adj: "sanfter", achse: "Chroma" },
  waermer: { adj: "wärmer", achse: "Unterton" },
  kuehler: { adj: "kühler", achse: "Unterton" },
};
const ZWISCHENTYP_ACHSE_LABEL = { Kontrastlevel: "deinem Kontrastlevel", Chroma: "deinem Chroma", Unterton: "deinem Unterton" };
function zwischentypDescription(zt) {
  const base = Config.FARBTYPEN_BY_ID[zt.baseTypeId];
  const mods = (zt.modifiers || []).map((m) => ZWISCHENTYP_MODIFIER[m]).filter(Boolean);
  if (!base || !mods.length) return null;
  // Die vollen (nicht die KURZ-) Adjektivformen, weil sie hier attributiv vor
  // "Toene" stehen und die Plural-"-e"-Endung brauchen ("sanfte Toene", nicht
  // "sanft Toene" wie in der unflektierten Podium-Kurzform).
  const chromaAdj = FARBEN_CHROMA_ADJ[base.koordinaten.chroma];
  const untertonAdj = FARBEN_UNTERTON_ADJ[base.koordinaten.unterton];
  const achsen = Array.from(new Set(mods.map((m) => m.achse))).map((a) => ZWISCHENTYP_ACHSE_LABEL[a]);
  // "der klassische Softer Sommer" ist grammatisch falsch (Softer/Sommer
  // stehen als Eigenname in starker Form, nach "der klassische" braeuchte
  // ein echtes Adjektiv die schwache Endung "-e") - Name deshalb als Typ-
  // Titel in Anfuehrungszeichen statt dekliniert (Adina-Bugreport 16.09.2026).
  // Kein Fetten mehr hier (Adina, 16.09.2026: "bitte nichts highlighten").
  return (
    <>
      {chromaAdj}, {untertonAdj} Töne, die etwas {listeMitUnd(mods.map((m) => m.adj))} sind als der klassische Typ „
      {base.name}“ – passend zu {listeMitUnd(achsen)}.
    </>
  );
}

// Topologie: Spitzenreiter gegen alle Typen >7%, plus das eine Top-3-Paar,
// das der Stern sonst ausliesse (Rang 2 vs. Rang 3) - bei n Kandidaten immer
// genau n Duelle (Adina, 14.09.2026, siehe Konzept-Chat). Weniger als 2
// Kandidaten >7% -> keine sinnvollen Duelle, nur der Plausi-Check.
function computeVergleicheTopology(rankingFull) {
  const candidates = (rankingFull || []).filter((r) => r.probability > 0.07).map((r) => r.type);
  if (candidates.length < 2) return { leader: candidates[0] || null, duels: [] };
  const leader = candidates[0];
  const duels = candidates.slice(1).map((id) => ({ a: leader, b: id }));
  if (candidates.length >= 3) duels.push({ a: candidates[1], b: candidates[2] });
  // Reihenfolge der Duelle: vom "deutlichsten" Vergleich (groesster Prozent-
  // Abstand zwischen den beiden Typen) zum "Closest Call" (kleinster Abstand)
  // - Adina, 16.09.2026: "vom am weitesten entfernten Pärchen zum nächsten
  // ranrobben". Aendert nur die Praesentationsreihenfolge, nicht die
  // Paarung/Anzahl der Duelle.
  const probByType = {};
  (rankingFull || []).forEach((r) => (probByType[r.type] = r.probability));
  duels.sort((x, y) => Math.abs(probByType[y.a] - probByType[y.b]) - Math.abs(probByType[x.a] - probByType[x.b]));
  return { leader, duels };
}

function dedupeVergleicheColors(colorLists) {
  const seen = new Set();
  const out = [];
  colorLists.flat().forEach((c) => {
    if (!seen.has(c.name)) {
      seen.add(c.name);
      out.push(c);
    }
  });
  return out;
}

// Erkennt das Muster "<Hautton-Stufe> oder dunklere/hellere Hauttoene" aus
// den secNotes-Zusaetzen (bisher nur Klarer Fruehling + Warmer Fruehling,
// siehe VERGLEICHE_DATA). Kein Zusatz oder unbekanntes Muster -> immer
// zeigen (sicherheitshalber nicht faelschlich etwas ausblenden).
function meetsHauttonCondition(qualifierText, hautton) {
  if (!qualifierText || !hautton) return true;
  const text = qualifierText.toLowerCase();
  const stufePattern = "(sehr dunkle|mitteldunkle|dunkle|mittelhelle|mittlere|sehr helle|helle)";
  const mDark = text.match(new RegExp(stufePattern + "\\s+oder\\s+dunklere?"));
  const mLight = text.match(new RegExp(stufePattern + "\\s+oder\\s+hellere?"));
  const toStufe = (w) =>
    ({ "sehr dunkle": "sehr dunkel", mitteldunkle: "mitteldunkel", dunkle: "dunkel", mittelhelle: "mittelhell", mittlere: "mittel", "sehr helle": "sehr hell", helle: "hell" }[w]);
  const hIdx = Config.HAUTTON_STUFEN.indexOf(hautton);
  if (mDark) {
    const a = Config.HAUTTON_STUFEN.indexOf(toStufe(mDark[1]));
    return hIdx <= a;
  }
  if (mLight) {
    const a = Config.HAUTTON_STUFEN.indexOf(toStufe(mLight[1]));
    return hIdx >= a;
  }
  return true;
}

// "Deine besten Farben" (alle Zeilen, Hautton-Zeile nur wenn zutreffend) +
// Farbtest gut/schwierig, dedupliziert - die "Bluete" fuer die gute Seite
// eines Typs (Plausi-Check UND Close-Call-Duelle nutzen dieselbe Funktion).
function typeFlowerColors(typeId, hautton) {
  const typ = Config.FARBTYPEN_BY_ID[typeId];
  const d = typ && VergleicheData[typ.name];
  if (!d) return [];
  const rowColors = d.besteFarbenZeilen.filter((row) => meetsHauttonCondition(row.hauttonHinweis, hautton)).map((row) => row.colors);
  return dedupeVergleicheColors([d.farbtest.good, d.farbtest.difficult, ...rowColors]);
}

// Farbtest "nicht so gut" + alle "entgegengesetzten Farben" - die "Bluete"
// fuer die schlechte Seite (nur der Plausi-Check braucht das).
function typeOppositeColors(typeId) {
  const typ = Config.FARBTYPEN_BY_ID[typeId];
  const d = typ && VergleicheData[typ.name];
  if (!d) return [];
  return dedupeVergleicheColors([d.farbtest.bad, d.entgegengesetzt]);
}

// Wardrobe-Palette (FARBTYPEN[].colors) hautton-filtern (Adina, 15.09.2026:
// "sollte auch später in der Farbpalette nicht im Hellen Sommer auftauchen.
// betrifft auch alle anderen Typen wo es diese Einschraenkung gab."). Nutzt
// dieselben Buchdaten/dieselbe Bedingungs-Logik wie die Vergleiche-Bluete
// (VergleicheData/meetsHauttonCondition), nur gegen die flache Wardrobe-
// Farbliste statt gegen die kombinierte Bluete. Farbnamen, die in KEINER
// hautton-beschraenkten Zeile vorkommen, sind immer sichtbar.
function hauttonRestrictedNames(typeName) {
  const d = VergleicheData[typeName];
  if (!d) return {};
  const map = {};
  d.besteFarbenZeilen.forEach((row) => {
    if (row.hauttonHinweis) row.colors.forEach((c) => { map[c.name] = row.hauttonHinweis; });
  });
  return map;
}
function visibleColors(typ, hautton) {
  if (!typ || !typ.colors) return [];
  const restricted = hauttonRestrictedNames(typ.name);
  if (!Object.keys(restricted).length) return typ.colors;
  return typ.colors.filter((c) => meetsHauttonCondition(restricted[c.name], hautton));
}

function conicGradient(colors) {
  if (!colors || !colors.length) return "#CCCCCC";
  const n = colors.length;
  const stops = colors.map((c, i) => `${c.hex} ${((i / n) * 100).toFixed(3)}% ${(((i + 1) / n) * 100).toFixed(3)}%`);
  return `conic-gradient(from 0deg, ${stops.join(", ")})`;
}

// Generisches 2-Kachel-Duell (Blumen-Rahmen statt Flachfarbe/Metallfoto),
// exakt dieselbe Optik/Struktur wie DigitalDrapingRound (ft-draping-*-
// Klassen, faceClip fuer den TN-Foto-Ausschnitt, TIE-Praeferenz-Knopf
// darunter) - nur dass "d.color" hier ein CSS-Gradient-String ist statt
// eines Flachfarben-Hex. roundValue/onPick im selben Rundenformat wie bei
// DigitalDrapingRound: null | [key] | [TIE] | [TIE,key].
// Kein Farbtyp-Name in den Vergleichen (Adina, 15.09.2026: "nicht zu
// spoilern bei den Tests"). Leitfragen (questions/questionsHeader) kommen als
// Props vom jeweiligen Aufrufer statt fest verdrahtet zu sein: Plausi-Check
// zeigt die ausfuehrliche 4-Punkte-Liste mit "Sondern:"-Header, die
// Close-Call-Duelle die vereinfachte 3-Punkte-Liste ohne Header (Adina,
// 16.09.2026: "auf den Folgevergleichsseiten vereinfachen").
function VergleicheDuellTiles({ photoUrl, framing, optionA, optionB, intro, questionsHeader, questions, roundValue, onPick }) {
  const { clipAspect, faceStyle } = faceClip(framing);
  const TIE = Model.VERGLEICHE_TIE_KEY;
  const isTie = !!roundValue && roundValue.includes(TIE);
  const pickedKey = roundValue ? roundValue.find((k) => k !== TIE) : null;
  const [nudge, setNudge] = useState(false);

  function pickOption(key) {
    const next = pickedKey === key ? null : key;
    setNudge(false);
    onPick(isTie ? (next ? [TIE, next] : [TIE]) : next ? [next] : null);
  }
  function toggleTie() {
    if (isTie) {
      setNudge(false);
      onPick(pickedKey ? [pickedKey] : null);
    } else {
      onPick(pickedKey ? [TIE, pickedKey] : [TIE]);
      if (!pickedKey) setNudge(true);
    }
  }
  function renderTile(opt) {
    const on = pickedKey === opt.key;
    return (
      <button
        key={opt.key}
        type="button"
        className={"ft-draping-tile ft-draping-duel-tile" + (on ? " is-selected" : "") + (nudge ? " is-nudge" : "")}
        onClick={() => pickOption(opt.key)}
        aria-pressed={on}
      >
        <span className="ft-draping-swatch" style={{ background: opt.gradient }}>
          <span className="ft-draping-face" style={{ aspectRatio: clipAspect }}>
            <img src={photoUrl} style={faceStyle} alt="" draggable={false} />
          </span>
        </span>
        <span className="ft-draping-label">{opt.label}</span>
        <span className={"ft-draping-pick" + (on ? " ft-draping-pick-done" : "")} aria-hidden="true">
          {on && <CheckIcon size={19} strokeWidth={1.2} />}
        </span>
      </button>
    );
  }
  return (
    <div className="ft-draping">
      {intro && <p className="ft-muted" style={{ marginBottom: 14 }}>{intro}</p>}
      <ul className="ft-draping-fragen">
        <li>
          {questionsHeader && <strong className="ft-draping-fragen-head">{questionsHeader}</strong>}
          <ul className="ft-draping-fragen-list">
            {questions.map((q, i) => (
              <li key={i}>{q}</li>
            ))}
          </ul>
        </li>
        <li>
          <strong className="ft-draping-fragen-head">Beide ähnlich gut?</strong>
          <span className="ft-draping-fragen-body">
            Wenn keine der beiden Varianten deutlich besser oder schlechter aussieht, wähle{" "}
            <strong>"Beide ähnlich" +</strong> deine <strong>Tendenz.</strong>
          </span>
        </li>
      </ul>
      <div className="ft-draping-duel-row">
        {renderTile(optionA)}
        {renderTile(optionB)}
      </div>
      <button type="button" className={"ft-draping-tie-tile" + (isTie ? " is-selected" : "")} onClick={toggleTie} aria-pressed={isTie}>
        <span className="ft-draping-label">
          Beide
          <br />
          ähnlich
        </span>
        <span className={"ft-draping-pick" + (isTie ? " ft-draping-pick-done" : "")} aria-hidden="true">
          {isTie && <CheckIcon size={19} strokeWidth={1.2} />}
        </span>
      </button>
    </div>
  );
}

// Schritt 0.0: Plausi-Check - Spitzenreiter-Typ, eigene gute Farbwelt (Blume
// aus Farbtest gut+schwierig + allen besten-Farben-Zeilen) gegen die eigene
// schlechte Farbwelt (Farbtest "nicht so gut" + alle entgegengesetzten
// Farben). Kein Duell zwischen zwei Typen, wirkt sich NICHT aufs Scoring
// aus (siehe Stufe5Ergebnis-Kommentar) - reiner Selbst-Check.
function VergleichePlausiCheck({ photoUrl, framing, leaderId, hautton, value, onPick }) {
  const good = useMemo(() => typeFlowerColors(leaderId, hautton), [leaderId, hautton]);
  const bad = useMemo(() => typeOppositeColors(leaderId), [leaderId]);
  return (
    <VergleicheDuellTiles
      photoUrl={photoUrl}
      framing={framing}
      optionA={{ key: "top", label: "Links", gradient: conicGradient(good) }}
      optionB={{ key: "bottom", label: "Rechts", gradient: conicGradient(bad) }}
      intro={
        <>
          Zuerst: <strong>2 komplett konträre Paletten.</strong>
          <br />
          <strong>Eine von beiden sollte deutlich besser passen, als die andere.</strong>
          <br />
          Hier geht es generell nicht darum, welche Farben du mehr magst.
        </>
      }
      questionsHeader="Sondern:"
      questions={[
        <React.Fragment>
          Welche Palette sieht <strong>intuitiv</strong> <strong>natürlicher</strong> und <strong>harmonischer</strong> aus?
        </React.Fragment>,
        <React.Fragment>
          Bei welcher schaust du intuitiv <strong>zuerst auf dein Gesicht</strong> und dann erst auf die Farben (im
          Vergleich zu: die Farben stechen zuerst hervor)?
        </React.Fragment>,
        <React.Fragment>
          Wo sieht dein Gesicht <strong>weniger "gequetscht"</strong> aus <strong>oder "fehl am Platz"</strong>?
        </React.Fragment>,
        <React.Fragment>
          <strong>Entspannter? Klarer?</strong>
        </React.Fragment>,
      ]}
      roundValue={value}
      onPick={onPick}
    />
  );
}

// Schritt 0.1+: Close-Call-Duell zwischen zwei konkreten Typen - je Typ 1
// Blume aus dessen eigener guter Farbwelt (typeFlowerColors). Kein Typname
// sichtbar (Spoiler-Schutz). Leitfragen-Liste ist kuerzer/einfacher als beim
// Plausi-Check (Adina, 16.09.2026: "auf den Folgevergleichsseiten
// vereinfachen") - kein eigener Header, kein Intro-Absatz.
function VergleicheCloseCall({ photoUrl, framing, typeA, typeB, hautton, value, onPick }) {
  const colorsA = useMemo(() => typeFlowerColors(typeA, hautton), [typeA, hautton]);
  const colorsB = useMemo(() => typeFlowerColors(typeB, hautton), [typeB, hautton]);
  return (
    <VergleicheDuellTiles
      photoUrl={photoUrl}
      framing={framing}
      optionA={{ key: typeA, label: "Links", gradient: conicGradient(colorsA) }}
      optionB={{ key: typeB, label: "Rechts", gradient: conicGradient(colorsB) }}
      questions={[
        "Wo schaust du zuerst aufs Gesicht, dann auf die Farbe (statt umgekehrt)?",
        'Wo sieht dein Gesicht weniger "gequetscht" aus oder "fehl am Platz"?',
        <React.Fragment>
          Wo <strong>entspannter? Klarer?</strong>
        </React.Fragment>,
      ]}
      roundValue={value}
      onPick={onPick}
    />
  );
}

function Stufe5Ergebnis({ toolData, onUpdate, onGotoStufe }) {
  const ft = toolData.farbtyp_test;
  const answers = { ...emptyAnswers(), ...(ft?.answers || {}) };
  const photos = toolData.photos || {};
  const { patchVergleichePlausi, patchVergleichePick } = farbtypActions(onUpdate);
  // Eigener Unterzaehler fuer Uebersicht + Plausi-Check + die Close-Call-
  // Duelle (analog zu pairIndex in Stufe3Unterton). Der eigentliche
  // Ergebnis-Reveal (FarbkreisErgebnis) liegt seit dem Umbau nicht mehr in
  // diesem Kapitel, sondern als erster Screen in Kapitel 6 "Farbpalette"
  // (Adina, 15.09.2026: "Das Farbtypergebnis soll schon der erste Screen
  // sein von Farbpalette") - dieses Kapitel endet daher direkt mit
  // goToFarbpalette() statt mit einem eigenen Ergebnis-Screen.
  const [subStep, setSubStep] = useChapterStep(toolData, onUpdate, "vergleicheSub");
  // quiz_progress.farbenTesten wird beim Verlassen dieses Kapitels explizit
  // auf 0 gesetzt statt einfach onGotoStufe(6) zu rufen: sonst landet man
  // beim Weiterklicken auf einem alten, zuvor besuchten Farbpalette-Schritt
  // (z.B. "Auswahl") statt auf dem frischen Ergebnis-Reveal - insbesondere
  // fuer TN, die den Flow schon vor diesem Umbau einmal durchlaufen hatten
  // (Adina-Bugreport 16.09.2026: "nach dem letzten Vergleich fehlt die
  // Farbkreis-Seite").
  function goToFarbpalette() {
    onUpdate((prev) => ({ ...prev, quiz_progress: { ...(prev.quiz_progress || {}), farbenTesten: 0 } }));
    onGotoStufe(6);
  }
  // includeVergleiche:false ist hier durchgehend richtig, nicht nur als
  // "eingefrorene" Topologie-Basis fuer die Duelle: die Zusammenfassungs-
  // Werte (P_K/P_U/P_C/effectiveP_*) haengen ohnehin nicht von den
  // Vergleiche-Multiplikatoren ab (die wirken nur auf die Typ-Rangliste
  // "full"/"result", siehe computeRanking), und die Duell-Paarung darf sich
  // waehrend des Durchklickens nicht verschieben (Adina-Bugreport
  // 15.09.2026). Die live inkl. Vergleiche gewichtete Rangliste braucht erst
  // der Ergebnis-Reveal in Kapitel 6.
  const ranking = useMemo(() => computeRanking(ft, { includeVergleiche: false }), [ft]);

  const hasK = Object.keys(ranking.P_K).length > 0;
  const hasU = Object.keys(ranking.P_U).length > 0;
  const hasC = Object.keys(ranking.P_C).length > 0;
  const kLabel = hasK ? rangeLabel(dominantLevels(ranking.effectiveP_K, Config.KONTRAST_LEVELS), Config.KONTRAST_LEVELS, KONTRAST_LABELS, ranking.effectiveP_K) : null;
  const uLabel = hasU ? untertonRangeLabel(dominantLevels(ranking.effectiveP_U, Config.UNTERTON_LEVELS), ranking.effectiveP_U) : null;
  const cLabel = hasC ? rangeLabel(dominantLevels(ranking.effectiveP_C, Config.CHROMA_LEVELS), Config.CHROMA_LEVELS, CHROMA_LABELS, ranking.effectiveP_C) : null;

  // Werte fuer die Zusammenfassung mit "/"-Trenner statt "bis".
  const slash = (l) => (l ? l.replace(/ bis /g, "/") : null);
  const uVal = slash(uLabel);
  const cVal = slash(cLabel);
  const kVal = slash(kLabel);
  const summaryCells = (label, letter, value, stufe) => (
    <React.Fragment>
      <span className="ft-summary-label">Dein {label}</span>
      <span className="ft-summary-letter">{letter}</span>
      {value ? (
        <span>: {value}</span>
      ) : (
        <span>
          :{" "}
          <button type="button" className="ft-btn-inline-link" onClick={() => onGotoStufe(stufe)}>
            ergänzen
          </button>
        </span>
      )}
    </React.Fragment>
  );

  // subStep === 0: Zusammenfassung + Ueberleitung zu den letzten Vergleichen
  // (Adina, 15.09.2026: "das gehört ganz an den Anfang vom Kapitel
  // Ergebnis" - mittlerweile "Letzte Tests"). Ohne vollstaendiges Ranking
  // gibt es nichts zu vergleichen, Weiter fuehrt dann direkt zur
  // Farbpalette.
  if (subStep === 0) {
    return (
      <QuizScreen
        screenKey="vergleiche-uebersicht"
        title="Ergebnis"
        onBack={() => { startChapterAtEnd(onUpdate, 4); onGotoStufe(4); }}
        onNext={() => (ranking.complete ? setSubStep(1) : goToFarbpalette())}
        nextDisabled={false}
        nextIcon="arrow"
      >
        <p className="ft-muted">
          Super, du hast den <strong>Unterton</strong>, das <strong>Chroma</strong> und das{" "}
          <strong>Kontrastlevel</strong> deiner Gesichtsmerkmale <strong>eingegrenzt.</strong>
        </p>
        <h3 className="ft-summary-title" style={{ marginTop: 16 }}>Zusammenfassung:</h3>
        <div className="ft-summary">
          {summaryCells("Unterton", "U", uVal, 3)}
          {summaryCells("Chroma", "C", cVal, 4)}
          {summaryCells("Kontrast", "K", kVal, 2)}
        </div>
        {ranking.complete ? (
          <p className="ft-muted" style={{ marginTop: 16 }}>
            Lass uns noch ein paar <strong>letzte Tests</strong> machen, um die Richtung zu bestätigen.
            <br />
            <strong>Danach</strong> zeigen wir dir deinen <strong>wahrscheinlichsten Farbtypen</strong> und deine{" "}
            <strong>stimmigsten Farben</strong>. 🎉
          </p>
        ) : (
          <p className="ft-muted" style={{ marginTop: 16 }}>
            Sobald Kontrastlevel, Unterton und Chroma ausgefüllt sind, können wir deinen wahrscheinlichsten Farbtyp
            berechnen.
          </p>
        )}
        {ranking.complete && (
          <p className="ft-muted" style={{ marginTop: 12 }}>
            Du kannst im Anschluss nochmal <strong>virtuell alle Farbpaletten an dir testen</strong> und daraus deine{" "}
            <strong>9-Farben-Farbpalette</strong> für deinen Kleiderschrank <strong>zusammenstellen</strong>.
          </p>
        )}
      </QuizScreen>
    );
  }

  const hautton = answers.hautton && answers.hautton[0];
  const photoUrl = photos.gesicht_vorne?.url;
  const framing = photos.gesicht_vorne_zugeschnitten;
  const { leader, duels } = ranking.complete ? computeVergleicheTopology(ranking.full) : { leader: null, duels: [] };

  if (subStep === 1) {
    return (
      <QuizScreen
        screenKey="vergleiche-plausi"
        eyebrow="Tests"
        title="Was passt besser?"
        onBack={() => setSubStep(0)}
        onNext={() => (duels.length ? setSubStep(2) : goToFarbpalette())}
        nextDisabled={!answers.vergleiche_plausi}
      >
        <VergleichePlausiCheck
          photoUrl={photoUrl}
          framing={framing}
          leaderId={leader}
          hautton={hautton}
          value={answers.vergleiche_plausi}
          onPick={patchVergleichePlausi}
        />
      </QuizScreen>
    );
  }

  const duelIndex = subStep - 2;
  const duel = duels[duelIndex];
  if (duel) {
    const key = duel.a + "|" + duel.b;
    const isLast = duelIndex === duels.length - 1;
    return (
      <QuizScreen
        screenKey={"vergleiche-duell-" + duelIndex}
        eyebrow="Tests"
        title="Was passt besser?"
        onBack={() => setSubStep(subStep - 1)}
        onNext={() => (isLast ? goToFarbpalette() : setSubStep(subStep + 1))}
        nextDisabled={!answers.vergleiche_picks[key]}
      >
        <VergleicheCloseCall
          photoUrl={photoUrl}
          framing={framing}
          typeA={duel.a}
          typeB={duel.b}
          hautton={hautton}
          value={answers.vergleiche_picks[key]}
          onPick={(v) => patchVergleichePick(key, v)}
        />
      </QuizScreen>
    );
  }

  // subStep zeigt auf kein (mehr gueltiges) Duell (z.B. per Direktlink oder
  // weil sich die Rangliste seither verschoben hat, nachdem man
  // zurueckgeblaettert und Antworten geaendert hat) -> statt einer leeren
  // Seite ein Weiter-Screen, der direkt zur Farbpalette fuehrt (kein
  // setState waehrend des Renders - das waere ein React-Verstoss).
  return (
    <QuizScreen screenKey="vergleiche-fallback" eyebrow="Tests" title="Vergleiche abgeschlossen" onBack={() => setSubStep(0)} onNext={goToFarbpalette} nextDisabled={false} nextIcon="arrow">
      <p className="ft-muted">Weiter geht's mit deinem Ergebnis.</p>
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 5 A - Auswahl (Konzept-Abschnitt 6, Schritt 6): Podium der Top 3 +
// Palettenauswahl (bis zu 6, Top 3 vorausgewaehlt) + Floating-Button in den
// Foto-Test. Ersetzt die frueheren Tabs "Farbtypen entdecken" / "Am Foto testen".
// ══════════════════════════════════════════════════════════

function newId() {
  return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

// Farben aus einer Liste von Farbtyp-IDs sammeln, doppelte Hex entfernen
// (Reihenfolge bleibt erhalten).
function colorsForTypes(ids) {
  const seen = new Set();
  const out = [];
  for (const id of ids || []) {
    for (const c of resolveTyp(id)?.colors || []) {
      if (seen.has(c.hex)) continue;
      seen.add(c.hex);
      out.push(c);
    }
  }
  return out;
}

// Bis zu 6 Farbpaletten fuer den Foto-Test (harte Obergrenze fuer die
// manuelle Auswahl). Solange die TN nichts angefasst hat, sind alle Typen
// ab 10% Score vorausgewaehlt statt fix der Top 3 (Adina, 16.09.2026: "nur
// Farbtypen hervorheben, deren Prozentzahl mindestens 10% ist, nicht mehr
// standardmaessig 6") - persistiert wird erst beim ersten Klick. Erreicht
// keiner die 10%-Schwelle (sehr gestreutes Ergebnis), faellt es auf den
// Spitzenreiter allein zurueck, statt leer zu bleiben.
// Kein Limit mehr (Adina, 17.09.2026: "beliebig viele Paletten an sich
// testen") - Infinity statt einer festen Zahl, damit alle bestehenden
// ">= MAX_TEST_SELECTION"-Pruefungen automatisch wirkungslos werden, ohne
// jede einzeln anfassen zu muessen.
const MAX_TEST_SELECTION = Infinity;

const SEASON_LABELS = { fruehling: "Frühling", sommer: "Sommer", herbst: "Herbst", winter: "Winter" };
const SEASON_ORDER = ["fruehling", "sommer", "herbst", "winter"];

function defaultTestSelection(ranking) {
  const qualified = (ranking.full || []).filter((r) => r.probability >= 0.1).map((r) => r.type);
  if (qualified.length) return qualified.slice(0, MAX_TEST_SELECTION);
  return (ranking.full || []).slice(0, 1).map((r) => r.type);
}

function effectiveTestSelection(farbtypTest, ranking) {
  const stored = farbtypTest?.test_selection;
  return stored && stored.length ? stored : defaultTestSelection(ranking);
}

// Wahrscheinlichkeitsgewichteter Skalen-Index UEBER DIE GESAMTE Verteilung
// (nicht nur die 1-2 "dominanten" Level gleich gewichtet gemittelt wie
// zuvor via dominantLevels()+levelIndex()) - Adina-Bugreport 16.09.2026:
// Heller Sommer zeigte "harmoniert perfekt mit allen 3 Achsen", obwohl er
// nur Rang 2 (21%) hinter Softer Sommer (41%) ist. Ursache: bei "(niedrig
// bis) mittel" behandelte die alte Mittelwert-Rechnung niedrig und mittel
// als exakt gleich gewichtet (Index 0.5), obwohl "mittel" laut Klammer-
// Logik anderswo im Tool klar der staerkere Wert ist - das echte Gewicht
// (z.B. 74% mittel / 26% niedrig) ging verloren und erzeugte zufaellige
// exakte 0.5-Bindungen, die die feste ">0.5"-Schwelle als "passt" durchliess.
function weightedIndex(dist, scale) {
  let sum = 0;
  let total = 0;
  scale.forEach((lvl, i) => {
    const p = dist?.[lvl] || 0;
    sum += p * i;
    total += p;
  });
  return total > 0 ? sum / total : null;
}

function primaryLevel(coord) {
  return Array.isArray(coord) ? coord[0] : coord;
}

// Kurzer Einordnungssatz, wie gut die Palette zur TN passt (Adina,
// 16.09.2026: "Texte kuerzen"). 0 Abweichungen: "starker Kandidat". Genau 1
// Abweichung: kurzes "Passt gut, aber...". 2-3 Abweichungen: kein "passt
// gut" mehr, stattdessen direkt alle Abweichungen aufzaehlen ("Ist
// wahrscheinlich vom X zu Y und vom A zu B..."). Ohne vollstaendiges
// Ergebnis: kein Satz.
function fitHintForType(typ, ranking) {
  if (!ranking.complete) return null;
  const dims = [
    { name: "Unterton", scale: Config.UNTERTON_LEVELS, dist: ranking.effectiveP_U, coord: typ.koordinaten.unterton, over: "kühl", under: "warm" },
    { name: "Chroma", scale: Config.CHROMA_LEVELS, dist: ranking.effectiveP_C, coord: typ.koordinaten.chroma, over: "grell", under: "gedämpft" },
    { name: "Kontrast", scale: Config.KONTRAST_LEVELS, dist: ranking.effectiveP_K, coord: typ.koordinaten.kontrast, over: "hart", under: "weich" },
  ];

  const issues = []; // { name, richtung, staerke }
  for (const dim of dims) {
    const domIdx = weightedIndex(dim.dist, dim.scale);
    if (domIdx == null) return null;
    const d = dim.scale.indexOf(primaryLevel(dim.coord)) - domIdx; // + => Typ liegt "hoeher" auf der Skala
    const abs = Math.abs(d);
    if (abs > 0.5) {
      // Gestufte Formulierung statt binaer "passt/passt nicht" (Adina,
      // 16.09.2026): "etwas" bei einer moderaten, "deutlich" bei einer
      // grossen Abweichung - macht sichtbar, WIE weit ein Typ wirklich
      // daneben liegt, statt nur DASS er daneben liegt.
      issues.push({ name: dim.name, richtung: d > 0 ? dim.over : dim.under, staerke: abs > 1.5 ? "deutlich" : "etwas" });
    }
  }

  if (!issues.length) return "Harmoniert mit Unterton, Chroma und Kontrast – ein starker Kandidat für dich.";
  // "etwas zu X"/"deutlich zu X" fett (Adina, 16.09.2026) - die eigentliche
  // Abweichungs-Info soll ins Auge springen, der Rest bleibt Fliesstext.
  if (issues.length === 1) {
    const only = issues[0];
    return (
      <>
        Passt gut, aber könnte vom {only.name}{" "}
        <strong>
          {only.staerke} zu {only.richtung}
        </strong>{" "}
        für dich sein.
      </>
    );
  }
  return (
    <>
      Ist wahrscheinlich{" "}
      {issues.map((i, idx) => (
        <React.Fragment key={i.name}>
          {idx > 0 && (idx === issues.length - 1 ? " und " : ", ")}
          vom {i.name}{" "}
          <strong>
            {i.staerke} zu {i.richtung}
          </strong>
        </React.Fragment>
      ))}{" "}
      für dich.
    </>
  );
}

// Kreis, der bei Auswahl zum Senf-Haken wird (kein neues Gruen - Senf ist unser
// "erledigt/ausgewaehlt"). Haken:Kreis-Verhaeltnis wie beim Weiter-Button-
// Goldstandard (2/3) - size-Prop noetig, weil .ft-facetile .ft-check den
// Kreis auf 20px verkleinert (statt der Standard-24px), sonst waere das
// Verhaeltnis dort falsch.
// tooltip ist optional und nur fuer Kontexte gedacht, in denen "auswaehlen"
// eine konkrete, erklaerungsbeduerftige Bedeutung hat (z.B. "In die engere
// Auswahl" auf "Am Foto testen") - andere Verwendungen (Podium, Zwischentyp-
// Karten) lassen tooltip weg und bleiben unveraendert (Adina, 17.09.2026).
function AuswahlCheck({ selected, className = "", size = 16, tooltip }) {
  const showTip = !selected && !!tooltip;
  return (
    <span
      className={"ft-check" + (selected ? " is-on" : "") + (className ? " " + className : "")}
      aria-hidden={showTip ? undefined : "true"}
      tabIndex={showTip ? 0 : undefined}
      data-tooltip={showTip ? tooltip : undefined}
    >
      {selected && <CheckIcon size={size} />}
    </span>
  );
}

// Podium der drei wahrscheinlichsten Typen: Platz 1 in der Mitte (hervorgehoben),
// Platz 2 links, Platz 3 rechts. Zeigt die komplette Palette und ist zugleich
// Auswahlkachel - dieselbe Auswahl wie in der Liste unten (geteilter State).
// Mini-Beschreibung je Podium-Karte (Adina, 16.09.2026, dann nochmal
// "abkuerzen"): der Spitzenreiter (Rang 1, Mitte) bekommt eine kurze
// Chroma/Unterton/Kontrast-Beschreibung aus seinen eigenen Typ-Koordinaten
// (FARBEN_*_ADJ_KURZ - eigene, unflektierte Kurzform ohne "Farben"/Punkt),
// die beiden Nachbarn bekommen stattdessen nur den UNTERSCHIED zum
// Spitzenreiter genannt - je Achse, auf der sich die Werte unterscheiden,
// ein Komparativ (heller/tiefer, sanfter/leuchtender, waermer/kuehler).
// Kurzform ohne Flexionsendung/"Farben"/Punkt (Adina, 16.09.2026: "im
// Treppchen abkuerzen") - eigene Woerterlisten statt der vollen
// FARBEN_*_ADJ von oben. Kontrast: der PRIMAERE Wert (kontrast[0]) bildet
// das Grundwort, ein davon abweichender sekundaerer Wert steht als reines
// Level-Wort in Klammern davor ("(mittel)tief" fuer Softer Sommer statt
// "mitteltiefe bis tiefe Farben").
const FARBEN_CHROMA_ADJ_KURZ = { soft: "sanft", mittel: "zart leuchtend", klar: "leuchtend" };
const FARBEN_UNTERTON_ADJ_KURZ = { kuehl: "kühl", "kuehl-neutral": "kühl-neutral", "warm-neutral": "warm-neutral", warm: "warm" };
const FARBEN_KONTRAST_ADJ_KURZ = { niedrig: "hell", mittel: "mitteltief", hoch: "tief", "sehr-hoch": "tief" };
const KONTRAST_LEVEL_PLAIN = { niedrig: "niedrig", mittel: "mittel", hoch: "hoch", "sehr-hoch": "sehr hoch" };
function podiumOwnDescription(typ) {
  const k = typ.koordinaten;
  const primaryAdj = FARBEN_KONTRAST_ADJ_KURZ[k.kontrast[0]];
  const secondaryAdj = k.kontrast[1] ? FARBEN_KONTRAST_ADJ_KURZ[k.kontrast[1]] : null;
  const kontrastShort = secondaryAdj && secondaryAdj !== primaryAdj ? `(${KONTRAST_LEVEL_PLAIN[k.kontrast[1]]})${primaryAdj}` : primaryAdj;
  return `${FARBEN_CHROMA_ADJ_KURZ[k.chroma]}, ${FARBEN_UNTERTON_ADJ_KURZ[k.unterton]}, ${kontrastShort}`;
}
// Vergleicht die komplette (sortierte) Kontrast-Bandbreite zweier Typen -
// nicht nur den Primaerwert, aber auch kein erfundener Mittelwert (Adina,
// 16.09.2026: "das klingt nicht nach dem was ich will"), sondern die
// tatsaechlichen Endpunkte: zuerst der untere (hellere) Wert entscheidet,
// bei Gleichstand der obere (tiefere).
function kontrastRangeSorted(typ) {
  return [...typ.koordinaten.kontrast].map((k) => Config.KONTRAST_LEVELS.indexOf(k)).sort((a, b) => a - b);
}
function compareKontrastRange(a, b) {
  const [aLo, aHi] = kontrastRangeSorted(a);
  const [bLo, bHi] = kontrastRangeSorted(b);
  return aLo !== bLo ? aLo - bLo : aHi - bHi;
}
function podiumDiffDescription(typ, centerTyp) {
  const parts = [];
  const kCmp = compareKontrastRange(typ, centerTyp);
  if (kCmp !== 0) {
    parts.push(kCmp < 0 ? "heller" : "tiefer");
  }
  const cA = typ.koordinaten.chroma;
  const cB = centerTyp.koordinaten.chroma;
  if (cA !== cB) {
    parts.push(Config.CHROMA_LEVELS.indexOf(cA) < Config.CHROMA_LEVELS.indexOf(cB) ? "sanfter" : "leuchtender");
  }
  const uA = typ.koordinaten.unterton;
  const uB = centerTyp.koordinaten.unterton;
  if (uA !== uB) {
    parts.push(Config.UNTERTON_LEVELS.indexOf(uA) < Config.UNTERTON_LEVELS.indexOf(uB) ? "wärmer" : "kühler");
  }
  if (!parts.length) return "Ebenfalls stimmig";
  return `Etwas ${parts.join(" und ")}`;
}

function AuswahlPodium({ ranking, selectedSet, onToggle, hautton, photo }) {
  const top = (ranking.full || []).slice(0, 3);
  if (top.length < 3) return null;
  const [first, second, third] = top;
  const centerTyp = Config.FARBTYPEN_BY_ID[first.type];
  const slots = [
    { rank: 2, entry: second },
    { rank: 1, entry: first },
    { rank: 3, entry: third },
  ];
  return (
    <div className="ft-podium">
      {slots.map(({ rank, entry }) => {
        const typ = Config.FARBTYPEN_BY_ID[entry.type];
        if (!typ) return null;
        const selected = selectedSet.has(entry.type);
        // Pfeil sitzt an der Seite, die zum Spitzenreiter in der Mitte
        // zeigt - links (Rang 2) also NACH dem Text, rechts (Rang 3) VOR
        // dem Text - damit klar wird, dass sich die Beschreibung auf die
        // Mitte bezieht (Adina, 16.09.2026: "muss rechts vom Text stehen,
        // damit deutlich wird, dass sich der Text auf die Mitte bezieht").
        const description =
          rank === 1 || !centerTyp
            ? podiumOwnDescription(typ)
            : rank === 2
              ? `${podiumDiffDescription(typ, centerTyp)} ⬅️`
              : `➡️ ${podiumDiffDescription(typ, centerTyp)}`;
        return (
          <button
            type="button"
            key={entry.type}
            className={"ft-podium-col" + (rank === 1 ? " ft-podium-col-first" : "") + (selected ? " is-selected" : "")}
            onClick={() => onToggle(entry.type)}
            aria-pressed={selected}
          >
            <AuswahlCheck selected={selected} className="ft-corner-check" />
            <span className="ft-podium-meta">
              <span className="ft-podium-name">{typ.name}</span>
              <span className="ft-podium-pct">{Math.round(entry.probability * 100)}%</span>
            </span>
            <span className="ft-podium-swatches">
              {visibleColors(typ, hautton).map((c, i) => (
                <span key={c.hex + i} className="ft-podium-swatch" style={{ background: c.hex }} />
              ))}
            </span>
            {photo && (
              <span className="ft-auswahl-hoverface" aria-hidden="true">
                <PaletteBlume colors={visibleColors(typ, hautton)} photo={photo} />
              </span>
            )}
            <span className="ft-podium-desc">{description}</span>
          </button>
        );
      })}
    </div>
  );
}

// Experiment (Adina, 19.09.2026): Palette als "Blume" um das TN-Foto statt
// Farbkacheln-Reihe auf der Auswahl-Seite. Rollback: Git-Tag
// vor-blume-experiment oder USE_BLUME_AUSWAHL auf false setzen.
const USE_BLUME_AUSWAHL = false; // Hover-Vorschau mit TN-Foto: ausgeschaltet, zum Wiedereinschalten auf true
function PaletteBlume({ colors, photo, className }) {
  const { clipAspect, faceStyle } = faceClip(photo.framing);
  return (
    <span className={"ft-draping-swatch ft-auswahl-blume " + (className || "")} style={{ background: conicGradient(colors) }}>
      <span className="ft-draping-face" style={{ aspectRatio: clipAspect }}>
        <img src={photo.url} style={faceStyle} alt="" draggable={false} />
      </span>
    </span>
  );
}

// Zwischentyp-Karte: wie AuswahlCard, aber ohne Prozentzahl (kein Teil des
// Ranking-Modells) - stattdessen ein kleines "Zwischentyp"-Badge und ein
// generierter Beschreibungstext statt eines Ranking-Einordnungssatzes.
function ZwischentypCard({ zt, selected, disabled, onToggle, hautton, photo }) {
  const desc = zwischentypDescription(zt);
  return (
    <button
      type="button"
      className={"ft-auswahl-card" + (selected ? " is-selected" : "") + (disabled ? " is-disabled" : "")}
      onClick={() => onToggle(zt.id)}
      aria-pressed={selected}
      disabled={disabled}
    >
      <AuswahlCheck selected={selected} className="ft-corner-check" />
      <span className="ft-zwischentyp-badge">Zwischentyp</span>
      <span className="ft-auswahl-head">
        <span className="ft-auswahl-name">{zt.name}</span>
      </span>
      <span className="ft-swatch-row ft-swatch-row-static">
        {visibleColors(zt, hautton).map((c, i) => (
          <span key={c.hex + i} className="ft-auswahl-swatch" style={{ background: c.hex }} title={c.name} />
        ))}
      </span>
      {photo && (
        <span className="ft-auswahl-hoverface" aria-hidden="true">
          <PaletteBlume colors={visibleColors(zt, hautton)} photo={photo} />
        </span>
      )}
      {desc && <span className="ft-auswahl-hinttext">{desc}</span>}
    </button>
  );
}

// Eine Farbpalette in der Auswahlliste (drei je Saison-Reihe): ganze Karte
// klickbar, Haken oben rechts, Prozent neben dem Namen, darunter der
// Einordnungssatz aus dem Ergebnis.
function AuswahlCard({ typ, pct, hint, selected, disabled, onToggle, hautton, photo }) {
  return (
    <button
      type="button"
      className={"ft-auswahl-card" + (selected ? " is-selected" : "") + (disabled ? " is-disabled" : "")}
      onClick={() => onToggle(typ.id)}
      aria-pressed={selected}
      disabled={disabled}
    >
      <span className="ft-auswahl-head">
        <span className="ft-auswahl-name">{typ.name}</span>
        {pct != null && <span className="ft-auswahl-pct">{pct}%</span>}
        <AuswahlCheck selected={selected} />
      </span>
      <span className="ft-swatch-row ft-swatch-row-static">
        {visibleColors(typ, hautton).map((c, i) => (
          <span key={c.hex + i} className="ft-auswahl-swatch" style={{ background: c.hex }} title={c.name} />
        ))}
      </span>
      {photo && (
        <span className="ft-auswahl-hoverface" aria-hidden="true">
          <PaletteBlume colors={visibleColors(typ, hautton)} photo={photo} />
        </span>
      )}
      {hint && <span className="ft-auswahl-hinttext">{hint}</span>}
    </button>
  );
}

// Schritt 0 von "Farbpalette": der eigentliche Ergebnis-Reveal
// (FarbkreisErgebnis mit dem "Farbtyp Ergebnis"-Button, Berechnungs-
// Animation und Top-3-Popup) - umgezogen aus Kapitel 5 "Letzte Tests"
// (Adina, 15.09.2026: "Das Farbtypergebnis soll schon der erste Screen sein
// von Farbpalette"). ranking kommt inkl. Vergleiche-Multiplikatoren (live,
// includeVergleiche:true-Default), damit die letzten Tests aus Kapitel 5
// tatsaechlich in die angezeigte Typ-Rangliste einfliessen.
function SchrittErgebnis({ toolData, ranking, onUpdate, onNext, onBack }) {
  const ft = toolData.farbtyp_test;
  const topType = ranking.result[0] && Config.FARBTYPEN_BY_ID[ranking.result[0].type];
  const candidates = ranking.result.slice(0, 3);
  const selected = effectiveTestSelection(ft, ranking);

  // "Farbtyp anzeigen" bleibt geklickt, wenn man zurueck auf diesen Screen
  // kommt (Adina, 16.09.2026: "damit ich nicht immer neu klicken muss") -
  // persistiert als Fingerprint der aktuellen Rangliste statt eines reinen
  // Bool, damit der Reveal automatisch zurueckgesetzt wird, sobald sich
  // Kontrast/Unterton/Chroma/Vergleiche seither geaendert haben (ranking.full
  // haengt exakt von diesen Antworten ab - jede Aenderung dort verschiebt
  // die Wahrscheinlichkeiten und damit den Fingerprint). Fotos oder andere
  // Aenderungen, die die Rangliste nicht beeinflussen, loesen bewusst
  // keinen Reset aus.
  const rankingFingerprint = JSON.stringify((ranking.full || []).map((r) => [r.type, Math.round(r.probability * 10000)]));
  const alreadyRevealed = !!ft?.ergebnis_reveal_fp && ft.ergebnis_reveal_fp === rankingFingerprint;
  function markRevealed() {
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      return { ...prev, farbtyp_test: { ...cur, ergebnis_reveal_fp: rankingFingerprint } };
    });
  }

  function toggleTest(id) {
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      const base = effectiveTestSelection(cur, ranking);
      let next;
      if (base.includes(id)) next = base.filter((x) => x !== id);
      else if (base.length >= MAX_TEST_SELECTION) next = base;
      else next = [...base, id];
      return { ...prev, farbtyp_test: { ...cur, test_selection: next } };
    });
  }

  return (
    <QuizScreen screenKey="farbpalette-ergebnis" eyebrow="Farbpalette" title="Wir haben deinen wahrscheinlichsten Farbtyp 🎉" onBack={onBack} onNext={onNext} nextDisabled={false}>
      {!ranking.complete ? (
        <p className="ft-muted">
          Sobald Kontrastlevel, Unterton und Chroma ausgefüllt sind, können wir deinen wahrscheinlichsten Farbtyp
          berechnen.
        </p>
      ) : (
        <React.Fragment>
          <p className="ft-muted" style={{ marginTop: 4, marginBottom: 16 }}>
            Dieser Farbtyp steht für ein <strong>Farbprofil</strong>, dessen <strong>Eigenschaften</strong> mit
            deinen <strong>Gesichtsmerkmalen</strong> am <strong>besten harmonieren</strong>. Und das musst du nicht
            glauben, sondern kannst es gleich danach selbst testen: Wähle beliebig viele Farbprofile aus und teste
            sie digital an deinem Foto.
          </p>
          <FarbkreisErgebnis
            ranking={ranking}
            selected={selected}
            onToggle={toggleTest}
            topType={topType}
            candidates={candidates}
            alreadyRevealed={alreadyRevealed}
            onReveal={markRevealed}
          />
          {/* KI-Zweitmeinung vorerst auskommentiert (Adina, 15.09.2026) -
              AiZweitmeinung bleibt unten definiert; Re-Aktivierung hier
              braucht ft.answers/overrides als zusaetzliche Props. */}
        </React.Fragment>
      )}
    </QuizScreen>
  );
}

function SchrittAuswahl({ toolData, ranking, onUpdate, onGotoStufe, onNext, onBack }) {
  const ft = toolData.farbtyp_test;
  const hautton = ft?.answers?.hautton && ft.answers.hautton[0];
  const selection = effectiveTestSelection(ft, ranking);
  const selectedSet = new Set(selection);
  const atMax = selection.length >= MAX_TEST_SELECTION;

  const probByType = {};
  (ranking.full || []).forEach((r) => (probByType[r.type] = r.probability));

  // Zwischentypen: zusaetzliche Paletten, wenn einer ihrer beiden Basistypen
  // im "Top-Set" (Score >= 10%) steht (Adina, 16.09.2026: "wenn jemand einen
  // dieser 4 im Top-Set hat, dann diese Paletten aus dem jeweiligen Ordner
  // anzeigen"). Voll ins Auswahl-/Test-System eingebunden (selben toggle()/
  // selectedSet wie die 12 Haupttypen), siehe resolveTyp() oben fuer die
  // Downstream-Schritte.
  const topSetIds = ranking.complete ? Object.entries(probByType).filter(([, p]) => p >= 0.1).map(([id]) => id) : [];
  const activeZwischentypen = zwischentypenForTopSet(topSetIds);

  // Saison-Gruppen, je drei Typen in einer Reihe. Reihenfolge der Saisons nach
  // ihrem staerksten Typ; innerhalb der Saison ebenfalls nach Score. Ohne
  // vollstaendiges Ergebnis: Config-Reihenfolge.
  const [moreOpen, setMoreOpen] = useState(!ranking.complete);
  const podiumIds = new Set(ranking.complete ? (ranking.full || []).slice(0, 3).map((r) => r.type) : []);
  const seasonGroups = SEASON_ORDER.map((season) => {
    const types = Config.FARBTYPEN.filter((t) => t.season === season && !podiumIds.has(t.id));
    const sorted = ranking.complete
      ? [...types].sort((a, b) => (probByType[b.id] || 0) - (probByType[a.id] || 0))
      : types;
    const maxProb = types.reduce((m, t) => Math.max(m, probByType[t.id] || 0), 0);
    return { season, types: sorted, maxProb };
  });
  if (ranking.complete) seasonGroups.sort((a, b) => b.maxProb - a.maxProb);
  const visibleSeasonGroups = seasonGroups.filter((g) => g.types.length > 0);

  function toggle(id) {
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      const base = cur.test_selection && cur.test_selection.length ? cur.test_selection : defaultTestSelection(ranking);
      let next;
      if (base.includes(id)) next = base.filter((x) => x !== id);
      else if (base.length >= MAX_TEST_SELECTION) next = base;
      else next = [...base, id];
      return { ...prev, farbtyp_test: { ...cur, test_selection: next } };
    });
  }

  const auswahlPhoto = toolData.photos?.gesicht_vorne;
  const blumePhoto =
    USE_BLUME_AUSWAHL && auswahlPhoto?.url ? { url: auswahlPhoto.url, framing: toolData.photos?.gesicht_vorne_zugeschnitten } : null;

  return (
    <QuizScreen screenKey="farben-auswahl" eyebrow="Beste Farbpaletten auswählen" title="Farbpaletten zum Testen auswählen" onBack={onBack} onNext={onNext} nextDisabled={selection.length === 0}>
      <div className="ft-auswahl">
        <p className="ft-auswahl-hint">
          Das sind deine <strong>Top 3 Farbprofile</strong>. <strong>Teste</strong> diese Farben auf der nächsten
          Seite einzeln <strong>an deinem Foto</strong>, <strong>markiere</strong> deine <strong>Favoriten</strong>.
        </p>
        {ranking.complete && <AuswahlPodium ranking={ranking} selectedSet={selectedSet} onToggle={toggle} hautton={hautton} photo={blumePhoto} />}

        {activeZwischentypen.length > 0 && (
          <div className="ft-season-group">
            <h3 className="ft-season-title">
              Zwischentypen für dich
              <InfoIconTip
                label="Was sind Zwischentypen?"
                text="Zwischentypen gehören nicht zum offiziellen 12-Jahreszeiten-System, können aber in der Praxis vorkommen. Wir zeigen dir hier die Zwischentypen an, die auf Basis deiner Antworten zu dir passen könnten."
              />
            </h3>
            <div className="ft-auswahl-grid">
              {activeZwischentypen.map((zt) => (
                <ZwischentypCard
                  key={zt.id}
                  zt={zt}
                  selected={selectedSet.has(zt.id)}
                  disabled={atMax && !selectedSet.has(zt.id)}
                  onToggle={toggle}
                  hautton={hautton}
                  photo={blumePhoto}
                />
              ))}
            </div>
          </div>
        )}

        <hr className="ft-auswahl-divider" />

        <p className={"ft-auswahl-hint ft-auswahl-more-head" + (moreOpen ? "" : " is-collapsed")}>
          <strong>Empfehlung:</strong> Teste nur Paletten an dir, die einen <strong>Score von mindestens 10%</strong> für
          dich haben. Wenn du dennoch weitere Paletten testen möchtest, <strong>klicke hier und wähle sie aus</strong>.
          <button
            type="button"
            className={"ft-term-toggle" + (moreOpen ? " ft-term-toggle-open" : "")}
            onClick={() => setMoreOpen((o) => !o)}
            aria-expanded={moreOpen}
            aria-label={moreOpen ? "Weitere Paletten ausblenden" : "Weitere Paletten anzeigen"}
          >
            <ChevronDownIcon size={9} strokeWidth={1.8} />
          </button>
        </p>

        {!ranking.complete && (
          <p className="ft-muted">
            Du hast den Test noch nicht komplett gemacht.{" "}
            <button type="button" className="ft-btn-inline-link" onClick={() => onGotoStufe(2)}>
              In Schritt 2 starten
            </button>
            . Du kannst hier trotzdem Paletten auswählen.
          </p>
        )}

        {moreOpen && visibleSeasonGroups.map((g) => (
          <div key={g.season} className="ft-season-group">
            <h3 className="ft-season-title">{SEASON_LABELS[g.season]}</h3>
            <div className="ft-auswahl-grid">
              {g.types.map((typ) => (
                <AuswahlCard
                  key={typ.id}
                  typ={typ}
                  pct={ranking.complete ? Math.round((probByType[typ.id] || 0) * 100) : null}
                  hint={fitHintForType(typ, ranking)}
                  selected={selectedSet.has(typ.id)}
                  disabled={atMax && !selectedSet.has(typ.id)}
                  onToggle={toggle}
                  hautton={hautton}
                  photo={blumePhoto}
                />
              ))}
            </div>
          </div>
        ))}
      </div>
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 5 B - Am Foto testen (Konzept-Abschnitt 6, Schritt 7): das
// zugeschnittene Frontal-Foto liegt in jeder Farbe der ausgewaehlten Paletten
// (je Typ eine Ueberschrift). Beliebig viele Farben markierbar -> engere Auswahl
// (farbtyp_test.test_shortlist).
// ══════════════════════════════════════════════════════════

// Wahrnehmungsnaher Farbabstand (Lab / ΔE76), um "sehr aehnliche" Farben zu
// einer Familie zusammenzuziehen. Schwellwert bewusst als Konstante zum
// Feinjustieren: bei ΔE<=10 sind die Familien ~4-8 Toene gross (je nach Zahl
// gewaehlter Paletten) - eine handliche "Optiker"-Auswahl.
const FAMILY_DELTA_E = 10;

function hexToLab(hex) {
  const h = hex.replace("#", "");
  const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(full, 16);
  const chan = (shift) => {
    const s = ((n >> shift) & 255) / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  };
  const R = chan(16);
  const G = chan(8);
  const B = chan(0);
  const x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
  const y = R * 0.2126 + G * 0.7152 + B * 0.0722;
  const z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
  const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
  const fx = f(x);
  const fy = f(y);
  const fz = f(z);
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
}

function deltaE(hexA, hexB) {
  const A = hexToLab(hexA);
  const B = hexToLab(hexB);
  return Math.hypot(A.L - B.L, A.a - B.a, A.b - B.b);
}

// Alle Farben aus den ausgewaehlten Paletten, die der Basisfarbe sehr aehnlich
// sind (inkl. Basisfarbe), sortiert nach Aehnlichkeit. Ein Eintrag PRO
// (Typ, Farbe)-Vorkommen, also (bewusst) ohne Hex-Dedup - dieselbe Farbe
// (z.B. Helles Magenta) taucht oft identisch in mehreren Typen auf, und nur
// diese Rohliste entscheidet zuverlaessig, ob es ueberhaupt etwas zu
// vergleichen gibt (openOrToggle). Fuer die ANZEIGE selbst werden identische
// Hex-Werte anschliessend zu einer Kachel zusammengefasst, siehe
// groupFamilyByHex() (Adina, 17.09.2026: "dunkles pink brauchen wir nicht 3x
// identisch").
function gatherFamily(base, selection, hautton) {
  const items = [];
  for (const typeId of selection) {
    const typ = resolveTyp(typeId);
    if (!typ) continue;
    for (const color of visibleColors(typ, hautton)) {
      const dist = deltaE(base.hex, color.hex);
      // id statt Hex als Identitaet: derselbe Hex kann hier (vor dem Gruppieren)
      // mehrfach vorkommen, id bleibt trotzdem eindeutig - fuer den React-key
      // und die FLIP-Drag-Animation in SortableFamilyGrid.
      if (dist <= FAMILY_DELTA_E) items.push({ id: `${typeId}-${color.hex}-${items.length}`, color, typeName: typ.name, dist });
    }
  }
  items.sort((a, b) => a.dist - b.dist);
  return items;
}

// Fasst die Rohliste aus gatherFamily() fuer die ANZEIGE zu einer Kachel pro
// Hex zusammen (z.B. Dunkles Pink kommt in 3 Typen identisch vor -> 1
// Kachel), sammelt dabei aber alle Herkunfts-Typen als Liste ein. Der Stern
// ("beste Farbe") ist typ-spezifisch (eine Farbe kann bei einem Typ zu den
// besten gehoeren, bei einem anderen nicht) - deshalb wandert er von der
// Kachel selbst (FaceSwatch mit showBest=false in SortableFamilyGrid) auf
// den jeweiligen Typnamen in der Herkunftszeile (Adina, 17.09.2026:
// "Sternchen lassen hinter dem Farbtyp wo es der beste Farbton ist").
function groupFamilyByHex(raw) {
  const map = new Map();
  for (const item of raw) {
    const key = item.color.hex.toLowerCase();
    let group = map.get(key);
    if (!group) {
      group = { id: key, color: item.color, dist: item.dist, origins: [] };
      map.set(key, group);
    } else if (item.dist < group.dist) {
      group.dist = item.dist;
    }
    group.origins.push({ typeName: item.typeName, best: !!item.color.best });
  }
  return Array.from(map.values()).sort((a, b) => a.dist - b.dist);
}

// Beschreibender deutscher Farbname aus dem Hex-Code statt "Farbe 20": eine
// Mischung aus Chroma (klar/gedeckt), Temperatur (warm/kühl), Helligkeit
// (hell/dunkel) und Grund-Farbton. Rein rechnerisch aus HSL, kein externer Dienst.
function hexToHsl(hex) {
  const h = hex.replace("#", "");
  const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(full, 16);
  const r = ((n >> 16) & 255) / 255;
  const g = ((n >> 8) & 255) / 255;
  const b = (n & 255) / 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const d = max - min;
  let hue = 0;
  if (d) {
    if (max === r) hue = ((g - b) / d) % 6;
    else if (max === g) hue = (b - r) / d + 2;
    else hue = (r - g) / d + 4;
    hue = (hue * 60 + 360) % 360;
  }
  const l = (max + min) / 2;
  const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
  return { h: hue, s: s * 100, l: l * 100 };
}

// Grund-Farbtoene (neutrum, damit die Adjektive stark auf "-es" enden:
// "gedecktes warmes helles Blau").
const HUE_ANCHORS = [
  { h: 0, name: "Rot" },
  { h: 30, name: "Orange" },
  { h: 55, name: "Gelb" },
  { h: 120, name: "Grün" },
  { h: 175, name: "Türkis" },
  { h: 225, name: "Blau" },
  { h: 275, name: "Violett" },
  { h: 335, name: "Pink" },
];

function hueDist(a, b) {
  const d = Math.abs(a - b) % 360;
  return d > 180 ? 360 - d : d;
}

function colorName(hex) {
  const { h, s, l } = hexToHsl(hex);
  if (s < 8) {
    if (l < 8) return "Schwarz";
    if (l < 22) return "Anthrazit";
    if (l < 40) return "Dunkelgrau";
    if (l < 58) return "Grau";
    if (l < 78) return "Hellgrau";
    if (l < 93) return "Fast-Weiß";
    return "Weiß";
  }
  const base = [...HUE_ANCHORS].sort((a, b) => hueDist(h, a.h) - hueDist(h, b.h))[0];
  // Reihenfolge der Qualifizierer: Chroma, Temperatur, Helligkeit.
  const quals = [];
  if (s >= 70) quals.push("klares");
  else if (s <= 25) quals.push("gedecktes");
  const warmth = Math.cos(((h - 40) * Math.PI) / 180); // Warm-Zentrum ~ Orange
  if (warmth > 0.45) quals.push("warmes");
  else if (warmth < -0.45) quals.push("kühles");
  if (l >= 70) quals.push("helles");
  else if (l <= 35) quals.push("dunkles");
  return `${quals.join(" ")} ${base.name}`.trim();
}

// Echten, selbst vergebenen Namen behalten; auto-generierte "Farbe N" durch den
// beschreibenden Namen ersetzen.
function displayColorName(color) {
  return !color.name || /^Farbe\s*\d+$/i.test(color.name) ? colorName(color.hex) : color.name;
}

// "HELLES MAGENTA" -> "Helles Magenta": jedes Wort gross-klein statt nur den
// ersten Buchstaben der ganzen ALLCAPS-Rohdaten (Adina, 16.09.2026) - eigene
// Funktion statt CSS text-transform, weil es dafuer keinen sauberen CSS-Weg
// gibt (capitalize liesse Reststring-Grossbuchstaben unangetastet). Nur fuer
// die Anzeige auf "Am Foto testen", die Rohdaten/anderen Bildschirme bleiben
// ALLCAPS wie bisher.
function titleCaseName(name) {
  return name.toLowerCase().replace(/(^|[\s-])([a-zà-ÿ])/g, (m, sep, ch) => sep + ch.toUpperCase());
}

// HSL -> Hex (fuer den feinen Color-Picker mit Helligkeit/Saettigung/Waerme).
function hslToHex(h, s, l) {
  s /= 100;
  l /= 100;
  const c = (1 - Math.abs(2 * l - 1)) * s;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = l - c / 2;
  let r = 0;
  let g = 0;
  let b = 0;
  if (h < 60) [r, g] = [c, x];
  else if (h < 120) [r, g] = [x, c];
  else if (h < 180) [g, b] = [c, x];
  else if (h < 240) [g, b] = [x, c];
  else if (h < 300) [r, b] = [x, c];
  else [r, b] = [c, x];
  const to = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
  return "#" + to(r) + to(g) + to(b);
}

// Ursprungs-Farbtyp einer Farbe (per Hex-Lookup in der Config), damit man in der
// finalen Palette sieht, woher ein Ton stammt. Lazy gecached.
let _hexToTypeName = null;
function originTypeName(hex) {
  if (!_hexToTypeName) {
    _hexToTypeName = {};
    for (const t of Config.FARBTYPEN) {
      for (const c of t.colors) {
        const k = c.hex.toLowerCase();
        if (!_hexToTypeName[k]) _hexToTypeName[k] = t.name;
      }
    }
  }
  return _hexToTypeName[hex.toLowerCase()] || null;
}

// Inhalt einer Farbkachel: gecroptes Gesicht in der Farbe, Haken oben rechts,
// Farbname darunter. Ohne eigenen Button, damit es in verschiedenen Containern
// (Button in der Uebersicht, ziehbare Zelle im Popup) genutzt werden kann.
function FaceSwatch({ color, photoUrl, clip, selected, dimmed, onReject, typeName, showBest = true }) {
  return (
    <React.Fragment>
      <span className="ft-draping-swatch" style={{ background: color.hex }}>
        <span className="ft-draping-face" style={{ aspectRatio: clip.clipAspect }}>
          <img src={photoUrl} style={clip.faceStyle} alt="" draggable={false} />
        </span>
        <AuswahlCheck selected={selected} className="ft-corner-check" size={13} tooltip="In die engere Auswahl" />
        {onReject && (
          <span className="ft-corner-x">
            <button
              type="button"
              aria-label={dimmed ? "Farbe wieder anzeigen" : "Farbe ausblenden"}
              data-tooltip={dimmed ? "Wieder anzeigen" : "Ausblenden"}
              onClick={(e) => {
                e.stopPropagation();
                onReject();
              }}
            >
              <CrossIcon size={13} strokeWidth={2} />
            </button>
          </span>
        )}
      </span>
      <span className="ft-facetile-name">
        {titleCaseName(displayColorName(color))}
        {showBest && color.best && (
          <span
            className="ft-facetile-star"
            data-tooltip={`Diese Farben stehen einem ${typeName || "diesem Farbtyp"} häufig am besten`}
            tabIndex={0}
          >
            ★
          </span>
        )}
      </span>
    </React.Fragment>
  );
}

// hiddenReason erklaert, WARUM eine bereits ausgeblendete Kachel ausgeblendet
// ist - "manual" (per X angeklickt) vs. "compare" (im Vergleichs-Popup gegen
// einen aehnlichen Ton verloren), siehe rejectedReason in SchrittFotoTest
// (Adina, 17.09.2026).
const HIDDEN_REASON_TEXT = {
  manual: "Du hast diesen Ton ausgeblendet",
  compare: "Du hast dich bereits für eine ähnliche Nuance entschieden",
};
function FaceTile({ color, photoUrl, clip, selected, dimmed, hiddenReason, onToggle, onReject, typeName, origins }) {
  // Kein <button> als Wrapper: FaceSwatch enthaelt selbst einen <button>
  // (X zum Ausblenden), und verschachtelte <button>-Elemente sind ungueltiges
  // HTML - Safari baut das DOM dabei intern um, wodurch Klicks auf die
  // Kachel ins Leere liefen ("Bildschirm wird grau, nichts passiert").
  const reasonText = dimmed ? HIDDEN_REASON_TEXT[hiddenReason] || HIDDEN_REASON_TEXT.compare : null;
  const showX = !!onReject && !(dimmed && hiddenReason !== "manual");
  return (
    <div
      role="button"
      tabIndex={0}
      className={"ft-facetile" + (selected ? " is-selected" : "") + (dimmed ? " is-dimmed" : "")}
      onClick={() => onToggle(color)}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          onToggle(color);
        }
      }}
      aria-pressed={selected}
      aria-label={displayColorName(color)}
      data-tooltip={reasonText || undefined}
    >
      <FaceSwatch
        color={color}
        photoUrl={photoUrl}
        clip={clip}
        selected={selected}
        dimmed={dimmed}
        onReject={showX ? onReject : undefined}
        typeName={typeName}
        showBest={!origins}
      />
      {origins && <FamilyOrigins origins={origins} />}
    </div>
  );
}

// Herkunftszeile im Vergleichs-Popup: eine zu einem Hex zusammengefasste
// Kachel (siehe groupFamilyByHex) kann aus mehreren Typen stammen - hier
// alle aufgelistet, mit Stern hinter dem jeweiligen Typ, wenn die Farbe
// GENAU DORT zu den besten zaehlt (kann je Typ unterschiedlich sein).
function FamilyOrigins({ origins }) {
  return (
    <span className="ft-family-origin">
      {origins.map((o, i) => (
        <React.Fragment key={o.typeName + i}>
          {i > 0 && ", "}
          {o.typeName}
          {o.best && (
            <span
              className="ft-facetile-star"
              data-tooltip={`Diese Farbe steht einem ${o.typeName} häufig am besten`}
              tabIndex={0}
            >
              ★
            </span>
          )}
        </React.Fragment>
      ))}
    </span>
  );
}

function moveItem(arr, from, to) {
  const next = arr.slice();
  const [it] = next.splice(from, 1);
  next.splice(to, 0, it);
  return next;
}

// Ziehbares Raster fuer das Familien-Popup: Kacheln lassen sich per Drag & Drop
// umsortieren ("Homescreen-Style"), damit man die interessanten Toene nebenein-
// ander legen kann. Umsortieren wird per FLIP animiert; ein kurzer Klick (ohne
// Ziehen) togglet die Farbe. Reihenfolge ist rein lokal (nur fuers Vergleichen).
function SortableFamilyGrid({ items, photoUrl, clip, shortlistSet, onToggle }) {
  const [order, setOrder] = useState(items);
  useEffect(() => {
    setOrder(items);
  }, [items]);

  const cells = useRef([]);
  const flipPrev = useRef(null);
  const dragMeta = useRef(null);
  const [ghost, setGhost] = useState(null);
  // Drosselt die Umsortier-Berechnung auf einmal pro Bildwechsel statt bei
  // jedem rohen pointermove-Event, sonst kann winziges Mauszittern ueber
  // eine Zellgrenze hinweg zu sofortigem Neu-Sortieren+Neu-Animieren fuehren
  // ("flattert", Adina, 17.09.2026 - selbe Ursache wie im Reduktions-Board).
  const rafRef = useRef(null);
  const pendingMoveRef = useRef(null);
  const lastSwapRef = useRef(null);
  useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);

  useLayoutEffect(() => {
    const prev = flipPrev.current;
    if (!prev) return;
    flipPrev.current = null;
    order.forEach((item, i) => {
      const el = cells.current[i];
      if (!el || el.style.visibility === "hidden") return;
      const old = prev.get(item.id);
      if (!old) return;
      const now = el.getBoundingClientRect();
      const dx = old.left - now.left;
      const dy = old.top - now.top;
      if (!dx && !dy) return;
      el.style.transition = "none";
      el.style.transform = `translate(${dx}px, ${dy}px)`;
      requestAnimationFrame(() => {
        el.style.transition = "transform 0.18s var(--ease)";
        el.style.transform = "";
      });
    });
  }, [order]);

  // Vor dem Messen jede laufende FLIP-Animation HART abbrechen (Transform
  // sofort entfernen statt auslaufen zu lassen) - sonst misst getBoundingClientRect
  // bei schnell aufeinanderfolgenden Umsortierungen eine Kachel mitten in
  // ihrer vorherigen Animation statt in der wahren Ruheposition, was sich
  // als ploetzlicher Sprung zeigte (Adina, 17.09.2026: "springt weiter weg,
  // dann flattert es").
  function snapshot() {
    const m = new Map();
    order.forEach((item, i) => {
      const el = cells.current[i];
      if (!el) return;
      el.style.transition = "none";
      el.style.transform = "";
      m.set(item.id, el.getBoundingClientRect());
    });
    flipPrev.current = m;
  }

  function slotAt(x, y, skip) {
    for (let i = 0; i < order.length; i++) {
      if (i === skip) continue;
      const el = cells.current[i];
      if (!el) continue;
      const r = el.getBoundingClientRect();
      if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return i;
    }
    return null;
  }

  function onPointerDown(e, idx) {
    if (e.button != null && e.button > 0) return;
    const r = e.currentTarget.getBoundingClientRect();
    dragMeta.current = { idx, startX: e.clientX, startY: e.clientY, offX: e.clientX - r.left, offY: e.clientY - r.top, w: r.width, moved: false };
    lastSwapRef.current = null;
    e.currentTarget.setPointerCapture(e.pointerId);
  }

  // Anti-Oszillation (siehe ReduceBoard.applyPendingMove fuer die
  // ausfuehrliche Begruendung): nach einem Tausch erst wieder eine
  // Mindestbewegung der Maus verlangen, sonst flattert die verdraengte
  // Kachel bei einem Tausch, der die Maus rechnerisch ueber die
  // Ausgangszelle zurueckbringt.
  function applyPendingMove() {
    rafRef.current = null;
    const pe = pendingMoveRef.current;
    const d = dragMeta.current;
    if (!pe || !d) return;
    if (lastSwapRef.current && Math.hypot(pe.x - lastSwapRef.current.x, pe.y - lastSwapRef.current.y) < 14) {
      setGhost({ idx: d.idx, x: pe.x - d.offX, y: pe.y - d.offY, w: d.w });
      return;
    }
    const target = slotAt(pe.x, pe.y, d.idx);
    if (target != null && target !== d.idx) {
      snapshot();
      setOrder((prev) => moveItem(prev, d.idx, target));
      d.idx = target;
      lastSwapRef.current = { x: pe.x, y: pe.y };
    }
    setGhost({ idx: d.idx, x: pe.x - d.offX, y: pe.y - d.offY, w: d.w });
  }

  function onPointerMove(e) {
    const d = dragMeta.current;
    if (!d) return;
    if (!d.moved) {
      if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < 6) return;
      d.moved = true;
    }
    pendingMoveRef.current = { x: e.clientX, y: e.clientY };
    if (rafRef.current) return;
    rafRef.current = requestAnimationFrame(applyPendingMove);
  }

  function onPointerUp() {
    if (rafRef.current) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    }
    const d = dragMeta.current;
    dragMeta.current = null;
    setGhost(null);
    if (d && !d.moved) onToggle(order[d.idx].color);
  }

  // Die gezogene Kachel bleibt unsichtbar im Layout stehen (Platz bleibt, kein
  // Springen/Schrumpfen des Popups); ein separater "Klon" folgt dem Finger.
  const dragItem = ghost ? order[ghost.idx] : null;
  return (
    <React.Fragment>
      <div
        className="ft-fototest-grid ft-family-grid"
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
      >
        {order.map((item, i) => {
          const hidden = ghost && ghost.idx === i;
          const selected = shortlistSet.has(item.color.hex.toLowerCase());
          return (
            <div
              key={item.id}
              ref={(el) => (cells.current[i] = el)}
              className={"ft-family-cell ft-facetile" + (selected ? " is-selected" : "")}
              style={hidden ? { visibility: "hidden" } : undefined}
              onPointerDown={(e) => onPointerDown(e, i)}
            >
              <FaceSwatch color={item.color} photoUrl={photoUrl} clip={clip} selected={selected} showBest={false} />
              <FamilyOrigins origins={item.origins} />
            </div>
          );
        })}
      </div>
      {dragItem && (
        <div
          className="ft-family-cell ft-facetile ft-family-clone"
          style={{ left: ghost.x + "px", top: ghost.y + "px", width: ghost.w + "px" }}
        >
          <FaceSwatch
            color={dragItem.color}
            photoUrl={photoUrl}
            clip={clip}
            selected={shortlistSet.has(dragItem.color.hex.toLowerCase())}
            showBest={false}
          />
          <FamilyOrigins origins={dragItem.origins} />
        </div>
      )}
    </React.Fragment>
  );
}

// Optiker-Popup: alle Toene einer Farbfamilie (von der ganzen Seite) nebenein-
// ander im Gesicht. Kacheln per Drag & Drop umsortierbar zum Vergleichen,
// beliebig viele behaltbar; Empfehlung ~20% (in ganzen Zahlen).
function FamilyPopup({ family, photoUrl, clip, shortlistSet, onToggle, onConfirm }) {
  const size = family.length;
  const raw = size * 0.2;
  const lo = Math.max(1, Math.floor(raw));
  const hi = Math.max(1, Math.ceil(raw));
  const rec = lo === hi ? `${lo}` : `${lo}–${hi}`;
  // Per Portal direkt an document.body, AUSSERHALB von .ft-app: .ft-app
  // bekommt ab bestimmten Breiten ein CSS-"zoom" (siehe .ft-app-Regeln) -
  // Safari/WebKit berechnet "position: fixed" innerhalb eines gezoomten
  // Containers nicht mehr relativ zum echten Viewport, sondern relativ zu
  // diesem (langen, scrollbaren) Container. Das Popup landete dadurch
  // "mittig der ganzen Seite" statt mittig im sichtbaren Bereich (Adina,
  // 17.09.2026: "so lang, dass ich ganz weit runter scrollen müsste").
  return ReactDOM.createPortal(
    <div className="ft-modal-backdrop" onClick={onConfirm}>
      <div className="ft-modal ft-family-modal" onClick={(e) => e.stopPropagation()}>
        <div className="ft-family-head">
          <h3>Ähnliche Töne</h3>
          <button type="button" className="ft-family-close" onClick={onConfirm} aria-label="Schließen">
            ×
          </button>
        </div>
        <p className="ft-family-q">
          {size === 2 ? "Welcher Ton wirkt" : "Welche Töne wirken"} intuitiv am harmonischsten, am edelsten, ohne
          sich zu sehr in den Vordergrund zu drängen oder von deinem Gesicht abzulenken? Dein Gesicht sollte von der
          Farbe nicht „übermannt“ werden, sondern es harmonisch einbetten.
        </p>
        <p className="ft-family-rec">
          {size > 2 && "Zieh die Kacheln per Drag & Drop nebeneinander, um sie direkt zu vergleichen. "}
          Wähle aus dieser Auswahl intuitiv deine <strong>{rec}</strong> Favoriten und klicke Fertig.
        </p>
        <SortableFamilyGrid
          items={family}
          photoUrl={photoUrl}
          clip={clip}
          shortlistSet={shortlistSet}
          onToggle={onToggle}
        />
        <div className="ft-modal-actions ft-family-actions">
          <button type="button" className="ft-btn ft-btn-primary" onClick={onConfirm}>
            Fertig
          </button>
        </div>
      </div>
    </div>,
    document.body
  );
}

function SchrittFotoTest({ toolData, ranking, onUpdate, onNext, onBack, onGotoStufe }) {
  const ft = toolData.farbtyp_test;
  const hautton = ft?.answers?.hautton && ft.answers.hautton[0];
  const selection = effectiveTestSelection(ft, ranking);
  const photos = toolData.photos || {};
  const photoUrl = photos.gesicht_vorne?.url;
  const framing = photos.gesicht_vorne_zugeschnitten;
  const clip = faceClip(framing);
  const [family, setFamily] = useState(null);

  const shortlist = ft?.test_shortlist || [];
  const shortlistSet = new Set(shortlist.map((c) => c.hex.toLowerCase()));
  const rejectedSet = new Set((ft?.test_rejected || []).map((h) => h.toLowerCase()));
  // Warum eine Farbe ausgeblendet ist ("manual" = per X angeklickt, "compare"
  // = im Vergleichs-Popup gegen einen aehnlichen Ton verloren) - fuer den
  // Erklaer-Tooltip auf bereits ausgeblendeten Kacheln (Adina, 17.09.2026).
  const rejectedReason = ft?.test_rejected_reason || {};

  function toggleColor(color) {
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      const list = cur.test_shortlist || [];
      const key = color.hex.toLowerCase();
      const next = list.some((c) => c.hex.toLowerCase() === key)
        ? list.filter((c) => c.hex.toLowerCase() !== key)
        : [...list, { hex: color.hex, name: color.name, is_neutral: !!color.is_neutral, best: !!color.best }];
      return { ...prev, farbtyp_test: { ...cur, test_shortlist: next } };
    });
  }

  // X-Kreis oben links auf jeder Kachel: Farbe direkt ausblenden (leicht
  // transparent), ohne erst das Familien-Popup zu oeffnen (Adina,
  // 16.09.2026: "um nicht weiter abzulenken"). Lag sie schon in der engeren
  // Auswahl, fliegt sie dort automatisch mit raus, damit "ausgeblendet" und
  // "gedimmt" nicht widersprechen.
  function toggleRejected(color) {
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      const key = color.hex.toLowerCase();
      const rej = new Set(cur.test_rejected || []);
      const reasons = { ...(cur.test_rejected_reason || {}) };
      let nextShortlist = cur.test_shortlist || [];
      if (rej.has(key)) {
        rej.delete(key);
        delete reasons[key];
      } else {
        rej.add(key);
        reasons[key] = "manual";
        nextShortlist = nextShortlist.filter((c) => c.hex.toLowerCase() !== key);
      }
      return {
        ...prev,
        farbtyp_test: { ...cur, test_rejected: [...rej], test_rejected_reason: reasons, test_shortlist: nextShortlist },
      };
    });
  }

  // Kompletter Reset des Farbpaletten-Flows (Auswahl/Foto-Test/Vergleich/
  // Palette) - Adina, 16.09.2026: nach den vielen Umbauten dieser Seite
  // (Zwischentypen, Hautton-Filterung etc.) soll sie den Test komplett neu
  // starten koennen, nicht nur rubrikweise. Faehrt NICHT die Fotos oder die
  // Kontrast/Unterton/Chroma/Vergleiche-Antworten zurueck, nur die
  // Farbpaletten-Arbeitsdaten. Springt zurueck zu "Auswahl" (Schritt 1).
  function resetAllPaletteWork() {
    if (
      !confirm(
        "Setzt die Farbauswahl auf dieser Seite zurück. Alle vorherigen Seiten bleiben bestehen."
      )
    )
      return;
    // Setzt NUR diese und die folgenden Seiten zurueck (Foto-Test/Vergleich/
    // Palette) - die Palettenauswahl selbst (test_selection, Schritt
    // "Auswahl") bleibt bewusst unangetastet (Adina, 16.09.2026: "die
    // vorherige Farbpalettenauswahl nicht zuruecksetzen").
    onUpdate((prev) => ({
      ...prev,
      farbtyp_test: prev.farbtyp_test
        ? { ...prev.farbtyp_test, test_shortlist: [], test_rejected: [], test_rejected_reason: {}, test_reduce: null }
        : prev.farbtyp_test,
      palettes: prev.palettes && prev.palettes[0] ? [{ ...prev.palettes[0], colors: [] }] : prev.palettes,
    }));
  }

  // Reset nur fuer diese eine Rubrik (Farbtyp-Gruppe): nimmt ihre Farben aus
  // engerer Auswahl UND aus den ausgegrauten/verworfenen raus, laesst andere
  // Gruppen unangetastet (Adina, 15.09.2026).
  function resetTypeGroup(typ) {
    const hexes = new Set(visibleColors(typ, hautton).map((c) => c.hex.toLowerCase()));
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      const reasons = { ...(cur.test_rejected_reason || {}) };
      for (const h of hexes) delete reasons[h];
      return {
        ...prev,
        farbtyp_test: {
          ...cur,
          test_shortlist: (cur.test_shortlist || []).filter((c) => !hexes.has(c.hex.toLowerCase())),
          test_rejected: (cur.test_rejected || []).filter((h) => !hexes.has(h.toLowerCase())),
          test_rejected_reason: reasons,
        },
      };
    });
  }

  // Klick auf eine Kachel: gibt es aehnliche Toene auf der Seite, oeffnet sich das
  // Familien-Popup; sonst wird die Farbe direkt markiert.
  function openOrToggle(color) {
    // Erst gruppieren (identische Hex-Werte ueber Typen hinweg zu 1 Kachel),
    // DANN entscheiden ob es ueberhaupt etwas zu vergleichen gibt - bleibt
    // nach dem Gruppieren nur 1 Kachel uebrig (Farbe kommt nur als exaktes
    // Duplikat in mehreren Typen vor, sonst nichts Aehnliches in der Naehe),
    // gibt es nichts zu vergleichen und die Farbe geht direkt in die Auswahl
    // (Adina, 17.09.2026: "wenn es keine aehnlichen toene gibt, dann einfach
    // direkt in die auswahl").
    const grouped = groupFamilyByHex(gatherFamily(color, selection, hautton));
    if (grouped.length <= 1) toggleColor(color);
    else setFamily(grouped);
  }

  // "Fertig" im Popup (oder Klick auf X/aussen): alle verglichenen Toene, die
  // nicht in der engeren Auswahl gelandet sind, werden in der Uebersicht
  // ausgegraut (verworfen); ausgewaehlte Toene werden wieder reaktiviert. Wird
  // das Popup geschlossen OHNE dass irgendeine der verglichenen Farben
  // ausgewaehlt wurde, ist das ein reiner Abbruch - dann bleibt auch die
  // urspruenglich angeklickte Kachel unangetastet/normal, statt ausgegraut zu
  // werden (Adina, 17.09.2026: "die urspruengliche trigger kachel bleibt
  // ausgegraut... bitte wieder normal anzeigen und nicht auswaehlen").
  function confirmFamily(fam) {
    onUpdate((prev) => {
      const cur = ensureFarbtypTest(prev);
      const short = new Set((cur.test_shortlist || []).map((c) => c.hex.toLowerCase()));
      const anySelected = fam.some(({ color }) => short.has(color.hex.toLowerCase()));
      if (!anySelected) return prev;
      const rej = new Set(cur.test_rejected || []);
      const reasons = { ...(cur.test_rejected_reason || {}) };
      for (const { color } of fam) {
        const key = color.hex.toLowerCase();
        if (short.has(key)) {
          rej.delete(key);
          delete reasons[key];
        } else {
          rej.add(key);
          reasons[key] = "compare";
        }
      }
      return { ...prev, farbtyp_test: { ...cur, test_rejected: [...rej], test_rejected_reason: reasons } };
    });
    setFamily(null);
  }

  const RUBRIC_ORDER = ["Rot- und Pinktöne", "Orange- und Gelbtöne", "Grüntöne", "Cyan- und Blautöne", "Violetttöne", "Neutraltöne"];
  const mergedRubrics = (() => {
    const byRubric = new Map();
    for (const typeId of selection) {
      const typ = resolveTyp(typeId);
      if (!typ) continue;
      for (const c of visibleColors(typ, hautton)) {
        const r = c.rubric || "Weitere";
        if (!byRubric.has(r)) byRubric.set(r, new Map());
        const m = byRubric.get(r);
        const k = c.hex.toLowerCase();
        if (!m.has(k)) m.set(k, { color: c, origins: [] });
        m.get(k).origins.push({ typeName: typ.name, best: !!c.best });
      }
    }
    const rubrics = [...byRubric.keys()].sort((a, b) => {
      const ia = RUBRIC_ORDER.indexOf(a);
      const ib = RUBRIC_ORDER.indexOf(b);
      return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
    });
    return rubrics.map((rubric) => {
      const entries = [...byRubric.get(rubric).values()];
      const sorted = harmonicSort(entries.map((e) => e.color));
      const byHex = new Map(entries.map((e) => [e.color.hex.toLowerCase(), e]));
      return { rubric, items: sorted.map((c) => byHex.get(c.hex.toLowerCase())) };
    });
  })();

  if (!photoUrl) {
    return (
      <QuizScreen screenKey="foto-test-leer" eyebrow="Farbpalette" title="Am Foto testen" onBack={onBack} onNext={onNext} nextDisabled={true}>
        <p className="ft-muted">
          Für diesen Schritt brauchen wir dein Frontal-Foto aus Schritt 1.{" "}
          <button type="button" className="ft-btn-inline-link" onClick={() => onGotoStufe(1)}>
            Jetzt hochladen
          </button>
          .
        </p>
      </QuizScreen>
    );
  }

  return (
    <QuizScreen screenKey="foto-test" eyebrow="Farbpalette: Vorauswahl" title="Welche Farben kommen in deine engere Auswahl?" onBack={onBack} onNext={onNext} nextDisabled={shortlist.length === 0}>
      <div className="ft-fototest">
        <QuizTips
          items={[
            "Nimm die harmonischsten Farben in deine engere Auswahl.",
            "Gibt es ähnliche Töne, zeigen wir sie dir nochmal im direkten Vergleich.",
            "Blende Farben aus, die nicht in Frage kommen.",
            "Klicke auf weiter, wenn du deine Vorauswahl getroffen hast (ca. 15-30 Farben)",
          ]}
        />
        <p className="ft-muted">
          <u>Welche Farben gehören in die engere Auswahl?</u>
        </p>
        <ul className="ft-fototest-intro-list">
          <li>Farben, die dein Gesicht harmonisch umranden, ohne damit zu konkurrieren</li>
        </ul>
        <p className="ft-muted">
          <u>Welche Farben gehören nicht in die engere Auswahl?</u>
        </p>
        <ul className="ft-fototest-intro-list">
          <li>Farben, die von deinem Gesicht ablenken, sich in den Vordergrund drängen</li>
          <li>
            Farben, die dein Gesicht gequetscht aussehen lassen oder eine „floating head“-Optik erzeugen (Gesicht
            scheint im Raum zu schweben)
          </li>
        </ul>
        <button
          type="button"
          className="ft-btn-inline-link ft-fototest-reset"
          onClick={resetAllPaletteWork}
          data-tooltip="Setzt die Farbauswahl auf dieser Seite zurück. Alle vorherigen Seiten bleiben bestehen."
        >
          Reset
        </button>

        {shortlist.length > 0 && (
        <div className="ft-card ft-engere">
          <h3>Meine engere Farbauswahl</h3>
          <div className="ft-fototest-grid">
            {shortlist.map((c, i) => (
              <FaceTile key={c.hex + i} color={c} photoUrl={photoUrl} clip={clip} selected onToggle={toggleColor} />
            ))}
          </div>
        </div>
      )}

      {/* Alle Farben der gewaehlten Paletten in EINER Liste, nur nach Farbrubrik
          (Rot- und Pinktoene ...) gruppiert und je Rubrik nach Aehnlichkeit
          sortiert - die Herkunft (Farbtyp) steht unter jeder Kachel (Adina,
          19.09.2026). */}
      <div className="ft-card ft-fototest-group">
        {mergedRubrics.map((g) => (
          <React.Fragment key={g.rubric}>
            <div className="ft-fototest-rubric">{g.rubric}</div>
            <div className="ft-fototest-grid">
              {g.items.map(({ color: c, origins }) => {
                const key = c.hex.toLowerCase();
                return (
                  <FaceTile
                    key={c.hex}
                    color={c}
                    photoUrl={photoUrl}
                    clip={clip}
                    selected={shortlistSet.has(key)}
                    dimmed={rejectedSet.has(key) && !shortlistSet.has(key)}
                    hiddenReason={rejectedReason[key]}
                    onToggle={openOrToggle}
                    onReject={() => toggleRejected(c)}
                    origins={origins}
                  />
                );
              })}
            </div>
          </React.Fragment>
        ))}
      </div>

      {family && (
        <FamilyPopup
          family={family}
          photoUrl={photoUrl}
          clip={clip}
          shortlistSet={shortlistSet}
          onToggle={toggleColor}
          onConfirm={() => confirmFamily(family)}
        />
      )}
      </div>
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 5 C - Vergleichen & reduzieren (Konzept-Abschnitt 6): die engere
// Auswahl wird auf 9 Farben eingedampft (5 neutrale als Anhaltspunkt + 4
// Akzente). Es treten immer die zwei aehnlichsten Toene gegeneinander an
// ("was gefaellt dir besser?"); der Verlierer faellt raus, bis die Zielzahl
// steht. Ergebnis -> Meine Palette.
// ══════════════════════════════════════════════════════════

// Ordnet Farben so an, dass sich aehnliche Toene nebeneinander befinden,
// statt in zufaelliger Datenreihenfolge zu stehen - erleichtert den
// visuellen Vergleich (Adina, 17.09.2026: "aehnliche Farben sollten
// nebeneinander liegen"). Gieriger naechster-Nachbar-Durchlauf ueber den
// wahrnehmungsnahen Lab-Farbabstand (deltaE, s.o.). Wird nur beim initialen
// Aufbau einer Zone und fuer frisch hinzukommende Farben angewendet - eine
// bereits von Hand gezogene Reihenfolge wird dadurch nicht ueberschrieben.
function harmonicSort(colors) {
  if (colors.length <= 2) return colors.slice();
  const remaining = colors.slice();
  const result = [remaining.shift()];
  while (remaining.length) {
    const last = result[result.length - 1];
    let bestIdx = 0;
    let bestDist = Infinity;
    remaining.forEach((c, i) => {
      const d = deltaE(last.hex, c.hex);
      if (d < bestDist) {
        bestDist = d;
        bestIdx = i;
      }
    });
    result.push(remaining.splice(bestIdx, 1)[0]);
  }
  return result;
}

const MAX_PALETTE_SLOTS = 12;
const DEFAULT_SLOTS = { neutral: 5, accent: 4 };
const SLOT_LABEL = { neutral: "Neutralfarbe", accent: "Akzentfarbe" };
const POOL_RUBRIC_ORDER = ["Rot- und Pinktöne", "Orange- und Gelbtöne", "Grüntöne", "Cyan- und Blautöne", "Violetttöne", "Neutraltöne"];

// Party-Emojis regnen ueber den Bildschirm (Adina, 19.09.2026: beim Klick auf
// Weiter zur fertigen Palette). Direkt an document.body, damit sie den
// Screenwechsel und das CSS-zoom von .ft-app ueberleben; per Web Animations
// API, raeumt sich nach dem Ende selbst auf.
function launchPartyEmojis() {
  if (typeof document === "undefined") return;
  if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  const layer = document.createElement("div");
  layer.setAttribute("aria-hidden", "true");
  layer.style.cssText = "position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:9999";
  const emojis = ["🎉", "🎊", "🥳", "✨", "🎈", "💃", "🌈"];
  const count = 46;
  let maxEnd = 0;
  for (let i = 0; i < count; i++) {
    const el = document.createElement("span");
    el.textContent = emojis[Math.floor(Math.random() * emojis.length)];
    const size = 20 + Math.random() * 26;
    const x = Math.random() * 100;
    el.style.cssText = `position:absolute;top:-60px;left:${x}vw;font-size:${size}px;line-height:1;will-change:transform,opacity`;
    layer.appendChild(el);
    const dur = 2600 + Math.random() * 2200;
    const delay = Math.random() * 1400;
    const sway = (Math.random() - 0.5) * 160;
    const rot = (Math.random() - 0.5) * 720;
    el.animate(
      [
        { transform: "translate(0, 0) rotate(0deg)", opacity: 1 },
        { transform: `translate(${sway}px, ${window.innerHeight * 0.55}px) rotate(${rot / 2}deg)`, opacity: 1, offset: 0.6 },
        { transform: `translate(${sway * 1.4}px, ${window.innerHeight + 80}px) rotate(${rot}deg)`, opacity: 0.9 },
      ],
      { duration: dur, delay, easing: "cubic-bezier(0.3, 0.1, 0.6, 1)", fill: "both" }
    );
    maxEnd = Math.max(maxEnd, dur + delay);
  }
  document.body.appendChild(layer);
  setTimeout(() => layer.remove(), maxEnd + 200);
}

function defaultSlots() {
  return { neutral: Array(DEFAULT_SLOTS.neutral).fill(null), accent: Array(DEFAULT_SLOTS.accent).fill(null) };
}
function copySlots(s) {
  return { neutral: [...s.neutral], accent: [...s.accent] };
}
function slotTotal(s) {
  return s.neutral.length + s.accent.length;
}
// Legt hex in Zeile `row` ab: auf Wunsch-Index idx, sonst in die erste freie
// Box, sonst als neue Box am Zeilenende (solange insgesamt <= 12). null =
// kein Platz.
function slotsPlace(slots, hex, row, idx) {
  const next = copySlots(slots);
  const arr = next[row];
  if (idx != null && idx < arr.length) {
    arr[idx] = hex;
    return next;
  }
  const free = arr.indexOf(null);
  if (free >= 0) {
    arr[free] = hex;
    return next;
  }
  if (slotTotal(next) >= MAX_PALETTE_SLOTS) return null;
  arr.push(hex);
  return next;
}
// Nimmt eine Farbe aus ihrer Box: ueber der Standardzahl (5/4) verschwindet
// die Box mit, sonst bleibt sie leer stehen.
function slotsRemove(slots, row, idx) {
  const next = copySlots(slots);
  const arr = next[row];
  if (arr.length > DEFAULT_SLOTS[row]) arr.splice(idx, 1);
  else arr[idx] = null;
  return next;
}
// from: {kind:"pool"} | {kind:"slot",row,idx}; target: {kind:"pool"} | {kind:"row",row} | {kind:"slot",row,idx}
function applyDrop(slots, hex, from, target) {
  if (target.kind === "pool") return from.kind === "slot" ? slotsRemove(slots, from.row, from.idx) : null;
  if (target.kind === "slot") {
    if (from.kind === "slot" && from.row === target.row && from.idx === target.idx) return null;
    const occupant = slots[target.row][target.idx];
    const n = slotsPlace(slots, hex, target.row, target.idx);
    if (from.kind === "slot") {
      if (occupant) {
        n[from.row][from.idx] = occupant;
        return n;
      }
      return slotsRemove(n, from.row, from.idx);
    }
    return n;
  }
  if (from.kind === "slot" && from.row === target.row) return null;
  const n = slotsPlace(slots, hex, target.row);
  if (!n) return null;
  return from.kind === "slot" ? slotsRemove(n, from.row, from.idx) : n;
}

function PaletteTile({ color, photoUrl, clip, hidden, onPointerDown, onActivate }) {
  return (
    <div
      role="button"
      tabIndex={0}
      className="ft-facetile ft-family-cell ft-pal-tile"
      style={hidden ? { visibility: "hidden" } : undefined}
      onPointerDown={onPointerDown}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          onActivate();
        }
      }}
    >
      <span className="ft-draping-swatch" style={{ background: color.hex }}>
        <span className="ft-draping-face" style={{ aspectRatio: clip.clipAspect }}>
          <img src={photoUrl} style={clip.faceStyle} alt="" draggable={false} />
        </span>
      </span>
      <span className="ft-facetile-name">{titleCaseName(displayColorName(color))}</span>
    </div>
  );
}

// Palette zusammenstellen (Adina, 19.09.2026): oben zwei Zeilen mit Boxen
// (Neutralfarben 5, Akzentfarben 4, per + erweiterbar, insgesamt max. 12),
// unten der Pool mit allen Farben der engeren Auswahl. Farben per Drag & Drop
// (oder Klick) in die Boxen und zurueck. Die Katalog-Einordnung (is_neutral)
// bestimmt nur, wohin ein Klick eine Farbe legt - die TN entscheidet selbst.
function SchrittVergleich({ toolData, onUpdate, onNext, onBack }) {
  const ft = toolData.farbtyp_test;
  const shortlist = ft?.test_shortlist || [];
  const photos = toolData.photos || {};
  const photoUrl = photos.gesicht_vorne?.url;
  const clip = faceClip(photos.gesicht_vorne_zugeschnitten);

  const sig = shortlist
    .map((c) => c.hex.toLowerCase())
    .sort()
    .join(",");
  const stored = ft?.test_reduce;
  const slots = stored && stored.sig === sig && stored.slots ? stored.slots : defaultSlots();

  const byHex = useMemo(() => new Map(shortlist.map((c) => [c.hex.toLowerCase(), c])), [sig]);
  // Pool-Reihenfolge wie auf der Seite davor: Rubriken, darin nach Aehnlichkeit
  // (ohne Rubrik-Ueberschriften).
  const poolOrder = useMemo(() => {
    const groups = new Map();
    for (const c of shortlist) {
      const r = c.rubric || "Weitere";
      if (!groups.has(r)) groups.set(r, []);
      groups.get(r).push(c);
    }
    const rubrics = [...groups.keys()].sort((a, b) => {
      const ia = POOL_RUBRIC_ORDER.indexOf(a);
      const ib = POOL_RUBRIC_ORDER.indexOf(b);
      return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
    });
    return rubrics.flatMap((r) => harmonicSort(groups.get(r)));
  }, [sig]);

  const placed = new Set([...slots.neutral, ...slots.accent].filter(Boolean));
  const pool = poolOrder.filter((c) => !placed.has(c.hex.toLowerCase()));
  const placedCount = placed.size;

  function save(nextSlots) {
    onUpdate((prev) => ({
      ...prev,
      farbtyp_test: { ...ensureFarbtypTest(prev), test_reduce: { sig, slots: nextSlots } },
    }));
  }
  function addSlot(row) {
    if (slotTotal(slots) >= MAX_PALETTE_SLOTS) return;
    const n = copySlots(slots);
    n[row].push(null);
    save(n);
  }
  function removeSlot(row, idx) {
    if (slots[row][idx]) return;
    const n = copySlots(slots);
    n[row].splice(idx, 1);
    save(n);
  }
  function resetAll() {
    save(defaultSlots());
  }

  // Klick auf eine Pool-Farbe: in die Zeile laut Katalog-Einordnung; Klick auf
  // eine platzierte Farbe: zurueck in den Pool.
  function activate(hex, from) {
    if (from.kind === "slot") {
      save(slotsRemove(slots, from.row, from.idx));
      return;
    }
    const c = byHex.get(hex);
    const row = c && c.is_neutral ? "neutral" : "accent";
    const n = slotsPlace(slots, hex, row) || slotsPlace(slots, hex, row === "neutral" ? "accent" : "neutral");
    if (n) save(n);
  }

  // ── Drag & Drop (Pointer-Events, Ghost per Portal wegen CSS-zoom) ──
  const dragRef = useRef(null);
  const [ghost, setGhost] = useState(null);
  const [over, setOver] = useState(null);
  const latest = useRef({ slots, save, activate });
  latest.current = { slots, save, activate };

  function targetAt(x, y) {
    const els = document.elementsFromPoint(x, y);
    for (const el of els) {
      if (el.dataset && el.dataset.slotRow != null) return { kind: "slot", row: el.dataset.slotRow, idx: Number(el.dataset.slotIdx) };
    }
    for (const el of els) {
      if (el.dataset && el.dataset.rowDrop) return { kind: "row", row: el.dataset.rowDrop };
    }
    for (const el of els) {
      if (el.dataset && el.dataset.poolDrop != null) return { kind: "pool" };
    }
    return null;
  }
  const targetKey = (t) => (t ? (t.kind === "slot" ? `s-${t.row}-${t.idx}` : t.kind === "row" ? `r-${t.row}` : "pool") : null);

  function startDrag(e, hex, from) {
    if (e.button != null && e.button > 0) return;
    const r = e.currentTarget.getBoundingClientRect();
    if (e.pointerType === "mouse") e.preventDefault();
    dragRef.current = { hex, from, startX: e.clientX, startY: e.clientY, offX: e.clientX - r.left, offY: e.clientY - r.top, w: r.width, moved: false };
    const move = (ev) => {
      const d = dragRef.current;
      if (!d) return;
      if (!d.moved) {
        if (Math.hypot(ev.clientX - d.startX, ev.clientY - d.startY) < 6) return;
        d.moved = true;
        document.body.style.userSelect = "none";
        document.body.style.webkitUserSelect = "none";
        window.getSelection && window.getSelection().removeAllRanges();
      }
      setGhost({ hex: d.hex, x: ev.clientX - d.offX, y: ev.clientY - d.offY, w: d.w });
      setOver(targetKey(targetAt(ev.clientX, ev.clientY)));
    };
    const up = (ev) => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
      document.body.style.userSelect = "";
      document.body.style.webkitUserSelect = "";
      const d = dragRef.current;
      dragRef.current = null;
      setGhost(null);
      setOver(null);
      if (!d) return;
      const cur = latest.current;
      if (!d.moved) {
        cur.activate(d.hex, d.from);
        return;
      }
      if (ev.type === "pointercancel") return;
      const t = targetAt(ev.clientX, ev.clientY);
      if (!t) return;
      const n = applyDrop(cur.slots, d.hex, d.from, t);
      if (n) cur.save(n);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
  }

  function finish() {
    const final = [
      ...slots.neutral.filter(Boolean).map((h) => ({ c: byHex.get(h), neutral: true })),
      ...slots.accent.filter(Boolean).map((h) => ({ c: byHex.get(h), neutral: false })),
    ].filter((x) => x.c);
    onUpdate((prev) => {
      const pal = prev.palettes[0] || { id: newId(), name: "Meine Palette", created_at: new Date().toISOString(), colors: [] };
      const colors = final.map(({ c, neutral }) => ({
        id: newId(),
        hex: c.hex,
        name: displayColorName(c),
        is_neutral: neutral,
        source: "farbtest",
      }));
      return { ...prev, palettes: [{ ...pal, colors }] };
    });
    launchPartyEmojis();
    onNext();
  }

  if (!photoUrl || shortlist.length === 0) {
    return (
      <QuizScreen screenKey="vergleich-leer" eyebrow="Farbpalette" title="Vergleichen & reduzieren" onBack={onBack} onNext={onNext} nextDisabled={true}>
        <p className="ft-muted">Für diesen Schritt brauchst du zuerst eine engere Farbauswahl aus dem Foto-Test.</p>
      </QuizScreen>
    );
  }

  const ghostColor = ghost ? byHex.get(ghost.hex) : null;
  const canAdd = slotTotal(slots) < MAX_PALETTE_SLOTS;

  function renderRow(row) {
    return (
      <div className="ft-pal-rowwrap">
        <div className={"ft-pal-row" + (over === `r-${row}` ? " is-drop-over" : "")} data-row-drop={row}>
          {slots[row].map((hex, i) => {
            const color = hex ? byHex.get(hex) : null;
            const from = { kind: "slot", row, idx: i };
            // Beim Herausziehen sofort die freie Box zeigen, falls sie nach
            // der Standardzahl-Regel stehen bleibt (sonst verschwindet sie).
            const vacated = !!hex && !!ghost && ghost.hex === hex && slots[row].length <= DEFAULT_SLOTS[row];
            return (
              <div
                key={row + i}
                className={"ft-pal-slot" + (over === `s-${row}-${i}` ? " is-drop-over" : "")}
                data-slot-row={row}
                data-slot-idx={i}
              >
                {color && !vacated ? (
                  <PaletteTile
                    color={color}
                    photoUrl={photoUrl}
                    clip={clip}
                    hidden={ghost && ghost.hex === hex}
                    onPointerDown={(e) => startDrag(e, hex, from)}
                    onActivate={() => activate(hex, from)}
                  />
                ) : (
                  <React.Fragment>
                    <span className="ft-pal-slot-box">
                      <span className="ft-pal-slot-label">
                        {i + 1}. {SLOT_LABEL[row]}
                      </span>
                      {!hex && (
                        <button
                          type="button"
                          className="ft-round-btn ft-round-btn-secondary ft-pal-removebtn"
                          onClick={() => removeSlot(row, i)}
                          aria-label="Leere Box entfernen"
                        >
                          <MinusIcon size={12} />
                        </button>
                      )}
                    </span>
                    <span className="ft-facetile-name">&nbsp;</span>
                  </React.Fragment>
                )}
              </div>
            );
          })}
          <button
            type="button"
            className="ft-round-btn ft-round-btn-secondary ft-pal-addbtn"
            onClick={() => addSlot(row)}
            disabled={!canAdd}
            aria-label={row === "neutral" ? "Weitere Box für Neutralfarben" : "Weitere Box für Akzentfarben"}
          >
            <PlusIcon size={12} />
          </button>
        </div>
      </div>
    );
  }

  return (
    <QuizScreen screenKey="vergleich" eyebrow="Eigene Farbpalette zusammenstellen" title="Erstelle deine 9-Farben-Palette" onBack={onBack} onNext={finish} nextDisabled={placedCount === 0}>
      <div className="ft-vergleich">
        <p className="ft-muted">
          <strong>Stelle nun deine Farbpalette zusammen.</strong> 🎉
        </p>
        <p className="ft-muted">
          Wir empfehlen: <strong>insgesamt 9 Farben</strong> - z. B. <strong>5 Neutralfarben + 4 Akzentfarben</strong>.
          <br />
          Diese Paletten haben sich in der Capsule Wardrobe Szene <strong>vielerseits bewährt</strong> - sie sind{" "}
          <strong>vielseitig</strong> und <strong>leicht kombinierbar</strong>.
        </p>
        <p className="ft-muted">
          <strong>Entscheide selbst</strong>, welche Farben für dich als <strong>Neutralfarbe</strong> zählen (eher
          unauffällig), und welche als <strong>Akzentfarbe</strong>. Zieh die Farben einfach per{" "}
          <strong>Drag & Drop</strong> dorthin, wo du sie willst.
        </p>
        <div className="ft-pal-builder">
          {renderRow("neutral")}
          {renderRow("accent")}
          <div className={"ft-card ft-fototest-grid ft-pal-pool" + (over === "pool" ? " is-drop-over" : "")} data-pool-drop="1">
            {pool.map((c) => (
              <PaletteTile
                key={c.hex}
                color={c}
                photoUrl={photoUrl}
                clip={clip}
                hidden={ghost && ghost.hex === c.hex.toLowerCase()}
                onPointerDown={(e) => startDrag(e, c.hex.toLowerCase(), { kind: "pool" })}
                onActivate={() => activate(c.hex.toLowerCase(), { kind: "pool" })}
              />
            ))}
            {pool.length === 0 && <p className="ft-reduce-empty">Alle Farben liegen in deiner Palette.</p>}
          </div>
        </div>
        <button type="button" className="ft-rubrik-reset" onClick={resetAll}>
          Reset
        </button>
      </div>
      {ghostColor &&
        ReactDOM.createPortal(
          <div className="ft-family-cell ft-facetile ft-family-clone" style={{ left: ghost.x + "px", top: ghost.y + "px", width: ghost.w + "px" }}>
            <span className="ft-draping-swatch" style={{ background: ghostColor.hex }}>
              <span className="ft-draping-face" style={{ aspectRatio: clip.clipAspect }}>
                <img src={photoUrl} style={clip.faceStyle} alt="" draggable={false} />
              </span>
            </span>
          </div>,
          document.body
        )}
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 5, "Meine Palette" (Konzept-Abschnitt 6, Schritt 8) - bleibt als
// finale Palette erhalten; das Ergebnis des Foto-Tests fliesst spaeter hierher.
// ══════════════════════════════════════════════════════════
// Max. 9 Farben, Neutral-Flag, Reihenfolge, Namen, Validierung. Genau eine
// Palette pro Userin (nur palettes[0]). Farbquellen: (a) Klick in den
// Farbtyp-Karten (Tab "Farbtypen entdecken", bereits verdrahtet), (b) freier
// Color-Picker.
// ══════════════════════════════════════════════════════════

const HEX_RE = /^#[0-9a-fA-F]{6}$/;

function ensurePalette(toolData) {
  return toolData.palettes[0] || { id: newId(), name: "Meine Palette", created_at: new Date().toISOString(), colors: [] };
}

// Feiner Color-Picker: Wärme (Farbton), Sättigung, Helligkeit als Regler mit
// Live-Vorschau. Zum Anpassen bestehender oder Hinzufuegen neuer Farben.
function ColorEditor({ value, onCancel, onSave }) {
  const init = hexToHsl(value.hex);
  const [h, setH] = useState(Math.round(init.h));
  const [s, setS] = useState(Math.round(init.s));
  const [l, setL] = useState(Math.round(init.l));
  const [name, setName] = useState(value.name && !/^Farbe\s*\d+$/i.test(value.name) ? value.name : "");
  const neutral = !!value.is_neutral;
  const [nameEditing, setNameEditing] = useState(false);
  const hex = hslToHex(h, s, l);
  const auto = colorName(hex);

  const waermeTrack = "linear-gradient(to right,#e74c3c,#e67e22,#f1c40f,#2ecc71,#1abc9c,#3498db,#9b59b6,#e74c3c)";
  const sattTrack = `linear-gradient(to right, hsl(${h},0%,${l}%), hsl(${h},100%,${l}%))`;
  const hellTrack = `linear-gradient(to right, hsl(${h},${s}%,12%), hsl(${h},${s}%,50%), hsl(${h},${s}%,92%))`;

  return (
    <div className="ft-modal-backdrop" onClick={onCancel}>
      <div className="ft-modal ft-coloredit" onClick={(e) => e.stopPropagation()}>
        <div className="ft-family-head">
          <h3>{value.id ? "Farbe anpassen" : "Farbe hinzufügen"}</h3>
          <button type="button" className="ft-family-close" onClick={onCancel} aria-label="Schließen">
            ×
          </button>
        </div>

        <div className="ft-coloredit-preview">
          <span className="ft-coloredit-swatch" style={{ background: hex }} />
          <div>
            {nameEditing ? (
              <input
                type="text"
                className="ft-coloredit-name ft-coloredit-name-input"
                autoFocus
                value={name}
                placeholder={auto}
                onChange={(e) => setName(e.target.value)}
                onFocus={(e) => e.target.select()}
                onBlur={() => setNameEditing(false)}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    e.preventDefault();
                    setNameEditing(false);
                  }
                }}
              />
            ) : (
              <div
                className="ft-coloredit-name ft-coloredit-name-click"
                role="button"
                tabIndex={0}
                title="Namen bearbeiten"
                onClick={() => {
                  if (!name.trim()) setName(auto);
                  setNameEditing(true);
                }}
                onKeyDown={(e) => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault();
                    if (!name.trim()) setName(auto);
                    setNameEditing(true);
                  }
                }}
              >
                {name.trim() || auto}
              </div>
            )}
            <div className="ft-coloredit-hex">{hex.toUpperCase()}</div>
          </div>
        </div>

        <label className="ft-slider-label">
          <span>Farbton (Wärme)</span>
          <input type="range" min="0" max="359" value={h} className="ft-slider" style={{ background: waermeTrack }} onChange={(e) => setH(+e.target.value)} />
        </label>
        <label className="ft-slider-label">
          <span>Sättigung</span>
          <input type="range" min="0" max="100" value={s} className="ft-slider" style={{ background: sattTrack }} onChange={(e) => setS(+e.target.value)} />
        </label>
        <label className="ft-slider-label">
          <span>Helligkeit</span>
          <input type="range" min="0" max="100" value={l} className="ft-slider" style={{ background: hellTrack }} onChange={(e) => setL(+e.target.value)} />
        </label>

        <div className="ft-modal-actions ft-coloredit-actions">
          <button type="button" className="ft-btn ft-btn-secondary" onClick={onCancel}>
            Abbrechen
          </button>
          <button type="button" className="ft-btn ft-btn-primary" onClick={() => onSave({ id: value.id, hex, name: name.trim(), is_neutral: neutral })}>
            {value.id ? "Übernehmen" : "Hinzufügen"}
          </button>
        </div>
      </div>
    </div>
  );
}

// PNG der Palette mit transparentem Hintergrund (Adina, 19.09.2026): Neutral-
// farben oben, Akzentfarben darunter, je Farbe Fläche + Name. Bewusst
// kompakt (~900px breit), gut zum Speichern/Verschicken auf dem Handy.
async function renderPalettePng(neutrals, accents) {
  try {
    if (document.fonts && document.fonts.ready) await document.fonts.ready;
  } catch (e) {}
  const SCALE = 1.5;
  const PER_ROW = 6;
  const PAD = 28;
  const TW = 92;
  const SH = 69;
  const GAP = 14;
  const ROW_GAP = 14;
  const LINE_H = 14;
  const FONT = '300 11px Montserrat, Helvetica, Arial, sans-serif';
  const mctx = document.createElement("canvas").getContext("2d");
  mctx.font = FONT;
  const wrapName = (name) => {
    const words = name.split(" ");
    const lines = [];
    let cur = "";
    for (const w of words) {
      const t = cur ? cur + " " + w : w;
      if (mctx.measureText(t).width <= TW + 6 || !cur) cur = t;
      else {
        lines.push(cur);
        cur = w;
      }
    }
    if (cur) lines.push(cur);
    return lines.slice(0, 2);
  };
  const rows = [];
  for (const list of [neutrals, accents]) {
    for (let i = 0; i < list.length; i += PER_ROW) {
      const items = list.slice(i, i + PER_ROW).map((c) => ({ c, lines: wrapName(displayColorName(c)) }));
      rows.push({ items, h: SH + 8 + Math.max(...items.map((it) => it.lines.length)) * LINE_H });
    }
  }
  const cols = Math.min(PER_ROW, Math.max(1, ...rows.map((r) => r.items.length)));
  const W = PAD * 2 + cols * TW + (cols - 1) * GAP;
  const H = PAD * 2 - 5 + rows.reduce((a, r) => a + r.h, 0) + Math.max(0, rows.length - 1) * ROW_GAP;
  const cv = document.createElement("canvas");
  cv.width = Math.round(W * SCALE);
  cv.height = Math.round(H * SCALE);
  const ctx = cv.getContext("2d");
  ctx.scale(SCALE, SCALE);
  ctx.textAlign = "center";
  ctx.textBaseline = "top";
  ctx.font = FONT;
  let y = PAD;
  rows.forEach((row) => {
    row.items.forEach(({ c, lines }, ci) => {
      const x = PAD + ci * (TW + GAP);
      ctx.fillStyle = c.hex;
      ctx.fillRect(x, y, TW, SH);
      ctx.strokeStyle = "rgba(0,0,0,0.08)";
      ctx.lineWidth = 1;
      ctx.strokeRect(x + 0.5, y + 0.5, TW - 1, SH - 1);
      ctx.fillStyle = "#444443";
      lines.forEach((ln, li) => ctx.fillText(ln, x + TW / 2, y + SH + 8 + li * LINE_H));
    });
    y += row.h + ROW_GAP;
  });
  return new Promise((resolve) => cv.toBlob((b) => resolve(b), "image/png"));
}

function TabPalette({ toolData, onUpdate, onBack }) {
  const palette = toolData.palettes[0] || null;
  const colors = palette?.colors || [];
  const [editing, setEditing] = useState(null); // { id?, hex, name, is_neutral }

  function updatePalette(mutator) {
    onUpdate((prev) => ({ ...prev, palettes: [mutator(ensurePalette(prev))] }));
  }
  function removeColor(id) {
    updatePalette((pal) => ({ ...pal, colors: pal.colors.filter((c) => c.id !== id) }));
  }
  function saveColor(edited) {
    updatePalette((pal) => {
      if (edited.id) {
        return {
          ...pal,
          colors: pal.colors.map((c) => (c.id === edited.id ? { ...c, hex: edited.hex, name: edited.name, is_neutral: edited.is_neutral } : c)),
        };
      }
      return {
        ...pal,
        colors: [...pal.colors, { id: newId(), hex: edited.hex, name: edited.name, is_neutral: edited.is_neutral, source: "custom" }],
      };
    });
    setEditing(null);
  }

  // Zeile 1 = Neutralfarben, Zeile 2 = Akzentfarben (Reihenfolge wie im
  // Palette-zusammenstellen-Schritt), Zeile 3 = Farbe hinzufuegen. Farben lassen
  // sich per Drag & Drop umsortieren und zwischen den Zeilen verschieben (setzt
  // dabei das Neutral-Flag).
  const neutrals = colors.filter((c) => c.is_neutral);
  const accents = colors.filter((c) => !c.is_neutral);

  const dragRef = useRef(null);
  const [ghost, setGhost] = useState(null);
  // Waehrend des Ziehens ordnen sich die Kacheln live um (mit FLIP-Animation),
  // die Zielposition ergibt sich aus festen Slot-Positionen je Zeile (nicht aus
  // den gerade verschobenen Kacheln) - dadurch kein Flattern.
  const [liveOrder, setLiveOrder] = useState(null); // { neutral: [ids], accent: [ids] }
  const liveRef = useRef(null);
  liveRef.current = liveOrder;
  const rowEls = useRef({});
  const tileEls = useRef({});
  const flipPrev = useRef(null);
  const latest = useRef(null);
  latest.current = { colors, updatePalette };

  useLayoutEffect(() => {
    const prev = flipPrev.current;
    if (!prev) return;
    flipPrev.current = null;
    Object.entries(tileEls.current).forEach(([id, el]) => {
      const old = prev.get(id);
      if (!el || !old) return;
      const now = el.getBoundingClientRect();
      const dx = old.left - now.left;
      const dy = old.top - now.top;
      if (!dx && !dy) return;
      el.style.transition = "none";
      el.style.transform = `translate(${dx}px, ${dy}px)`;
      requestAnimationFrame(() => {
        el.style.transition = "transform 0.2s var(--ease)";
        el.style.transform = "";
      });
    });
  }, [liveOrder]);

  function snapshot() {
    const m = new Map();
    Object.entries(tileEls.current).forEach(([id, el]) => {
      if (!el) return;
      el.style.transition = "none";
      el.style.transform = "";
      m.set(id, el.getBoundingClientRect());
    });
    flipPrev.current = m;
  }

  // Zielzeile + Index aus der Mausposition, ueber feste Slot-Geometrie.
  function slotTarget(x, y, dragId) {
    const nEl = rowEls.current.neutral;
    const aEl = rowEls.current.accent;
    if (!nEl || !aEl) return null;
    const nr = nEl.getBoundingClientRect();
    const ar = aEl.getBoundingClientRect();
    const row = y < (nr.bottom + ar.top) / 2 ? "neutral" : "accent";
    const rr = row === "neutral" ? nr : ar;
    const ref = tileEls.current[dragId];
    if (!ref) return null;
    const tr = ref.getBoundingClientRect();
    const cols = window.innerWidth <= 560 ? 3 : 6;
    const gap = cols > 1 ? Math.max(0, (rr.width - cols * tr.width) / (cols - 1)) : 0;
    const col = Math.max(0, Math.min(cols - 1, Math.floor((x - rr.left) / (tr.width + gap))));
    const line = Math.max(0, Math.floor((y - rr.top) / (tr.height + gap)));
    return { row, slot: line * cols + col };
  }

  function startDrag(e, c) {
    if (e.button != null && e.button > 0) return;
    if (e.pointerType === "mouse") e.preventDefault();
    const r = e.currentTarget.getBoundingClientRect();
    dragRef.current = { id: c.id, startX: e.clientX, startY: e.clientY, offX: e.clientX - r.left, offY: e.clientY - r.top, w: r.width, h: r.height, moved: false };
    const move = (ev) => {
      const d = dragRef.current;
      if (!d) return;
      if (!d.moved) {
        if (Math.hypot(ev.clientX - d.startX, ev.clientY - d.startY) < 6) return;
        d.moved = true;
        document.body.style.userSelect = "none";
        document.body.style.webkitUserSelect = "none";
        window.getSelection && window.getSelection().removeAllRanges();
        const cs = latest.current.colors;
        const init = { neutral: cs.filter((x) => x.is_neutral).map((x) => x.id), accent: cs.filter((x) => !x.is_neutral).map((x) => x.id) };
        liveRef.current = init;
        setLiveOrder(init);
      }
      setGhost({ id: d.id, x: ev.clientX - d.offX, y: ev.clientY - d.offY, w: d.w, h: d.h });
      const t = slotTarget(ev.clientX, ev.clientY, d.id);
      const cur = liveRef.current;
      if (!t || !cur) return;
      const without = { neutral: cur.neutral.filter((id) => id !== d.id), accent: cur.accent.filter((id) => id !== d.id) };
      const idx = Math.min(t.slot, without[t.row].length);
      const next = { neutral: [...without.neutral], accent: [...without.accent] };
      next[t.row].splice(idx, 0, d.id);
      if (JSON.stringify(next) === JSON.stringify(cur)) return;
      snapshot();
      liveRef.current = next;
      setLiveOrder(next);
    };
    const up = (ev) => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
      document.body.style.userSelect = "";
      document.body.style.webkitUserSelect = "";
      const d = dragRef.current;
      const live = liveRef.current;
      dragRef.current = null;
      setGhost(null);
      setLiveOrder(null);
      liveRef.current = null;
      if (!d || !d.moved || !live || ev.type === "pointercancel") return;
      latest.current.updatePalette((pal) => {
        const byId = new Map(pal.colors.map((x) => [x.id, x]));
        const N = live.neutral.map((id) => byId.get(id)).filter(Boolean).map((x) => ({ ...x, is_neutral: true }));
        const A = live.accent.map((id) => byId.get(id)).filter(Boolean).map((x) => ({ ...x, is_neutral: false }));
        return { ...pal, colors: [...N, ...A] };
      });
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
  }

  function renderTile(c) {
    return (
      <div
        key={c.id}
        ref={(el) => {
          if (el) tileEls.current[c.id] = el;
          else delete tileEls.current[c.id];
        }}
        className="ft-mp-tile"
        style={ghost && ghost.id === c.id ? { visibility: "hidden" } : undefined}
        onPointerDown={(e) => startDrag(e, c)}
      >
        <span className="ft-mp-swatch" style={{ background: c.hex }}>
          <span className="ft-mp-tools">
            <button
              type="button"
              className="ft-round-btn ft-round-btn-secondary ft-mp-btn"
              onPointerDown={(e) => e.stopPropagation()}
              onClick={() => setEditing({ id: c.id, hex: c.hex, name: c.name, is_neutral: c.is_neutral })}
              aria-label="Farbe anpassen"
            >
              <PencilIcon size={12} />
            </button>
            <button
              type="button"
              className="ft-round-btn ft-round-btn-secondary ft-mp-btn"
              onPointerDown={(e) => e.stopPropagation()}
              onClick={() => removeColor(c.id)}
              aria-label="Farbe entfernen"
            >
              <MinusIcon size={12} />
            </button>
          </span>
        </span>
        <span className="ft-mp-name">{displayColorName(c)}</span>
      </div>
    );
  }

  const byIdAll = new Map(colors.map((x) => [x.id, x]));
  const shownNeutrals = liveOrder ? liveOrder.neutral.map((id) => byIdAll.get(id)).filter(Boolean) : neutrals;
  const shownAccents = liveOrder ? liveOrder.accent.map((id) => byIdAll.get(id)).filter(Boolean) : accents;

  const ghostColor = ghost ? colors.find((c) => c.id === ghost.id) : null;

  // Ausgangsstand = so, wie ihn der Schritt "Palette zusammenstellen" geliefert
  // hat (Neutral-Boxen, dann Akzent-Boxen, mit deren Namen).
  function baselineColors() {
    const ft = toolData.farbtyp_test;
    const slots = ft?.test_reduce?.slots;
    if (!slots) return null;
    const byHex = new Map((ft.test_shortlist || []).map((c) => [c.hex.toLowerCase(), c]));
    const make = (hex, neutral) => {
      const c = byHex.get(hex);
      return c ? { id: newId(), hex: c.hex, name: displayColorName(c), is_neutral: neutral, source: "farbtest" } : null;
    };
    const list = [...slots.neutral.filter(Boolean).map((h) => make(h, true)), ...slots.accent.filter(Boolean).map((h) => make(h, false))].filter(Boolean);
    return list.length ? list : null;
  }
  const baseline = baselineColors();
  function resetPalette() {
    if (!baseline) return;
    if (!confirm("Setzt deine Palette auf den Stand deiner Auswahl von der vorherigen Seite zurück. Sämtliche manuelle Änderungen an deiner Palette werden dabei zurückgesetzt.")) return;
    updatePalette((pal) => ({ ...pal, colors: baseline.map((c) => ({ ...c, id: newId() })) }));
  }

  async function exportPng() {
    if (!colors.length) return;
    const blob = await renderPalettePng(neutrals, accents);
    if (!blob) return;
    const file = new File([blob], "meine-farbpalette.png", { type: "image/png" });
    try {
      if (navigator.canShare && navigator.canShare({ files: [file] }) && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
        await navigator.share({ files: [file], title: "Meine Farbpalette" });
        return;
      }
    } catch (e) {
      if (e && e.name === "AbortError") return;
    }
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "meine-farbpalette.png";
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 2000);
  }

  return (
    <QuizScreen
      screenKey="palette"
      eyebrow="Da ist sie 🎉"
      title="Meine Palette"
      titleAfter={
        <button type="button" className="ft-btn-inline-link ft-mp-export" onClick={exportPng} disabled={!colors.length}>
          Export
        </button>
      } onBack={onBack}
      onNext={() => {}}
      showNext={false}
      nextDisabled={true}
    >
      {colors.length === 0 && <p className="ft-muted">Deine Palette ist noch leer. Füge unten über das Plus eine Farbe hinzu.</p>}

      <div className="ft-mp">
        <div className="ft-mp-row" ref={(el) => (rowEls.current.neutral = el)}>
          {shownNeutrals.map(renderTile)}
        </div>
        <div className="ft-mp-row" ref={(el) => (rowEls.current.accent = el)}>
          {shownAccents.map(renderTile)}
        </div>
        <div className="ft-mp-row">
          <button type="button" className="ft-pal-add" onClick={() => setEditing({ hex: "#c9a3c7", name: "", is_neutral: false })}>
            <span className="ft-tile-plus">
              <PlusIcon />
            </span>
            <span className="ft-pal-add-label">Farbe hinzufügen</span>
          </button>
        </div>
      </div>

      {baseline && (
        <button type="button" className="ft-btn-inline-link ft-fototest-reset" onClick={resetPalette}>
          Reset
        </button>
      )}

      {editing && <ColorEditor value={editing} onCancel={() => setEditing(null)} onSave={saveColor} />}
      {ghostColor &&
        ReactDOM.createPortal(
          <div className="ft-mp-tile ft-family-clone" style={{ left: ghost.x + "px", top: ghost.y + "px", width: ghost.w + "px" }}>
            <span className="ft-mp-swatch" style={{ background: ghostColor.hex }} />
            <span className="ft-mp-name">{displayColorName(ghostColor)}</span>
          </div>,
          document.body
        )}
    </QuizScreen>
  );
}

// ══════════════════════════════════════════════════════════
// Schritt 5: Farben testen - linearer Flow. Schritt A "Auswahl" (Podium +
// Palettenauswahl) ist gebaut; B "Am Foto testen" und C "Vergleichen &
// reduzieren" folgen als naechste Phasen. "Meine Palette" ist ueber die
// Auswahl erreichbar und wird spaeter das Endergebnis des Flows.
// ══════════════════════════════════════════════════════════

// Fuenf Einzelschritte statt Tabs (Adina, 15.09.2026: "kommt alles ins
// Kapitel Farbpalette als einzelne Schritte") - persistiert wie Stufe 1-5
// ueber useChapterStep/quiz_progress.farbenTesten (0-4) statt des alten
// string-keyed farbtyp_test.flow_step. Runde Weiter-/Zurueck-Buttons via
// QuizScreen statt der eckigen "Weiter zu X"-FAB-Buttons. Schritt 0 ist der
// Ergebnis-Reveal (Adina, 15.09.2026: "Das Farbtypergebnis soll schon der
// erste Screen sein von Farbpalette") - vormals der Abschluss von Kapitel 5.
function Stufe5FarbenTesten({ toolData, onUpdate, onGotoStufe }) {
  const ranking = useMemo(() => computeRanking(toolData.farbtyp_test), [toolData.farbtyp_test]);
  const [step, setStep] = useChapterStep(toolData, onUpdate, "farbenTesten");
  const goBack = () => (step === 0 ? (startChapterAtEnd(onUpdate, 5), onGotoStufe(5)) : setStep(step - 1));
  const goNext = () => setStep(step + 1);

  if (step === 1) {
    return <SchrittAuswahl toolData={toolData} ranking={ranking} onUpdate={onUpdate} onGotoStufe={onGotoStufe} onNext={goNext} onBack={goBack} />;
  }
  if (step === 2) {
    return <SchrittFotoTest toolData={toolData} ranking={ranking} onUpdate={onUpdate} onNext={goNext} onBack={goBack} onGotoStufe={onGotoStufe} />;
  }
  if (step === 3) {
    return <SchrittVergleich toolData={toolData} onUpdate={onUpdate} onNext={goNext} onBack={goBack} />;
  }
  if (step === 4) {
    return <TabPalette toolData={toolData} onUpdate={onUpdate} onBack={goBack} />;
  }
  return <SchrittErgebnis toolData={toolData} ranking={ranking} onUpdate={onUpdate} onNext={goNext} onBack={goBack} />;
}

// Startscreen (stufe 0): Cover vor dem eigentlichen Flow - selbes Look&Feel
// wie die QuizScreen-Karten (.ft-card), gleicher runder Senf-Haken-Button
// wie das Weiter auf jedem Folgeschritt (Adina: "der als Startknopf
// fungiert" statt eines eigenen grossen Rechteck-Buttons).
function StartScreen({ onStart }) {
  return (
    <div className="ft-card ft-start-card">
      <p className="ft-start-lead">Bereit, deine besten Farben zu entdecken?</p>
      <p className="ft-start-sub">
        Finde heraus, welche Farben dich strahlen lassen, und bringe deine Garderobe in Einklang mit deiner
        natürlichen Erscheinung.
      </p>
      <button type="button" className="ft-round-btn ft-round-btn-primary" onClick={onStart} aria-label="Farbtypanalyse starten">
        <CheckIcon size={32} strokeWidth={0.8} />
      </button>
      <span className="ft-start-caption">Farbtypanalyse starten</span>
    </div>
  );
}

// ══════════════════════════════════════════════════════════
// App-Shell
// ══════════════════════════════════════════════════════════

function App() {
  const { status, recheck } = useSession();
  const [stufe, setStufe] = useStufeFromUrl();
  const tool = useToolData(status === "in");

  if (status === "checking") {
    return <div className="ft-loading">Lädt …</div>;
  }
  if (status === "anon") {
    return <LoginGate onLoggedIn={recheck} />;
  }

  if (tool.loading) {
    return <div className="ft-loading">Deine Do-it-yourself Farbtypanalyse lädt …</div>;
  }
  if (tool.loadError) {
    return (
      <div className="ft-center-card">
        <h1>Kurze Störung</h1>
        <p className="ft-muted">Deine Daten konnten gerade nicht geladen werden. Bitte lade die Seite neu.</p>
      </div>
    );
  }

  return (
    <div className="ft-app">
      <header className="ft-header">
        <div className="ft-eyebrow">The Miracle Wardrobe</div>
        <h1>Do-it-yourself Farbtypanalyse</h1>
      </header>
      {stufe === 0 ? (
        <StartScreen onStart={() => { startChapterAtBeginning(tool.update, "fotos"); setStufe(1); }} />
      ) : (
        <React.Fragment>
          <ChapterProgress stufe={stufe} setStufe={setStufe} toolData={tool.data} onUpdate={tool.update} />
          <main className="ft-main">
            {stufe === 1 && <Stufe1Fotos toolData={tool.data} onUpdate={tool.update} onGotoStufe={setStufe} />}
            {stufe === 2 && <Stufe2Kontrast toolData={tool.data} onUpdate={tool.update} onGotoStufe={setStufe} />}
            {stufe === 3 && <Stufe3Unterton toolData={tool.data} onUpdate={tool.update} onGotoStufe={setStufe} />}
            {stufe === 4 && <Stufe4Chroma toolData={tool.data} onUpdate={tool.update} onGotoStufe={setStufe} />}
            {stufe === 5 && <Stufe5Ergebnis toolData={tool.data} onUpdate={tool.update} onGotoStufe={setStufe} />}
            {stufe === 6 && <Stufe5FarbenTesten toolData={tool.data} onUpdate={tool.update} onGotoStufe={setStufe} />}
          </main>
        </React.Fragment>
      )}
      <SaveBar dirty={tool.dirty} saving={tool.saving} saveError={tool.saveError} onSave={tool.save} />
      <ConflictDialog
        conflict={tool.conflict}
        onKeepMine={tool.keepMineAfterConflict}
        onTakeServer={tool.takeServerAfterConflict}
      />
    </div>
  );
}

// Faengt Render-Fehler ab, statt dass die Seite (durch das Unmounten des
// ganzen Baums nach einem uncaught error) einfach leer/grau wird, ohne dass
// erkennbar ist warum - der Fehlertext steht dann direkt auf dem Bildschirm
// und kann abgetippt/kopiert werden, auch ohne Browser-Konsole (Adina,
// 16.09.2026: Grau-Bildschirm-Bug beim Farbe-Anklicken bisher nicht
// reproduzierbar, da unklar welcher Fehler dahintersteckt).
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  static getDerivedStateFromError(error) {
    return { error };
  }
  componentDidCatch(error, info) {
    console.error("Farbtool-Fehler:", error, info);
  }
  render() {
    if (this.state.error) {
      const msg = this.state.error && (this.state.error.stack || this.state.error.message || String(this.state.error));
      return (
        <div className="ft-error-boundary">
          <h1>Kurze Störung</h1>
          <p className="ft-muted">
            Bitte kopiere den Text unten und schick ihn uns - so finden wir schneller, was schiefgelaufen ist.
          </p>
          <pre className="ft-error-boundary-msg">{msg}</pre>
          <button type="button" className="ft-btn ft-btn-primary" onClick={() => window.location.reload()}>
            Seite neu laden
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>
);
