/* eslint-disable */
/* global React, ReactDOM, WaveSurfer, StudioArrange, PipiAudio */
// Pipi Studio — internal authoring view for the branching audiobooks.
// v4 (B2): full-arrange editor —
//   • MasterLane: one WaveSurfer (v7.12.7 pinned) + Regions plugin; each split
//     is a zero-width draggable marker → splitAtSec, optional snap to the
//     nearest sentence-end from the Scribe transcript. Timeline + wheel Zoom.
//   • ArrangeView: per-split clip lanes (bridge → per-choice ack+tail, plus
//     re-ask/fallback). Custom-canvas waveforms from client-side peaks
//     (decodeAudioData → min/max buckets, IndexedDB-memoized) — per-clip
//     WaveSurfer was dropped earlier for perf (see MiniClip note below).
//   • Non-destructive Clip objects (manifest v2.1 sibling fields, e.g.
//     bridgeClip:{src,trimStart,trimEnd,fadeIn,fadeOut,gainDb}); preview via
//     the studio audio-engine ({offset,duration} → source.start). The bake to
//     real files happens only in scripts/studio-publish.mjs.
//   • Save bar (v3) persists drafts to studio.manifests; 🚀 publish marks the
//     draft 'review' and hands back the CLI command for the local bake.

const { useState, useEffect, useRef, useMemo, useCallback, forwardRef, useImperativeHandle } = React;

// Palette + a couple of icons re-used from the main app components.
// PIPI is exported by /components/pipi-scribble.jsx; IconPlay / IconPause
// come from /components/pipi-app.jsx. Both are loaded by index.html.
const STUDIO = (typeof PIPI !== 'undefined') ? PIPI : {
  paper:'#FBF1D9', paperLt:'#F5E7C7', ink:'#1A1A2E', inkSoft:'#3A3A52',
  yellow:'#FFD93D', pink:'#E8526B', pinkLt:'#F8B7C1',
  blue:'#7CC4E8', green:'#8FCB9B', lilac:'#C7B4E8', red:'#D94A4A',
};

const BOOK_LABELS = {
  circus:     { title: 'Pippi Goes to the Circus', emoji: '🎪' },
  outing:     { title: 'Pippi Goes on an Outing',  emoji: '🧺' },
  burglars:   { title: 'Pippi and the Burglars',   emoji: '🌙' },
  goldilocks: { title: 'Goldilocks and the Three Bears', emoji: '🐻' },
  cirkus:     { title: 'Pipi ide do cirkusu',  emoji: '🎪' },
  vylet:      { title: 'Pipi usporiada výlet', emoji: '🗺️' },
  zlodeji:    { title: 'Pipi navštívia zlodeji', emoji: '🌙' },
  cirkusTest: { title: 'Cirkus test (5 min)',  emoji: '🧪' },
};

// Studio key ↔ manifest file/API name ('cirkusTest' state key ↔ 'cirkus-test').
const BOOK_NAMES = { circus: 'circus', outing: 'outing', burglars: 'burglars', goldilocks: 'goldilocks', cirkus: 'cirkus', vylet: 'vylet', zlodeji: 'zlodeji', cirkusTest: 'cirkus-test' };

// Supabase client for studio auth (session in localStorage, like /admin).
// Null when supabase-js or the config didn't load — local dev falls back
// to the legacy /api/login gate so the dev loop keeps working offline.
const studioSb = (typeof window !== 'undefined' && window.supabase && window.PIPI_SUPABASE_URL)
  ? window.supabase.createClient(window.PIPI_SUPABASE_URL, window.PIPI_SUPABASE_KEY)
  : null;

async function studioToken() {
  if (!studioSb) return null;
  const { data } = await studioSb.auth.getSession();
  return data && data.session ? data.session.access_token : null;
}

// fetch() with the studio Bearer token attached.
async function studioFetch(url, opts = {}) {
  const tok = await studioToken();
  const headers = { ...(opts.headers || {}) };
  if (tok) headers['Authorization'] = `Bearer ${tok}`;
  return fetch(url, { ...opts, headers });
}

const fmtTime = (s) => {
  if (!isFinite(s)) return '0:00';
  const m = Math.floor(s / 60);
  const r = Math.floor(s % 60);
  return m + ':' + (r < 10 ? '0' : '') + r;
};

// ───────────────────────────────────────────────────────────────
// Mermaid story tree — built in-browser from the same manifest data
// the rest of the studio uses. Mirrors scripts/build-story-diagrams.mjs.
// ───────────────────────────────────────────────────────────────
const mmEsc = (s) => String(s || '').replace(/[„""]/g, '').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');

function buildAuthoredMermaid(m) {
  const lines = [];
  lines.push('%%{init: {"theme":"base", "themeVariables":{"fontFamily":"Patrick Hand, cursive", "primaryColor":"#FBF1D9", "primaryTextColor":"#1A1A2E", "primaryBorderColor":"#1A1A2E", "lineColor":"#1A1A2E"}}}%%');
  lines.push('flowchart TD');
  lines.push('  classDef chap fill:#FFD93D,stroke:#1A1A2E,stroke-width:3px,color:#1A1A2E;');
  lines.push('  classDef bridge fill:#C7B4E8,stroke:#1A1A2E,stroke-width:3px,color:#1A1A2E;');
  lines.push('  classDef reask fill:#FFF7B0,stroke:#D94A4A,stroke-width:2px,color:#1A1A2E;');
  lines.push('  classDef fb fill:#F8B7C1,stroke:#1A1A2E,stroke-width:2px,color:#1A1A2E;');
  lines.push('  classDef ack fill:#F5E7C7,stroke:#1A1A2E,stroke-width:2px,color:#1A1A2E;');
  lines.push('  classDef chend fill:#8FCB9B,stroke:#1A1A2E,stroke-width:3px,color:#1A1A2E;');
  lines.push('');
  lines.push(`  ch1["<b>kapitola 1</b><br/>audio 0:00 — ${fmtTime(m.splits[0].splitAtSec)}"]:::chap`);
  m.splits.forEach((s, i) => {
    const bid = `b${i + 1}`, reid = `r${i + 1}`, fbid = `f${i + 1}`;
    const q = mmEsc(s.questionText).slice(0, 80);
    lines.push(`  ${bid}["❓ split ${i + 1} · ${fmtTime(s.splitAtSec)}<br/><i>${q}</i>"]:::bridge`);
    lines.push(`  ch${i + 1} --> ${bid}`);
    lines.push(`  ${reid}("⏱ re-ask po ${s.reAskAfterSec || 8}s<br/>(ticho)"):::reask`);
    lines.push(`  ${fbid}("⚠ fallback<br/>(AI nerozumie)"):::fb`);
    lines.push(`  ${bid} -. čaká .-> ${reid}`);
    lines.push(`  ${reid} -. po 2x .-> ${fbid}`);
    lines.push(`  ${fbid} -. manuálny tap .-> ${bid}`);
    s.choices.forEach((c) => {
      const cid = `c${i + 1}_${c.id}`;
      lines.push(`  ${cid}["▶ ${mmEsc(c.label)}<br/>ack + tail (~25s)"]:::ack`);
      lines.push(`  ${bid} --> ${cid}`);
    });
    const isLast = i === m.splits.length - 1;
    const nextChapKey = isLast ? 'fin' : `ch${i + 2}`;
    const nextLabel = isLast
      ? `<b>finále</b><br/>audio ${fmtTime(s.splitAtSec)} — koniec`
      : `<b>kapitola ${i + 2}</b><br/>audio ${fmtTime(s.splitAtSec)} — ${fmtTime(m.splits[i + 1].splitAtSec)}`;
    lines.push(`  ${nextChapKey}["${nextLabel}"]:::${isLast ? 'chend' : 'chap'}`);
    s.choices.forEach((c) => {
      const cid = `c${i + 1}_${c.id}`;
      lines.push(`  ${cid} --> ${nextChapKey}`);
    });
  });
  return lines.join('\n');
}

function buildVilaMermaid(v) {
  const lines = [];
  lines.push('%%{init: {"theme":"base", "themeVariables":{"fontFamily":"Patrick Hand, cursive", "primaryColor":"#FBF1D9", "primaryTextColor":"#1A1A2E", "primaryBorderColor":"#1A1A2E", "lineColor":"#1A1A2E"}}}%%');
  lines.push('flowchart TD');
  lines.push('  classDef opener fill:#C7B4E8,stroke:#1A1A2E,stroke-width:3px;');
  lines.push('  classDef q fill:#FFD93D,stroke:#1A1A2E,stroke-width:3px;');
  lines.push('  classDef listen fill:#7CC4E8,stroke:#1A1A2E,stroke-width:3px;');
  lines.push('  classDef ai fill:#F8B7C1,stroke:#1A1A2E,stroke-width:3px;');
  lines.push('  classDef wrap fill:#8FCB9B,stroke:#1A1A2E,stroke-width:3px;');
  lines.push('');
  v.rounds.forEach((r) => {
    const op = `o${r.n}`, qn = `q${r.n}`, ls = `l${r.n}`, ai = `a${r.n}`;
    lines.push(`  ${op}["🎬 opener kolo ${r.n}<br/><i>${mmEsc(r.openerText).slice(0, 60)}...</i>"]:::opener`);
    lines.push(`  ${qn}["❓ otázka<br/>${mmEsc(r.questionText).slice(0, 60)}"]:::q`);
    lines.push(`  ${ls}("🎤 počúvam mic"):::listen`);
    lines.push(`  ${ai}["✦ AI generuje pokračovanie<br/>(/api/custom-story-chunked)"]:::ai`);
    lines.push(`  ${op} --> ${qn} --> ${ls} --> ${ai}`);
    if (r.n < v.rounds.length) lines.push(`  ${ai} --> o${r.n + 1}`);
  });
  const lastN = v.rounds.length;
  lines.push(`  a${lastN} --> w["✦ wrap-up<br/>splete ${lastN} voľby + jedno ponaučenie"]:::wrap`);
  lines.push('  w --> done["🌙 koniec rozprávky"]:::wrap');
  return lines.join('\n');
}

// Initialize mermaid once on load (with our paper palette).
if (typeof window !== 'undefined' && typeof window.mermaid !== 'undefined' && !window.__pipiMermaidReady) {
  try {
    window.mermaid.initialize({
      startOnLoad: false,
      securityLevel: 'loose',
      theme: 'base',
      themeVariables: {
        fontFamily: 'Patrick Hand, cursive',
        primaryColor: '#FBF1D9',
        primaryTextColor: '#1A1A2E',
        primaryBorderColor: '#1A1A2E',
        lineColor: '#1A1A2E',
      },
    });
    window.__pipiMermaidReady = true;
  } catch (e) { /* mermaid not yet loaded — handled per-render */ }
}

// Renders a mermaid graph from source text. Re-renders when `code` changes.
// Polls window.mermaid for ~3 s in case the CDN script is still parsing
// when this component first mounts (Babel-transformed JSX runs before
// the unpkg mermaid bundle finishes on a cold load). After that we
// surface an inline error + the raw mermaid source so the user always
// sees SOMETHING in the green card — even on flaky CDN or strict CSP.
function MermaidDiagram({ code, title }) {
  const hostRef = useRef(null);
  const [open, setOpen] = useState(true);
  const [err, setErr] = useState('');
  const idRef = useRef('mm-' + Math.random().toString(36).slice(2, 10));

  useEffect(() => {
    if (!open) return;
    const host = hostRef.current;
    if (!host) return;

    let cancelled = false;
    setErr('');
    host.innerHTML = '<div style="padding:18px;font-family:\'Caveat\',cursive;font-size:20px;opacity:0.6">načítavam diagram…</div>';

    const tryRender = (attempt = 0) => {
      if (cancelled || !host) return;
      if (typeof window === 'undefined' || !window.mermaid || !window.mermaid.render) {
        if (attempt > 30) { // ~3 s @ 100 ms
          setErr('mermaid sa nenačítal z CDN — pozri raw zdroj nižšie');
          host.innerHTML = '';
          return;
        }
        setTimeout(() => tryRender(attempt + 1), 100);
        return;
      }
      if (!window.__pipiMermaidReady) {
        try {
          window.mermaid.initialize({
            startOnLoad: false, securityLevel: 'loose', theme: 'base',
            themeVariables: {
              fontFamily: 'Patrick Hand, cursive',
              primaryColor: '#FBF1D9', primaryTextColor: '#1A1A2E',
              primaryBorderColor: '#1A1A2E', lineColor: '#1A1A2E',
            },
          });
          window.__pipiMermaidReady = true;
        } catch {}
      }
      window.mermaid.render(idRef.current, code)
        .then(({ svg }) => { if (!cancelled && host) host.innerHTML = svg; })
        .catch((e) => {
          if (cancelled) return;
          setErr(String((e && e.message) || e));
          host.innerHTML = '';
        });
    };
    tryRender(0);
    return () => { cancelled = true; };
  }, [code, open]);

  return (
    <div style={{
      background: STUDIO.green, border: `4px solid ${STUDIO.ink}`, borderRadius: 18,
      padding: '14px 16px', marginTop: 14,
      boxShadow: '6px 8px 0 rgba(0,0,0,0.18)',
    }}>
      <div onClick={() => setOpen((v) => !v)} style={{
        display: 'flex', alignItems: 'baseline', gap: 10, cursor: 'pointer',
        flexWrap: 'wrap',
      }}>
        <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 32, color: STUDIO.ink, lineHeight: 1 }}>
          🌳 {title || 'strom príbehu'}
        </span>
        <span style={{
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16,
          background: STUDIO.paper, border: `2px solid ${STUDIO.ink}`,
          borderRadius: 999, padding: '2px 10px',
        }}>
          {open ? '▾ skry' : '▸ ukáž'}
        </span>
        <span style={{ marginLeft: 'auto', fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.6 }}>
          flowchart vetiev
        </span>
      </div>
      {open && (
        <>
          {err ? (
            <div style={{
              marginTop: 10, padding: '8px 10px', background: '#FFE7E7',
              border: `2px dashed ${STUDIO.pink}`, borderRadius: 10,
              fontFamily: '"JetBrains Mono", monospace', fontSize: 12, color: STUDIO.pink,
            }}>mermaid chyba: {err}</div>
          ) : null}
          <div ref={hostRef} style={{
            marginTop: 10, overflowX: 'auto', textAlign: 'center',
            background: STUDIO.paperLt, borderRadius: 12, padding: 12,
            border: `1.5px dashed ${STUDIO.inkSoft}55`,
            minHeight: 200,
          }}/>
          {err && (
            <details style={{
              marginTop: 10, background: STUDIO.paperLt,
              border: `1.5px dashed ${STUDIO.inkSoft}55`, borderRadius: 10,
              padding: '8px 10px',
            }}>
              <summary style={{ cursor: 'pointer', fontSize: 13, opacity: 0.75 }}>
                Raw mermaid zdroj (na kopírovanie do mermaid.live)
              </summary>
              <pre style={{
                margin: '8px 0 0', fontFamily: '"JetBrains Mono", monospace',
                fontSize: 11, lineHeight: 1.4, whiteSpace: 'pre-wrap',
                background: STUDIO.paper, color: STUDIO.ink,
                padding: 10, borderRadius: 8, maxHeight: 320, overflow: 'auto',
              }}>{code}</pre>
            </details>
          )}
          <div style={{
            marginTop: 8, fontSize: 12, opacity: 0.6, lineHeight: 1.5,
            display: 'flex', flexWrap: 'wrap', gap: 12,
          }}>
            <span><span style={{ background: '#FFD93D', border: `1.5px solid ${STUDIO.ink}`, padding: '1px 6px', borderRadius: 4 }}>kapitola</span> pôvodná nahrávka</span>
            <span><span style={{ background: '#C7B4E8', border: `1.5px solid ${STUDIO.ink}`, padding: '1px 6px', borderRadius: 4 }}>bridge</span> otázka narátora</span>
            <span><span style={{ background: '#F5E7C7', border: `1.5px solid ${STUDIO.ink}`, padding: '1px 6px', borderRadius: 4 }}>vetva</span> ack + tail ~25s</span>
            <span><span style={{ background: '#FFF7B0', border: `1.5px solid ${STUDIO.red}`, padding: '1px 6px', borderRadius: 4 }}>re-ask</span> ticho</span>
            <span><span style={{ background: '#F8B7C1', border: `1.5px solid ${STUDIO.ink}`, padding: '1px 6px', borderRadius: 4 }}>fallback</span> AI nerozumie</span>
            <span><span style={{ background: '#8FCB9B', border: `1.5px solid ${STUDIO.ink}`, padding: '1px 6px', borderRadius: 4 }}>finále</span> koniec</span>
          </div>
        </>
      )}
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// Studio login — Supabase email+password checked against studio.members
// (the isolated studio schema, see supabase/migrations/0006). Falls back
// to the legacy /api/login password gate when Supabase isn't configured
// (pure-local dev without network).
// ───────────────────────────────────────────────────────────────
function StudioLogin({ onUnlock }) {
  const [email, setEmail] = useState('');
  const [pw, setPw] = useState('');
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);
  const submit = async (e) => {
    e.preventDefault();
    if (busy) return;
    setBusy(true); setErr('');
    try {
      if (studioSb) {
        const { error } = await studioSb.auth.signInWithPassword({ email: email.trim(), password: pw });
        if (error) { setErr('Prihlásenie nesedí.'); return; }
        // Membership check: any studio API call 403s for non-members.
        const probe = await studioFetch('/api/studio/manifest?book=cirkus');
        if (probe.status === 403) {
          await studioSb.auth.signOut();
          setErr('Tento účet nemá prístup do studia.');
          return;
        }
        onUnlock();
        return;
      }
      // Legacy fallback (local dev without Supabase config).
      const r = await fetch('/api/login', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ password: pw }),
      });
      if (r.status === 404) { onUnlock(); return; }
      const j = await r.json().catch(() => ({}));
      if (j && j.authenticated) onUnlock();
      else setErr('Heslo nesedí.');
    } catch { setErr('Neviem osloviť server.'); }
    finally { setBusy(false); }
  };
  return (
    <div style={{
      minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: 24, background: STUDIO.ink, color: STUDIO.paper,
    }}>
      <form onSubmit={submit} style={{
        background: STUDIO.paper, color: STUDIO.ink, padding: '24px 26px',
        border: `4px solid ${STUDIO.ink}`, borderRadius: 28,
        maxWidth: 380, width: '100%',
        boxShadow: '8px 10px 0 rgba(0,0,0,0.32)',
      }}>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 38, lineHeight: 1, marginBottom: 4 }}>
          Ippy studio
        </div>
        <div style={{ fontSize: 15, opacity: 0.75, marginBottom: 14 }}>
          {studioSb ? 'Interný editor príbehov. Prihlás sa.' : 'Interný editor príbehov. Zadaj heslo.'}
        </div>
        {studioSb && (
          <input
            autoFocus type="email" value={email} onChange={(e) => setEmail(e.target.value)}
            placeholder="email" autoComplete="username"
            style={{
              width: '100%', border: `3px solid ${STUDIO.ink}`, borderRadius: 14,
              padding: '12px 14px', fontFamily: '"Patrick Hand", cursive', fontSize: 19,
              background: STUDIO.paperLt, color: STUDIO.ink, outline: 'none', marginBottom: 10,
            }}
          />
        )}
        <input
          autoFocus={!studioSb} type="password" value={pw} onChange={(e) => setPw(e.target.value)}
          placeholder="heslo" autoComplete="current-password"
          style={{
            width: '100%', border: `3px solid ${STUDIO.ink}`, borderRadius: 14,
            padding: '12px 14px', fontFamily: '"Patrick Hand", cursive', fontSize: 19,
            background: STUDIO.paperLt, color: STUDIO.ink, outline: 'none',
          }}
        />
        {err && (
          <div style={{
            background: STUDIO.pink, color: STUDIO.paper, padding: '8px 12px', borderRadius: 12,
            border: `2.5px solid ${STUDIO.ink}`, fontSize: 14, marginTop: 10,
          }}>{err}</div>
        )}
        <button type="submit" disabled={busy} style={{
          marginTop: 12, width: '100%', background: STUDIO.yellow, border: `3px solid ${STUDIO.ink}`,
          borderRadius: 999, padding: '12px 18px',
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22,
          cursor: busy ? 'default' : 'pointer', color: STUDIO.ink,
          boxShadow: '4px 5px 0 rgba(0,0,0,0.22)',
        }}>{busy ? '…' : 'otvor studio'}</button>
      </form>
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// One audio clip with its own waveform + play/pause.
// Used for bridge / ack / tail rows in the split panel.
// ───────────────────────────────────────────────────────────────
// Plain HTML5 audio element with a custom play button + progress bar.
// We dropped the per-clip WaveSurfer instance because rendering 37+
// waveforms in parallel saturated the browser and audio was never
// becoming 'ready'. The big book-level waveform on top still uses
// WaveSurfer (just one instance — fine).
function MiniClip({ src, label, kind, color = STUDIO.blue, missing = false }) {
  const audioRef = useRef(null);
  const [playing, setPlaying] = useState(false);
  const [duration, setDuration] = useState(0);
  const [progress, setProgress] = useState(0);
  const [err, setErr] = useState('');

  useEffect(() => {
    if (!src || missing) return;
    const a = audioRef.current;
    if (!a) return;
    setErr(''); setDuration(0); setProgress(0); setPlaying(false);
    const onMeta = () => setDuration(a.duration || 0);
    const onTime = () => setProgress(a.duration ? a.currentTime / a.duration : 0);
    const onPlay = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    const onEnded = () => { setPlaying(false); setProgress(0); };
    const onErr = () => setErr('audio fail');
    a.addEventListener('loadedmetadata', onMeta);
    a.addEventListener('timeupdate', onTime);
    a.addEventListener('play', onPlay);
    a.addEventListener('pause', onPause);
    a.addEventListener('ended', onEnded);
    a.addEventListener('error', onErr);
    return () => {
      try { a.pause(); } catch {}
      a.removeEventListener('loadedmetadata', onMeta);
      a.removeEventListener('timeupdate', onTime);
      a.removeEventListener('play', onPlay);
      a.removeEventListener('pause', onPause);
      a.removeEventListener('ended', onEnded);
      a.removeEventListener('error', onErr);
    };
  }, [src, missing]);

  const togglePlay = () => {
    const a = audioRef.current;
    if (!a || missing) return;
    if (a.paused) a.play().catch((e) => setErr(String((e && e.message) || 'play fail')));
    else a.pause();
  };

  const onBarClick = (e) => {
    const a = audioRef.current;
    if (!a || !a.duration) return;
    const r = e.currentTarget.getBoundingClientRect();
    const x = Math.max(0, Math.min(r.width, e.clientX - r.left));
    a.currentTime = (x / r.width) * a.duration;
  };

  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '72px 36px 1fr 56px', gap: 10, alignItems: 'center',
      padding: '6px 8px', borderRadius: 10,
      background: missing ? '#FFE7E7' : STUDIO.paperLt,
      border: `1.5px dashed ${missing ? STUDIO.pink : STUDIO.inkSoft}`,
      opacity: missing ? 0.7 : 1,
    }}>
      <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.75, lineHeight: 1.2 }}>
        {kind}
      </div>
      <button onClick={togglePlay} disabled={missing} style={{
        width: 32, height: 32, borderRadius: '50%', border: `2px solid ${STUDIO.ink}`,
        background: missing ? '#ddd' : (playing ? color : STUDIO.paper),
        cursor: missing ? 'default' : 'pointer', padding: 0,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }} aria-label={playing ? 'pauza' : 'hraj'}>
        {playing
          ? <span style={{ fontFamily: 'monospace', fontWeight: 700 }}>❚❚</span>
          : <span style={{ fontFamily: 'monospace', fontWeight: 700, marginLeft: 2 }}>▶</span>}
      </button>
      <div style={{ minWidth: 0 }}>
        {missing ? (
          <div style={{ fontSize: 11, color: STUDIO.pink, fontStyle: 'italic' }}>chýba audio — treba re-renderovať</div>
        ) : err ? (
          <div style={{ fontSize: 11, color: STUDIO.pink }}>{err}</div>
        ) : (
          <div onClick={onBarClick} style={{
            position: 'relative', height: 8, borderRadius: 4,
            background: STUDIO.paper, border: `1px solid ${STUDIO.inkSoft}55`,
            cursor: 'pointer', overflow: 'hidden',
          }}>
            <div style={{
              position: 'absolute', top: 0, left: 0, bottom: 0,
              width: `${progress * 100}%`, background: color,
            }}/>
          </div>
        )}
        {!missing && (
          <audio ref={audioRef} src={src} preload="metadata" style={{ display: 'none' }}/>
        )}
      </div>
      <div style={{ textAlign: 'right', fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.7 }}>
        {duration ? fmtTime(duration) : '…'}
      </div>
    </div>
  );
}

// Locked textarea with click-to-edit. Once unlocked, an inline warning
// appears + the user knows the audio for this clip will be stale until
// they hit "Re-render" (round 2 endpoint).
function LockedField({ value, onChange, multiline = true, rows = 2, big = false, dirty = false, frozen = false }) {
  const [editing, setEditing] = useState(false);
  const wasEdited = dirty;
  // When the parent book is frozen, the field is hard-readonly — the
  // "upraviť" pill never appears and edits cannot start. This is the
  // UI half of the frozen-manifest contract; the audit script enforces
  // the other half at the data layer.
  const baseStyle = {
    width: '100%', borderRadius: 10,
    padding: '8px 10px',
    fontFamily: big ? '"Caveat", cursive' : '"Patrick Hand", cursive',
    fontWeight: big ? 700 : 400,
    fontSize: big ? 18 : 15,
    color: STUDIO.ink, outline: 'none',
    border: editing ? `2.5px solid ${STUDIO.pink}` : `2px solid ${STUDIO.inkSoft}55`,
    background: editing ? '#FFF8E1' : (wasEdited ? '#FFEFCC' : STUDIO.paperLt),
    resize: 'vertical',
  };
  const canEdit = !frozen;
  const showEditing = editing && canEdit;
  return (
    <div style={{ position: 'relative' }}>
      {multiline ? (
        <textarea readOnly={!showEditing} rows={rows} value={value} onChange={(e) => onChange(e.target.value)} style={baseStyle}/>
      ) : (
        <input readOnly={!showEditing} value={value} onChange={(e) => onChange(e.target.value)} style={{ ...baseStyle, resize: 'none' }}/>
      )}
      {canEdit ? (
        <button onClick={() => setEditing((v) => !v)} style={{
          position: 'absolute', top: 4, right: 4,
          background: showEditing ? STUDIO.pink : STUDIO.paper,
          color: showEditing ? STUDIO.paper : STUDIO.ink,
          border: `2px solid ${STUDIO.ink}`, borderRadius: 999,
          padding: '2px 10px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 13,
          cursor: 'pointer',
        }}>{showEditing ? '✓ hotovo' : '✎ upraviť'}</button>
      ) : (
        <span style={{
          position: 'absolute', top: 4, right: 4,
          background: STUDIO.green, color: STUDIO.ink,
          border: `2px solid ${STUDIO.ink}`, borderRadius: 999,
          padding: '2px 10px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 13,
        }}>🔒 frozen</span>
      )}
      {wasEdited && (
        <div style={{
          marginTop: 4, fontSize: 12, color: STUDIO.pink,
          fontFamily: '"JetBrains Mono", monospace',
        }}>
          ⚠ text bol zmenený — audio treba re-renderovať
        </div>
      )}
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// MasterLane — the source recording on ONE WaveSurfer instance with the
// Regions plugin: every split is a zero-width DRAGGABLE marker. Dragging a
// marker (optionally snapped to the nearest transcript sentence-end) updates
// splitAtSec through onSplitMove → the Save bar dirty-state just works.
// Spike-proven on wavesurfer.js 7.12.7: marker drag emits region 'update'
// live + plugin 'region-updated' on release; setOptions({start}) re-positions
// (markers keep end === start). Zoom = wheel (Zoom plugin) + buttons.
// ───────────────────────────────────────────────────────────────
const MasterLane = forwardRef(function MasterLane(
  { manifest, onMarkerClick, activeSplitIdx, enders, frozen, onSplitMove }, apiRef) {
  const wsRef = useRef(null);
  const regionsRef = useRef(null);     // Regions plugin instance
  const regionMapRef = useRef([]);     // split idx -> region
  const containerRef = useRef(null);
  const [playing, setPlaying] = useState(false);
  const [duration, setDuration] = useState(0);
  const [currentTime, setCurrentTime] = useState(0);
  const [snapOn, setSnapOn] = useState(true);
  const [snapMsg, setSnapMsg] = useState('');
  const [pluginsOk, setPluginsOk] = useState(true);
  const fitZoomRef = useRef(0);
  const zoomRef = useRef(0);

  // Refs so the ws event handlers never go stale without re-creating ws.
  const splitsRef = useRef(manifest.splits);
  splitsRef.current = manifest.splits;
  const endersRef = useRef(enders);
  endersRef.current = enders;
  const snapRef = useRef(snapOn);
  snapRef.current = snapOn;
  const frozenRef = useRef(frozen);
  frozenRef.current = frozen;
  const onSplitMoveRef = useRef(onSplitMove);
  onSplitMoveRef.current = onSplitMove;
  const onMarkerClickRef = useRef(onMarkerClick);
  onMarkerClickRef.current = onMarkerClick;

  const markerColor = (i, active) => (active ? STUDIO.pink : STUDIO.red);

  const styleRegionContent = (region, i, active) => {
    const el = region.content;
    if (!el) return;
    el.style.cssText = [
      'font-family:"Caveat",cursive', 'font-weight:700', 'font-size:14px',
      `background:${active ? STUDIO.pink : STUDIO.yellow}`,
      `border:2px solid ${STUDIO.ink}`, `color:${STUDIO.ink}`,
      'border-radius:6px', 'padding:0 6px', 'line-height:1.2',
      'transform:translateX(-50%)', 'margin-top:-2px',
      'cursor:grab', 'user-select:none', 'white-space:nowrap',
    ].join(';');
  };

  useEffect(() => {
    if (!containerRef.current || typeof WaveSurfer === 'undefined') return;
    const hasRegions = Boolean(WaveSurfer.Regions);
    setPluginsOk(hasRegions);
    const ws = WaveSurfer.create({
      container: containerRef.current,
      waveColor: STUDIO.inkSoft,
      progressColor: STUDIO.lilac,
      cursorColor: STUDIO.pink,
      cursorWidth: 2,
      height: 80,
      barWidth: 2,
      barGap: 1,
      barRadius: 2,
      normalize: true,
      url: manifest.source,
    });
    wsRef.current = ws;
    let regions = null;
    if (hasRegions) {
      regions = ws.registerPlugin(WaveSurfer.Regions.create());
      regionsRef.current = regions;
    }
    if (WaveSurfer.Timeline) {
      ws.registerPlugin(WaveSurfer.Timeline.create({
        height: 16,
        style: { fontSize: '10px', fontFamily: '"JetBrains Mono", monospace', color: STUDIO.inkSoft },
      }));
    }
    if (WaveSurfer.Zoom) {
      ws.registerPlugin(WaveSurfer.Zoom.create({ scale: 0.18, maxZoom: 400 }));
    }

    const makeRegions = () => {
      if (!regions) return;
      regions.clearRegions();
      regionMapRef.current = [];
      splitsRef.current.forEach((s, i) => {
        const r = regions.addRegion({
          id: `split-${i}`,
          start: s.splitAtSec,
          content: `S${i + 1}`,
          color: markerColor(i, false),
          drag: !frozenRef.current,
          resize: false,
        });
        styleRegionContent(r, i, false);
        regionMapRef.current[i] = r;
      });
    };

    ws.on('decode', () => {
      const dur = ws.getDuration() || 0;
      setDuration(dur);
      const w = containerRef.current ? containerRef.current.clientWidth : 800;
      fitZoomRef.current = dur > 0 ? w / dur : 0;
      zoomRef.current = fitZoomRef.current;
      makeRegions();
    });
    ws.on('play',   () => setPlaying(true));
    ws.on('pause',  () => setPlaying(false));
    ws.on('finish', () => setPlaying(false));
    ws.on('audioprocess', (t) => setCurrentTime(t));
    ws.on('seeking',      (t) => setCurrentTime(t));

    if (regions) {
      // Drag END → snap (optional) → write splitAtSec into the draft.
      regions.on('region-updated', (region) => {
        const m = /^split-(\d+)$/.exec(region.id || '');
        if (!m) return;
        const idx = Number(m[1]);
        let t = region.start;
        let msg = `S${idx + 1} → ${t.toFixed(2)}s`;
        if (snapRef.current && Array.isArray(endersRef.current) && endersRef.current.length) {
          const hit = StudioArrange.snapToSentenceEnd(t, endersRef.current, 0.6);
          if (hit && Math.abs(hit.time - t) > 0.005) {
            t = hit.time;
            region.setOptions({ start: t });
            msg = `S${idx + 1} ⇒ prichytené na koniec vety: ${t.toFixed(2)}s (Δ ${(hit.delta * 1000).toFixed(0)} ms)`;
          } else if (hit) {
            msg = `S${idx + 1} = koniec vety @ ${t.toFixed(2)}s ✓`;
          } else {
            msg = `S${idx + 1} → ${t.toFixed(2)}s (žiadny koniec vety do 600 ms — bez prichytenia)`;
          }
        }
        setSnapMsg(msg);
        if (onSplitMoveRef.current) onSplitMoveRef.current(idx, t);
      });
      regions.on('region-clicked', (region, e) => {
        if (e) e.stopPropagation();
        const m = /^split-(\d+)$/.exec(region.id || '');
        if (!m) return;
        const idx = Number(m[1]);
        ws.setTime(splitsRef.current[idx] ? splitsRef.current[idx].splitAtSec : region.start);
        if (onMarkerClickRef.current) onMarkerClickRef.current(idx);
      });
    }

    return () => {
      regionMapRef.current = [];
      regionsRef.current = null;
      try { ws.destroy(); } catch {}
    };
  }, [manifest.source]);

  // Keep regions in sync when splitAtSec changes from elsewhere (undo, edits).
  useEffect(() => {
    const map = regionMapRef.current;
    if (!map.length) return;
    manifest.splits.forEach((s, i) => {
      const r = map[i];
      if (r && Math.abs(r.start - s.splitAtSec) > 0.005) {
        r.setOptions({ start: s.splitAtSec });
      }
    });
  }, [manifest.splits]);

  // Active marker tint.
  useEffect(() => {
    regionMapRef.current.forEach((r, i) => {
      if (!r) return;
      r.setOptions({ color: markerColor(i, activeSplitIdx === i) });
      styleRegionContent(r, i, activeSplitIdx === i);
    });
  }, [activeSplitIdx, duration]);

  // Frozen → read-only markers; un-freezing (draft copy) re-enables drag
  // without re-creating the wavesurfer instance.
  useEffect(() => {
    regionMapRef.current.forEach((r) => { if (r) r.setOptions({ drag: !frozen }); });
  }, [frozen, duration]);

  const togglePlay = () => wsRef.current && wsRef.current.playPause();
  const seekTo = (sec) => {
    const ws = wsRef.current;
    if (!ws || !ws.getDuration()) return;
    ws.setTime(sec);
  };
  const zoomBy = (f) => {
    const ws = wsRef.current;
    if (!ws || !ws.getDuration()) return;
    const fit = fitZoomRef.current || 1;
    const next = Math.max(fit, Math.min(400, (zoomRef.current || fit) * f));
    zoomRef.current = next;
    try { ws.zoom(next); } catch {}
  };

  // API for the arrange preview (duck the master, resume after the branch).
  useImperativeHandle(apiRef, () => ({
    seekTo,
    play: () => { try { wsRef.current && wsRef.current.play(); } catch {} },
    pause: () => { try { wsRef.current && wsRef.current.pause(); } catch {} },
    time: () => (wsRef.current ? wsRef.current.getCurrentTime() : 0),
    mediaEl: () => (wsRef.current && wsRef.current.getMediaElement ? wsRef.current.getMediaElement() : null),
    ready: () => duration > 0,
  }), [duration]);

  return (
    <div style={{
      background: STUDIO.paper, border: `3px solid ${STUDIO.ink}`, borderRadius: 18,
      padding: 14, position: 'relative',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
        <button onClick={togglePlay} style={{
          width: 44, height: 44, borderRadius: '50%', border: `3px solid ${STUDIO.ink}`,
          background: STUDIO.yellow, cursor: 'pointer', padding: 0,
          fontSize: 18, fontWeight: 700,
        }} aria-label={playing ? 'pauza' : 'hraj'}>
          {playing ? '❚❚' : '▶'}
        </button>
        <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 12, opacity: 0.8 }}>
          {fmtTime(currentTime)} / {fmtTime(duration)}
        </div>
        <label style={{
          display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17,
          background: snapOn ? STUDIO.green : STUDIO.paperLt,
          border: `2px solid ${STUDIO.ink}`, borderRadius: 999, padding: '2px 12px',
        }} title="Po pustení markeru ho prichytí na najbližší koniec vety z transkriptu (±600 ms).">
          <input type="checkbox" checked={snapOn} onChange={(e) => setSnapOn(e.target.checked)} style={{ accentColor: STUDIO.ink }}/>
          🧲 snap na koniec vety {Array.isArray(enders) && enders.length ? '' : '(transkript chýba)'}
        </label>
        <div style={{ display: 'flex', gap: 4 }}>
          <button onClick={() => zoomBy(1.6)} title="zoom in" style={{
            width: 30, height: 30, borderRadius: 8, border: `2px solid ${STUDIO.ink}`,
            background: STUDIO.paperLt, cursor: 'pointer', fontWeight: 700,
          }}>＋</button>
          <button onClick={() => zoomBy(1 / 1.6)} title="zoom out" style={{
            width: 30, height: 30, borderRadius: 8, border: `2px solid ${STUDIO.ink}`,
            background: STUDIO.paperLt, cursor: 'pointer', fontWeight: 700,
          }}>－</button>
        </div>
        <div style={{ marginLeft: 'auto', fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.55 }}>
          {manifest.source.split('/').pop()} · koliesko myši = zoom
        </div>
      </div>

      {!pluginsOk && (
        <div style={{
          marginBottom: 8, padding: '6px 10px', background: '#FFE7E7',
          border: `2px dashed ${STUDIO.pink}`, borderRadius: 10,
          fontFamily: '"JetBrains Mono", monospace', fontSize: 11, color: STUDIO.pink,
        }}>Regions plugin sa nenačítal z CDN — markery sú len na čítanie.</div>
      )}

      {/* Waveform (regions render inside the wavesurfer wrapper → zoom-safe).
          Static fallback markers only when the Regions plugin is missing. */}
      <div style={{ position: 'relative', paddingTop: 20 }}>
        <div ref={containerRef}/>
        {!pluginsOk && duration > 0 && manifest.splits.map((s, i) => (
          <div key={i}
            onClick={() => { seekTo(s.splitAtSec); onMarkerClick && onMarkerClick(i); }}
            style={{ position: 'absolute', top: 14, bottom: 0, left: `${(s.splitAtSec / duration) * 100}%`, width: 2,
              background: activeSplitIdx === i ? STUDIO.pink : STUDIO.red, cursor: 'pointer' }}/>
        ))}
      </div>

      {/* Drag feedback + timestamps */}
      <div style={{ marginTop: 8, display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
        {snapMsg && (
          <span style={{
            fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
            background: STUDIO.paperLt, border: `1.5px dashed ${STUDIO.inkSoft}`,
            borderRadius: 6, padding: '2px 8px',
          }}>{snapMsg}</span>
        )}
        <div style={{ marginLeft: 'auto', display: 'flex', gap: 14,
          fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.6 }}>
          {manifest.splits.map((s, i) => (
            <span key={i}>S{i + 1} {fmtTime(s.splitAtSec)}</span>
          ))}
          <span>∎ {fmtTime(duration)}</span>
        </div>
      </div>
    </div>
  );
});

// ───────────────────────────────────────────────────────────────
// ArrangeView — DAW-ish clip lanes. One SplitLane per split:
//   [bridge] → [choice A: ack+tail][choice B…][choice C…] · [re-ask][fallback]
// Block width ∝ clip duration; waveforms are min/max-bucket peaks computed
// client-side (decodeAudioData) and memoized in memory + IndexedDB.
// Trim/fade/gain edits are NON-destructive Clip objects in the draft JSON;
// preview plays them via PipiAudio {offset,duration} + gain ramps.
// ───────────────────────────────────────────────────────────────
const PX_PER_SEC = 14;          // lane scale: 25 s tail ≈ 350 px
const CLIP_H = 56;              // canvas height
const CLIP_FIELDS = {
  bridge:   ['bridgeAudio',   'bridgeClip'],
  reask:    ['reAskAudio',    'reAskClip'],
  fallback: ['fallbackAudio', 'fallbackClip'],
  ack:      ['ackAudio',      'ackClip'],
  tail:     ['tailAudio',     'tailClip'],
};
const KIND_LABEL = { bridge: 'bridge', reask: 're-ask', fallback: 'fallbk', ack: 'ack', tail: 'tail' };

function getClipOf(container, kind) {
  const [srcF, clipF] = CLIP_FIELDS[kind];
  return StudioArrange.normalizeClip(container ? container[clipF] : null, container ? container[srcF] : '');
}

// Decode + cache peaks for every clip URL of the book (concurrency-limited
// inside arrange-core). Returns { url: {duration,mins,maxs} | null(failed) }.
function usePeaks(urls) {
  const [peaks, setPeaks] = useState({});
  const requested = useRef(new Set());
  const key = urls.join('|');
  useEffect(() => {
    let dead = false;
    if (typeof StudioArrange === 'undefined' || !StudioArrange.getPeaks) return;
    for (const u of urls) {
      if (!u || requested.current.has(u)) continue;
      requested.current.add(u);
      StudioArrange.getPeaks(u)
        .then((meta) => { if (!dead) setPeaks((p) => ({ ...p, [u]: meta })); })
        .catch(() => { if (!dead) setPeaks((p) => ({ ...p, [u]: null })); });
    }
    return () => { dead = true; };
  }, [key]);
  return peaks;
}

// One audio block: canvas waveform + trim handles + fade handles + gain badge.
// All edits clamp through StudioArrange.clampClipPatch and commit on release;
// live drag feedback stays local so the draft isn't re-serialized per pixel.
function ClipBlock({ kind, tint, clip, meta, frozen, onPatch, onAudition, auditioning }) {
  const canvasRef = useRef(null);
  const [drag, setDrag] = useState(null); // {field, value} during a handle drag
  const eff = drag ? { ...clip, [drag.field]: drag.value } : clip;
  const dur = meta ? meta.duration : 0;
  const width = meta ? Math.max(54, Math.round(dur * PX_PER_SEC)) : 110;
  const failed = meta === null;

  useEffect(() => {
    const cv = canvasRef.current;
    if (!cv || !meta) return;
    const dpr = window.devicePixelRatio || 1;
    cv.width = width * dpr; cv.height = CLIP_H * dpr;
    const g = cv.getContext('2d');
    g.scale(dpr, dpr);
    g.clearRect(0, 0, width, CLIP_H);
    const { mins, maxs } = meta;
    const n = maxs.length;
    const mid = CLIP_H / 2;
    const amp = Math.min(1.8, StudioArrange.dbToGain(eff.gainDb || 0));
    const tsPx = dur ? (eff.trimStart / dur) * width : 0;
    const tePx = dur ? width - (eff.trimEnd / dur) * width : width;
    for (let b = 0; b < n; b++) {
      const x = (b / n) * width;
      const inside = x >= tsPx && x <= tePx;
      const hi = Math.max(0.02, maxs[b] * amp);
      const lo = Math.min(-0.02, mins[b] * amp);
      g.fillStyle = inside ? tint : 'rgba(58,58,82,0.22)';
      const y0 = mid - Math.min(1, hi) * (mid - 3);
      const y1 = mid - Math.max(-1, lo) * (mid - 3);
      g.fillRect(x, y0, Math.max(1, width / n - 0.4), Math.max(1, y1 - y0));
    }
    // trimmed zones veil
    g.fillStyle = 'rgba(26,26,46,0.16)';
    if (tsPx > 0) g.fillRect(0, 0, tsPx, CLIP_H);
    if (tePx < width) g.fillRect(tePx, 0, width - tePx, CLIP_H);
    // fade triangles
    const fiPx = dur ? (eff.fadeIn / dur) * width : 0;
    const foPx = dur ? (eff.fadeOut / dur) * width : 0;
    g.strokeStyle = STUDIO.ink; g.lineWidth = 1.6; g.setLineDash([3, 2]);
    if (fiPx > 1) {
      g.beginPath(); g.moveTo(tsPx, CLIP_H - 2); g.lineTo(tsPx + fiPx, 2); g.stroke();
    }
    if (foPx > 1) {
      g.beginPath(); g.moveTo(tePx, CLIP_H - 2); g.lineTo(tePx - foPx, 2); g.stroke();
    }
    g.setLineDash([]);
  }, [meta, width, eff.trimStart, eff.trimEnd, eff.fadeIn, eff.fadeOut, eff.gainDb, tint, dur]);

  // dir: which way the handle adds value (trimEnd/fadeOut grow leftwards).
  const startHandleDrag = (field, dir) => (e) => {
    if (frozen || !meta) return;
    e.preventDefault(); e.stopPropagation();
    const startX = e.clientX, startY = e.clientY;
    const base = Number(clip[field]) || 0;
    const valueAt = (ev) => {
      if (field === 'gainDb') return base - (ev.clientY - startY) * 0.08;
      return base + dir * (ev.clientX - startX) / PX_PER_SEC;
    };
    const move = (ev) => {
      const patched = StudioArrange.clampClipPatch({ ...clip, [field]: valueAt(ev) }, dur);
      setDrag({ field, value: patched[field] });
    };
    const up = (ev) => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      setDrag(null);
      onPatch({ [field]: valueAt(ev) }, dur);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };

  const tsPx = dur ? (eff.trimStart / dur) * width : 0;
  const tePx = dur ? width - (eff.trimEnd / dur) * width : width;
  const fiPx = dur ? (eff.fadeIn / dur) * width : 0;
  const foPx = dur ? (eff.fadeOut / dur) * width : 0;
  const effDur = StudioArrange.effectiveDuration(eff, dur);
  const edited = StudioArrange.clipIsEdited(eff);

  const handleStyle = (left) => ({
    position: 'absolute', top: 0, bottom: 0, left: left - 5, width: 10,
    cursor: frozen ? 'default' : 'ew-resize', zIndex: 3,
  });
  const fadeDotStyle = (left) => ({
    position: 'absolute', top: -7, left: left - 7, width: 14, height: 14,
    borderRadius: '50%', background: STUDIO.paper, border: `2.5px solid ${STUDIO.ink}`,
    cursor: frozen ? 'default' : 'ew-resize', zIndex: 4, boxSizing: 'border-box',
  });

  return (
    <div style={{ display: 'grid', gap: 2, justifyItems: 'start' }}>
      <div style={{
        position: 'relative', width, height: CLIP_H,
        background: STUDIO.paperLt,
        border: `2.5px solid ${edited ? STUDIO.pink : STUDIO.ink}`,
        borderRadius: 10, overflow: 'visible',
        boxShadow: edited ? `3px 4px 0 ${STUDIO.pinkLt}` : '2px 3px 0 rgba(0,0,0,0.12)',
      }} title={`${KIND_LABEL[kind]} · ${clip.src.split('/').pop()}`}>
        {failed ? (
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
            justifyContent: 'center', fontSize: 11, color: STUDIO.pink, fontFamily: '"JetBrains Mono", monospace' }}>
            audio fail
          </div>
        ) : meta ? (
          <canvas ref={canvasRef} style={{ width, height: CLIP_H, display: 'block', borderRadius: 8 }}/>
        ) : (
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
            justifyContent: 'center', fontSize: 12, opacity: 0.5 }}>dekódujem…</div>
        )}

        {meta && !frozen && (
          <>
            {/* trim handles */}
            <div onPointerDown={startHandleDrag('trimStart', +1)} style={handleStyle(tsPx)}
              title={`trim začiatku: ${eff.trimStart.toFixed(2)}s (ťahaj)`}>
              <div style={{ position: 'absolute', top: 2, bottom: 2, left: 4, width: 3, borderRadius: 2, background: STUDIO.ink }}/>
            </div>
            <div onPointerDown={startHandleDrag('trimEnd', -1)} style={handleStyle(tePx)}
              title={`trim konca: ${eff.trimEnd.toFixed(2)}s (ťahaj)`}>
              <div style={{ position: 'absolute', top: 2, bottom: 2, left: 4, width: 3, borderRadius: 2, background: STUDIO.ink }}/>
            </div>
            {/* fade handles */}
            <div onPointerDown={startHandleDrag('fadeIn', +1)} style={fadeDotStyle(tsPx + fiPx)}
              title={`fade-in: ${eff.fadeIn.toFixed(2)}s (ťahaj)`}/>
            <div onPointerDown={startHandleDrag('fadeOut', -1)} style={fadeDotStyle(tePx - foPx)}
              title={`fade-out: ${eff.fadeOut.toFixed(2)}s (ťahaj)`}/>
          </>
        )}

        {/* play / stop this block (honors trim+fade+gain via Web Audio) */}
        <button onClick={() => onAudition(eff, dur)} disabled={!meta} style={{
          position: 'absolute', left: 3, bottom: 3, width: 22, height: 22,
          borderRadius: '50%', border: `2px solid ${STUDIO.ink}`,
          background: auditioning ? tint : STUDIO.paper, cursor: 'pointer',
          fontSize: 10, fontWeight: 700, padding: 0, zIndex: 5,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        }} title="vypočuť klip s trim/fade/gain">{auditioning ? '■' : '▶'}</button>

        {/* gain badge: drag ↑/↓, double-click reset */}
        {meta && (
          <div
            onPointerDown={frozen ? undefined : startHandleDrag('gainDb', 0)}
            onDoubleClick={frozen ? undefined : () => onPatch({ gainDb: 0 }, dur)}
            style={{
              position: 'absolute', right: 3, bottom: 3, zIndex: 5,
              fontFamily: '"JetBrains Mono", monospace', fontSize: 9.5,
              background: (eff.gainDb || 0) === 0 ? STUDIO.paper : STUDIO.yellow,
              border: `1.5px solid ${STUDIO.ink}`, borderRadius: 6, padding: '1px 4px',
              cursor: frozen ? 'default' : 'ns-resize', userSelect: 'none',
            }}
            title="gain — ťahaj hore/dole, dvojklik = 0 dB">
            {(eff.gainDb > 0 ? '+' : '') + (eff.gainDb || 0).toFixed(1)}dB
          </div>
        )}
      </div>
      <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 9.5, opacity: 0.7, paddingLeft: 2 }}>
        {KIND_LABEL[kind]} {meta ? `${effDur.toFixed(1)}s${edited ? ` / ${dur.toFixed(1)}s` : ''}` : ''}
      </div>
    </div>
  );
}

// A choice's ack+tail pair, draggable (⠿) to reorder choices within the split.
function ChoiceGroup({ choice, ci, tint, frozen, children, onReorder, groupRefs }) {
  const ref = useRef(null);
  const [dragX, setDragX] = useState(null);
  const [target, setTarget] = useState(null);

  useEffect(() => { groupRefs.current[ci] = ref.current; });

  const startReorder = (e) => {
    if (frozen) return;
    e.preventDefault(); e.stopPropagation();
    const startX = e.clientX;
    const rects = groupRefs.current.map((el) => (el ? el.getBoundingClientRect() : null));
    const myRect = rects[ci];
    if (!myRect) return;
    const move = (ev) => {
      const dx = ev.clientX - startX;
      setDragX(dx);
      const center = myRect.left + myRect.width / 2 + dx;
      let best = ci, bestD = Infinity;
      rects.forEach((r, i) => {
        if (!r) return;
        const d = Math.abs(center - (r.left + r.width / 2));
        if (d < bestD) { bestD = d; best = i; }
      });
      setTarget(best);
    };
    const up = (ev) => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      const dx = ev.clientX - startX;
      const center = myRect.left + myRect.width / 2 + dx;
      let best = ci, bestD = Infinity;
      rects.forEach((r, i) => {
        if (!r) return;
        const d = Math.abs(center - (r.left + r.width / 2));
        if (d < bestD) { bestD = d; best = i; }
      });
      setDragX(null); setTarget(null);
      if (best !== ci) onReorder(ci, best);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };

  const isDragging = dragX !== null;
  return (
    <div ref={ref} style={{
      border: `2.5px solid ${STUDIO.ink}`, borderRadius: 12,
      background: target !== null && target === ci && !isDragging ? '#FFF2BF' : STUDIO.paper,
      padding: '6px 8px 4px', position: 'relative',
      transform: isDragging ? `translateX(${dragX}px)` : 'none',
      zIndex: isDragging ? 10 : 1,
      opacity: isDragging ? 0.88 : 1,
      boxShadow: isDragging ? '6px 8px 0 rgba(0,0,0,0.25)' : 'none',
      transition: isDragging ? 'none' : 'transform 0.12s ease',
      flexShrink: 0,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
        <span
          onPointerDown={startReorder}
          style={{ cursor: frozen ? 'default' : 'grab', fontSize: 14, lineHeight: 1, opacity: 0.7, userSelect: 'none' }}
          title={frozen ? 'frozen' : 'ťahaj — zmení poradie možností'}>⠿</span>
        <span style={{
          background: tint, border: `2px solid ${STUDIO.ink}`, borderRadius: 999,
          padding: '0 8px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 14,
        }}>{choice.id}</span>
        <span style={{ fontSize: 11, opacity: 0.65, maxWidth: 150, overflow: 'hidden',
          textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{choice.label}</span>
      </div>
      <div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>{children}</div>
    </div>
  );
}

function SplitLane({ split, idx, peaks, frozen, onClipPatch, onChoicesReorder, onAudition,
  auditionKey, onPreview, previewState }) {
  const groupRefs = useRef([]);
  const tintOf = (ci) => [STUDIO.green, STUDIO.yellow, STUDIO.blue][ci % 3];
  const previewing = previewState && previewState.splitIdx === idx;
  const [previewChoice, setPreviewChoice] = useState(0);

  const block = (kind, container, ci) => {
    const clip = getClipOf(container, kind);
    const key = `${idx}|${kind}|${ci == null ? '-' : ci}`;
    return (
      <ClipBlock
        kind={kind}
        tint={kind === 'bridge' ? STUDIO.lilac : kind === 'reask' ? '#E8D27C' : kind === 'fallback' ? STUDIO.pinkLt : tintOf(ci)}
        clip={clip}
        meta={clip.src ? peaks[clip.src] : null}
        frozen={frozen}
        onPatch={(patch, srcDur) => onClipPatch(idx, kind, ci, patch, srcDur)}
        onAudition={(effClip, srcDur) => onAudition(key, effClip, srcDur)}
        auditioning={auditionKey === key}
      />
    );
  };

  return (
    <div style={{
      background: STUDIO.paper, border: `3px solid ${STUDIO.ink}`, borderRadius: 16,
      padding: '10px 12px', marginTop: 12,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
        <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, color: STUDIO.red }}>
          Split {idx + 1}
        </span>
        <span style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.7 }}>
          @ {fmtTime(split.splitAtSec)} ({split.splitAtSec.toFixed(2)}s)
        </span>
        <button onClick={() => onPreview(idx, previewChoice)} style={{
          background: previewing ? STUDIO.pink : STUDIO.lilac,
          color: previewing ? STUDIO.paper : STUDIO.ink,
          border: `2.5px solid ${STUDIO.ink}`, borderRadius: 999, padding: '2px 12px',
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16, cursor: 'pointer',
        }} title="prehrá koniec kapitoly → bridge → ack → tail → návrat do nahrávky (s trim/fade/gain)">
          {previewing ? `■ stop (${previewState.stage})` : '▶ preview prechodu'}
        </button>
        <div style={{ display: 'flex', gap: 4 }}>
          {split.choices.map((c, ci) => (
            <button key={c.id} onClick={() => setPreviewChoice(ci)} style={{
              border: `2px solid ${STUDIO.ink}`, borderRadius: 999, padding: '0 8px',
              fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 13, cursor: 'pointer',
              background: previewChoice === ci ? tintOf(ci) : STUDIO.paperLt,
              opacity: previewChoice === ci ? 1 : 0.6,
            }} title={`preview vetvy: ${c.label}`}>{c.id}</button>
          ))}
        </div>
      </div>

      <div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', overflowX: 'auto', paddingTop: 10, paddingBottom: 4 }}>
        {block('bridge', split, null)}
        <div style={{ alignSelf: 'center', fontSize: 16, opacity: 0.5, flexShrink: 0 }}>→</div>
        {split.choices.map((c, ci) => (
          <ChoiceGroup key={c.id} choice={c} ci={ci} tint={tintOf(ci)} frozen={frozen}
            groupRefs={groupRefs} onReorder={(from, to) => onChoicesReorder(idx, from, to)}>
            {block('ack', c, ci)}
            {block('tail', c, ci)}
          </ChoiceGroup>
        ))}
        <div style={{ alignSelf: 'stretch', width: 1, background: `${STUDIO.inkSoft}44`, flexShrink: 0 }}/>
        {block('reask', split, null)}
        {block('fallback', split, null)}
      </div>
    </div>
  );
}

function ArrangeView({ manifest, bookId, frozen, onManifestEdit, masterRef }) {
  const urls = useMemo(() => {
    const list = [];
    for (const s of manifest.splits) {
      list.push(s.bridgeAudio, s.reAskAudio, s.fallbackAudio);
      for (const c of s.choices) list.push(c.ackAudio, c.tailAudio);
    }
    return [...new Set(list.filter(Boolean))];
  }, [manifest]);
  const peaks = usePeaks(urls);
  const decoded = urls.filter((u) => peaks[u]).length;
  const failedN = urls.filter((u) => peaks[u] === null).length;

  const [auditionKey, setAuditionKey] = useState(null);
  const [previewState, setPreviewState] = useState(null);
  const previewToken = useRef(null);

  // ── clip edits → draft (clamped; pristine clips drop the sibling object) ──
  const onClipPatch = useCallback((splitIdx, kind, choiceIdx, patch, srcDur) => {
    onManifestEdit(bookId, (m) => {
      const next = JSON.parse(JSON.stringify(m));
      const split = next.splits[splitIdx];
      const target = choiceIdx == null ? split : split.choices[choiceIdx];
      if (!target) return m;
      const [srcF, clipF] = CLIP_FIELDS[kind];
      const merged = StudioArrange.clampClipPatch(
        { ...StudioArrange.normalizeClip(target[clipF], target[srcF]), ...patch },
        srcDur || 0);
      merged.src = target[srcF] || merged.src;
      if (StudioArrange.clipIsEdited(merged)) target[clipF] = merged;
      else delete target[clipF];
      return next;
    });
  }, [bookId, onManifestEdit]);

  const onChoicesReorder = useCallback((splitIdx, from, to) => {
    onManifestEdit(bookId, (m) => {
      const next = JSON.parse(JSON.stringify(m));
      const arr = next.splits[splitIdx].choices;
      const [g] = arr.splice(from, 1);
      arr.splice(to, 0, g);
      return next;
    });
  }, [bookId, onManifestEdit]);

  // ── audition one block (Web Audio honors offset/duration/fades/gain) ──────
  const onAudition = useCallback((key, clip, srcDur) => {
    if (typeof PipiAudio === 'undefined') return;
    if (auditionKey === key) {
      PipiAudio.stop(); setAuditionKey(null); return;
    }
    PipiAudio.unlock();
    setAuditionKey(key);
    PipiAudio.play(clip.src, {
      offset: clip.trimStart, trimEnd: clip.trimEnd,
      fadeIn: Math.max(clip.fadeIn, 0.012), fadeOut: Math.max(clip.fadeOut, 0.012),
      gainDb: clip.gainDb,
    }).then(() => setAuditionKey((k) => (k === key ? null : k)));
  }, [auditionKey]);

  // ── split-boundary preview: master … duck → bridge → ack → tail → master ──
  const stopPreview = useCallback(() => {
    const token = previewToken.current;
    if (token) token.cancelled = true;
    previewToken.current = null;
    setPreviewState(null);
    if (typeof PipiAudio !== 'undefined') PipiAudio.stop();
    const api = masterRef.current;
    if (api) {
      api.pause();
      const el = api.mediaEl();
      if (el) el.volume = 1;
    }
  }, [masterRef]);

  const onPreview = useCallback(async (splitIdx, choiceIdx) => {
    if (previewToken.current) { stopPreview(); return; }
    const api = masterRef.current;
    if (!api || !api.ready() || typeof PipiAudio === 'undefined') return;
    const split = manifest.splits[splitIdx];
    const choice = split.choices[choiceIdx] || split.choices[0];
    const token = { cancelled: false };
    previewToken.current = token;
    const LEAD = 4;
    const waitUntil = (cond, timeoutMs) => new Promise((resolve) => {
      const t0 = Date.now();
      (function tick() {
        if (token.cancelled) return resolve(false);
        if (cond()) return resolve(true);
        if (Date.now() - t0 > timeoutMs) return resolve(false);
        requestAnimationFrame(tick);
      })();
    });
    try {
      PipiAudio.unlock();
      const el = api.mediaEl();
      setPreviewState({ splitIdx, choiceIdx, stage: 'kapitola' });
      if (el) el.volume = 1;
      api.seekTo(Math.max(0, split.splitAtSec - LEAD));
      api.play();
      const reached = await waitUntil(() => api.time() >= split.splitAtSec - 0.04, (LEAD + 8) * 1000);
      if (!reached || token.cancelled) return;
      await PipiAudio.duck(el, 0, 150);
      api.pause();
      const stages = [
        ['bridge', getClipOf(split, 'bridge')],
        ['ack',    getClipOf(choice, 'ack')],
        ['tail',   getClipOf(choice, 'tail')],
      ];
      for (const [stage, clip] of stages) {
        if (token.cancelled) return;
        if (!clip.src) continue;
        setPreviewState({ splitIdx, choiceIdx, stage });
        await PipiAudio.play(clip.src, {
          offset: clip.trimStart, trimEnd: clip.trimEnd,
          fadeIn: Math.max(clip.fadeIn, 0.012), fadeOut: Math.max(clip.fadeOut, 0.012),
          gainDb: clip.gainDb,
        });
      }
      if (token.cancelled) return;
      setPreviewState({ splitIdx, choiceIdx, stage: 'návrat' });
      if (el) el.volume = 0;
      api.seekTo(split.splitAtSec);
      api.play();
      await PipiAudio.duck(el, 1, 220);
    } finally {
      if (previewToken.current === token) {
        previewToken.current = null;
        setPreviewState(null);
      }
    }
  }, [manifest, masterRef, stopPreview]);

  useEffect(() => () => stopPreview(), [stopPreview]);

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
        <span style={{
          fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
          background: decoded === urls.length ? STUDIO.green : STUDIO.paperLt,
          border: `2px solid ${STUDIO.ink}`, borderRadius: 999, padding: '2px 10px',
        }}>
          peaks {decoded}/{urls.length}{failedN ? ` · ${failedN} fail` : ''}
        </span>
        <span style={{ fontSize: 12, opacity: 0.6 }}>
          ťahaj okraje = trim · krúžky hore = fade · dB vpravo dole = gain · ⠿ = poradie možností · zmeny sú nedeštruktívne (bake až pri publish)
        </span>
      </div>
      {manifest.splits.map((s, i) => (
        <SplitLane
          key={i} split={s} idx={i} peaks={peaks} frozen={frozen}
          onClipPatch={onClipPatch}
          onChoicesReorder={onChoicesReorder}
          onAudition={onAudition} auditionKey={auditionKey}
          onPreview={onPreview} previewState={previewState}
        />
      ))}
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// Per-split panel — question + 3 choices, each with text + audio rows.
// Local-only edits in round 1 (no save endpoint yet).
// ───────────────────────────────────────────────────────────────
function SplitPanel({ split, original, idx, onTextChange, frozen = false }) {
  const [open, setOpen] = useState(true);
  // Compare current draft text vs original manifest to flag "dirty" fields.
  const isDirty = (path) => {
    const get = (obj, p) => p.split('.').reduce((o, k) => (o == null ? o : o[isNaN(k) ? k : Number(k)]), obj);
    return get(split, path) !== get(original, path);
  };
  return (
    <div id={`split-${idx}`} style={{
      background: STUDIO.paper, border: `3px solid ${STUDIO.ink}`, borderRadius: 16,
      padding: '14px 16px', marginTop: 14,
    }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 8, cursor: 'pointer' }}
        onClick={() => setOpen((v) => !v)}>
        <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 28, color: STUDIO.red }}>
          ▶ Split {idx + 1}
        </span>
        <span style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 12, opacity: 0.7 }}>
          @ {fmtTime(split.splitAtSec)} ({split.splitAtSec.toFixed(2)} s)
        </span>
        <span style={{ marginLeft: 'auto', fontSize: 14, opacity: 0.55 }}>{open ? '−' : '+'}</span>
      </div>

      {open && (
        <>
          {/* Bridge — the narrator question */}
          <SectionLabel text="bridge — narrator pýta otázku" sub="prehráva sa keď audio dorazí na split"/>
          <LockedField
            value={split.questionText} big rows={2}
            onChange={(v) => onTextChange('questionText', v)}
            dirty={isDirty('questionText')} frozen={frozen}
          />
          <div style={{ marginTop: 6 }}>
            <MiniClip src={split.bridgeAudio} kind="bridge" color={STUDIO.lilac}/>
          </div>

          {/* Choices */}
          <SectionLabel text="3 možnosti pre dieťa" sub="každá má krátky ack + tail (~25 s) ktorý sa pripojí späť na hlavnú nahrávku" top={14}/>
          <div style={{ display: 'grid', gap: 10 }}>
            {split.choices.map((c, ci) => {
              const tint = [STUDIO.green, STUDIO.yellow, STUDIO.blue][ci % 3];
              return (
                <div key={c.id} style={{
                  border: `2.5px solid ${STUDIO.ink}`, borderRadius: 12,
                  background: STUDIO.paperLt, padding: '10px 12px',
                }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
                    <span style={{
                      background: tint, border: `2px solid ${STUDIO.ink}`, borderRadius: 999,
                      padding: '2px 10px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16,
                    }}>{c.id}</span>
                    <div style={{ flex: 1 }}>
                      <LockedField
                        value={c.label} multiline={false} big
                        onChange={(v) => onTextChange(`choices.${ci}.label`, v)}
                        dirty={isDirty(`choices.${ci}.label`)} frozen={frozen}
                      />
                    </div>
                  </div>
                  <div style={{ display: 'grid', gap: 6 }}>
                    <LockedField
                      value={c.ackText} rows={1}
                      onChange={(v) => onTextChange(`choices.${ci}.ackText`, v)}
                      dirty={isDirty(`choices.${ci}.ackText`)} frozen={frozen}
                    />
                    <MiniClip src={c.ackAudio} kind="ack" color={tint}/>
                    <LockedField
                      value={c.tailText} rows={3}
                      onChange={(v) => onTextChange(`choices.${ci}.tailText`, v)}
                      dirty={isDirty(`choices.${ci}.tailText`)} frozen={frozen}
                    />
                    <MiniClip src={c.tailAudio} kind="tail" color={tint}/>
                  </div>
                </div>
              );
            })}
          </div>

          {/* Re-ask & fallback — what happens if the child doesn't answer
              or if the AI can't classify what they said. The defaults are
              populated by the render agent; we show "missing" badge when
              the manifest doesn't have them yet. */}
          <SectionLabel text="fallbacky — keď dieťa mlčí alebo AI nerozumie" sub="vie sa to spýtať znova s vymenovaním možností" top={14}/>
          <div style={{
            display: 'grid', gap: 10,
            background: STUDIO.paperLt, border: `2px dashed ${STUDIO.inkSoft}`,
            borderRadius: 12, padding: 12,
          }}>
            <div>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
                <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20 }}>
                  ⏱ re-ask po
                </span>
                <input type="number" min={3} max={20} value={split.reAskAfterSec || 8}
                  onChange={(e) => onTextChange('reAskAfterSec', Math.max(3, Math.min(20, Number(e.target.value) || 8)))}
                  style={{
                    width: 56, border: `2px solid ${STUDIO.ink}`, borderRadius: 8,
                    padding: '2px 6px', fontFamily: '"JetBrains Mono", monospace', fontSize: 14,
                    background: STUDIO.paper, color: STUDIO.ink, outline: 'none',
                  }}/>
                <span style={{ fontSize: 13, opacity: 0.7 }}>sekundách mlčania</span>
              </div>
              <LockedField
                value={split.reAskText || ''} rows={2}
                onChange={(v) => onTextChange('reAskText', v)}
                dirty={isDirty('reAskText')} frozen={frozen}
              />
              <div style={{ marginTop: 6 }}>
                <MiniClip src={split.reAskAudio} kind="re-ask" color={STUDIO.yellow} missing={!split.reAskAudio}/>
              </div>
            </div>
            <div>
              <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20, marginBottom: 4 }}>
                ⚠ fallback (AI nerozumie)
              </div>
              <LockedField
                value={split.fallbackText || ''} rows={2}
                onChange={(v) => onTextChange('fallbackText', v)}
                dirty={isDirty('fallbackText')} frozen={frozen}
              />
              <div style={{ marginTop: 6 }}>
                <MiniClip src={split.fallbackAudio} kind="fallback" color={STUDIO.pink} missing={!split.fallbackAudio}/>
              </div>
            </div>
          </div>
        </>
      )}
    </div>
  );
}

function SectionLabel({ text, sub, top = 8 }) {
  return (
    <div style={{ marginTop: top, marginBottom: 6 }}>
      <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: 0.5 }}>
        {text}
      </div>
      {sub && <div style={{ fontSize: 12, opacity: 0.55 }}>{sub}</div>}
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// One book section — header + timeline + per-split panels.
// ───────────────────────────────────────────────────────────────
// Which transcript backs which book (cirkus-test reuses the cirkus master).
const TRANSCRIPT_BASE = { cirkus: 'cirkus', vylet: 'vylet', zlodeji: 'zlodeji', cirkusTest: 'cirkus' };

function BookSection({ bookId, manifest, original, onManifestEdit }) {
  const label = BOOK_LABELS[bookId];
  const frozen = manifest && manifest.frozen === true;
  const [activeSplit, setActiveSplit] = useState(null);
  const [view, setView] = useState('arrange'); // arrange | texty | strom
  const [enders, setEnders] = useState(null);  // sentence-end times (snap targets)
  const masterRef = useRef(null);
  const mermaidCode = useMemo(() => buildAuthoredMermaid(manifest), [manifest]);

  // Lazy transcript fetch for snap-to-sentence-end. Studio static copy first,
  // dev-server /outputs fallback second; 404 everywhere → snap disabled.
  useEffect(() => {
    let dead = false;
    const base = TRANSCRIPT_BASE[bookId];
    if (!base) { setEnders([]); return; }
    (async () => {
      for (const url of [`/studio/transcripts/${base}-full.json`, `/outputs/transcripts/pipi-${base}-full.json`]) {
        try {
          const r = await fetch(url);
          if (!r.ok) continue;
          const j = await r.json();
          if (!dead) setEnders(StudioArrange.sentenceEndTimes(j.words));
          return;
        } catch {}
      }
      if (!dead) setEnders([]);
    })();
    return () => { dead = true; };
  }, [bookId]);

  // Path-based setter: 'questionText', 'choices.0.label', etc.
  const onTextChange = useCallback((splitIdx) => (path, value) => {
    onManifestEdit(bookId, (m) => {
      const next = JSON.parse(JSON.stringify(m));
      const parts = path.split('.');
      let cur = next.splits[splitIdx];
      for (let i = 0; i < parts.length - 1; i++) {
        cur = cur[isNaN(parts[i]) ? parts[i] : Number(parts[i])];
      }
      cur[parts[parts.length - 1]] = value;
      return next;
    });
  }, [bookId, onManifestEdit]);

  // Marker drag-end (already snapped by MasterLane) → splitAtSec in the draft.
  const onSplitMove = useCallback((idx, sec) => {
    onManifestEdit(bookId, (m) => {
      const next = JSON.parse(JSON.stringify(m));
      if (!next.splits[idx]) return m;
      next.splits[idx].splitAtSec = Math.round(sec * 100) / 100;
      return next;
    });
  }, [bookId, onManifestEdit]);

  const viewTabs = [
    { id: 'arrange', label: '🎛 arrange' },
    { id: 'texty',   label: '📝 texty + klipy' },
    { id: 'strom',   label: '🌳 strom' },
  ];

  return (
    <section style={{ padding: '24px 16px', maxWidth: 1080, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
        <h2 style={{
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 44, lineHeight: 1,
          margin: 0, color: STUDIO.ink,
        }}>{label.emoji} {label.title}</h2>
        <span style={{
          fontFamily: '"JetBrains Mono", monospace', fontSize: 12, opacity: 0.65,
          background: STUDIO.paperLt, border: `1.5px dashed ${STUDIO.inkSoft}`,
          padding: '2px 8px', borderRadius: 6,
        }}>{manifest.splits.length} splitov</span>
        {manifest.frozen && (
          <span style={{
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18,
            background: STUDIO.green, border: `2.5px solid ${STUDIO.ink}`,
            color: STUDIO.ink, borderRadius: 999, padding: '2px 12px',
          }}
            title={manifest.frozenReason || 'Frozen — un-freeze in a separate commit before editing.'}>
            🔒 frozen — gold standard
          </span>
        )}
      </div>

      {manifest.frozen && (
        <div style={{
          background: '#FFE7E7', border: `2.5px dashed ${STUDIO.pink}`, borderRadius: 14,
          padding: '10px 14px', marginBottom: 14,
          fontFamily: '"Patrick Hand", cursive', fontSize: 14, color: STUDIO.ink,
        }}>
          <b>Frozen book.</b> {manifest.frozenReason || 'Hand-validated end-to-end. Read-only.'}
          {' '}Na úpravy vytvor draft kópiu (tlačidlo hore) — súbor s <code>frozen: true</code> zostáva nedotknutý.
        </div>
      )}

      <MasterLane
        ref={masterRef}
        manifest={manifest}
        activeSplitIdx={activeSplit}
        enders={enders}
        frozen={frozen}
        onSplitMove={onSplitMove}
        onMarkerClick={(i) => {
          setActiveSplit(i);
          if (view === 'texty') {
            setTimeout(() => {
              const el = document.getElementById(`split-${i}-${bookId}`);
              if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
            }, 50);
          }
        }}
      />

      <nav style={{ display: 'flex', gap: 8, marginTop: 14 }}>
        {viewTabs.map((t) => (
          <button key={t.id} onClick={() => setView(t.id)} style={{
            background: view === t.id ? STUDIO.yellow : 'transparent',
            border: `2.5px solid ${STUDIO.ink}`, padding: '4px 14px', borderRadius: 999,
            cursor: 'pointer', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17,
            color: STUDIO.ink, boxShadow: view === t.id ? '2px 3px 0 rgba(0,0,0,0.18)' : 'none',
          }}>{t.label}</button>
        ))}
      </nav>

      {view === 'arrange' && (
        <ArrangeView
          manifest={manifest}
          bookId={bookId}
          frozen={frozen}
          onManifestEdit={onManifestEdit}
          masterRef={masterRef}
        />
      )}

      {view === 'texty' && (
        <div>
          {manifest.splits.map((split, i) => (
            <div key={i} id={`split-${i}-${bookId}`}>
              <SplitPanel
                split={split}
                original={original && original.splits[i] ? original.splits[i] : split}
                idx={i}
                onTextChange={onTextChange(i)}
                frozen={frozen}
              />
            </div>
          ))}
        </div>
      )}

      {view === 'strom' && (
        <MermaidDiagram code={mermaidCode} title={`strom · ${label.title}`}/>
      )}
    </section>
  );
}

// ───────────────────────────────────────────────────────────────
// Vila custom — special read-only view (no fixed branches, runtime gen)
// ───────────────────────────────────────────────────────────────
function VilaPanel({ manifest }) {
  // IMPORTANT: hooks must run on every render even when manifest is null,
  // otherwise React sees a different hook count between the loading and
  // loaded states ("rendered more hooks than during the previous render").
  const mermaidCode = useMemo(
    () => (manifest ? buildVilaMermaid(manifest) : ''),
    [manifest]);
  if (!manifest) return null;
  return (
    <section style={{ padding: '24px 16px', maxWidth: 1080, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
        <h2 style={{
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 44, lineHeight: 1,
          margin: 0, color: STUDIO.ink,
        }}>🪄 Pipi a tajomstvo vily — custom</h2>
        <span style={{
          fontFamily: '"JetBrains Mono", monospace', fontSize: 12, opacity: 0.65,
          background: STUDIO.lilac, border: `1.5px solid ${STUDIO.ink}`,
          padding: '2px 8px', borderRadius: 6,
        }}>{manifest.rounds.length} kolá, AI rozšírenie runtime</span>
      </div>
      <MermaidDiagram code={mermaidCode} title="strom · Pipi a tajomstvo vily (custom)"/>
      {manifest.rounds.map((r) => (
        <div key={r.n} style={{
          background: STUDIO.paper, border: `3px solid ${STUDIO.ink}`, borderRadius: 16,
          padding: '14px 16px', marginTop: 14,
        }}>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 28, color: STUDIO.red, marginBottom: 8 }}>
            Kolo {r.n}
          </div>
          <div style={{ display: 'grid', gap: 8 }}>
            <div style={{ fontStyle: 'italic', fontSize: 16 }}>„{r.openerText}"</div>
            <MiniClip src={r.openerAudio} kind="opener" color={STUDIO.lilac}/>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20, color: STUDIO.ink }}>„{r.questionText}"</div>
            <MiniClip src={r.questionAudio} kind="question" color={STUDIO.blue}/>
            <div style={{ marginTop: 4, fontSize: 13, opacity: 0.65 }}>
              príklady odpovedí: {r.exampleAnswers.map((a) => `„${a}"`).join(' · ')}
            </div>
          </div>
        </div>
      ))}
      {manifest.wrap && manifest.wrap.promptInstruction && (
        <div style={{
          background: STUDIO.paperLt, border: `2.5px dashed ${STUDIO.inkSoft}`, borderRadius: 14,
          padding: '12px 14px', marginTop: 14,
          fontFamily: '"JetBrains Mono", monospace', fontSize: 12, lineHeight: 1.5,
        }}>
          <div style={{ opacity: 0.6, marginBottom: 6 }}>wrap-up prompt (posiela sa do OpenRouteru):</div>
          {manifest.wrap.promptInstruction}
        </div>
      )}
    </section>
  );
}

// ───────────────────────────────────────────────────────────────
// QA board (C6) — mass story-QA across every authored book. Reads the
// board JSON produced by scripts/qa-board-data.mjs (dev /outputs first,
// then the static snapshot the deployed studio ships). Renders a per-book
// health card (timing + align + coverage → ✓/~/✗), expandable per-split
// rows with a status chip per clip, the authored Mermaid path diagram with
// FAIL paths painted red, and a ranked "čo opraviť" list. Logic helpers
// live in /studio/qa-board.js (window.QABoard); this is the view only.
// ───────────────────────────────────────────────────────────────
const QA = (typeof window !== 'undefined' && window.QABoard) ? window.QABoard : null;

function QAStatusPill({ status, big }) {
  const map = {
    pass: { bg: STUDIO.green, txt: 'OK' },
    warn: { bg: STUDIO.yellow, txt: 'POZOR' },
    fail: { bg: STUDIO.pinkLt, txt: 'CHYBA' },
  };
  const m = map[status] || map.warn;
  const mark = status === 'pass' ? '✓' : status === 'warn' ? '~' : '✗';
  return (
    <span style={{
      fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: big ? 22 : 15,
      background: m.bg, border: `2.5px solid ${STUDIO.ink}`, color: STUDIO.ink,
      borderRadius: 999, padding: big ? '2px 14px' : '1px 10px', whiteSpace: 'nowrap',
    }}>{mark} {m.txt}</span>
  );
}

// One clip status chip (ok / missing / ack=tail / no-text), with the align
// verdict appended when align-QA has graded that clip.
function ClipChip({ clip }) {
  const c = QA ? QA.chip(clip.status) : { bg: '#EEE', glyph: '?', label: clip.status };
  const dur = QA ? QA.fmtDur(clip.durationSec) : (clip.durationSec || '—');
  return (
    <span title={clip.src || ''} style={{
      display: 'inline-flex', alignItems: 'center', gap: 6,
      fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
      background: c.bg, border: `1.5px solid ${STUDIO.ink}`, borderRadius: 8,
      padding: '2px 8px', margin: '2px 4px 2px 0', color: STUDIO.ink,
    }}>
      <b>{clip.role}</b>
      <span>{c.glyph}</span>
      <span style={{ opacity: 0.7 }}>{c.label}</span>
      {clip.status === 'ok' && <span style={{ opacity: 0.6 }}>· {dur}</span>}
      {clip.align && (
        <span style={{
          background: clip.align === 'PASS' ? STUDIO.green : clip.align === 'FAIL' ? STUDIO.red : STUDIO.yellow,
          color: clip.align === 'FAIL' ? '#fff' : STUDIO.ink,
          borderRadius: 6, padding: '0 5px', marginLeft: 2,
        }} title={clip.alignReason || ''}>align {clip.align}</span>
      )}
    </span>
  );
}

function QABookCard({ book }) {
  const [open, setOpen] = useState(book.status !== 'pass'); // problems expanded by default
  const mermaidBase = useMemo(() => {
    // Reuse the studio's authored diagram builder, then decorate FAIL paths red.
    try {
      const m = { splits: (book.coverage.rows || []).map((r) => ({
        n: r.n, splitAtSec: r.at, questionText: r.question,
        reAskAfterSec: 8,
        choices: (r.choices || []).map((label, i) => ({ id: (r.clips.find((c) => c.role.includes('·')) ? '' : '') + i, label })),
      })) };
      // Rebuild choices with their real ids so node ids line up with failPathKeys.
      m.splits.forEach((s, i) => {
        const row = book.coverage.rows[i];
        const ids = [...new Set((row.clips || []).filter((c) => c.role.includes('·')).map((c) => c.role.split('·')[0]))];
        s.choices = ids.map((id, k) => ({ id, label: (row.choices && row.choices[k]) || id }));
      });
      return buildAuthoredMermaid(m);
    } catch (e) { return ''; }
  }, [book]);
  const mermaidCode = useMemo(
    () => (QA && mermaidBase ? QA.mermaidWithFails(book, mermaidBase) : mermaidBase),
    [book, mermaidBase]
  );

  const cov = book.coverage || {};
  const timing = book.timing || {};
  const align = book.align || {};

  return (
    <div style={{
      background: STUDIO.paper, border: `4px solid ${STUDIO.ink}`, borderRadius: 18,
      padding: '16px 18px', marginBottom: 18, boxShadow: '6px 8px 0 rgba(0,0,0,0.15)',
    }}>
      <div onClick={() => setOpen((v) => !v)} style={{
        display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', flexWrap: 'wrap',
      }}>
        <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 34, lineHeight: 1 }}>
          {book.emoji} {book.label}
        </span>
        <QAStatusPill status={book.status} big/>
        {book.frozen && (
          <span style={{
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 15,
            background: STUDIO.green, border: `2px solid ${STUDIO.ink}`, borderRadius: 999, padding: '1px 10px',
          }} title={book.frozenReason || 'frozen — gold standard'}>🔒 frozen</span>
        )}
        <span style={{ marginLeft: 'auto', fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.6 }}>
          {open ? '▾ skry detail' : '▸ detail'}
        </span>
      </div>

      {/* Three-signal summary line. */}
      <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 12 }}>
        <QASignal label="split timing"
          value={timing.available ? `✓${timing.perfect} ~${timing.near} ✗${timing.bad}` : '—'}
          bad={timing.available ? timing.bad > 0 : false}
          warn={timing.available ? timing.near > 0 : true}/>
        <QASignal label="align-QA"
          value={align.available ? `✓${align.tally.PASS} ~${align.tally.WARN} ✗${align.tally.FAIL}${align.partial ? ' (časť)' : ''}` : 'žiadny report'}
          bad={align.available ? align.tally.FAIL > 0 : false}
          warn={align.available ? align.tally.WARN > 0 : true}
          sub={align.available && align.ageDays != null ? `${align.ageDays}d · model ${align.model}` : null}/>
        <QASignal label="coverage"
          value={`${cov.clipsOk}/${cov.clipsTotal} (${cov.coveragePct}%)`}
          bad={(cov.uniqueMissingFiles || 0) > 0 || (cov.clipsDup || 0) > 0}
          warn={(cov.clipsNoText || 0) > 0}
          sub={`${cov.uniqueMissingFiles || 0} chýba · ${cov.clipsDup || 0} ack=tail · ${cov.clipsNoText || 0} bez textu`}/>
        <QASignal label="verzia"
          value={book.version && book.version.draftVersion != null ? `draft v${book.version.draftVersion}` : 'súbor (v0)'}
          sub={book.version ? (book.version.draftStatus || book.version.note || 'published') : null}/>
      </div>

      {/* Ranked "what to fix next". */}
      {book.fix && book.fix.length > 0 && (
        <div style={{
          marginTop: 12, background: STUDIO.paperLt, border: `2px dashed ${STUDIO.inkSoft}`,
          borderRadius: 12, padding: '10px 12px',
        }}>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20, marginBottom: 4 }}>
            🛠 čo opraviť (podľa priority)
          </div>
          <ol style={{ margin: '4px 0 0', paddingLeft: 22, fontSize: 14, lineHeight: 1.6 }}>
            {book.fix.map((f, i) => (
              <li key={i} style={{ color: f.rank === 'fail' ? STUDIO.red : STUDIO.inkSoft }}>
                <span style={{ fontWeight: 700 }}>{f.rank === 'fail' ? '✗' : '~'}</span> {f.what}
              </li>
            ))}
          </ol>
        </div>
      )}

      {open && (
        <>
          {/* Per-split rows with a chip per clip. */}
          <div style={{ marginTop: 14 }}>
            {(cov.rows || []).map((row, i) => (
              <div key={i} style={{
                background: row.ok ? STUDIO.paperLt : '#FFE7E7',
                border: `2px solid ${row.ok ? STUDIO.inkSoft + '55' : STUDIO.pink}`,
                borderRadius: 12, padding: '8px 12px', marginBottom: 8,
              }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
                  <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20 }}>
                    {row.ok ? '✓' : '✗'} split {row.n}
                  </span>
                  <span style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.6 }}>
                    @ {fmtTime(row.at)}
                  </span>
                  <span style={{ fontSize: 13, opacity: 0.75, fontStyle: 'italic' }}>„{row.question}…"</span>
                </div>
                <div style={{ marginTop: 6 }}>
                  {(row.clips || []).map((clip, k) => <ClipChip key={k} clip={clip}/>)}
                </div>
              </div>
            ))}
          </div>

          {/* Authored path diagram with FAIL branches in red. */}
          {mermaidCode
            ? <MermaidDiagram code={mermaidCode} title={`cesty · ${book.label} (červené = chyba)`}/>
            : null}
        </>
      )}
    </div>
  );
}

function QASignal({ label, value, sub, bad, warn }) {
  const color = bad ? STUDIO.red : warn ? '#B8860B' : STUDIO.ink;
  return (
    <div style={{ minWidth: 120 }}>
      <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 10, opacity: 0.55, textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</div>
      <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, color, lineHeight: 1.1 }}>{value}</div>
      {sub && <div style={{ fontSize: 11, opacity: 0.6, fontFamily: '"JetBrains Mono", monospace' }}>{sub}</div>}
    </div>
  );
}

function StudioQABoard() {
  const [state, setState] = useState({ loading: true, board: null, source: null, err: '' });

  useEffect(() => {
    let dead = false;
    (async () => {
      if (!QA) { setState({ loading: false, board: null, source: null, err: 'qa-board.js sa nenačítal' }); return; }
      const { board, source } = await QA.loadBoard();
      if (dead) return;
      if (!board) { setState({ loading: false, board: null, source: null, err: 'board.json / snapshot sa nenašiel — spusti node scripts/qa-board-data.mjs' }); return; }
      setState({ loading: false, board, source, err: '' });
    })();
    return () => { dead = true; };
  }, []);

  if (state.loading) {
    return <div style={{ padding: '40px 24px', textAlign: 'center', opacity: 0.7, fontFamily: '"Caveat", cursive', fontSize: 24 }}>načítavam QA board…</div>;
  }
  if (state.err || !state.board) {
    return (
      <section style={{ padding: '28px 16px', maxWidth: 1080, margin: '0 auto' }}>
        <div style={{
          background: '#FFE7E7', border: `2.5px dashed ${STUDIO.pink}`, borderRadius: 14,
          padding: '16px 18px', fontFamily: '"Patrick Hand", cursive', fontSize: 15,
        }}>
          <b>QA board nie je k dispozícii.</b><br/>{state.err}<br/>
          <code style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 12 }}>node scripts/qa-board-data.mjs</code> vygeneruje
          {' '}<code>outputs/qa/board.json</code> aj deployment snapshot.
        </div>
      </section>
    );
  }

  const b = state.board;
  const s = b.summary || { pass: 0, warn: 0, fail: 0, books: 0 };
  const gen = b.generatedAt ? new Date(b.generatedAt) : null;
  return (
    <section style={{ padding: '24px 16px', maxWidth: 1080, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 14, flexWrap: 'wrap', marginBottom: 4 }}>
        <h2 style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 44, margin: 0 }}>🩺 QA board</h2>
        <span style={{
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20,
          background: STUDIO.paper, border: `2.5px solid ${STUDIO.ink}`, borderRadius: 999, padding: '2px 14px',
        }}>✓ {s.pass} · ~ {s.warn} · ✗ {s.fail} ({s.books} kníh)</span>
      </div>
      <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11, opacity: 0.55, marginBottom: 18 }}>
        zdroj: {state.source}{gen ? ` · vygenerované ${gen.toLocaleString('sk-SK')}` : ''} · regeneruj: node scripts/qa-board-data.mjs
      </div>
      {(b.books || []).map((book) => <QABookCard key={book.id} book={book}/>)}
      <div style={{
        marginTop: 6, fontSize: 12, opacity: 0.6, lineHeight: 1.6,
        fontFamily: '"Patrick Hand", cursive',
      }}>
        Tri signály na knihu: <b>split timing</b> (±200 ms na koniec vety, ako audit-splits.mjs) ·
        {' '}<b>align-QA</b> (WhisperX: hovorí klip svoj text? z outputs/qa/align-&lt;book&gt;.json) ·
        {' '}<b>coverage</b> (každý split × voľba × klip: súbor na disku? text? dĺžka?). Červené uzly v strome = FAIL cesta.
      </div>
    </section>
  );
}

// ───────────────────────────────────────────────────────────────
// Top-level Studio
// ───────────────────────────────────────────────────────────────
function StudioApp() {
  const [unlocked, setUnlocked] = useState(null); // null = checking
  const [manifests, setManifests] = useState({ circus: null, outing: null, burglars: null, goldilocks: null, cirkus: null, vylet: null, zlodeji: null, cirkusTest: null, vilaCustom: null });
  const [activeBook, setActiveBook] = useState('circus');
  const [draft, setDraft] = useState({}); // local edits per book — { cirkus: <manifest>, ... }
  const [studioMeta, setStudioMeta] = useState({}); // { cirkus: {version, status, savedAt}, ... }
  const [saveState, setSaveState] = useState({ busy: false, msg: '', err: false });
  const [publishInfo, setPublishInfo] = useState(null); // {busy} | {cmd, book, version} | {err}
  const savedJsonRef = useRef({}); // last-saved JSON string per book → drives the dirty badge
  const [, forceTick] = useState(0); // re-render after ref updates
  const undoRef = useRef({});      // bookId -> [previous draft states] (Ctrl+Z)
  const activeBookRef = useRef(activeBook);
  activeBookRef.current = activeBook;

  useEffect(() => {
    let dead = false;
    if (studioSb) {
      // Supabase session = unlocked (membership re-checked by every API call).
      studioSb.auth.getSession().then(({ data }) => { if (!dead) setUnlocked(!!(data && data.session)); });
      return () => { dead = true; };
    }
    // Legacy fallback: GET /api/login on Vercel returns { required, authenticated }.
    // Locally there's no such endpoint — treat 404 / network error as
    // "no gate, open studio" so the dev loop doesn't require fake auth.
    fetch('/api/login')
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        if (dead) return;
        if (!j) { setUnlocked(true); return; }
        if (j.required === false) { setUnlocked(true); return; }
        setUnlocked(!!j.authenticated);
      })
      .catch(() => { if (!dead) setUnlocked(true); });
    return () => { dead = true; };
  }, []);

  useEffect(() => {
    if (unlocked !== true) return;
    const load = (name, key) => fetch(`/demo/manifests/${name}.json`, { cache: 'no-store' })
      .then((r) => (r.ok ? r.json() : null))
      .catch(() => null)
      .then(async (d) => {
        setManifests((prev) => ({ ...prev, [key]: d }));
        if (!d || key === 'vilaCustom') return;
        // Prefer the latest studio draft over the shipped file (server-side
        // drafts are the new save path; the file stays the published truth).
        let drafted = null;
        if (studioSb) {
          try {
            const r = await studioFetch(`/api/studio/manifest?book=${name}`);
            if (r.ok) {
              const j = await r.json();
              if (j && j.source === 'studio' && j.draft && j.draft.json) drafted = j.draft;
            }
          } catch {}
        }
        setDraft((prev) => ({ ...prev, [key]: drafted ? drafted.json : d }));
        savedJsonRef.current[key] = JSON.stringify(drafted ? drafted.json : d);
        if (drafted) {
          setStudioMeta((prev) => ({ ...prev, [key]: {
            version: drafted.version, status: drafted.status, savedAt: drafted.updated_at,
          }}));
        }
      });
    load('circus',      'circus');
    load('outing',      'outing');
    load('burglars',    'burglars');
    load('goldilocks',  'goldilocks');
    load('cirkus',      'cirkus');
    load('vylet',       'vylet');
    load('zlodeji',     'zlodeji');
    load('cirkus-test', 'cirkusTest');
    load('vila-custom', 'vilaCustom');
  }, [unlocked]);

  const onManifestEdit = useCallback((bookId, mutator) => {
    setDraft((prev) => {
      const cur = prev[bookId];
      if (!cur) return prev;
      const next = mutator(cur);
      if (next === cur) return prev;
      const stack = undoRef.current[bookId] || (undoRef.current[bookId] = []);
      stack.push(cur);
      if (stack.length > 60) stack.shift();
      return { ...prev, [bookId]: next };
    });
  }, []);

  // Ctrl/Cmd+Z = undo the last arrange/text edit of the active book
  // (ignored while typing — textareas keep their native undo).
  useEffect(() => {
    const onKey = (e) => {
      if (!(e.ctrlKey || e.metaKey) || e.shiftKey) return;
      if (String(e.key).toLowerCase() !== 'z') return;
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      const bookId = activeBookRef.current;
      const stack = undoRef.current[bookId];
      if (!stack || !stack.length) return;
      e.preventDefault();
      const prev = stack.pop();
      setDraft((d) => ({ ...d, [bookId]: prev }));
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  // iOS/Safari: AudioContext must resume inside a user gesture.
  useEffect(() => {
    const f = () => { try { if (typeof PipiAudio !== 'undefined') PipiAudio.unlock(); } catch {} };
    window.addEventListener('pointerdown', f, { once: true });
    return () => window.removeEventListener('pointerdown', f);
  }, []);

  // Save the active book's draft as a new version in studio.manifests.
  const saveDraft = useCallback(async (bookId) => {
    const name = BOOK_NAMES[bookId];
    const json = draft[bookId];
    if (!name || !json) return;
    if (!studioSb) { setSaveState({ busy: false, msg: 'ukladanie potrebuje Supabase login', err: true }); return; }
    setSaveState({ busy: true, msg: 'ukladám…', err: false });
    try {
      const r = await studioFetch('/api/studio/manifest', {
        method: 'PUT', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ book: name, json, baseVersion: (studioMeta[bookId] || {}).version }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      setStudioMeta((prev) => ({ ...prev, [bookId]: {
        version: j.draft.version, status: j.draft.status, savedAt: j.draft.updated_at,
      }}));
      savedJsonRef.current[bookId] = JSON.stringify(json);
      forceTick((t) => t + 1);
      setSaveState({ busy: false, msg: `uložené ako v${j.draft.version}`, err: false });
    } catch (e) {
      setSaveState({ busy: false, msg: 'chyba: ' + (e.message || e), err: true });
    }
  }, [draft, studioMeta]);

  // Publish = two-step: this marks the saved draft 'review' in studio.manifests
  // and returns the CLI command; the actual ffmpeg bake + QA + upload runs
  // locally via scripts/studio-publish.mjs (too heavy for a 60 s function).
  const requestPublish = useCallback(async (bookId) => {
    const name = BOOK_NAMES[bookId];
    const meta = studioMeta[bookId];
    if (!name || !meta || !meta.version) {
      setPublishInfo({ err: 'najprv ulož draft (publish berie uloženú verziu)' });
      return;
    }
    setPublishInfo({ busy: true });
    try {
      const r = await studioFetch('/api/studio/publish', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ book: name, version: meta.version }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      setStudioMeta((prev) => ({ ...prev, [bookId]: { ...prev[bookId], status: j.draft.status } }));
      setPublishInfo({ cmd: j.command, book: name, version: j.draft.version });
    } catch (e) {
      setPublishInfo({ err: String((e && e.message) || e) });
    }
  }, [studioMeta]);

  // Frozen book → start an UNFROZEN draft copy in studio.manifests.
  // The shipped file (and its frozen flag) is never touched.
  const createDraftCopy = useCallback(async (bookId) => {
    const src = draft[bookId] || manifests[bookId];
    if (!src) return;
    const copy = JSON.parse(JSON.stringify(src));
    copy.frozen = false;
    copy.frozenReason = undefined;
    copy.draftOfFrozen = true; // provenance: this draft started from a frozen book
    setDraft((prev) => ({ ...prev, [bookId]: copy }));
    setSaveState({ busy: false, msg: 'draft kópia vytvorená — ulož ju', err: false });
  }, [draft, manifests]);

  if (unlocked === null) return null;
  if (unlocked === false) return <StudioLogin onUnlock={() => setUnlocked(true)}/>;

  const tabs = [
    { id: 'circus',     label: '🎪 circus EN' },
    { id: 'outing',     label: '🧺 outing EN' },
    { id: 'burglars',   label: '🌙 burglars EN' },
    { id: 'goldilocks', label: '🐻 goldilocks EN' },
    { id: 'cirkus',     label: '🎪 cirkus' },
    { id: 'vylet',      label: '🗺️ vylet' },
    { id: 'zlodeji',    label: '🌙 zlodeji' },
    { id: 'cirkusTest', label: '🧪 cirkus test' },
    { id: 'vilaCustom', label: '🪄 vila (custom)' },
    { id: 'qaBoard',    label: '🩺 QA board' },
  ];
  // Per-book panels (save bar, manifest editor) apply only to the book tabs —
  // the QA board and the custom-vila view are standalone.
  const isBookTab = activeBook !== 'qaBoard' && activeBook !== 'vilaCustom';

  return (
    <div style={{ minHeight: '100vh', background: STUDIO.paperLt, color: STUDIO.ink }}>
      <header style={{
        position: 'sticky', top: 0, zIndex: 20,
        background: STUDIO.paper, borderBottom: `2px solid ${STUDIO.ink}`,
        padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 12,
      }}>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 30, lineHeight: 1 }}>
          Pipi · studio
        </div>
        <span style={{
          fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
          background: STUDIO.green, border: `2px solid ${STUDIO.ink}`,
          borderRadius: 999, padding: '2px 8px', marginLeft: 4,
        }}>v4 · arrange</span>
        <div style={{ flex: 1 }}/>
        {/* Save bar — drafts persist into studio.manifests (isolated schema). */}
        {isBookTab && draft[activeBook] && (() => {
          const meta = studioMeta[activeBook];
          const isDirtyBook = JSON.stringify(draft[activeBook]) !== savedJsonRef.current[activeBook];
          const isFrozenDraft = draft[activeBook].frozen === true;
          return (
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              {saveState.msg && (
                <span style={{
                  fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
                  color: saveState.err ? STUDIO.red : STUDIO.inkSoft,
                }}>{saveState.msg}</span>
              )}
              {meta && (
                <span style={{
                  fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
                  background: STUDIO.lilac, border: `2px solid ${STUDIO.ink}`,
                  borderRadius: 999, padding: '2px 8px',
                }}>draft v{meta.version}</span>
              )}
              {isFrozenDraft ? (
                <button onClick={() => createDraftCopy(activeBook)} style={{
                  background: STUDIO.paperLt, border: `2.5px solid ${STUDIO.ink}`,
                  borderRadius: 999, padding: '4px 14px', cursor: 'pointer',
                  fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18, color: STUDIO.ink,
                }} title="Frozen kniha — vytvorí nezmrazenú draft kópiu; súbor zostáva nedotknutý.">
                  ✎ vytvoriť draft kópiu
                </button>
              ) : (
                <>
                  <button onClick={() => saveDraft(activeBook)} disabled={saveState.busy || !isDirtyBook} style={{
                    background: isDirtyBook ? STUDIO.yellow : STUDIO.paperLt,
                    border: `2.5px solid ${STUDIO.ink}`, borderRadius: 999, padding: '4px 16px',
                    cursor: saveState.busy || !isDirtyBook ? 'default' : 'pointer',
                    fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18, color: STUDIO.ink,
                    opacity: isDirtyBook ? 1 : 0.55,
                    boxShadow: isDirtyBook ? '2px 3px 0 rgba(0,0,0,0.18)' : 'none',
                  }}>
                    {saveState.busy ? '…' : isDirtyBook ? '💾 uložiť draft' : '✓ uložené'}
                  </button>
                  {meta && meta.version > 0 && (
                    <button
                      onClick={() => requestPublish(activeBook)}
                      disabled={isDirtyBook || (publishInfo && publishInfo.busy)}
                      title={isDirtyBook
                        ? 'najprv ulož draft — publish berie uloženú verziu'
                        : `označí draft v${meta.version} na review a vráti CLI príkaz na bake`}
                      style={{
                        background: isDirtyBook ? STUDIO.paperLt : STUDIO.green,
                        border: `2.5px solid ${STUDIO.ink}`, borderRadius: 999, padding: '4px 14px',
                        cursor: isDirtyBook ? 'default' : 'pointer',
                        fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18, color: STUDIO.ink,
                        opacity: isDirtyBook ? 0.55 : 1,
                      }}>
                      {publishInfo && publishInfo.busy ? '…' : '🚀 publish'}
                    </button>
                  )}
                </>
              )}
            </div>
          );
        })()}
        <a href={(window.PIPPAROO_LINKS.app || "") + "/demo"} style={{
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18,
          color: STUDIO.ink, textDecoration: 'none',
          border: `2px solid ${STUDIO.ink}`, borderRadius: 999, padding: '4px 12px',
          background: STUDIO.paperLt,
        }}>← demo</a>
      </header>

      {/* Publish hand-off: the draft is marked 'review'; bake runs locally. */}
      {publishInfo && (publishInfo.cmd || publishInfo.err) && (
        <div style={{
          margin: '10px 16px 0', padding: '10px 14px',
          background: publishInfo.err ? '#FFE7E7' : STUDIO.green,
          border: `2.5px solid ${STUDIO.ink}`, borderRadius: 14,
          display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
        }}>
          {publishInfo.err ? (
            <span style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 12, color: STUDIO.red }}>
              publish chyba: {publishInfo.err}
            </span>
          ) : (
            <>
              <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20 }}>
                ✓ {publishInfo.book} v{publishInfo.version} je na review — spusti bake lokálne:
              </span>
              <code style={{
                fontFamily: '"JetBrains Mono", monospace', fontSize: 12,
                background: STUDIO.ink, color: STUDIO.paper,
                borderRadius: 8, padding: '6px 10px', userSelect: 'all',
              }}>{publishInfo.cmd}</code>
              <button onClick={() => { try { navigator.clipboard.writeText(publishInfo.cmd); } catch {} }} style={{
                border: `2px solid ${STUDIO.ink}`, borderRadius: 999, padding: '2px 10px',
                background: STUDIO.paper, cursor: 'pointer',
                fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 15,
              }}>kopírovať</button>
            </>
          )}
          <button onClick={() => setPublishInfo(null)} style={{
            marginLeft: 'auto', border: `2px solid ${STUDIO.ink}`, borderRadius: 999,
            padding: '2px 10px', background: STUDIO.paperLt, cursor: 'pointer',
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 15,
          }}>zavrieť</button>
        </div>
      )}

      <nav style={{
        display: 'flex', gap: 8, padding: '12px 16px',
        overflowX: 'auto', borderBottom: `1px solid ${STUDIO.inkSoft}33`,
      }}>
        {tabs.map((t) => (
          <button key={t.id} onClick={() => setActiveBook(t.id)} style={{
            background: activeBook === t.id ? STUDIO.yellow : 'transparent',
            border: `2.5px solid ${STUDIO.ink}`, padding: '6px 14px', borderRadius: 999,
            cursor: 'pointer', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18,
            color: STUDIO.ink, whiteSpace: 'nowrap',
            boxShadow: activeBook === t.id ? '2px 3px 0 rgba(0,0,0,0.18)' : 'none',
          }}>{t.label}</button>
        ))}
      </nav>

      {activeBook === 'qaBoard' ? (
        <StudioQABoard/>
      ) : activeBook === 'vilaCustom' ? (
        <VilaPanel manifest={manifests.vilaCustom}/>
      ) : draft[activeBook] ? (
        <BookSection
          key={activeBook} // remount per book: cirkus & cirkus-test share one
          // master mp3, so wavesurfer wouldn't re-create and the imperative
          // region markers would go stale (7 vs 6 splits).
          bookId={activeBook}
          manifest={draft[activeBook]}
          original={manifests[activeBook]}
          onManifestEdit={onManifestEdit}
        />
      ) : (
        <div style={{ padding: '40px 24px', textAlign: 'center', opacity: 0.7 }}>
          Načítavam manifesty…
        </div>
      )}

      <footer style={{
        padding: '24px 16px 40px', textAlign: 'center',
        opacity: 0.55, fontSize: 13,
      }}>
        v4: arrange editor — ťahaj S-markery (snap na koniec vety), trim/fade/gain klipov, ▶ preview prechodov, Ctrl+Z undo<br/>
        💾 uložiť draft → studio.manifests · 🚀 publish → review + lokálny bake (scripts/studio-publish.mjs)
      </footer>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<StudioApp/>);
