/* ============================================================
   HeroWelten — 3-Welten-Hero (heller Aurora-Look) als SPA-Sektion.
   Ersetzt Splash + alten Hero. Wahl der Welt setzt die Pfad-Logik
   darunter (ha→altbau, nb→neubau, lox→gewerbe) via onPick().
   Markup + Interaktion 1:1 aus „Smartmacherei - 3 Welten v3".
   CSS unter .smw gescopet; Welt-Farb-Variablen leben global in index.html.

   Hero-Rework v4 (Feedback-Runde): Bühne ist jetzt ein festes Mit/Ohne-Kachelpaar
   links vom Phone (keine ?stage=a|b|c-Varianten mehr), öffnet ausschließlich beim
   Hover auf #smPhone, einheitlicher Geräte-Bezel statt Doppelrahmen, Boot-Animation
   beim Laden, Mikro-Interaktionen (Hotspot-Scale, Detail-Slide, Chip-Press) und ein
   "Echte App"-Tab, der den Phone-Screen live per <iframe> auf die HA-Demo umschaltet.
============================================================ */
function HeroWelten({ world = "ha", onPick }) {
  const ref = React.useRef(null);
  const onPickRef = React.useRef(onPick);
  const phoneWorldRef = React.useRef(null);

  React.useEffect(() => {
    onPickRef.current = onPick;
  }, [onPick]);

  const HERO_CSS = `
  .smw{ position:relative; overflow:hidden; color:#fff; background:var(--hell-grad);
    font-family:'Outfit',system-ui,sans-serif; -webkit-font-smoothing:antialiased; }
  .smw a{ color:inherit; text-decoration:none; }
  .smw .wrap{ max-width:1240px; margin:0 auto; padding:0 40px; }
  .smw .aurora{ position:absolute; inset:-12%; z-index:0; pointer-events:none; overflow:hidden; }
  .smw .blob{ position:absolute; border-radius:50%; filter:blur(75px); will-change:transform;
    background:radial-gradient(circle at 50% 50%, rgba(255,255,255,.9), rgba(255,255,255,.45) 36%, rgba(255,255,255,0) 70%); }
  .smw .blob.tr{ width:62vw; height:62vw; max-width:760px; max-height:760px; top:-20%; right:-16%; opacity:.96; animation:smwDrift2 24s ease-in-out infinite; }
  .smw .blob.br{ width:40vw; height:40vw; max-width:520px; max-height:520px; right:2%; bottom:-22%; opacity:.5; animation:smwDrift4 30s ease-in-out infinite; }
  .smw .blob.tint{ width:40vw; height:40vw; max-width:520px; max-height:520px; left:50%; bottom:-6%;
    background:radial-gradient(circle at 50% 50%, var(--accent), rgba(255,255,255,0) 66%); opacity:.4; mix-blend-mode:screen; filter:blur(85px); animation:smwDrift1 40s ease-in-out infinite; }
  @keyframes smwDrift1{ 0%,100%{ transform:translate(0,0) scale(1);} 50%{ transform:translate(5%,5%) scale(1.08);} }
  @keyframes smwDrift2{ 0%,100%{ transform:translate(0,0) scale(1);} 50%{ transform:translate(-6%,5%) scale(1.1);} }
  @keyframes smwDrift4{ 0%,100%{ transform:translate(0,0) scale(1);} 50%{ transform:translate(-5%,-6%) scale(1.08);} }
  @media (prefers-reduced-motion: reduce){ .smw .blob{ animation:none !important; } }

  .smw .hero-inner{ display:grid; grid-template-columns:1.05fr .95fr; gap:48px; align-items:center;
    padding:40px 40px 80px; min-height:calc(100vh - 80px); position:relative; z-index:2; }
  /* Fixer .sm-header (index.html) hat z-index:50 und liegt oberhalb dieses Stacking-Contexts —
     ohne diesen Hebel bliebe .detail-back (oben links im Phone) unklickbar, egal welchen
     eigenen z-index er bekommt. Nur waehrend das Detail offen ist ueberholen. */
  .smw .hero-inner:has(#phoneDetail:not([hidden])){ z-index:60; }
  .smw .hero-copy{ max-width:600px; }
  /* Task 1: das Kachelpaar ist so hoch wie das Phone und ueberlagert links davon fast die
     komplette Copy-Spalte (H1 spannt ebenso fast die volle Stage-Hoehe auf). Damit das wie ein
     bewusster "Spotlight auf den Vergleich"-Moment wirkt statt wie ein zufaellig verdecktes
     Wort, dimmen H1 + Lede zusammen mit den Choice-Cards, sobald die Bühne offen ist. */
  .smw .hero-copy h1, .smw .hero-copy .lede{ transition:opacity .4s ease; }
  .smw .hero-copy:has(.choice-zone.app-active) h1,
  .smw .hero-copy:has(.choice-zone.app-active) .lede{ opacity:.22; }
  .smw .hero-copy h1{ font-size:clamp(2.9rem,6vw,5rem); font-weight:800; line-height:.98; letter-spacing:-.035em; }
  .smw .hero-copy h1 .light{ color:rgba(255,255,255,.62); }
  .smw .lede{ margin-top:24px; font-family:'Inter',sans-serif; font-size:1.12rem; line-height:1.55; color:var(--on); font-weight:400; max-width:40ch; }
  .smw .lede b{ color:#fff; font-weight:600; }

  .smw .choices{ margin-top:32px; display:flex; flex-direction:column; gap:10px; }
  .smw .choice{ display:flex; align-items:center; gap:16px; padding:15px 18px; border-radius:16px;
    background:rgba(255,255,255,.13); border:1px solid rgba(255,255,255,.22); cursor:pointer;
    transition:background .2s, transform .2s, box-shadow .2s; text-align:left; width:100%; font-family:inherit; color:#fff; }
  .smw .choice:hover{ background:rgba(255,255,255,.22); transform:translateX(3px); }
  .smw .choice.active{ background:#fff; color:var(--ink); box-shadow:0 16px 36px rgba(0,40,70,.22); transform:translateX(3px); }
  .smw .choice .ci{ width:46px; height:46px; border-radius:12px; flex-shrink:0; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,.2); color:#fff; }
  .smw .choice.active .ci{ background:var(--card-soft); color:var(--accent); }
  .smw .choice .cmid{ flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
  .smw .choice .cname{ font-size:1.08rem; font-weight:700; letter-spacing:-.01em; }
  .smw .choice .cdesc{ font-family:'Inter',sans-serif; font-size:.84rem; color:var(--on-dim); font-weight:400; }
  .smw .choice.active .cdesc{ color:var(--ink-soft); }
  .smw .choice .csys{ font-family:'Inter',sans-serif; font-size:.68rem; font-weight:600; letter-spacing:.02em;
    padding:5px 10px; border-radius:100px; white-space:nowrap; flex-shrink:0; background:rgba(255,255,255,.18); color:#fff; display:flex; align-items:center; gap:6px; }
  .smw .choice.active .csys{ background:var(--card-soft); color:var(--ink-soft); }
  .smw .csys .sysdot{ width:7px; height:7px; border-radius:50%; }
  .smw .sysdot.ha{ background:#03A9F4; } .smw .sysdot.lox{ background:#6CC44F; }
  .smw .csys.split .sysdot{ width:13px; height:7px; border-radius:100px; background:linear-gradient(90deg,#03A9F4 0 50%,#6CC44F 50% 100%); }

  .smw .choice-zone{ position:relative; }
  .smw .choices, .smw .cta-row{ transition:opacity .4s ease, transform .4s ease; }
  .smw .choice-zone.app-active .choices, .smw .choice-zone.app-active .cta-row{ opacity:0; transform:translateY(10px); pointer-events:none; }

  .smw .cta-row{ margin-top:30px; display:flex; align-items:center; gap:14px 24px; flex-wrap:wrap; }
  .smw .btn-go{ display:inline-flex; align-items:center; gap:11px; font-family:'Inter',sans-serif; font-size:1rem; font-weight:600; padding:16px 30px; border-radius:100px; background:#fff; color:var(--deep); transition:.2s; }
  .smw .btn-go:hover{ transform:translateY(-2px); box-shadow:0 14px 30px rgba(0,40,70,.22); }
  .smw .btn-go .ar{ transition:transform .2s; } .smw .btn-go:hover .ar{ transform:translateX(4px); }
  .smw .text-link{ display:inline-flex; align-items:center; gap:8px; font-family:'Inter',sans-serif; font-size:.95rem; font-weight:600; color:#fff; } .smw .text-link::after{ content:"→"; opacity:.8; }
  .smw .text-link:hover{ opacity:.82; }

  .smw .stage{ display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative; min-height:700px; }
  .smw .stage::before{ content:""; position:absolute; width:400px; height:400px; border-radius:50%; background:rgba(255,255,255,.16); filter:blur(12px); }
  /* Neubau: zwei Phones (HA + Loxone) nebeneinander, CGI-Paar aus. */
  .smw .hero-inner:has(.stage.two-phones){ grid-template-columns:0.75fr 1.25fr; }
  .smw .stage.two-phones{ flex-direction:row; gap:22px; align-items:center; justify-content:center; }
  .smw .stage.two-phones::before{ display:none; }
  /* Jedes Phone = halbe Buehne (minus halber Gap), gedeckelt bei 330px -> passt immer ohne
     Horizontal-Scroll in die (fuer nb verbreiterte) Buehne. */
  .smw .stage.two-phones #smPhone, .smw .stage.two-phones #smPhone2{ width:min(330px, calc(50% - 11px), calc((100vh - 180px) * 0.4545)); }
  /* Neubau: Mit/Ohne-CGI-Videos wie in den Einzel-Welten LINKS neben den Phones (im freigeraeumten
     Copy-Bereich, per alignCgiStage mit dem Phone fluchtend) — nur schmaler, damit sie neben die
     breitere Zwei-Phone-Buehne passen. Positionierung bleibt das absolute right:calc(100%+28px). */
  .smw .stage.two-phones .cgi-pair{ width:min(400px, 34vw); }
  .smw #smPhone2{ position:relative; z-index:1; scroll-margin-top:96px; }
  @media (max-width:960px){ .smw .stage.two-phones{ flex-direction:column; gap:26px; } .smw .stage.two-phones #smPhone, .smw .stage.two-phones #smPhone2{ width:min(340px,62vw); } }

  /* — Phone: EIN Geräte-Bezel (Task 3). Vorher: weiße Außenkarte (12px Padding) + separater
     schwarzer Inset-Rahmen (10px) mit sichtbar ungleichen Breiten. Jetzt: ein einziger,
     durchgehender dunkler Rahmen fester Breite (~10px), das Phone-Frame IST der Bezel. */
  /* Dritter min()-Term deckelt die Phone-HOEHE auf Viewport-minus-Header (400/880 -> Faktor .4545):
     das Phone bleibt komplett sichtbar und rutscht nicht mehr unter die fixe Menueleiste. */
  .smw #smPhone{ position:relative; width:min(376px, 42vw, calc((100vh - 180px) * 0.4545)); opacity:1; z-index:1; scroll-margin-top:96px; }
  .smw .phone-frame{ position:relative; width:100%; aspect-ratio:400/880; border-radius:40px;
    background:#0d1117; padding:10px; box-sizing:border-box;
    box-shadow:0 40px 90px rgba(0,42,70,.30), 0 8px 24px rgba(0,42,70,.16), inset 0 0 0 1px rgba(255,255,255,.08); }
  .smw .phone-frame::before, .smw .phone-frame::after{ content:""; position:absolute; background:rgba(255,255,255,.18); }
  .smw .phone-frame::before{ right:-2px; top:19%; width:3px; height:15%; border-radius:2px 0 0 2px; } /* Powertaste */
  .smw .phone-frame::after{ left:-2px; top:12%; width:3px; height:9%; border-radius:0 2px 2px 0; } /* Lautstaerke */
  .smw .phone-screen{ position:relative; width:100%; height:100%; border-radius:30px; overflow:hidden; background:#fff; }
  .smw .phone-shot{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:top; }
  .smw .phone-hotspots{ position:absolute; inset:0; }
  .smw .hotspot{ position:absolute; cursor:pointer; border-radius:14px; background:transparent; border:0; padding:0;
    appearance:none; -webkit-appearance:none; transform-origin:center; transition:transform .15s ease, background .15s ease; }
  /* Task 6: leichtes Anheben (scale) obendrauf zur bestehenden Tint-Hervorhebung. */
  .smw .hotspot:hover, .smw .hotspot:focus-visible{ outline:2px solid rgba(17,168,212,.9); outline-offset:2px;
    background:rgba(17,168,212,.10); transform:scale(1.02); }
  /* Lox-Guided-Mode: eigener Markenakzent (Loxone-Gruen) statt des HA-Blaus, ueberall dort wo
     die Welt sichtbar wird — Hover-Ring hier, Boot-Spinner + Preset-Chips weiter unten. */
  .smw .phone-screen[data-world="lox"] .hotspot:hover, .smw .phone-screen[data-world="lox"] .hotspot:focus-visible{
    outline-color:rgba(108,196,79,.9); background:rgba(108,196,79,.14); }

  /* — Detail-Overlay: Slide-Up beim Öffnen, Slide-Down beim Schließen (Task 6). Statt des
     harten hidden-Umschaltens läuft [hidden] entfernen -> .open (naechster Frame) -> Transition;
     Schließen: .open entfernen -> Transition abwarten -> [hidden] setzen (siehe JS). */
  .smw .phone-detail{ position:absolute; inset:0; background:#fff; opacity:0; transform:translateY(24px);
    transition:opacity .28s ease-out, transform .28s ease-out; }
  .smw .phone-detail.open{ opacity:1; transform:translateY(0); }
  @media (prefers-reduced-motion: reduce){ .smw .phone-detail{ transition:opacity .15s linear; transform:none !important; } }
  .smw .detail-drag{ position:absolute; z-index:3; cursor:grab; touch-action:none; border-radius:999px; }
  .smw .detail-drag:active{ cursor:grabbing; }
  .smw .detail-drag:focus-visible{ outline:2px solid rgba(17,168,212,.9); outline-offset:3px; }
  .smw .detail-drag-handle{ position:absolute; left:50%; top:0; width:64%; height:15%; min-height:16px;
    border-radius:999px; background:#fff; box-shadow:0 6px 16px rgba(8,28,48,.32),0 2px 6px rgba(8,28,48,.2),
    inset 0 0 0 1px rgba(8,28,48,.08); transform:translate(-50%,-50%); pointer-events:none; }
  .smw .detail-drag-handle::after{ content:""; position:absolute; left:50%; top:50%; width:44%; height:3px;
    border-radius:2px; background:rgba(18,32,44,.32); transform:translate(-50%,-50%); }
  /* bottom:26% statt eines Fixpixel-Werts: die site-weite Cookie-Consent-Bar (.sm-cc-overlay,
     z-index 9999, fixed am Viewport-Boden) faengt beim Erstbesuch alle Klicks unterhalb ihrer
     Oberkante ab. Der Detail-Slider endet deshalb schon bei ~75% Dialoghoehe (siehe yBottom in
     hero-assets.js) — die Presets bleiben knapp darueber statt am echten unteren Rand. */
  .smw .detail-presets{ position:absolute; left:0; right:0; bottom:26%; z-index:3; display:flex;
    flex-wrap:wrap; justify-content:center; gap:6px; padding:0 16px; }
  .smw .preset-chip{ font-family:'Inter',sans-serif; font-size:.72rem; font-weight:600; color:#3c4a55;
    background:#fff; border:1px solid #dfe3e7; border-radius:100px; padding:6px 13px; cursor:pointer;
    box-shadow:0 4px 10px rgba(10,20,30,.14); transition:background .15s ease, color .15s ease, transform .15s ease; }
  .smw .preset-chip:hover{ transform:translateY(-1px); }
  .smw .preset-chip:active{ transform:scale(.94); } /* Task 6: Press-Feedback */
  .smw .preset-chip.active{ background:#12202c; color:#fff; border-color:#12202c; }
  /* Lox-Guided-Mode: Loxone-Screens sind dunkel — die HA-weissen Chips wuerden aus der App-Optik
     fallen. Dunkle Pille (Loxone-Kartenfarbe #1C1C1C, siehe LOXONE-APP-SPEC.md) + Markengruen im
     aktiven Zustand statt Weiss/Dunkelblau. */
  .smw .phone-screen[data-world="lox"] .preset-chip{ background:#1c1c1c; color:#e7ece8; border-color:#333;
    box-shadow:0 4px 10px rgba(0,0,0,.4); }
  .smw .phone-screen[data-world="lox"] .preset-chip.active{ background:#6cc44f; color:#0d1117; border-color:#6cc44f; }
  .smw .detail-back{ position:absolute; left:14px; top:14px; width:38px; height:38px; border-radius:50%;
    border:0; background:#fff; color:#12202c; font-size:19px; line-height:1; display:flex;
    align-items:center; justify-content:center; box-shadow:0 6px 16px rgba(8,28,48,.28),0 2px 5px rgba(8,28,48,.18);
    cursor:pointer; z-index:2; }
  .smw .detail-back:hover{ background:#f2f6f9; }

  /* — Boot-Animation (Task 5): kurzer "Gerät lädt"-Zustand beim ersten Mount und bei jedem
     Welt-Wechsel — weißer Screen, Ring-Spinner + Skeleton-Kacheln, dann Screenshot. */
  .smw .phone-boot{ position:absolute; inset:0; z-index:4; background:#fff; display:flex; flex-direction:column;
    align-items:center; padding:30% 18px 0; gap:22px; opacity:0; pointer-events:none; transition:opacity .25s ease; }
  .smw .phone-screen.booting .phone-boot{ opacity:1; }
  .smw .phone-screen.booting .phone-shot, .smw .phone-screen.booting .phone-hotspots{ opacity:0; transition:opacity .2s; }
  .smw .boot-ring{ width:32px; height:32px; border-radius:50%; border:3px solid rgba(3,169,244,.18);
    border-top-color:#03A9F4; animation:smwSpin .8s linear infinite; }
  .smw .phone-screen[data-world="lox"] .boot-ring{ border-color:rgba(108,196,79,.18); border-top-color:#6cc44f; }
  @keyframes smwSpin{ to{ transform:rotate(360deg); } }
  .smw .boot-skel{ width:100%; display:grid; grid-template-columns:1fr 1fr; gap:10px; }
  .smw .boot-skel .bk{ height:50px; border-radius:14px; background:linear-gradient(100deg,#eef2f5 30%,#f8fafb 50%,#eef2f5 70%);
    background-size:200% 100%; animation:smwShimmer 1.3s ease-in-out infinite; }
  @keyframes smwShimmer{ 0%{ background-position:200% 0; } 100%{ background-position:-200% 0; } }
  @media (prefers-reduced-motion: reduce){
    .smw .boot-ring{ animation:none; border-top-color:rgba(3,169,244,.5); }
    .smw .boot-skel .bk{ animation:none; background:#eef2f5; }
  }

  /* — "Echte App" (Task 7): Live-Iframe ersetzt den Screenshot-Modus im Phone-Screen. */
  .smw .phone-live{ position:absolute; inset:0; width:100%; height:100%; border:0; background:#fff; }
  .smw .phone-live[hidden]{ display:none; }
  .smw .phone-tag{ margin-top:12px; text-align:center; font:600 .68rem 'JetBrains Mono',monospace;
    letter-spacing:.13em; text-transform:uppercase; color:rgba(255,255,255,.6); }
  .smw .phone-tabs{ margin:8px auto 0; display:flex; justify-content:center; gap:3px; padding:4px; border-radius:100px;
    background:rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.24); backdrop-filter:blur(12px);
    -webkit-backdrop-filter:blur(12px); width:max-content; }
  .smw .phone-tab{ font-family:'Inter',sans-serif; font-size:.74rem; font-weight:600; color:rgba(255,255,255,.85);
    padding:8px 14px; border-radius:100px; background:transparent; border:0; cursor:pointer; white-space:nowrap;
    transition:background .2s ease, color .2s ease; }
  .smw .phone-tab.active{ background:#fff; color:var(--ink); }
  .smw .phone-tab:not(.active):hover{ background:rgba(255,255,255,.18); color:#fff; }
  .smw .phone-tab[hidden]{ display:none; }
  .smw .stage-hint{ position:absolute; bottom:8px; left:0; right:0; text-align:center; font-family:'Inter',sans-serif; font-size:.8rem; color:var(--on-dim); }

  /* ============================================================
     Task 1 — Mit/Ohne-Kachelpaar: zwei uebereinander gestapelte 16:10-Kacheln links vom
     Phone, kombinierte Hoehe ~ Phone-Hoehe, Pillen-Badges statt Toggle, EINE Bildunterschrift
     unter dem Paar. Öffnet ausschließlich per #smPhone-Hover (Task 2 — siehe JS), niemals per
     Hover auf die Choice-Cards, damit diese immer klickbar bleiben.
  ============================================================ */
  /* top wird per JS (alignCgiStage) auf die Y-Position von .phone-frame gesetzt, NICHT
     zentriert auf choiceZone — das Paar soll oben/unten mit dem Phone fluchten ("ein
     komponiertes Ganzes"), nicht mittig in die Choice-Card-Spalte hineinragen (dort ragt
     es sonst bis in H1/Lede hinein, da seine Gesamthoehe ~ Phone-Hoehe deutlich groesser
     ist als die Choice-Zone). */
  .smw .cgi-pair{ position:absolute; right:calc(100% + 28px); top:0; width:min(560px,46vw);
    display:flex; flex-direction:column; gap:16px; opacity:0; pointer-events:none;
    transform:translateX(14px); transition:opacity .45s ease, transform .45s ease; }
  .smw .cgi-pair.open{ opacity:1; transform:translateX(0); pointer-events:auto; }
  .smw .pair-pane{ margin:0; }
  .smw .pair-media{ position:relative; aspect-ratio:16/10; border-radius:20px; overflow:hidden;
    background:#0c1420; box-shadow:0 20px 46px rgba(6,20,34,.28), 0 4px 14px rgba(6,20,34,.16); }
  .smw .pair-media img, .smw .pair-media video{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
  .smw .pair-active{ opacity:0; transition:opacity .2s linear; }
  .smw .pair-scrub{ opacity:0; }
  .smw .pair-person{ opacity:0; transition:opacity .45s ease; }
  .smw .pair-media.video-ready .pair-scrub{ opacity:1; }
  .smw .pair-media.person-playing .pair-person{ opacity:1; }
  .smw .pair-patch{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity .3s ease; pointer-events:none; }
  .smw .pair-media.patch-on .pair-patch{ opacity:1; }
  .smw .pair-keycanvas{ position:absolute; inset:0; width:100%; height:100%; opacity:0; transition:opacity .3s ease; pointer-events:none; z-index:1; }
  .smw .pair-media.key-on .pair-keycanvas{ opacity:1; }
  .smw .pair-badge{ position:absolute; left:12px; top:12px; z-index:2; display:inline-flex; align-items:center; gap:7px;
    font-family:'Inter',sans-serif; font-size:.72rem; font-weight:600; padding:6px 13px 6px 11px; border-radius:100px;
    backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px); box-shadow:0 6px 16px rgba(4,16,28,.2); }
  .smw .pair-badge .dot{ width:6px; height:6px; border-radius:50%; flex-shrink:0; }
  .smw .pair-badge.good{ background:rgba(54,201,142,.24); color:#eafff5; border:1px solid rgba(120,255,200,.4); }
  .smw .pair-badge.good .dot{ background:#3ddc84; }
  .smw .pair-badge.bad{ background:rgba(255,111,111,.22); color:#fff2f2; border:1px solid rgba(255,160,160,.4); }
  .smw .pair-badge.bad .dot{ background:#ff6f6f; }
  .smw .pair-caption{ margin:0; font-family:'Inter',sans-serif; font-size:.86rem; line-height:1.45; color:var(--on-dim); max-width:44ch; }
  @media (prefers-reduced-motion: reduce){ .smw .pair-scrub, .smw .pair-person{ display:none; } }

  @media (max-width:960px){
    .smw .hero-inner{ grid-template-columns:1fr; gap:50px; padding:96px 40px 50px; min-height:0; } /* padding-top raeumt den fixen Header frei */
    .smw .stage{ order:2; min-height:0; } .smw .hero-copy{ order:1; }
    /* 38vw ist fuer breite Desktop-Spalten kalibriert (z.B. 1440px*.38=547, gedeckelt bei 340px) —
       auf Handybreite (390px*.38=148px) waere das Phone winzig und kaum lesbar. Eigener,
       grosszuegigerer vw-Anteil unterhalb 960px. */
    .smw #smPhone{ width:min(376px,66vw); }
    /* Auf Handybreite gibt es keinen Hover — das Paar liegt deshalb immer sichtbar und
       statisch unter dem Phone statt per Hover einzublenden. */
    .smw .cgi-pair{ position:static; width:100%; margin-top:22px; opacity:1; pointer-events:auto; transform:none; }
    /* .stage hat auf Mobil keine grosszuegige min-height mehr (das Paar ist jetzt selbst hoch
       genug) — der frueher absolut am Stage-Boden verankerte Hint wuerde dadurch die Caption
       ueberlagern statt darunter zu stehen. Normaler Fluss statt bottom-Anker. */
    .smw .stage-hint{ position:static; margin-top:14px; }
  }
  @media (max-width:560px){ .smw .wrap{ padding:0 22px; } .smw .hero-inner{ padding:88px 22px 44px; }
    .smw .detail-presets{ gap:5px; padding:0 8px; } .smw .preset-chip{ padding:4px 8px; font-size:.62rem; }
    /* Beide Tabs passen bei sehr schmalen Phones (<~300px) nicht mehr nebeneinander —
       zweizeilig statt ueber den Bildrand hinaus abgeschnitten. */
    .smw .phone-tabs{ width:auto; max-width:100%; flex-wrap:wrap; } }
  `;

  // Markup (ohne eigenen Header/Footer/Tweaks — die liefert die SPA-Shell)
  const MARKUP = `
  <div class="aurora" aria-hidden="true">
    <span class="blob tr"></span><span class="blob br"></span><span class="blob tint"></span>
  </div>
  <div class="hero-inner wrap">
    <div class="hero-copy">
      <h1>Dein Weg zum<br /><span class="light">smarten Zuhause.</span></h1>
      <p class="lede">Wir machen dein Zuhause smart — <b>ehrlich beraten, sauber installiert</b>. Vom Altbau bis zum Neubau. Wähle deine Ausgangslage:</p>

      <div class="choice-zone" id="choiceZone">
        <div class="choices" id="choices">
          <button class="choice" data-phone="ha">
            <span class="ci"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M3 11l9-7 9 7"/><path d="M5 10v9h14v-9"/><path d="M10 19v-5h4v5"/></svg></span>
            <span class="cmid"><span class="cname">Sanierung</span><span class="cdesc">Bestehendes Zuhause smart nachrüsten</span></span>
            <span class="csys"><span class="sysdot ha"></span>Home Assistant</span>
          </button>
          <button class="choice" data-phone="nb">
            <span class="ci"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M6 21V9h12v12"/><path d="M4 9h16"/><path d="M9 21v-5h3v5"/><path d="M14 12h2.5v2.5H14z"/></svg></span>
            <span class="cmid"><span class="cname">Neubau</span><span class="cdesc">Von Anfang an alles aus einer Hand</span></span>
            <span class="csys split"><span class="sysdot"></span>Loxone + HA</span>
          </button>
          <button class="choice" data-phone="lox">
            <span class="ci"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></span>
            <span class="cmid"><span class="cname">Objekt</span><span class="cdesc">Gewerbe & Vermietung automatisieren</span></span>
            <span class="csys"><span class="sysdot lox"></span>Loxone</span>
          </button>
        </div>
        <div class="cta-row">
          <a href="#konfigurator" class="btn-go" data-scroll="konfigurator">Smart-Home planen <span class="ar">→</span></a>
          <a href="#erklaerung" class="text-link" data-scroll="erklaerung">Wie funktioniert das?</a>
        </div>
      </div>
    </div>

    <div class="stage">
      <!-- Original App-Screenshot mit Klickzonen und Detail-Overlay -->
      <div id="smPhone">
        <div class="phone-frame">
          <div class="phone-screen" id="phoneScreen">
            <img class="phone-shot" id="phoneShot" alt="Original App-Ansicht" draggable="false" />
            <div class="phone-hotspots" id="phoneHotspots"></div>
            <div class="phone-detail" id="phoneDetail" hidden>
              <img class="phone-shot" id="detailShot" alt="" draggable="false" />
              <div class="detail-drag" id="detailDrag" role="slider" tabindex="0" aria-label="Gerät steuern"
                   aria-orientation="vertical" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100">
                <div class="detail-drag-handle" id="detailHandle"></div>
              </div>
              <div class="detail-presets" id="detailPresets" role="group" aria-label="Voreinstellungen"></div>
              <button class="detail-back" id="detailBack" aria-label="Zurück">‹</button>
            </div>
            <div class="phone-boot" id="phoneBoot" aria-hidden="true">
              <div class="boot-ring"></div>
              <div class="boot-skel"><span class="bk"></span><span class="bk"></span><span class="bk"></span><span class="bk"></span></div>
            </div>
            <iframe class="phone-live" id="phoneLive" hidden loading="lazy" title="Home Assistant Demo (echte App)"
              sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"></iframe>
          </div>
        </div>
        <div class="phone-tag" id="phoneTag">Original Home Assistant App</div>
        <div class="phone-tabs" id="phoneTabs" role="tablist" aria-label="Ansicht wählen">
          <button type="button" class="phone-tab active" data-mode="guided" role="tab" aria-selected="true">Geführte Demo</button>
          <button type="button" class="phone-tab" data-mode="live" role="tab" aria-selected="false">Echte App ausprobieren</button>
        </div>
      </div>

      <div id="smPhone2" style="display:none">
        <div class="phone-frame">
          <div class="phone-screen" id="phoneScreen2" data-world="lox">
            <div class="phone-boot" aria-hidden="true">
              <div class="boot-ring"></div>
              <div class="boot-skel"><span class="bk"></span><span class="bk"></span><span class="bk"></span><span class="bk"></span></div>
            </div>
          </div>
        </div>
        <div class="phone-tag">Original Loxone App</div>
      </div>

      <div class="cgi-pair" id="cgiStage">
        <figure class="pair-pane" data-side="mit">
          <div class="pair-media">
            <img class="pair-base" data-base alt="" />
            <img class="pair-active" data-active alt="" />
            <video class="pair-scrub" data-scrub muted playsinline preload="metadata"></video>
            <span class="pair-badge good"><span class="dot"></span>Mit Smartmacherei</span>
          </div>
        </figure>
        <figure class="pair-pane" data-side="ohne">
          <div class="pair-media">
            <img class="pair-base" data-base alt="" />
            <video class="pair-person" data-person muted playsinline preload="metadata"></video>
            <video class="pair-patch" data-patch muted playsinline preload="metadata"></video>
            <canvas class="pair-keycanvas" data-keycanvas></canvas>
            <span class="pair-badge bad"><span class="dot"></span>Ohne</span>
          </div>
        </figure>
        <p class="pair-caption" id="pairCaption"></p>
      </div>

      <div class="stage-hint">↑ Auswahl ändern, um die App zu wechseln</div>
    </div>
  </div>`;

  // Init: house-demo + phone-hover (einmalig); world-getriebene Visuals separat.
  React.useEffect(() => {
    const root = ref.current;
    if (!root) return;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    // Welt-Auswahl -> nach oben melden
    const choices = root.querySelectorAll('.choice');
    const onChoiceClick = (e) => {
      const c = e.currentTarget;
      if (onPickRef.current) onPickRef.current(c.dataset.phone);
      if (phoneWorldRef.current) phoneWorldRef.current(c.dataset.phone);
    };
    choices.forEach((c) => c.addEventListener('click', onChoiceClick));

    // Smooth-scroll CTAs
    const scrollers = root.querySelectorAll('[data-scroll]');
    const onScrollClick = (e) => {
      e.preventDefault();
      const id = e.currentTarget.dataset.scroll;
      if (window.smoothScrollTo) window.smoothScrollTo(id);
      else { const el = document.getElementById(id); if (el) el.scrollIntoView({ behavior: 'smooth' }); }
    };
    scrollers.forEach((a) => a.addEventListener('click', onScrollClick));

    // Original-Screenshot-Phone: Hotspots, Detail-Overlay, Slider (ersetzt frühere Mini-Screen-Repliken)
    const A = window.SM_HERO_ASSETS;
    const phoneScreen = root.querySelector('#phoneScreen');
    const phoneScreen2 = root.querySelector('#phoneScreen2');
    const phone2El = root.querySelector('#smPhone2');
    const phoneShot = root.querySelector('#phoneShot');
    const hotspotHost = root.querySelector('#phoneHotspots');
    const detail = root.querySelector('#phoneDetail');
    const detailShot = root.querySelector('#detailShot');
    const detailDrag = root.querySelector('#detailDrag');
    const detailHandle = root.querySelector('#detailHandle');
    const detailPresets = root.querySelector('#detailPresets');
    const detailBack = root.querySelector('#detailBack');
    const phoneTag = root.querySelector('#phoneTag');
    const stageHint = root.querySelector('.stage-hint');
    let currentEx = null;
    let haApp = null;
    let loxApp = null;
    let loxApp2 = null; // zweites Phone (Loxone) fuer die Neubau-Welt
    let currentWorld = 'ha';
    let dragLevel = 1;
    let tweenRaf = null;
    const clamp01 = (v) => Math.max(0, Math.min(1, v));

    // Task 5: Boot-Animation (weißer Screen + Spinner/Skeleton) beim Mount und bei jedem
    // Welt-Wechsel. Rein zeitgesteuert (kein echtes Warten auf Bildladen nötig — die
    // Screenshots sind winzige lokale Assets), ~700ms wie gefordert.
    let bootTimer = null;
    const showBoot = () => { if (phoneScreen) phoneScreen.classList.add('booting'); };
    const hideBoot = () => { if (phoneScreen) phoneScreen.classList.remove('booting'); };
    const bootPulse = (ms) => {
      showBoot();
      if (bootTimer) clearTimeout(bootTimer);
      bootTimer = setTimeout(hideBoot, ms || 700);
    };

    const setPhoneWorld = (w0) => {
      if (!A || !phoneShot) return;
      const w = (w0 === 'lox') ? 'lox' : (w0 === 'nb') ? 'nb' : 'ha';
      currentWorld = w;
      setMode('guided');
      if (liveTab) liveTab.hidden = (w !== 'ha') || !liveEnabled;
      if (stageHint) stageHint.style.display = ((w !== 'ha') || !liveEnabled) ? 'none' : '';
      // Neubau (nb) = beide Systeme nebeneinander -> keine Tabs, zwei Phones.
      if (phoneTabs) phoneTabs.style.display = (w === 'nb') ? 'none' : '';
      if (stage) stage.classList.toggle('two-phones', w === 'nb');
      bootPulse(700);
      if (phoneScreen) phoneScreen.dataset.world = (w === 'lox') ? 'lox' : 'ha';
      if (phoneTag) phoneTag.textContent = w === 'lox' ? 'Original Loxone App' : 'Home Assistant App';
      closeDetail();
      phoneShot.style.display = 'none';
      hotspotHost.style.display = 'none';
      hotspotHost.innerHTML = '';
      // Phone 1 (#phoneScreen): lox -> Loxone-App, sonst (ha/nb) -> HA-App.
      if (w === 'lox') {
        if (haApp) haApp.hide();
        if (!loxApp && window.LoxAppDemo) loxApp = window.LoxAppDemo.mount(phoneScreen, { actionTarget: root, world: 'lox' });
        if (loxApp) { loxApp.show(); loxApp.showHome(); }
      } else {
        if (loxApp) loxApp.hide();
        if (!haApp && window.HaAppDemo) haApp = window.HaAppDemo.mount(phoneScreen, { actionTarget: root, world: 'ha' });
        if (haApp) { haApp.show(); haApp.showHome(); }
      }
      // Phone 2 (#phoneScreen2): nur in nb -> Loxone-App neben dem HA-Phone.
      if (phone2El) phone2El.style.display = (w === 'nb') ? '' : 'none';
      if (w === 'nb') {
        // Beide Apps "laden": Phone 2 (Loxone) bekommt denselben Boot-Puls wie Phone 1.
        if (phoneScreen2) { phoneScreen2.classList.add('booting'); setTimeout(() => phoneScreen2.classList.remove('booting'), 700); }
        if (!loxApp2 && window.LoxAppDemo && phoneScreen2) loxApp2 = window.LoxAppDemo.mount(phoneScreen2, { actionTarget: root, world: 'lox' });
        if (loxApp2) { loxApp2.show(); loxApp2.showHome(); }
      } else if (loxApp2) { loxApp2.hide(); }
    };

    // Task 7: "Echte App ausprobieren" — Tabs schalten den Phone-Screen zwischen der
    // geführten Screenshot-Demo und einem echten <iframe> auf die HA-Demo um.
    const liveEnabled = window.SM_ENABLE_LIVE_APP !== false;
    const phoneTabs = root.querySelector('#phoneTabs');
    const tabBtns = phoneTabs ? Array.from(phoneTabs.querySelectorAll('.phone-tab')) : [];
    const liveTab = phoneTabs ? phoneTabs.querySelector('[data-mode="live"]') : null;
    const phoneLive = root.querySelector('#phoneLive');
    let liveLoaded = false;
    let liveFallbackTimer = null;
    const setMode = (mode) => {
      tabBtns.forEach((b) => {
        const isActive = b.dataset.mode === mode;
        b.classList.toggle('active', isActive);
        b.setAttribute('aria-selected', String(isActive));
      });
      if (mode === 'live') {
        closeDetail();
        if (haApp) haApp.hide();
        if (loxApp) loxApp.hide();
        if (phoneShot) phoneShot.style.display = 'none';
        if (hotspotHost) hotspotHost.style.display = 'none';
        if (phoneLive) {
          // Fokus-Scroll-Guard: der iframe darf die Seite nicht unter den fixen Header ziehen.
          const y = window.scrollY;
          phoneLive.setAttribute('tabindex', '-1');
          phoneLive.hidden = false;
          if (!liveLoaded) {
            showBoot();
            phoneLive.src = 'https://demo.home-assistant.io';
            if (liveFallbackTimer) clearTimeout(liveFallbackTimer);
            liveFallbackTimer = setTimeout(hideBoot, 8000); // Sicherheitsnetz falls 'load' nie feuert
            phoneLive.addEventListener('load', () => window.scrollTo(0, y), { once: true });
          }
          requestAnimationFrame(() => window.scrollTo(0, y));
        }
      } else {
        if (phoneLive) phoneLive.hidden = true;
        if (currentWorld === 'ha' || currentWorld === 'nb') {
          // Phone 1 zeigt die HA-App (in nb steht die Loxone-App im zweiten Phone).
          if (loxApp) loxApp.hide();
          if (haApp) haApp.show();
          if (phoneShot) phoneShot.style.display = 'none';
          if (hotspotHost) hotspotHost.style.display = 'none';
        } else {
          if (haApp) haApp.hide();
          if (loxApp) loxApp.show();
          if (phoneShot) phoneShot.style.display = 'none';
          if (hotspotHost) hotspotHost.style.display = 'none';
        }
        if (!liveLoaded) hideBoot();
      }
    };
    if (phoneLive) {
      phoneLive.addEventListener('load', () => {
        if (!phoneLive.src) return; // initialer leerer src-Zustand feuert ebenfalls 'load'
        liveLoaded = true;
        if (liveFallbackTimer) clearTimeout(liveFallbackTimer);
        hideBoot();
      });
    }
    const onTabClick = (e) => setMode(e.currentTarget.dataset.mode);
    tabBtns.forEach((b) => b.addEventListener('click', onTabClick));

    // Geometrie kommt 1:1 aus SM_HERO_ASSETS.detailSlider (x/yTop/yBottom/w in % relativ zu
    // #phoneDetail) — vorher fuer den rotierten <input type=range> genutzt, jetzt direkt fuer
    // die pointer-gesteuerte Drag-Zone (kein rotate mehr noetig, Track liegt schon vertikal).
    const positionSlider = (ex) => {
      const g = (A.detailSlider[currentWorld] || {})[ex];
      if (detailDrag) detailDrag.hidden = !g;
      if (!g) return;
      detailDrag.style.left = g.x + '%';
      detailDrag.style.top = g.yTop + '%';
      detailDrag.style.width = (g.w || 8) + '%';
      detailDrag.style.height = (g.yBottom - g.yTop) + '%';
    };

    const setDragVisual = (level) => {
      dragLevel = level;
      if (detailHandle) detailHandle.style.top = ((1 - level) * 100) + '%';
      if (detailDrag) detailDrag.setAttribute('aria-valuenow', String(Math.round(level * 100)));
    };

    const emitLevel = (level) => {
      if (!currentEx) return;
      setDragVisual(level);
      root.dispatchEvent(new CustomEvent('sm-hero-action', { detail: { ex: currentEx, level } }));
    };

    const clearPresetActive = () => {
      if (detailPresets) detailPresets.querySelectorAll('.preset-chip.active').forEach((c) => c.classList.remove('active'));
    };

    // Sanftes Tween zum Preset-Ziel statt Hard-Cut — die sm-hero-action-Konsumenten (Video
    // currentTime/Opacity) reagieren auf jedes Zwischenlevel, dadurch entsteht die Animation.
    const tweenTo = (targetLevel, duration = 600) => {
      if (tweenRaf) cancelAnimationFrame(tweenRaf);
      const startLevel = dragLevel;
      const startTime = performance.now();
      const step = (now) => {
        const t = Math.min(1, (now - startTime) / duration);
        emitLevel(startLevel + (targetLevel - startLevel) * t);
        tweenRaf = (t < 1) ? requestAnimationFrame(step) : null;
      };
      tweenRaf = requestAnimationFrame(step);
    };

    const PRESET_SETS = {
      heizung: [{ label: '18°', level: 0 }, { label: '21°', level: 0.6 }, { label: '23°', level: 1 }],
    };
    const PERCENT_PRESETS = [0, 0.25, 0.5, 0.75, 1].map((level) => ({ label: Math.round(level * 100) + ' %', level }));

    const renderPresets = (ex) => {
      if (!detailPresets) return;
      detailPresets.innerHTML = '';
      // Loxone-Screens haben eigene Controls — unsere Prozent-Chips wirken dort deplatziert
      // (User-Feedback: "mitten in Lightning 0/25/50/75/100% ergibt keinen Sinn"). Voller
      // Loxone-Rebuild folgt separat; bis dahin keine Chips im lox-Guided-Mode.
      if (currentWorld === 'lox') return;
      const list = PRESET_SETS[ex] || PERCENT_PRESETS;
      list.forEach((p) => {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'preset-chip';
        btn.textContent = p.label;
        btn.addEventListener('click', () => {
          clearPresetActive();
          btn.classList.add('active');
          tweenTo(p.level);
        });
        detailPresets.appendChild(btn);
      });
    };

    const levelFromClientY = (clientY) => {
      const rect = detailDrag.getBoundingClientRect();
      return clamp01(1 - (clientY - rect.top) / rect.height);
    };
    let dragging = false;
    const onDragPointerDown = (e) => {
      dragging = true;
      clearPresetActive();
      if (tweenRaf) { cancelAnimationFrame(tweenRaf); tweenRaf = null; }
      detailDrag.setPointerCapture(e.pointerId);
      emitLevel(levelFromClientY(e.clientY));
      e.preventDefault();
    };
    const onDragPointerMove = (e) => {
      if (!dragging) return;
      emitLevel(levelFromClientY(e.clientY));
    };
    const onDragPointerUp = (e) => {
      if (!dragging) return;
      dragging = false;
      try { detailDrag.releasePointerCapture(e.pointerId); } catch (err) {}
    };
    const onDragKeydown = (e) => {
      if (e.key === 'ArrowUp') { clearPresetActive(); emitLevel(clamp01(dragLevel + 0.05)); e.preventDefault(); }
      else if (e.key === 'ArrowDown') { clearPresetActive(); emitLevel(clamp01(dragLevel - 0.05)); e.preventDefault(); }
    };
    if (detailDrag) {
      detailDrag.addEventListener('pointerdown', onDragPointerDown);
      detailDrag.addEventListener('pointermove', onDragPointerMove);
      detailDrag.addEventListener('pointerup', onDragPointerUp);
      detailDrag.addEventListener('pointercancel', onDragPointerUp);
      detailDrag.addEventListener('keydown', onDragKeydown);
    }

    // Task 6: Detail-Overlay faehrt beim Öffnen hoch (translateY 24px -> 0, Fade), beim
    // Schließen umgekehrt. [hidden] steuert weiterhin Sichtbarkeit fuer A11y/Interaktion,
    // die .open-Klasse steuert nur die Transition; deshalb hidden erst NACH der Exit-Transition
    // setzen (sonst verschwindet der Screen vor der Animation).
    let closeTimer = null;
    const openDetail = (ex) => {
      if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
      currentEx = ex;
      detailShot.src = A.phone[currentWorld].detail[ex];
      detail.hidden = false;
      positionSlider(ex);
      renderPresets(ex);
      setDragVisual(1);
      void detail.offsetWidth; // Reflow erzwingen, damit der naechste Klassenwechsel transitioniert
      requestAnimationFrame(() => detail.classList.add('open'));
      root.dispatchEvent(new CustomEvent('sm-hero-action', { detail: { ex, level: 1 } }));
    };
    const closeDetail = () => {
      currentEx = null;
      if (tweenRaf) { cancelAnimationFrame(tweenRaf); tweenRaf = null; }
      if (detail && !detail.hidden) {
        detail.classList.remove('open');
        if (closeTimer) clearTimeout(closeTimer);
        closeTimer = setTimeout(() => { detail.hidden = true; }, reduced ? 0 : 280);
      }
      root.dispatchEvent(new CustomEvent('sm-hero-reset'));
    };
    if (detailBack) detailBack.addEventListener('click', closeDetail);
    const onDetailKeydown = (e) => {
      if (e.key === 'Escape' && detail && !detail.hidden) closeDetail();
    };
    document.addEventListener('keydown', onDetailKeydown);

    const phoneEl = root.querySelector('#smPhone');
    const phoneFrame = root.querySelector('.phone-frame');
    const choiceZone = root.querySelector('#choiceZone');
    const stage = root.querySelector('.stage');
    const stageEl = root.querySelector('#cgiStage');
    // Task 1: Paar + Phone als EIN komponiertes Ganzes — oben/unten fluchtend mit dem
    // Geraet, nicht zentriert auf choiceZone (das ragte bei der neuen, deutlich groesseren
    // Paar-Hoehe ~ Phone-Hoehe bis in H1/Lede hinein).
    const alignCgiStage = () => {
      if (!stageEl || !phoneFrame || !stage) return;
      const stageRect = stage.getBoundingClientRect();
      const frameRect = phoneFrame.getBoundingClientRect();
      if (!frameRect.height) return; // gestapeltes Mobil-Layout (position:static) — kein Alignment noetig
      // +56px: sonst faellt die obere Pillen-Badge exakt unter den fixen Glass-Header
      // (der bei Bühne-offen per :has()-Hebel weiterhin ueber allem liegt, siehe oben).
      stageEl.style.top = (frameRect.top - stageRect.top + 56) + 'px';
    };
    alignCgiStage();
    window.addEventListener('resize', alignCgiStage);
    // Task 2: Trigger ausschließlich auf #smPhone, NICHT auf die ganze .stage (die vorher auch
    // die Choice-Cards-Spalte erfasste, sobald das Kachelpaar sie optisch überlappte) — die
    // Choice-Cards bleiben dadurch immer klickbar, unabhängig vom Bühnenstatus.
    const onEnter = () => {
      alignCgiStage();
      if (choiceZone) choiceZone.classList.add('app-active');
      if (stageEl) stageEl.classList.add('open');
    };
    const onLeave = () => {
      // Neubau: nicht schliessen, solange das jeweils ANDERE Phone noch gehovert wird (Maus wandert
      // von Phone 1 zu Phone 2) — sonst flackert die Buehne beim Wechsel zwischen den zwei Phones.
      if (phone2El && phone2El.style.display !== 'none' &&
          ((phoneEl && phoneEl.matches(':hover')) || phone2El.matches(':hover'))) return;
      if (choiceZone) choiceZone.classList.remove('app-active');
      if (stageEl) stageEl.classList.remove('open');
      closeDetail();
    };
    // Hover ueber BEIDE Phones oeffnet die Buehne (im Neubau steht das Loxone-Phone daneben) —
    // vorher nur Phone 1, dadurch kam beim Hovern des Loxone-Phones kein Video.
    if (phoneEl) { phoneEl.addEventListener('mouseenter', onEnter); phoneEl.addEventListener('mouseleave', onLeave); }
    if (phone2El) { phone2El.addEventListener('mouseenter', onEnter); phone2El.addEventListener('mouseleave', onLeave); }

    // CGI-Bühne: Blend/Scrub/Person-Engine, gesteuert über sm-hero-action/sm-hero-reset (Task 5,
    // Kontrakt unveraendert). Muss vor setPhoneWorld/openDetail registriert werden, da diese
    // sofort (synchron) feuern.
    const SC = window.SM_HERO_SCENES;
    const mit = stageEl.querySelector('[data-side="mit"]');
    const ohne = stageEl.querySelector('[data-side="ohne"]');
    const scrubV = mit.querySelector('[data-scrub]');
    const personV = ohne.querySelector('[data-person]');
    const patchV = ohne.querySelector('[data-patch]');
    const pairCaption = root.querySelector('#pairCaption');

    let scene = null, lastLevel = 1;
    let personTimer = null, personRaf = null, lastPersonLevel = 1;
    let patchReady = false, personPlaying = false;
    let ohnePatchLevel = 1, patchSyncRaf = null;
    // Difference-Key (Fable v2): Person aus dem v3-Clip gegen den leeren Raum (scene.base) freistellen
    // und VOR das exakt-%-Rollo (Patch) auf ein Canvas zeichnen -> Person im Vordergrund, Rollo dahinter.
    const keyCanvas = ohne.querySelector('[data-keycanvas]');
    const keyCtx = keyCanvas ? keyCanvas.getContext('2d', { willReadFrequently: true }) : null;
    const KW = 640, KH = 400;
    if (keyCanvas) { keyCanvas.width = KW; keyCanvas.height = KH; }
    const tmpCanvas = document.createElement('canvas'); tmpCanvas.width = KW; tmpCanvas.height = KH;
    const tmpCtx = tmpCanvas.getContext('2d', { willReadFrequently: true });
    const baseCanvas = document.createElement('canvas'); baseCanvas.width = KW; baseCanvas.height = KH;
    const baseCtx = baseCanvas.getContext('2d', { willReadFrequently: true });
    let baseData = null, keyRaf = null;
    const THR_LOW = 42, THR_HIGH = 105; // Alpha rampt von 0 (Raum) bis 255 (Person) ueber die Farbdifferenz
    const loadKeyBase = (src) => {
      baseData = null;
      const img = new Image();
      img.onload = () => { try { baseCtx.drawImage(img, 0, 0, KW, KH); baseData = baseCtx.getImageData(0, 0, KW, KH).data; } catch (e) { baseData = null; } };
      img.onerror = () => { baseData = null; };
      img.src = src;
    };
    const keyLoop = () => {
      if (!personPlaying || !keyCtx || !baseData) { keyRaf = null; return; }
      try {
        tmpCtx.drawImage(personV, 0, 0, KW, KH);
        const img = tmpCtx.getImageData(0, 0, KW, KH);
        const d = img.data;
        for (let i = 0; i < d.length; i += 4) {
          const diff = Math.abs(d[i] - baseData[i]) + Math.abs(d[i + 1] - baseData[i + 1]) + Math.abs(d[i + 2] - baseData[i + 2]);
          const a = (diff - THR_LOW) / (THR_HIGH - THR_LOW);
          d[i + 3] = a <= 0 ? 0 : a >= 1 ? 255 : (a * 255) | 0;
        }
        keyCtx.putImageData(img, 0, 0);
      } catch (e) {}
      keyRaf = requestAnimationFrame(keyLoop);
    };
    const startKeyLoop = () => { if (!keyRaf) keyRaf = requestAnimationFrame(keyLoop); };
    const stopKeyLoop = () => { if (keyRaf) { cancelAnimationFrame(keyRaf); keyRaf = null; } };

    const loadScene = (ex) => {
      scene = SC[ex] || SC.rollladen;
      // triggerAny (Licht): null -> die ERSTE Aktion loest immer aus, auch wenn sie zufaellig
      // auf Level 1 landet (Lampe war an, wird ausgeschaltet -> sonst "keine Aenderung" ggü. Init 1
      // -> es kaeme niemand zum Ausschalten). Rollladen/Heizung behalten Init 1 (Person nur beim Zufahren).
      lastPersonLevel = scene.triggerAny ? null : 1;
      ohnePatchLevel = 1;
      if (personTimer) { clearTimeout(personTimer); personTimer = null; }
      if (patchSyncRaf) { cancelAnimationFrame(patchSyncRaf); patchSyncRaf = null; }
      stopKeyLoop();
      clearPersonRaf();
      const loadedScene = scene; // Staleness-Marker: verhindert, dass ein spaeter feuerndes
      // loadeddata-Event einer bereits verlassenen Szene die Anzeige wieder umschaltet.
      if (pairCaption) pairCaption.textContent = scene.caption || '';
      for (const pane of [mit, ohne]) {
        const baseImg = pane.querySelector('[data-base]');
        baseImg.style.display = '';
        baseImg.onerror = () => { baseImg.style.display = 'none'; };
        baseImg.src = scene.base;
      }
      const act = mit.querySelector('[data-active]');
      act.style.display = '';
      act.onerror = () => { act.style.display = 'none'; };
      act.src = scene.active; act.style.opacity = 0;
      mit.querySelector('.pair-media').classList.remove('video-ready');
      if (!reduced && scene.mode === 'scrub' && scene.video) {
        scrubV.onerror = () => { if (scene === loadedScene) mit.querySelector('.pair-media').classList.remove('video-ready'); };
        scrubV.src = scene.video;
        // preload="metadata" laedt nur den Header — ohne aktiven Seek dekodiert der Browser nie
        // einen Frame, wodurch 'loadeddata' nie feuert und das Video für immer unsichtbar bleibt.
        // loadedmetadata kennt bereits die duration; ein Seek dorthin stösst den Frame-Download an.
        scrubV.addEventListener('loadedmetadata', () => {
          if (scene !== loadedScene) return;
          scrubV.currentTime = Math.max((1 - lastLevel) * scrubV.duration, 0.001);
        }, { once: true });
        scrubV.addEventListener('loadeddata', () => {
          if (scene !== loadedScene) return; // Szene wurde inzwischen gewechselt
          scrubV.pause();
          mit.querySelector('.pair-media').classList.add('video-ready');
          // Video wurde ggf. erst NACH dem letzten setLevel-Aufruf bereit — Frame nachziehen,
          // sonst zeigt es fälschlich Frame 0 statt des zuletzt angeforderten Levels.
          if (Number.isFinite(scrubV.duration)) scrubV.currentTime = (1 - lastLevel) * scrubV.duration;
        }, { once: true });
      } else {
        scrubV.removeAttribute('src');
        scrubV.load();
      }
      if (!reduced && scene.person) { personV.src = scene.person; personV.onerror = () => { personV.removeAttribute('src'); }; }
      if (!reduced && scene.key && scene.base) loadKeyBase(scene.base); else { baseData = null; if (keyCtx) keyCtx.clearRect(0, 0, KW, KH); }
      // Fenster-Patch (Fable-Ansatz): die Scrub-Rollo-Ebene, per CSS-Maske auf das Fenster-Rechteck
      // begrenzt, ueber den durchlaufenden Personen-Clip gelegt -> zeigt das EXAKTE % (die
      // eingebackene Rollo-Bewegung im Person-Clip wird im Fenster verdeckt). Nur continuous (Rollladen).
      const ohneMedia = ohne.querySelector('.pair-media');
      patchReady = false;
      ohneMedia.classList.remove('patch-on');
      if (!reduced && scene.continuous && scene.video && scene.window) {
        const w = scene.window;
        const mask = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='" + w.x + "' y='" + w.y + "' width='" + w.w + "' height='" + w.h + "' rx='1.5' fill='white'/%3E%3C/svg%3E\") 0 0 / 100% 100% no-repeat";
        patchV.style.webkitMask = mask; patchV.style.mask = mask;
        patchV.onerror = () => { ohneMedia.classList.remove('patch-on'); };
        patchV.src = scene.video;
        patchV.addEventListener('loadedmetadata', () => {
          if (scene !== loadedScene) return;
          patchV.currentTime = Math.max((1 - ohnePatchLevel) * patchV.duration, 0.001);
        }, { once: true });
        patchV.addEventListener('loadeddata', () => {
          if (scene !== loadedScene) return;
          patchV.pause();
          patchReady = true;
          ohneMedia.classList.add('patch-on');
          if (Number.isFinite(patchV.duration)) patchV.currentTime = (1 - ohnePatchLevel) * patchV.duration;
        }, { once: true });
      } else {
        patchV.removeAttribute('src'); patchV.load();
      }
    };

    const setLevel = (level) => { // level 1 = offen/aus, 0 = zu/an — invertiert je Szene egal, konsistent halten
      if (!scene) return;
      lastLevel = level;
      const p = 1 - level;
      if (scene.mode === 'scrub' && mit.querySelector('.pair-media').classList.contains('video-ready')) {
        if (Number.isFinite(scrubV.duration)) scrubV.currentTime = p * scrubV.duration;
      } else {
        mit.querySelector('[data-active]').style.opacity = p;
      }
      // Fenster-Patch: beim OEFFNEN (Level steigt) folgt das Rollo direkt; beim SCHLIESSEN wartet
      // es auf die Person (drivePatchWithPerson), damit es sich erst bewegt, wenn sie am Fenster ist.
      if (scene.continuous && patchReady && !personPlaying && level > ohnePatchLevel + 0.01) {
        if (Number.isFinite(patchV.duration)) patchV.currentTime = p * patchV.duration;
        ohnePatchLevel = level;
      }
      schedulePerson(level);
    };

    // Personen-Komposition (Task C5): Person laeuft rein -> fuehrt die Aktion bis zum exakten
    // Level aus (Rollo) -> Pane blendet aus ("geht"; die Clips haben kein Rausgehen). Delta
    // re-triggert. Licht/Heizung sind diskret: rein + Aktion einmal, dann ausblenden.
    const clearPersonRaf = () => { if (personRaf) { cancelAnimationFrame(personRaf); personRaf = null; } };
    // Personen-Clip laeuft IMMER komplett durch (rein -> Aktion -> raus), friert nie ein. Das exakte
    // % zeigt der Fenster-Patch (continuous/Rollladen) bzw. der Mit-Blend (licht/heizung). Am Ende
    // sanft ausblenden.
    const playPersonFull = () => {
      if (reduced || !personV.src || !scene) return;
      const media = ohne.querySelector('.pair-media');
      personPlaying = true;
      if (scene.key) { media.classList.add('key-on'); startKeyLoop(); }
      else media.classList.add('person-playing');
      const fadeOut = () => { media.classList.remove('person-playing'); media.classList.remove('key-on'); personPlaying = false; stopKeyLoop(); };
      personV.onended = () => setTimeout(fadeOut, 250);
      try { personV.currentTime = 0; } catch (e) {}
      personV.play().catch(() => fadeOut());
      // seg.out: Clip vor dem Wieder-Reinlaufen der Person hart stoppen + ausblenden (licht v2).
      const outT = (scene.seg && scene.seg.out) || null;
      if (outT) {
        const watchOut = () => {
          if (!personPlaying) return;
          if (personV.currentTime >= outT) { try { personV.pause(); } catch (e) {} fadeOut(); return; }
          requestAnimationFrame(watchOut);
        };
        requestAnimationFrame(watchOut);
      }
      const dur = outT || (Number.isFinite(personV.duration) ? personV.duration : 4);
      setTimeout(() => { if (personPlaying) fadeOut(); }, (dur + 1.5) * 1000); // Fallback falls 'ended' nie feuert
    };
    // Fenster-Patch an die Personen-Bewegung koppeln: waehrend sie am Fenster kurbelt (Zeitfenster
    // scene.crank = [start,end] Sek. im Person-Clip) faehrt das Rollo von ohnePatchLevel aufs Ziel;
    // davor steht es (sie ist noch nicht da), danach bleibt es stehen.
    const drivePatchWithPerson = (targetLevel) => {
      if (!scene || !scene.continuous) return;
      if (patchSyncRaf) cancelAnimationFrame(patchSyncRaf);
      const from = ohnePatchLevel;
      const cs = (scene.crank && scene.crank[0]) || 2.0;
      const ce = (scene.crank && scene.crank[1]) || 3.0;
      const seekP = (lvl) => { if (patchReady && Number.isFinite(patchV.duration)) patchV.currentTime = (1 - lvl) * patchV.duration; };
      const loop = () => {
        const t = personV.currentTime;
        if (t >= ce || !personPlaying) { ohnePatchLevel = targetLevel; seekP(targetLevel); patchSyncRaf = null; return; }
        const lvl = (t <= cs) ? from : from + (targetLevel - from) * ((t - cs) / (ce - cs));
        seekP(lvl);
        patchSyncRaf = requestAnimationFrame(loop);
      };
      patchSyncRaf = requestAnimationFrame(loop);
    };
    // Debounce + nur bei der AKTIVIERENDEN Aktion (Level sinkt = Rollo zu / Licht an / Heizung hoch)
    // triggern, und nicht waehrend die Person schon laeuft ("rein-raus-rein" vermeiden).
    const schedulePerson = (level) => {
      if (personTimer) clearTimeout(personTimer);
      personTimer = setTimeout(() => {
        const from = lastPersonLevel;
        lastPersonLevel = level;
        // Rollo/Heizung: nur bei der aktivierenden Richtung (Level sinkt). Licht (triggerAny):
        // bei jeder Aenderung (an UND aus) kommt jemand — aber nur einmal, nicht waehrend sie laeuft.
        const changed = (from == null) || (Math.abs(level - from) > 0.03);
        const activating = scene.triggerAny ? changed : ((from == null) || (level < from - 0.03));
        if (activating && !personPlaying) { playPersonFull(); if (scene.continuous) drivePatchWithPerson(level); }
      }, 220);
    };

    const onHeroAction = (e) => {
      stageEl.classList.add('open');
      // choiceZone blendet bisher nur beim Hover aus (onEnter) — ohne das hier faellt die
      // Bühne bei einem reinen ?smopen=-Deep-Link ohne Hover mit den weiterhin sichtbaren
      // Choice-Cards zusammen.
      if (choiceZone) choiceZone.classList.add('app-active');
      if (!scene || SC[e.detail.ex] !== scene) loadScene(e.detail.ex);
      setLevel(e.detail.level);
    };
    const onHeroReset = () => {
      stageEl.classList.remove('open');
      // Nicht ausblenden, solange das Phone noch gehovert wird — sonst blitzen die Choice-Cards
      // beim Schliessen des Phone-Details (Zurueck-Button/Escape) kurz auf, obwohl onLeave sie
      // gleich wieder ausblenden würde.
      if (choiceZone && phoneEl && !phoneEl.matches(':hover')) choiceZone.classList.remove('app-active');
    };
    root.addEventListener('sm-hero-action', onHeroAction);
    root.addEventListener('sm-hero-reset', onHeroReset);
    loadScene('rollladen'); // Idle-Szene fürs Hover (Spec: Bühne öffnet bei Hover)

    phoneWorldRef.current = setPhoneWorld;
    const params = new URLSearchParams(location.search);
    const smopen = params.get('smopen');
    if (smopen) openDetail(smopen);
    const smlevel = params.get('smlevel');
    if (smopen && smlevel) emitLevel(clamp01(Number(smlevel)));

    return () => {
      choices.forEach((c) => c.removeEventListener('click', onChoiceClick));
      scrollers.forEach((a) => a.removeEventListener('click', onScrollClick));
      if (phoneEl) { phoneEl.removeEventListener('mouseenter', onEnter); phoneEl.removeEventListener('mouseleave', onLeave); }
      if (phone2El) { phone2El.removeEventListener('mouseenter', onEnter); phone2El.removeEventListener('mouseleave', onLeave); }
      window.removeEventListener('resize', alignCgiStage);
      tabBtns.forEach((b) => b.removeEventListener('click', onTabClick));
      root.removeEventListener('sm-hero-action', onHeroAction);
      root.removeEventListener('sm-hero-reset', onHeroReset);
      document.removeEventListener('keydown', onDetailKeydown);
      if (detailDrag) {
        detailDrag.removeEventListener('pointerdown', onDragPointerDown);
        detailDrag.removeEventListener('pointermove', onDragPointerMove);
        detailDrag.removeEventListener('pointerup', onDragPointerUp);
        detailDrag.removeEventListener('pointercancel', onDragPointerUp);
        detailDrag.removeEventListener('keydown', onDragKeydown);
      }
      if (tweenRaf) cancelAnimationFrame(tweenRaf);
      if (bootTimer) clearTimeout(bootTimer);
      if (closeTimer) clearTimeout(closeTimer);
      if (liveFallbackTimer) clearTimeout(liveFallbackTimer);
      if (personTimer) clearTimeout(personTimer);
      if (personRaf) cancelAnimationFrame(personRaf);
      if (patchSyncRaf) cancelAnimationFrame(patchSyncRaf);
      if (keyRaf) cancelAnimationFrame(keyRaf);
      if (haApp) haApp.destroy();
      if (loxApp) loxApp.destroy();
      if (loxApp2) loxApp2.destroy();
    };
  }, []);

  // Welt -> aktive Choice + sichtbares Phone
  // Einzige Quelle fuer setPhoneWorld (statt body.dataset.world beim Mount zu lesen):
  // laeuft bei jedem Wechsel des world-Props, auch wenn der Parent es erst nach dem
  // Mount aus localStorage nachliefert (Deep-Link/Wiederkehrer-Fall) — sonst bleibt
  // das Phone auf der Default-Welt haengen, waehrend Bühne/Body schon umschalten.
  React.useEffect(() => {
    const root = ref.current;
    if (!root) return;
    root.querySelectorAll('.choice').forEach((c) => c.classList.toggle('active', c.dataset.phone === world));
    if (phoneWorldRef.current) phoneWorldRef.current(world);
  }, [world]);

  return (
    <React.Fragment>
      <style>{HERO_CSS}</style>
      <section className="smw" ref={ref} id="start" dangerouslySetInnerHTML={{ __html: MARKUP }} />
    </React.Fragment>
  );
}

window.HeroWelten = HeroWelten;
