// ─────────────────────────────────────────────────────────────
// WORKBENCH — alternate to Atelier. No tabs, no continuous feed:
// cards are grouped by WHO is blocking ("Needs you" / "In flight"
// / "Done"). Each card foregrounds the literal next-step action
// as a full-width primary button. Utilitarian / workshop aesthetic
// (bone paper, charcoal ink, single cobalt accent) — deliberate
// contrast with Atelier's editorial warmth.
// ─────────────────────────────────────────────────────────────

const workbenchPalette = {
  bg:       '#e8e4d8',   // bone — paler/cooler than Atelier parchment
  paper:    '#fbfaf6',   // card surface — warmer white
  raised:   '#ffffff',   // hover / inline editor
  ink:      '#14110b',   // near-black with warm bias
  inkDim:   '#5a544a',
  inkFaint: '#6f6a5c',   // B17: darkened from #8f8979 for AA contrast on cream
  rule:     '#b3ac98',   // B17: darkened from #cdc7b6 so 1px borders clear 3:1
  ruleSoft: '#dfd9c8',
  accent:   '#1d3557',   // deep cobalt — the only saturated colour
  accentSoft:'#2e4d7a',
  urgent:   '#a83d2a',   // sharp red — only for "needs you" badge
  success:  '#2f6b4a',   // forest green
  amber:    '#8a5e15',   // B17: darkened from #a87320 for AA contrast
};

// Stage ordering for progress dots — 7 stages, A/V split into Audio + Animation
const WB_PIPELINE = ['pending', 'approved', 'curating', 'audio', 'animation', 'final', 'published'];
// slideshow-flow-2026-06-22 (Scott): facts_slideshow is images-only — NO Audio,
// NO Animation. Its real flow is Ideas → Build → Curate → Ready → Done. Used for
// the progress dots + send-back so a slideshow never walks the video-only stages.
const WB_PIPELINE_SLIDESHOW = ['pending', 'approved', 'curating', 'final', 'published'];

const WB_STAGE_META = {
  pending:   { idx: 0, label: 'Pending',   next: 'Approve & render',     nextStage: 'approved',  toast: 'Renderer claimed.' },
  approved:  { idx: 1, label: 'Building',  next: 'Simulate ready',       nextStage: 'curating',  toast: 'Keyframes ready.' },
  curating:  { idx: 2, label: 'Curate',    next: 'Continue → Audio',      nextStage: 'audio',     toast: 'Picks locked.' },
  audio:     { idx: 3, label: 'Audio',     next: 'Promote · $3.00',       nextStage: 'animation', toast: 'Running full render.' },
  // H3 (2026-06-03): the $3 promote already happened at Audio→Animation. The
  // Animate stage's forward action is "Finalise animation" — a FREE composite
  // from the cached clips (routed to finalize-animation, not a 2nd promote).
  animation: { idx: 4, label: 'Animation', next: 'Finalise animation',    nextStage: 'final',     toast: 'Finalising animation.' },
  // 2026-06-09 publish-truth: the forward action QUEUES the publish prep
  // (audio re-mux); it does not post. "Mark posted" (kebab) flips to Live.
  final:     { idx: 5, label: 'Ready',     next: 'Queue for publish',    nextStage: 'published', toast: 'Publish prep queued.' },
  published: { idx: 6, label: 'Live',      next: null,                    nextStage: null },
  // legacy alias: any imported idea with stage='preview' is treated as audio
  preview:   { idx: 3, label: 'Audio',     next: 'Continue → Animation',  nextStage: 'animation', toast: 'Mix saved.' },
};

// Who is blocking
function bucketFor(stage) {
  if (stage === 'approved') return 'flight';
  if (stage === 'published') return 'done';
  return 'you';
}

// Seed fixtures used in mock mode (and as a stand-in until the first
// SK_DATA.loadIdeas() resolves in live mode).
function seedIdeasFromFixtures() {
  return INITIAL_IDEAS.map(seed => {
    const stage = seed.stage === 'preview' ? 'audio' : seed.stage;
    return {
      ...seed,
      stage,
      mix: seed.mix || {
        master: 1.0, voice: 1.15, bg: 0.50, sfx: 0.35,
        bgPrompt: 'low strings, slow march, foreboding',
        bgTone: 'historic',
        sfxCues: [],
      },
      animationStyle: seed.animationStyle || 'ken_burns_local',
      hashtags: seed.hashtags || ['#history', '#shorts'],
    };
  });
}

// Merge an SK_DATA idea record onto the shell's expected shape. The
// API may omit fields the UI assumes; default them in so the JSX
// doesn't blow up on null mix / animationStyle / hashtags.
function normalizeRemoteIdea(raw) {
  if (!raw) return raw;
  const stage = raw.stage === 'preview' ? 'audio' : raw.stage;
  return {
    ...raw,
    stage,
    mix: raw.mix || {
      master: 1.0, voice: 1.15, bg: 0.50, sfx: 0.35,
      bgPrompt: '', bgTone: 'historic', sfxCues: [],
    },
    animationStyle: raw.animationStyle || 'ken_burns_local',
    hashtags: raw.hashtags || [],
    engines: raw.engines || [],
  };
}

function WorkbenchShell() {
  const [ideas, setIdeas] = React.useState(() => {
    // Live mode → start empty; SK.loadIdeas() populates on mount.
    // Mock mode → seed from fixtures so the prototype works offline.
    if (window.SK && window.SK.isLive) return [];
    return seedIdeasFromFixtures();
  });
  const [loading, setLoading] = React.useState(() => !!(window.SK && window.SK.isLive));
  const [toast, setToast] = React.useState(null);
  const [expandedId, setExpandedId] = React.useState(null);
  const [openMenuId, setOpenMenuId] = React.useState(null);
  const [showDone, setShowDone] = React.useState(false);
  // sheet shape: null | { kind: string, ideaId?: string, extra?: any }
  const [sheet, setSheet] = React.useState(null);
  const [filterBucket, setFilterBucket] = React.useState('all');
  const [scrollToId, setScrollToId] = React.useState(null);

  // ── IN-FLIGHT TICK: drive the progress bar + auto-advance to curating
  // MOCK ONLY (2026-06-09): in live mode this 14s simulation flipped a real
  // building card to a fake Curate (mock scenes), then the 15s poll snapped it
  // back to Building — the "skips back and forth between sections" bug. Real
  // renders take minutes; the server poll is the only source of truth in live.
  React.useEffect(() => {
    if (window.SK && window.SK.isLive) return;
    const iv = setInterval(() => {
      setIdeas(prev => {
        let changed = false;
        const next = prev.map(i => {
          if (i.stage !== 'approved' || !i._approvedAt) return i;
          const elapsed = (Date.now() - i._approvedAt) / 1000;
          const TOTAL = 14; // seconds of simulated render
          if (elapsed >= TOTAL) {
            changed = true;
            let scenes = i.scenes;
            if (!scenes) {
              scenes = makeScenes(i);
              if (scenes[0]?.candidates[0]) scenes[0].pickedId = scenes[0].candidates[0].id;
            }
            return { ...i, stage: 'curating', statusNote: 'pick a winner', _approvedAt: null, _flightPct: null, scenes };
          }
          const newPct = Math.min(99, Math.round((elapsed / TOTAL) * 100));
          if (newPct !== i._flightPct) {
            changed = true;
            return { ...i, _flightPct: newPct };
          }
          return i;
        });
        return changed ? next : prev;
      });
    }, 220);
    return () => clearInterval(iv);
  }, []);

  const w = workbenchPalette;
  const mainRef = React.useRef(null);

  const flashToast = React.useCallback((text) => {
    if (!text) return;
    setToast({ text: String(text) });
    setTimeout(() => setToast(null), 2600);
  }, []);

  // ── Expose flashToast globally so the SK bridge (and anything else
  //    living outside the React tree) can surface errors. The bridge
  //    calls window.flashToast(err.message) on every rejected mutation.
  React.useEffect(() => {
    window.flashToast = flashToast;
    return () => { if (window.flashToast === flashToast) delete window.flashToast; };
  }, [flashToast]);

  // stale-poll-guard-2026-06-10 (Scott: "clicked back to Ideas, went, then a
  // second later was back in Curate"): a poll fetch that STARTED before a
  // local mutation can resolve after it, carrying the pre-mutation snapshot
  // and stomping the optimistic state for up to 15s. Every mutation stamps
  // this ref; poll results older than the stamp are discarded (the next
  // cycle re-syncs from fresh server state).
  const lastMutationRef = React.useRef(0);

  // ── Initial data load + 15s poll (live + readonly modes).
  //    In mock mode this effect is inert — fixtures already loaded.
  React.useEffect(() => {
    if (!window.SK || !window.SK.canRead) return;
    let alive = true;
    const tick = async () => {
      const startedAt = Date.now();
      try {
        const data = await window.SK.loadIdeas();
        if (!alive) return;
        if (startedAt < lastMutationRef.current) return; // stale snapshot — skip
        if (Array.isArray(data)) {
          // Preserve any local _flightPct/_approvedAt drift; otherwise
          // adopt remote state wholesale. (Backend is source of truth.)
          setIdeas(prev => {
            const prevById = Object.fromEntries(prev.map(i => [i.id, i]));
            return data.map(raw => {
              const norm = normalizeRemoteIdea(raw);
              const local = prevById[norm.id];
              if (local && local.stage === 'approved' && norm.stage === 'approved') {
                return { ...norm, _approvedAt: local._approvedAt, _flightPct: local._flightPct };
              }
              // Preserve in-progress curate picks the user just made — they
              // aren't persisted until Continue→Audio, so don't let the 15s
              // poll wipe them (2026-06-04).
              if (local && norm.stage === 'curating'
                  && Array.isArray(local.scenes) && Array.isArray(norm.scenes)) {
                const localPick = {};
                local.scenes.forEach(s => { if (s.pickedId) localPick[s.idx] = s.pickedId; });
                if (Object.keys(localPick).length) {
                  norm.scenes = norm.scenes.map(s =>
                    (localPick[s.idx] && !s.pickedId) ? { ...s, pickedId: localPick[s.idx] } : s);
                }
              }
              return norm;
            });
          });
        }
      } catch (e) {
        // safeRead already toasted via window.flashToast; swallow here.
      } finally {
        if (alive) setLoading(false);
      }
    };
    tick();
    const iv = setInterval(tick, 15000);
    return () => { alive = false; clearInterval(iv); };
  }, []);

  // RACE FIX (2026-06-10): advanceIdea/removeIdea captured `original` via a
  // side effect INSIDE a setState updater, then read it on the next line.
  // React 18 only runs updaters eagerly when its queue is empty — during an
  // active poll render the updater runs LATER, `original` was still null, and
  // advanceIdea returned after the optimistic update WITHOUT ever calling the
  // server. No error, no toast; the next poll reverted the UI. This silently
  // ate engine picks (Scott's Telescope + the robot's Rosetta repro) and could
  // eat any save. Read state through a ref instead — never from updater side
  // effects.
  const ideasRef = React.useRef(ideas);
  ideasRef.current = ideas;

  const advanceIdea = React.useCallback((id, patch, opts = {}) => {
    lastMutationRef.current = Date.now(); // arm the stale-poll guard
    // Capture the pre-patch idea so we can revert if SK rejects.
    const original = ideasRef.current.find(x => x.id === id) || null;
    setIdeas(prev => prev.map(i => i.id === id ? { ...i, ...patch } : i));
    if (!original) return;

    const revert = () => setIdeas(p => p.map(i => i.id === id ? original : i));

    // Route the patch into the appropriate SK_DATA verb. Stage
    // transitions are the only kind that gets a revert-on-error
    // handler attached — text/slider edits are debounced fire-and-
    // forget (the bridge surfaces their errors via flashToast too).
    if (window.SK) {
      // Stage transitions
      if (patch.stage && patch.stage !== original.stage) {
        // H3 (2026-06-03): Animate → Ready goes through finalize-animation
        // (composite from cached clips, no $3 re-render), NOT advanceStage's
        // promote path. The clips were already paid for at the earlier promote.
        if (patch._noVerb) {
          // complete-all-3-2026-06-12: caller already fired the server verb
          // (e.g. per-platform markPosted) — this patch is display-only.
        } else if (patch._remux && typeof window.SK.rebuildAudioMix === 'function') {
          // remux-no-lag-2026-06-12: card already flipped to in-flight by the
          // optimistic patch; the server verb is the audio re-mux, not a
          // stage-advance. Revert the card if the POST fails.
          const p = window.SK.rebuildAudioMix(id);
          if (p && typeof p.catch === 'function') p.catch(() => revert());
        } else if (patch._finalizeAnimation && typeof window.SK.finalizeAnimation === 'function') {
          const p = window.SK.finalizeAnimation(id);
          if (p && typeof p.catch === 'function') p.catch(() => revert());
        } else if (patch._markPosted && typeof window.SK.markPosted === 'function') {
          // Publish-truth fix (2026-06-09): Ready → Live happens ONLY via the
          // explicit "Mark posted" action after Scott actually posts. This is
          // the only place posted_*_at gets stamped from dev2.
          Promise.all([
            window.SK.markPosted(id, 'tiktok'),
            window.SK.markPosted(id, 'youtube'),
          ]).catch(() => revert());
        } else if (patch._finalizeToAudio && typeof window.SK.advanceStage === 'function') {
          // false-audio-flash-fix-2026-06-11: the local card shows in-flight
          // 'approved' (honest while the preview renders), but the server verb
          // is still the Curate→Audio finalize.
          const p = window.SK.advanceStage(id, original.stage, 'audio', patch);
          if (p && typeof p.catch === 'function') p.catch(() => revert());
        } else if (patch._promoteToAnimation && typeof window.SK.advanceStage === 'function') {
          // promote-no-flash-2026-06-12: in-flight card locally; promote verb
          // (with its money confirm) server-side. Cancelled confirm = revert.
          const p = window.SK.advanceStage(id, original.stage, 'animation', patch);
          if (p && typeof p.then === 'function') {
            p.then(r => { if (r && r.cancelled) revert(); }).catch(() => revert());
          }
        } else {
          const p = window.SK.advanceStage(id, original.stage, patch.stage, patch);
          if (p && typeof p.catch === 'function') p.catch(() => revert());
        }
      }
      // Publish-truth fix (2026-06-09): "Queue for publish" runs the audio
      // re-mux publish prep WITHOUT flipping the card to Live — the row isn't
      // posted anywhere yet. (The old code flipped the stage AND auto-stamped
      // posted_tiktok_at/posted_youtube_at, so the card claimed Live before
      // anything was posted.)
      if (patch._prepareForPublish && typeof window.SK.prepareForPublish === 'function') {
        const p = window.SK.prepareForPublish(id);
        if (p && typeof p.catch === 'function') p.catch(() => revert());
      }
      // Inline metadata edits → saveEdits (debounced 800ms in the bridge)
      const editKeys = ['title', 'subtitle', 'transcript', 'voiceId'];
      const editPatch = {};
      editKeys.forEach(k => { if (k in patch) editPatch[k] = patch[k]; });
      if (Object.keys(editPatch).length) window.SK.saveEdits(id, editPatch);
      // Mix → saveMix (debounced 400ms)
      if ('mix' in patch) window.SK.saveMix(id, patch.mix);
      // Animation style → saveAnimationStyle (immediate).
      // C2: when imageEngine is co-patched (Ideas-stage picker), animationStyle
      // is only a LOCAL display mirror — persistence goes to saveSceneEngines
      // below, NOT saveAnimationStyle. So skip the network write in that case.
      if ('animationStyle' in patch && patch.animationStyle !== original.animationStyle
          && !('imageEngine' in patch)) {
        const p = window.SK.saveAnimationStyle(id, patch.animationStyle);
        if (p && typeof p.catch === 'function') p.catch(() => revert());
      }
      // C2 (2026-06-03): image/video engine picked at the Ideas stage — ONE
      // engine for the whole video. Routes to saveSceneEngines (scene_engines_json),
      // NOT saveAnimationStyle. We can't know the exact scene count yet, so we
      // stamp 'title' + scenes 0..29 with the same key; the renderer reads
      // whichever slots exist and ignores the rest.
      if ('imageEngine' in patch && patch.imageEngine !== original.imageEngine && patch.imageEngine) {
        // ISSUE 5 (2026-06-05): PRESERVE any slot the user already flipped to
        // free ken_burns_local — changing the post engine must not silently
        // un-free a slide. Mirror the map locally so the Curate toggle + cost
        // total update immediately.
        const prev = original.sceneEngines || {};
        const map = { title: (prev.title === 'ken_burns_local') ? 'ken_burns_local' : patch.imageEngine };
        for (let s = 0; s < 30; s++) {
          const k = String(s);
          map[k] = (prev[k] === 'ken_burns_local') ? 'ken_burns_local' : patch.imageEngine;
        }
        setIdeas(p => p.map(i => i.id === id ? { ...i, sceneEngines: map } : i));
        const pr = window.SK.saveSceneEngines(id, map);
        if (pr && typeof pr.catch === 'function') pr.catch(() => revert());
      }
      // ISSUE 5 (2026-06-05): the per-slide Ken Burns toggle (Curate rows) sends
      // the full {slot: engine} map directly. The optimistic merge already
      // mirrored patch.sceneEngines onto the local idea; persist it here. Skip
      // when imageEngine is co-patched (that branch saved already).
      if ('sceneEngines' in patch && !('imageEngine' in patch)) {
        const p = window.SK.saveSceneEngines(id, patch.sceneEngines);
        if (p && typeof p.catch === 'function') p.catch(() => revert());
      }
      // 2026-06-10: ART style (keyframe aesthetics) → /animation-style.
      // Toast fires on CONFIRMED save only (Scott's pick once vanished
      // silently); failure reverts loudly.
      if ('artStyle' in patch && patch.artStyle !== original.artStyle
          && window.SK_DATA && typeof window.SK_DATA.saveAnimationStyle === 'function'
          && window.SK.isLive) {
        window.SK_DATA.saveAnimationStyle(id, patch.artStyle || null)
          .then(() => { if (typeof window.flashToast === 'function') window.flashToast('Art style saved.'); })
          .catch(e => {
            revert();
            if (typeof window.flashToast === 'function') window.flashToast('Art style save FAILED — ' + ((e && e.message) || 'reverted.'));
          });
      }
      // C7/H13 (2026-06-03): once a render reaches Ready (final), generate the
      // per-platform copy (titles/description/hashtags) so it's staged before
      // publish. Fire-and-forget — failure just toasts, doesn't block the row.
      if (patch.stage === 'final' && original.stage !== 'final'
          && typeof window.SK.generatePlatformCopy === 'function') {
        const p = window.SK.generatePlatformCopy(id);
        if (p && typeof p.catch === 'function') p.catch(() => {});
      }
      // (2026-06-09) The old auto-markPosted-on-published block lived here. It
      // stamped posted_tiktok_at/posted_youtube_at the moment Publish was
      // clicked — before anything was actually posted — while ALSO firing
      // prepareForPublish. Removed; see patch._markPosted above for the
      // explicit, truthful path.
    }
    if (opts.toast) flashToast(opts.toast);
  }, [flashToast]);

  const duplicateIdea = React.useCallback((id) => {
    // RACE FIX (2026-06-10): compute outside the updater (same bug class).
    const src = ideasRef.current.find(i => i.id === id);
    if (!src) return;
    const slug = Math.random().toString(36).slice(2, 8);
    const createdId = 'idea_dup_' + Date.now() + '_' + slug;
    const dup = {
      ...src,
      id: createdId,
      slug,
      title: src.title + ' (copy)',
      stage: 'pending',
      statusNote: null,
      scenes: null,
      _flightPct: null, _approvedAt: null,
      posted: null, postedAt: null, costTotal: null,
    };
    setIdeas(prev => [dup, ...prev]);
    flashToast('Duplicated.');
    // Optional live-mode hook: if SK exposes a duplicate verb, use it.
    if (window.SK && typeof window.SK_DATA?.duplicateIdea === 'function') {
      window.SK_DATA.duplicateIdea(id).catch(e => {
        setIdeas(p => p.filter(i => i.id !== createdId));
        window.flashToast?.(e.message || 'Could not duplicate.');
      });
    }
  }, [flashToast]);

  const removeIdea = React.useCallback((id, opts = {}) => {
    lastMutationRef.current = Date.now(); // arm the stale-poll guard
    // RACE FIX (2026-06-10): same updater-side-effect bug as advanceIdea.
    const removed = ideasRef.current.find(i => i.id === id) || null;
    // For published rows, require an explicit confirm — these are real
    // posted videos and the spec wants a confirm dialog before deleteVideo.
    if (removed && removed.stage === 'published') {
      const ok = window.confirm(`Delete \u201c${removed.title}\u201d?\n\nThis removes the posted video from TikTok and YouTube.`);
      if (!ok) return;
    }
    setIdeas(prev => prev.filter(i => i.id !== id));
    if (opts.toast) flashToast(opts.toast);
    if (!removed) return;
    if (window.SK) {
      const verb = removed.stage === 'published' ? 'deleteVideo' : 'rejectIdea';
      const p = window.SK[verb](id);
      if (p && typeof p.catch === 'function') {
        p.catch(() => {
          setIdeas(prev => [removed, ...prev]);
        });
      }
    }
  }, [flashToast]);

  const addIdea = React.useCallback((draft) => {
    const slug = Math.random().toString(36).slice(2, 8);
    const tempId = 'idea_seed_' + Date.now() + '_' + slug;
    const stub = {
      id: tempId, slug,
      title: '(generating idea…)',
      subtitle: '',
      transcript: draft.prompt || draft.transcript || '',
      source: 'user_prompt',
      createdAt: Math.floor(Date.now() / 1000),
      voiceId: draft.voiceId || 'knox_dark',
      durationSec: 60,
      engines: [{ key: 'photon', label: 'Photon Flash', title: 1, scene: 1 }],
      animationStyle: 'ken_burns_local',
      postType: draft.postType || 'ai_video',
      regenNotes: '',
      stage: 'pending',
      statusNote: null,
      sceneTexts: [],
      // 2026-06-09: this stub has a TEMPORARY id until the server row replaces
      // it (≤15s poll). Any save against the temp id hits a nonexistent row —
      // Scott's engine pick on a fresh idea vanished exactly this way. The
      // flag makes the card non-interactive until the real row arrives.
      _generating: true,
    };
    setIdeas(prev => [stub, ...prev]);
    flashToast('New idea seeded.');
    if (window.SK) {
      const _topic = draft.prompt || draft.transcript || '';
      const _isSlideshow = draft.postType === 'facts_slideshow' || draft.postType === 'random_slideshow';
      const p = _isSlideshow
        ? window.SK.generateFactsSlideshow({ topic: _topic, mode: draft.mode || 'themed', count: draft.slideCount || 6 })
        : window.SK.generateIdea({ prompt: _topic });
      if (p && typeof p.then === 'function') {
        p.then(real => {
          if (!real || typeof real !== 'object') return;
          // Swap stub for the server-assigned record.
          setIdeas(prev => prev.map(i => i.id === tempId ? normalizeRemoteIdea({ ...stub, ...real }) : i));
        }).catch(() => {
          // Roll the stub back out.
          setIdeas(prev => prev.filter(i => i.id !== tempId));
        });
      }
    }
  }, [flashToast]);

  // Group ideas
  const grouped = { you: [], flight: [], done: [] };
  ideas.forEach(i => grouped[bucketFor(i.stage)].push(i));
  // Sort "needs you" by pipeline position (closer to ship = top)
  // Guard against a stage not in WB_STAGE_META (e.g. 'failed') — an unguarded
  // WB_STAGE_META[stage].idx threw "Cannot read properties of undefined" and
  // blanked the whole dashboard the moment any failed idea existed (2026-06-06).
  grouped.you.sort((a, b) => ((WB_STAGE_META[b.stage]?.idx) || 0) - ((WB_STAGE_META[a.stage]?.idx) || 0));

  const counts = {
    you: grouped.you.length,
    flight: grouped.flight.length,
    done: grouped.done.length,
  };

  // Filter logic: 'all' = bucket view; everything else = flat filtered list
  const STAGE_KEYS = ['pending', 'approved', 'curating', 'audio', 'animation', 'final'];
  const stageCountMap = ideas.reduce((acc, i) => {
    acc[i.stage] = (acc[i.stage] || 0) + 1;
    return acc;
  }, {});
  const isStageFilter = STAGE_KEYS.includes(filterBucket);
  const filteredIdeas = filterBucket === 'all'
    ? null
    : filterBucket === 'attention'
      ? ideas.filter(i => bucketFor(i.stage) === 'you')
      : filterBucket === 'done'
        ? ideas.filter(i => i.stage === 'published')
        : ideas.filter(i => i.stage === filterBucket);

  return (
    <div style={{
      width: '100%', height: '100%',
      background: w.bg,
      color: w.ink,
      fontFamily: '"Geist", -apple-system, sans-serif',
      fontSize: 13.5, lineHeight: 1.45,
      display: 'flex', flexDirection: 'column',
      position: 'relative', overflow: 'hidden',
    }}>
      <WBHeader w={w} counts={counts} onOpenMenu={() => setSheet({ kind: 'menu' })} />
      <WBFilterBar w={w} active={filterBucket} ideas={ideas} stageCountMap={stageCountMap} counts={counts} onChange={setFilterBucket} />

      <main
        ref={mainRef}
        onWheel={e => e.stopPropagation()}
        style={{
          flex: 1, overflow: 'auto',
          padding: '4px 14px 92px',
          position: 'relative', zIndex: 1,
        }}>

        {loading && ideas.length === 0 && (
          <div style={{
            padding: '64px 16px', textAlign: 'center',
          }}>
            <div style={{
              width: 8, height: 8, borderRadius: '50%',
              background: w.accent, margin: '0 auto 12px',
              animation: 'wbBarStripe 1.2s ease-in-out infinite',
              boxShadow: `0 0 6px ${w.accent}`,
            }} />
            <div style={{
              fontFamily: '"Geist Mono", monospace',
              fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
              color: w.inkDim,
            }}>loading ideas from {window.SK && window.SK.api ? new URL(window.SK.api).host : 'server'}</div>
          </div>
        )}


        {/* ALL = bucket view */}
        {filterBucket === 'all' && <>
          <WBSection
            w={w}
            title="Needs you"
            count={counts.you}
            accent={w.urgent}
            empty="Nothing waiting on you. Nice."
          >
            {grouped.you.map(idea => (
              <WBCard
                key={idea.id} idea={idea} w={w}
                expanded={expandedId === idea.id}
                menuOpen={openMenuId === idea.id}
                onToggleExpand={() => setExpandedId(expandedId === idea.id ? null : idea.id)}
                onMenuToggle={() => setOpenMenuId(openMenuId === idea.id ? null : idea.id)}
                onMenuClose={() => setOpenMenuId(null)}
                advanceIdea={advanceIdea}
                removeIdea={removeIdea}
                duplicateIdea={duplicateIdea}
                flashToast={flashToast}
                setSheet={setSheet}
              />
            ))}
          </WBSection>

          {counts.flight > 0 && (
            <WBSection
              w={w}
              title="In flight"
              subtitle="Server is working"
              count={counts.flight}
              accent={w.accent}
              empty="No background jobs."
            >
              {grouped.flight.map(idea => (
                <WBCard
                  key={idea.id} idea={idea} w={w}
                  expanded={expandedId === idea.id}
                  menuOpen={openMenuId === idea.id}
                  onToggleExpand={() => setExpandedId(expandedId === idea.id ? null : idea.id)}
                  onMenuToggle={() => setOpenMenuId(openMenuId === idea.id ? null : idea.id)}
                  onMenuClose={() => setOpenMenuId(null)}
                  advanceIdea={advanceIdea}
                  removeIdea={removeIdea}
                  duplicateIdea={duplicateIdea}
                  flashToast={flashToast}
                  setSheet={setSheet}
                />
              ))}
            </WBSection>
          )}

          {counts.done > 0 && (
            <div style={{ marginTop: 18 }}>
              <button data-no-swipe
                onClick={() => setShowDone(s => !s)}
                style={{
                  display: 'flex', alignItems: 'center', gap: 6,
                  background: 'transparent', border: 'none',
                  color: w.inkDim, fontFamily: 'inherit',
                  fontSize: 10, fontWeight: 700,
                  letterSpacing: '0.16em', textTransform: 'uppercase',
                  padding: '8px 0', cursor: 'pointer',
                  width: '100%',
                }}>
                <span style={{
                  display: 'inline-block',
                  width: 16, textAlign: 'center',
                  transform: showDone ? 'rotate(90deg)' : 'none',
                  transition: 'transform 160ms',
                }}>▸</span>
                Done
                <span style={{
                  fontFamily: '"Geist Mono", monospace',
                  fontVariantNumeric: 'tabular-nums',
                  fontWeight: 500, color: w.inkFaint,
                  letterSpacing: '0',
                }}>{counts.done}</span>
                <span style={{ flex: 1, height: 1, background: w.ruleSoft, marginLeft: 8 }} />
              </button>
              {showDone && (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
                  {grouped.done.map(idea => (
                    <WBCard
                      key={idea.id} idea={idea} w={w}
                      expanded={expandedId === idea.id}
                      menuOpen={openMenuId === idea.id}
                      onToggleExpand={() => setExpandedId(expandedId === idea.id ? null : idea.id)}
                      onMenuToggle={() => setOpenMenuId(openMenuId === idea.id ? null : idea.id)}
                      onMenuClose={() => setOpenMenuId(null)}
                      advanceIdea={advanceIdea}
                      removeIdea={removeIdea}
                      duplicateIdea={duplicateIdea}
                      flashToast={flashToast}
                      setSheet={setSheet}
                      dim
                    />
                  ))}
                </div>
              )}
            </div>
          )}
        </>}

        {/* FILTERED (single stage / attention / done) = flat list */}
        {filterBucket !== 'all' && (
          <WBFilteredSection
            w={w}
            filterBucket={filterBucket}
            ideas={filteredIdeas}
          >
            {filteredIdeas.map(idea => (
              <WBCard
                key={idea.id} idea={idea} w={w}
                expanded={expandedId === idea.id}
                menuOpen={openMenuId === idea.id}
                onToggleExpand={() => setExpandedId(expandedId === idea.id ? null : idea.id)}
                onMenuToggle={() => setOpenMenuId(openMenuId === idea.id ? null : idea.id)}
                onMenuClose={() => setOpenMenuId(null)}
                advanceIdea={advanceIdea}
                removeIdea={removeIdea}
                duplicateIdea={duplicateIdea}
                flashToast={flashToast}
                setSheet={setSheet}
                dim={filterBucket === 'done'}
              />
            ))}
          </WBFilteredSection>
        )}
      </main>

      <WBFab w={w} onClick={() => setSheet({ kind: 'new' })} />
      <WBToast toast={toast} w={w} />
      {sheet?.kind === 'new'      && <WBNewSheet     w={w} onClose={() => setSheet(null)} onCreate={(d) => { addIdea(d); setSheet(null); }} />}
      {sheet?.kind === 'menu'     && <WBMenu         w={w} onClose={() => setSheet(null)} onPick={(k) => setSheet({ kind: k })} />}
      {sheet?.kind === 'library'  && <WBLibrarySheet w={w} ideas={ideas} onClose={() => setSheet(null)} onJump={(id) => { setExpandedId(id); }} />}
      {sheet?.kind === 'deploy'   && <WBDeploySheet  w={w} onClose={() => setSheet(null)} />}
      {sheet?.kind === 'settings' && <WBSettingsSheet w={w} onClose={() => setSheet(null)} onSwitchProd={() => setSheet({ kind: 'prod' })} />}
      {sheet?.kind === 'prod'     && <WBProdConfirm  w={w} onClose={() => setSheet(null)} onConfirm={() => { setSheet(null); flashToast('Stayed in DEV. (mock)'); }} />}
      {sheet?.kind === 'json'     && <WBJsonSheet    w={w} idea={ideas.find(i => i.id === sheet.ideaId)} onClose={() => setSheet(null)} />}
      {sheet?.kind === 'voice'    && <WBVoiceSheet   w={w} idea={ideas.find(i => i.id === sheet.ideaId)}
                                       onClose={() => setSheet(null)}
                                       onPick={(vid) => { advanceIdea(sheet.ideaId, { voiceId: vid }, { toast: 'Voice changed.' }); setSheet(null); }} />}
      {sheet?.kind === 'bg'       && <WBBgSheet      w={w} idea={ideas.find(i => i.id === sheet.ideaId)}
                                       onClose={() => setSheet(null)}
                                       onSave={(prompt, tone) => {
                                         const idea = ideas.find(i => i.id === sheet.ideaId);
                                         // Custom score prompt = generate fresh music: clear any library
                                         // pin or the renderer keeps serving the pinned track instead.
                                         advanceIdea(sheet.ideaId, { mix: { ...idea.mix, bgPrompt: prompt, bgTone: tone, bgLibraryId: null, bgLibrarySha: null, bgLibraryPath: '' } }, { toast: 'Re-scoring …' });
                                         setSheet(null);
                                       }} />}
      {sheet?.kind === 'video'    && <WBVideoModal   w={w} idea={ideas.find(i => i.id === sheet.ideaId)} onClose={() => setSheet(null)} />}
      {sheet?.kind === 'platform' && <WBPlatformSheet w={w} idea={ideas.find(i => i.id === sheet.ideaId)} platform={sheet.extra} onClose={() => setSheet(null)} />}
      {sheet?.kind === 'audio_library' && <WBAudioLibrarySheet w={w}
                                            idea={ideas.find(i => i.id === sheet.ideaId)}
                                            initialTab={sheet.tab}
                                            onCustomBg={() => setSheet({ kind: 'bg', ideaId: sheet.ideaId })}
                                            onClose={() => setSheet(null)}
                                            onUseSfx={(item) => {
                                              const idea = ideas.find(i => i.id === sheet.ideaId);
                                              if (!idea) return;
                                              const mix = idea.mix || {};
                                              const cues = mix.sfxCues || [];
                                              // bg-pick-wipe-fix-2026-06-11: carry the library pin on the
                                              // optimistic cue too, or the next debounced save erases it.
                                              const next = [...cues, { prompt: item.label || item.name, start: 0, duration: item.duration || 1.0, volume: 0.4, libraryId: item.id, librarySha: item.sha || null, libraryPath: item.library_path || item.category_path || '' }];
                                              advanceIdea(idea.id, { mix: { ...mix, sfxCues: next } }, { toast: 'SFX added.' });
                                              if (window.SK) window.SK.addSfxFromLibrary(idea.id, item);
                                            }}
                                            onUseBg={(item) => {
                                              const idea = ideas.find(i => i.id === sheet.ideaId);
                                              if (!idea) return;
                                              const mix = idea.mix || {};
                                              advanceIdea(idea.id, { mix: { ...mix, bgPrompt: item.label || item.name, bgTone: item.tone || mix.bgTone, bgLibraryId: item.id, bgLibrarySha: item.sha || null, bgLibraryPath: item.library_path || item.category_path || '' } }, { toast: 'BG track set.' });
                                              if (window.SK) window.SK.setBgFromLibrary(idea.id, item);
                                            }} />}
      {sheet?.kind === 'log'      && <WBAuditLogSheet w={w}
                                       videoId={sheet.ideaId || null}
                                       onClose={() => setSheet(null)} />}
    </div>
  );
}

// ──────────────── HEADER ────────────────
function WBHeader({ w, counts, onOpenMenu }) {
  return (
    <header style={{
      flexShrink: 0,
      padding: '12px 16px 10px',
      borderBottom: `1px solid ${w.rule}`,
      background: w.bg,
      position: 'relative', zIndex: 3,
    }}>
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        marginBottom: 6,
      }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
          <h1 style={{
            fontSize: 19, fontWeight: 700, letterSpacing: '-0.025em',
            color: w.ink, margin: 0, lineHeight: 1,
          }}>Workbench</h1>
          <span style={{
            fontFamily: '"Geist Mono", monospace',
            fontSize: 10, letterSpacing: '0.04em',
            color: w.inkFaint,
            fontVariantNumeric: 'tabular-nums',
          }}>
            {counts.you} <span style={{ color: w.inkFaint }}>awaiting</span> · {counts.flight} <span style={{ color: w.inkFaint }}>in flight</span>
          </span>
        </div>
        <button data-no-swipe onClick={onOpenMenu}
          title="More"
          style={{
            background: 'transparent', border: 'none', padding: 0,
            color: w.inkDim, cursor: 'pointer',
            fontFamily: 'inherit', fontSize: 18, lineHeight: 1,
            transform: 'translateY(-1px)',
          }}>⋯</button>
      </div>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 10,
        fontSize: 10.5, color: w.inkDim,
        fontFamily: '"Geist Mono", monospace',
        fontVariantNumeric: 'tabular-nums',
      }}>
        <span style={{
          fontSize: 9, fontWeight: 700, letterSpacing: '0.18em',
          color: w.ink, textTransform: 'uppercase',
          border: `1px solid ${w.ink}`, padding: '1px 5px',
          fontFamily: '"Geist Mono", monospace',
        }}>DEV</span>
        {/* B4 (2026-06-03): spend/sha/poll are MOCK data. Showing a fake
            "$1.42/$8" gauge while spending real money is dangerous, so only
            render it off-live until a real status read is wired. */}
        {!(window.SK && window.SK.isLive) ? (
        <React.Fragment>
        <span>
          <span style={{ color: w.ink, fontWeight: 600 }}>${STATUS.spendToday.toFixed(2)}</span>
          <span style={{ color: w.inkFaint }}>/{STATUS.spendBudget.toFixed(0)}</span>
        </span>
        <span style={{ color: w.rule }}>·</span>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
          <span style={{
            width: 6, height: 6, borderRadius: '50%',
            background: w.success,
            boxShadow: `0 0 5px ${w.success}`,
          }} />
          {STATUS.deploySha.slice(0, 7)}
        </span>
        <span style={{ color: w.rule }}>·</span>
        <span style={{ color: w.inkFaint }}>polled {STATUS.lastPoll}</span>
        </React.Fragment>
        ) : (
          <span style={{ color: w.inkFaint }}>live</span>
        )}
      </div>
    </header>
  );
}

// ──────────────── FILTER BAR (horizontal scroll, pills) ────────────────
function WBFilterBar({ w, active, ideas, stageCountMap, counts, onChange }) {
  const opts = [
    { key: 'all',       label: 'All',             count: ideas.length,             accent: w.ink     },
    { key: 'attention', label: 'Needs Attention', count: counts.you,               accent: w.urgent  },
    { key: 'pending',   label: 'Pending',         count: stageCountMap.pending  || 0, accent: w.ink   },
    { key: 'approved',  label: 'Building',        count: stageCountMap.approved || 0, accent: w.accent},
    { key: 'curating',  label: 'Curate',          count: stageCountMap.curating || 0, accent: w.ink   },
    { key: 'audio',     label: 'Audio',           count: stageCountMap.audio    || 0, accent: w.ink   },
    { key: 'animation', label: 'Animation',       count: stageCountMap.animation|| 0, accent: w.ink   },
    { key: 'final',     label: 'Ready',           count: stageCountMap.final    || 0, accent: w.amber },
    { key: 'done',      label: 'Done',            count: stageCountMap.published|| 0, accent: w.success},
  ];
  const scrollRef = React.useRef(null);
  // Keep active pill in view when it changes
  React.useEffect(() => {
    const el = scrollRef.current?.querySelector('[data-active="true"]');
    if (el && el.scrollIntoView) {
      el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
    }
  }, [active]);

  return (
    <div ref={scrollRef}
      data-no-swipe
      onWheel={e => e.stopPropagation()}
      style={{
        flexShrink: 0,
        background: w.bg,
        borderBottom: `1px solid ${w.ruleSoft}`,
        padding: '8px 12px',
        display: 'flex', gap: 5,
        overflowX: 'auto', overflowY: 'hidden',
        scrollbarWidth: 'none',
        position: 'relative', zIndex: 2,
        WebkitOverflowScrolling: 'touch',
      }}>
      {opts.map(o => {
        const isActive = active === o.key;
        const isEmpty = o.count === 0 && o.key !== 'all';
        return (
          <button key={o.key} data-no-swipe data-active={isActive}
            onClick={() => onChange(o.key)}
            style={{
              flexShrink: 0,
              background: isActive ? o.accent : 'transparent',
              color: isActive ? '#fff' : (isEmpty ? w.inkFaint : w.inkDim),
              border: `1px solid ${isActive ? o.accent : w.rule}`,
              borderRadius: 100,
              padding: '5px 11px',
              fontFamily: 'inherit', fontSize: 11,
              fontWeight: isActive ? 600 : 500,
              cursor: 'pointer',
              display: 'inline-flex', alignItems: 'center', gap: 6,
              whiteSpace: 'nowrap',
              letterSpacing: '-0.005em',
              opacity: isEmpty && !isActive ? 0.55 : 1,
              transition: 'background 120ms, color 120ms, border-color 120ms',
            }}>
            {o.label}
            <span style={{
              fontFamily: '"Geist Mono", monospace',
              fontSize: 9.5, fontWeight: 700,
              color: isActive ? 'rgba(255,255,255,0.75)' : w.inkFaint,
              fontVariantNumeric: 'tabular-nums',
            }}>{o.count}</span>
          </button>
        );
      })}
    </div>
  );
}

// ──────────────── FILTERED SECTION (single stage / attention / done) ────────────────
function WBFilteredSection({ w, filterBucket, ideas, children }) {
  const meta = {
    attention: { title: 'Needs Attention', sub: 'every card waiting on you', accent: w.urgent },
    pending:   { title: 'Pending',         sub: 'fresh ideas, not yet approved', accent: w.ink },
    approved:  { title: 'Building',        sub: 'renderer is working',          accent: w.accent },
    curating:  { title: 'Curate',          sub: 'pick winning keyframes',       accent: w.ink },
    audio:     { title: 'Audio',           sub: 'mix the buses + cues',         accent: w.ink },
    animation: { title: 'Animation',       sub: 'choose motion style',          accent: w.ink },
    final:     { title: 'Ready',           sub: 'queued for publish',           accent: w.amber },
    done:      { title: 'Done',            sub: 'live on TikTok + YouTube',     accent: w.success },
  }[filterBucket] || { title: filterBucket, sub: '', accent: w.ink };

  if (ideas.length === 0) {
    return (
      <div style={{ padding: '48px 16px', textAlign: 'center' }}>
        <div style={{
          fontSize: 9, fontWeight: 700,
          letterSpacing: '0.16em', textTransform: 'uppercase',
          color: meta.accent, marginBottom: 6,
        }}>{meta.title}</div>
        <div style={{
          fontSize: 15, fontWeight: 600, color: w.ink,
          marginBottom: 4,
        }}>Nothing here.</div>
        <div style={{
          fontSize: 12, color: w.inkDim, lineHeight: 1.5,
          maxWidth: 240, margin: '0 auto',
        }}>No cards match this filter right now.</div>
      </div>
    );
  }

  return (
    <section style={{ marginTop: 12 }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '6px 2px 10px',
      }}>
        <span style={{
          width: 7, height: 7, borderRadius: '50%',
          background: meta.accent,
        }} />
        <span style={{
          fontSize: 10, fontWeight: 700,
          letterSpacing: '0.16em', textTransform: 'uppercase',
          color: w.ink,
        }}>{meta.title}</span>
        {meta.sub && (
          <span style={{
            fontSize: 10, color: w.inkFaint, fontStyle: 'italic',
          }}>· {meta.sub}</span>
        )}
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 10, fontWeight: 600,
          color: w.inkFaint,
          fontVariantNumeric: 'tabular-nums',
        }}>{ideas.length}</span>
        <span style={{ flex: 1, height: 1, background: w.ruleSoft }} />
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {children}
      </div>
    </section>
  );
}

// ──────────────── SECTION (header + children) ────────────────
function WBSection({ w, title, subtitle, count, accent, empty, children }) {
  const hasChildren = React.Children.count(children) > 0;
  return (
    <section style={{ marginTop: 16 }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '6px 2px 10px',
      }}>
        <span style={{
          width: 7, height: 7, borderRadius: '50%',
          background: accent,
        }} />
        <span style={{
          fontSize: 10, fontWeight: 700,
          letterSpacing: '0.16em', textTransform: 'uppercase',
          color: w.ink,
        }}>{title}</span>
        {subtitle && (
          <span style={{
            fontSize: 10, color: w.inkFaint,
            fontStyle: 'italic',
          }}>· {subtitle}</span>
        )}
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 10, fontWeight: 600,
          color: w.inkFaint,
          fontVariantNumeric: 'tabular-nums',
        }}>{count}</span>
        <span style={{ flex: 1, height: 1, background: w.ruleSoft }} />
      </div>
      {hasChildren ? (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {children}
        </div>
      ) : (
        <div style={{
          padding: '12px 0 4px',
          color: w.inkFaint, fontSize: 12, fontStyle: 'italic',
        }}>{empty}</div>
      )}
    </section>
  );
}

// H8 (2026-06-03): failed-render detection. The worker stamps failed rows with
// failure_reason + status/pipeline_stage='failed'. rowToIdea may surface these
// under a few shapes, so probe defensively. Returns the human reason or null.
function wbFailureReason(idea) {
  if (!idea) return null;
  const reason = idea.failureReason || idea.failure_reason
    || (idea.buildProgress && idea.buildProgress.user_message)
    || idea.userMessage || null;
  const isFailed = idea.failed === true
    || idea.status === 'failed'
    || idea.pipeline_stage === 'failed'
    || idea.pipelineStage === 'failed'
    || idea.ideaStatus === 'failed'
    || idea.idea_status === 'failed';
  if (!isFailed && !reason) return null;
  return reason || 'Last render failed.';
}

// Inline recovery row for a failed render: Retry re-runs the build
// (/videos/{id}/retry-render); Dismiss clears it from the queue
// (/ideas/{id}/dismiss-failed). Both verbs are called through window.SK if the
// bridge exposes them, falling back to window.SK_DATA so this works even before
// the bridge is updated.
function WBFailedRecovery({ idea, w, reason, removeIdea, flashToast, advanceIdea }) {
  const callSK = (verbs, ...args) => {
    const fn = verbs
      .map(v => (window.SK && window.SK[v]) || (window.SK_DATA && window.SK_DATA[v]))
      .find(f => typeof f === 'function');
    if (!fn) return null;
    try { return fn(...args); } catch (e) { return null; }
  };
  const onRetry = () => {
    const p = callSK(['retryRender', 'retry_render'], idea.id);
    // retry-feedback-2026-06-18 (Scott: "clicked retry, nothing happened"): the
    // verb re-armed the row server-side but the card stayed on Failed until the
    // 15s poll. Optimistically flip to the in-flight card so retry visibly does
    // something. _noVerb = display-only (the retryRender verb already fired).
    if (advanceIdea) advanceIdea(idea.id, {
      stage: 'approved', _noVerb: true,
      statusNote: 'retrying', flightLabel: 'Retrying render', _approvedAt: Date.now(),
    });
    if (p && typeof p.then === 'function') {
      p.then(() => flashToast && flashToast('Retrying render…')).catch(() => {});
    } else {
      flashToast && flashToast('Retrying render…');
    }
  };
  const onDismiss = () => {
    const p = callSK(['dismissFailed', 'dismissFailedIdea', 'dismiss_failed'], idea.id);
    // Optimistically remove the card; dismiss endpoint hides it server-side too.
    if (removeIdea) removeIdea(idea.id, { toast: 'Dismissed.' });
    else if (flashToast) flashToast('Dismissed.');
    if (p && typeof p.catch === 'function') p.catch(() => {});
  };
  return (
    <div style={{
      marginBottom: 10,
      border: `1px solid ${w.urgent}`,
      borderLeft: `3px solid ${w.urgent}`,
      borderRadius: 6,
      background: 'rgba(168,61,42,0.06)',
      padding: '9px 11px',
    }}>
      <div style={{
        fontSize: 10, fontWeight: 700,
        letterSpacing: '0.12em', textTransform: 'uppercase',
        color: w.urgent, marginBottom: 4,
      }}>⚠ Last render failed</div>
      <div style={{
        fontSize: 12, color: w.inkDim, lineHeight: 1.45,
        marginBottom: 9, textWrap: 'pretty',
        wordBreak: 'break-word',
      }}>{String(reason).slice(0, 240)}</div>
      <div style={{ display: 'flex', gap: 6 }}>
        <button data-no-swipe onClick={onRetry}
          style={{
            flex: 1, height: 32,
            background: w.accent, color: '#fff',
            border: 'none', borderRadius: 6,
            fontFamily: 'inherit', fontSize: 12, fontWeight: 600,
            cursor: 'pointer',
          }}>↻ Retry render</button>
        <button data-no-swipe onClick={onDismiss}
          style={{
            flex: 1, height: 32,
            background: 'transparent', color: w.inkDim,
            border: `1px solid ${w.rule}`, borderRadius: 6,
            fontFamily: 'inherit', fontSize: 12, fontWeight: 500,
            cursor: 'pointer',
          }}>Dismiss</button>
      </div>
    </div>
  );
}

// M2 (2026-06-03): kebab "Regenerate transcript" — Haiku rewrites the idea's
// script. The worker's /ideas/{id}/regen-with-feedback accepts optional
// guidance notes, so we prompt for them (blank = plain re-roll).
function regenTranscript(idea, flashToast) {
  let notes = '';
  if (typeof window !== 'undefined' && typeof window.prompt === 'function') {
    const r = window.prompt('Regenerate the transcript.\n\nOptional: what to change or keep (leave blank to just re-roll):', '');
    if (r === null) return; // cancelled
    notes = r;
  }
  if (window.SK && typeof window.SK.regenWithFeedback === 'function') {
    const p = window.SK.regenWithFeedback(idea.id, notes);
    if (p && typeof p.then === 'function') {
      p.then(() => flashToast && flashToast('Regenerating transcript…')).catch(() => {});
      return;
    }
  }
  flashToast && flashToast('Regenerating transcript…');
}

// ──────────────── IDEA CARD ────────────────
function WBCard({
  idea, w, expanded, menuOpen,
  onToggleExpand, onMenuToggle, onMenuClose,
  advanceIdea, removeIdea, duplicateIdea, flashToast, setSheet, dim,
}) {
  // Always-defined meta so an unknown stage (e.g. 'failed') can't crash the card
  // render or doNext() (2026-06-06). A failed idea shows a 'Failed' chip + no
  // forward action.
  const meta = WB_STAGE_META[idea.stage] || {
    idx: -1, label: idea.stage === 'failed' ? 'Failed' : (idea.stage || '—'),
    next: null, nextStage: null,
  };
  const voice = voiceById(idea.voiceId);
  // facts-slideshow-2026-06-21 (Scott): the slideshow post type is images only —
  // NO narration, so hide the voice actor on the card. ("Random" is just a MODE
  // of a facts_slideshow; seeded rows are always post_type='facts_slideshow'.)
  const isSlideshow = idea.postType === 'facts_slideshow';
  const isInFlight = idea.stage === 'approved';
  const isDone = idea.stage === 'published';

  const doNext = () => {
    if (!meta.nextStage) return;
    let patch = { stage: meta.nextStage };
    // facts-slideshow-2026-06-20: Curate finalize composes + finishes (no Audio).
    // The finalize-curation call still fires (audio branch); only the optimistic
    // card stage jumps straight to the finished/Download view.
    if (idea.postType === 'facts_slideshow' && idea.stage === 'curating') patch.stage = 'final';
    if (meta.nextStage === 'curating') {
      patch.statusNote = 'pick a winner';
      // Lazy-init scene candidates if not already present; preserves prior data otherwise
      if (!idea.scenes) {
        const scs = makeScenes(idea);
        if (scs[0]?.candidates[0]) scs[0].pickedId = scs[0].candidates[0].id;
        patch.scenes = scs;
      }
    }
    if (meta.nextStage === 'audio') {
      patch.statusNote = 'set the mix';
      // B3 (2026-06-03): Curate → Audio must send the user's picks to
      // finalize-curation as {sceneIdx: candidateIndex(1-based)} or it 400s.
      // 2026-06-09: key picks by the scene's REAL key ('title' or scene.idx),
      // not the array position — with a title row at position 0, array-keyed
      // picks shifted every scene by one (title pick stored as scene 0, etc.).
      const scs = idea.scenes || [];
      const picks = {};
      scs.forEach((s, i) => {
        const idx = (s.candidates || []).findIndex(c => c.id === s.pickedId);
        const key = s.key === 'title' ? 'title' : String(s.idx != null ? s.idx : i);
        picks[key] = (idx >= 0 ? idx : 0) + 1;
      });
      if (Object.keys(picks).length === 0) picks['0'] = 1; // fallback so the worker accepts
      patch.picks = picks;
      // dev2-honors-engine-2026-06-04: carry the picked engine so the bridge can
      // confirm the ~$3 spend before a paid render (Ken Burns stays free).
      patch.idea_vendor = idea.animationStyle || 'ken_burns_local';
      // false-audio-flash-fix-2026-06-11 (Scott: "Audio popped up then went
      // back to building"): finalize kicks a real preview render — Audio isn't
      // true until preview-ready lands, and its players have nothing to play
      // until then. Show the honest in-flight card instead; the poll advances
      // to Audio when build_status='preview'. Mock keeps the instant jump.
      // _finalizeToAudio keeps the API verb on finalize-curation (a plain
      // stage:'approved' patch would route to approveIdea).
      if (window.SK && window.SK.isLive) {
        patch.stage = 'approved';
        patch._finalizeToAudio = true;
        patch.statusNote = 'assembling preview';
        patch.flightLabel = 'Assembling preview · voice + slideshow';
        patch._approvedAt = Date.now();
      }
    }
    if (meta.nextStage === 'animation') {
      patch.statusNote = 'choose motion style';
      // promote-needs-engine-2026-06-13 (Scott: clicked Promote with Ken Burns,
      // nothing happened): the engine dropdown DEFAULTS to Ken Burns but only
      // saves scene_engines on a CHANGE event, so an unchanged default never
      // persists (scene_engines={}, idea_vendor=null) and the worker rejects the
      // promote with "idea_vendor required" → silent no-op. ALWAYS send a valid
      // video engine. idea.animationStyle can hold the ART style ('cartoon_2d')
      // when nothing was saved, so validate against WB_VIDEO_ENGINES and default
      // to Ken Burns.
      patch.idea_vendor = WB_VIDEO_ENGINES.some(e => e.value === idea.animationStyle)
        ? idea.animationStyle : 'ken_burns_local';
      // promote-no-flash-2026-06-12: Promote kicks the PAID render — Animation
      // isn't real until clips exist. Show the honest in-flight card right away
      // so the Promote button can't be double-clicked during the claim gap.
      // _promoteToAnimation keeps the API verb on the promote path (with its
      // money confirm); a cancelled confirm reverts the card.
      if (window.SK && window.SK.isLive) {
        patch.stage = 'approved';
        patch._promoteToAnimation = true;
        patch.statusNote = 'rendering animation';
        patch.flightLabel = 'Rendering animation';
        patch._approvedAt = Date.now();
      }
    }
    if (meta.nextStage === 'final') {
      patch.statusNote = 'ready to publish';
      // H3: Animation → Ready composites from cached clips via finalize-animation
      // (no second $3 promote). The _finalizeAnimation flag routes advanceIdea
      // to SK.finalizeAnimation instead of advanceStage's promote path.
      patch._finalizeAnimation = true;
      // final-no-flash-2026-06-12 (Scott: clicking Continue landed on a half-
      // rendered Ready page): Ready isn't real until the final composite lands.
      // Same honest in-flight card as the Curate→Audio and Promote steps; the
      // poll advances to Ready when the renderer marks the row rerendered.
      if (window.SK && window.SK.isLive) {
        patch.stage = 'approved';
        patch.statusNote = 'assembling final video';
        patch.flightLabel = 'Assembling final video · clips + audio';
        patch._approvedAt = Date.now();
      }
    }
    if (meta.nextStage === 'published') {
      // Publish-truth fix (2026-06-09): don't flip the card to Live — the
      // video isn't posted yet. Queue the publish prep (audio re-mux) and
      // stay in Ready; "Mark posted" flips to Live after Scott actually
      // posts. Mock mode keeps the old simulated jump.
      // publish-flow-2026-06-12 (Scott: "i clicked queue for publish and
      // nothing happened"): two-step now. Step 1 queues the prep and the
      // button label flips to "Mark posted" when the poll sees the row
      // prepped (readyToPost). Step 2 IS Mark posted.
      if (window.SK && window.SK.isLive) {
        // complete-all-3-2026-06-12: once prepped, posting happens via the
        // per-platform Mark-posted buttons on the locked card — no bulk verb.
        if (!idea.readyToPost) {
          advanceIdea(idea.id, { statusNote: 'preparing post…', _prepareForPublish: true },
            { toast: 'Publish prep queued — the card unlocks per-platform posting (~30s).' });
        }
        return;
      }
      patch.statusNote = 'posted just now';
      patch.postedAt = 'just now';
      patch.posted = { tiktok: { views: 0, likes: 0, comments: 0 }, youtube: { views: 0, likes: 0, comments: 0 } };
    }
    if (meta.nextStage === 'approved')  { patch.statusNote = 'building'; patch._approvedAt = Date.now(); patch._flightPct = 0; }
    advanceIdea(idea.id, patch, { toast: meta.toast });
  };

  const doSendBack = () => {
    // slideshow-flow-2026-06-22 (Scott): slideshows skip Audio + Animation, so
    // their back-path is final → Curate → Ideas. audio/animation are mapped to
    // Curate too, to RESCUE any slideshow that a prior (buggy) send-back stranded
    // on a video-only stage.
    const back = (isSlideshow ? {
      approved:  'pending',
      curating:  'pending',
      audio:     'curating',
      animation: 'curating',
      preview:   'curating',
      final:     'curating',
    } : {
      approved:  'pending',
      curating:  'pending',
      audio:     'curating',
      animation: 'audio',
      preview:   'curating', // legacy alias
      final:     'animation',
    })[idea.stage];
    if (!back) return;
    // 2026-06-10 (Scott: accidental click, no warning): send-back rewinds the
    // card — and back-to-Ideas resets curation/engines/audio server-side.
    // Always confirm before firing.
    const _msg = back === 'pending'
      ? 'Send this back to Ideas?\n\nKeyframe picks, engine setup and audio will be reset (snapshots are kept server-side).'
      : `Send this back one step (to ${WB_STAGE_META[back] ? WB_STAGE_META[back].label : back})?`;
    if (typeof window !== 'undefined' && typeof window.confirm === 'function' && !window.confirm(_msg)) return;
    advanceIdea(idea.id, { stage: back, statusNote: back === 'pending' ? null : `back to ${back}` }, { toast: 'Sent back.' });
  };

  const canSendBack = !!{
    approved: 1, curating: 1, audio: 1, animation: 1, preview: 1, final: 1,
  }[idea.stage];

  // Bug #8: actionable ("you"-bucket) cards start COLLAPSED — only a compact
  // summary row shows until tapped. In-flight (building) and Done (published)
  // cards are informational and stay fully rendered (progress / play controls).
  const collapsible = !isInFlight && !isDone;
  const open = expanded || !collapsible;

  return (
    <article style={{
      background: w.paper,
      border: `1px solid ${w.rule}`,
      borderRadius: 8,
      padding: '12px 14px',
      position: 'relative',
      opacity: dim ? 0.78 : 1,
    }}>
      {/* Header (tappable when collapsible): progress dots + title + chevron.
          Acts as the compact summary row when collapsed; toggles expand. */}
      {collapsible ? (
        <div
          role="button" tabIndex={0}
          aria-expanded={expanded}
          onClick={onToggleExpand}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleExpand && onToggleExpand(); }
          }}
          style={{ cursor: 'pointer', outline: 'none' }}
        >
          <WBProgressDots w={w} stage={idea.stage} postType={idea.postType} />
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
            <h2 style={{
              flex: 1, minWidth: 0,
              fontSize: 16.5, fontWeight: 600,
              letterSpacing: '-0.018em',
              lineHeight: 1.2,
              color: w.ink,
              margin: open ? '8px 0 2px' : '8px 0 0',
              textWrap: 'pretty',
            }}>{idea.title}</h2>
            <span aria-hidden="true" style={{
              flexShrink: 0,
              marginTop: 9,
              fontSize: 13, lineHeight: 1,
              color: w.inkDim,
              transition: 'transform 180ms',
              transform: expanded ? 'rotate(90deg)' : 'none',
            }}>▸</span>
          </div>
        </div>
      ) : (
        <>
          {/* Top row: progress dots + slug */}
          <WBProgressDots w={w} stage={idea.stage} postType={idea.postType} />

          {/* Title */}
          <h2 style={{
            fontSize: 16.5, fontWeight: 600,
            letterSpacing: '-0.018em',
            lineHeight: 1.2,
            color: w.ink,
            margin: '8px 0 2px',
            textWrap: 'pretty',
          }}>{idea.title}</h2>
        </>
      )}

      {open && idea.subtitle && (
        <p style={{
          fontSize: 12, color: w.inkDim, lineHeight: 1.45,
          margin: '6px 0 10px',
          textWrap: 'pretty',
        }}>{idea.subtitle}</p>
      )}

      {/* H8: failed-render recovery — Retry / Dismiss. Shown above the stage
          body whenever the row carries a failure stamp. */}
      {open && wbFailureReason(idea) && (
        <WBFailedRecovery idea={idea} w={w} reason={wbFailureReason(idea)}
          removeIdea={removeIdea} flashToast={flashToast} advanceIdea={advanceIdea} />
      )}

      {/* Stage-specific preview — most are interactive */}
      {open && <WBStageBody idea={idea} w={w} advanceIdea={advanceIdea} flashToast={flashToast} setSheet={setSheet} />}

      {/* Meta row */}
      {open && !isDone && (
        <div style={{
          marginTop: 10, paddingTop: 10,
          borderTop: `1px solid ${w.ruleSoft}`,
          display: 'flex', alignItems: 'center', gap: 8,
          fontSize: 11, color: w.inkDim,
          fontFamily: '"Geist Mono", monospace',
          fontVariantNumeric: 'tabular-nums',
        }}>
          {/* slideshow has no voice actor + no fixed duration — show the post
              type in the voice slot and drop the (video-only) seconds. */}
          <span style={{ color: w.ink, fontWeight: 600, fontFamily: '"Geist", sans-serif' }}>{isSlideshow ? 'Slideshow' : voice.name}</span>
          {!isSlideshow && <span>· {idea.durationSec}s</span>}
          <span>· {sourceLabel(idea.source)}</span>
          <div style={{ flex: 1 }} />
          {idea.costTotal != null && (
            <span style={{ color: w.ink }}>${idea.costTotal.toFixed(3)}</span>
          )}
        </div>
      )}

      {/* Action row — hidden while the script is still generating (2026-06-09):
          approving a half-written row renders an empty/placeholder script. */}
      {open && meta.next && !isInFlight && !(idea._generating || idea.generating) && (
        <div style={{ display: 'flex', gap: 6, marginTop: 12, position: 'relative' }}>
          {canSendBack && (
            <button data-no-swipe onClick={doSendBack}
              title="Send back one step"
              style={{
                width: 36, height: 36,
                background: 'transparent',
                color: w.inkDim,
                border: `1px solid ${w.rule}`,
                borderRadius: 6,
                fontFamily: 'inherit', fontSize: 13,
                cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
              }}>↩</button>
          )}
          {/* 2026-06-10 (Scott): the ✎/▾ collapse duplicate that sat next to ↩
              is gone — collapsing lives where it belongs, in the card header's
              top-right chevron. */}
          {((idea.stage === 'final' && idea.readyToPost && window.SK && window.SK.isLive)
            || (isSlideshow && idea.stage === 'final')) ? (
            // complete-all-3-2026-06-12: post-prep there is no single "next" —
            // posting happens per platform on the card. Show progress instead.
            // facts-slideshow-2026-06-21 (Scott): a slideshow has no "Queue for
            // publish" audio-prep step — its post/mark UI is inline on the card,
            // so skip the forward button and show the same per-platform status.
            <div style={{
              flex: 1, height: 36,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              border: `1px dashed ${w.rule}`, borderRadius: 6,
              fontFamily: '"Geist Mono", monospace', fontSize: 11, color: w.inkDim,
            }}>
              {(() => {
                const pp = idea.postedPlatforms || {};
                const n = ['tiktok', 'youtube', 'instagram'].filter(k => pp[k]).length;
                return `posted ${n}/3 — mark each platform above`;
              })()}
            </div>
          ) : (
          <button data-no-swipe onClick={doNext}
            style={{
              flex: 1, height: 36,
              background: w.accent,
              color: '#fff',
              border: 'none', borderRadius: 6,
              fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
              letterSpacing: '-0.005em',
              cursor: 'pointer',
              display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
            }}>
            {/* 2026-06-10: Promote shows the REAL cost of the picked engine */}
            <span>{(() => {
              // facts-slideshow-2026-06-20 (Scott): Curate is the last step —
              // the forward action composes + finishes the post (no Audio).
              if (idea.postType === 'facts_slideshow' && idea.stage === 'curating') return 'Generate post →';
              if (idea.stage !== 'audio' && idea.stage !== 'preview') return meta.next;
              const _e = WB_VIDEO_ENGINES.find(x => x.value === (idea.animationStyle || 'ken_burns_local'));
              return 'Promote · ' + (_e ? (_e.cost === 'free' ? 'free (Ken Burns)' : _e.cost) : '$?');
            })()}</span>
            <span style={{ fontSize: 12, opacity: 0.85 }}>→</span>
          </button>
          )}
          <button data-no-swipe onClick={onMenuToggle}
            title="More"
            style={{
              width: 36, height: 36,
              background: 'transparent',
              color: w.inkDim,
              border: `1px solid ${w.rule}`,
              borderRadius: 6,
              fontFamily: 'inherit', fontSize: 16,
              cursor: 'pointer',
              lineHeight: 1,
            }}>⋯</button>
          {menuOpen && (
            <WBKebabMenu w={w} idea={idea} onClose={onMenuClose}
              onDelete={() => removeIdea(idea.id, { toast: 'Deleted.' })}
              onDuplicate={() => duplicateIdea(idea.id)}
              onChangeVoice={() => setSheet({ kind: 'voice', ideaId: idea.id })}
              onViewJson={() => setSheet({ kind: 'json', ideaId: idea.id })}
              onRegenTranscript={() => regenTranscript(idea, flashToast)}
              onMarkPosted={() => advanceIdea(idea.id,
                { stage: 'published', _markPosted: true, statusNote: 'posted', postedAt: 'just now' },
                { toast: 'Marked posted on TikTok + YouTube.' })} />
          )}
        </div>
      )}

      {/* "In flight" — show progress bar plus a sim-finish button to advance the mockup */}
      {isInFlight && (
        <div style={{ marginTop: 12 }}>
          {(() => {
            // curate-eta-2026-06-13 (Scott: don't show keyframes as they render —
            // just a progress count + time remaining; wait and see them all at
            // once). During keyframe generation the renderer pushes kf_done/
            // kf_total + kf_started_at — show a REAL bar + ETA instead of the
            // indeterminate spinner. Other in-flight phases (clip render,
            // assembling) keep the indeterminate bar.
            const liveKf = (window.SK && window.SK.isLive) && idea.kfTotal > 0 && idea.kfDone < idea.kfTotal;
            if (!liveKf) {
              return <WBProgressBar w={w} label={idea.flightLabel || 'Building keyframes'}
                pct={(window.SK && window.SK.isLive) ? null : (idea._flightPct ?? 5)} />;
            }
            const pct = Math.min(99, Math.round((idea.kfDone / idea.kfTotal) * 100));
            // time-remaining from the server-anchored start stamp + items done.
            const fmtEta = (s) => {
              if (!isFinite(s) || s <= 0) return null;
              s = Math.round(s);
              const m = Math.floor(s / 60), sec = s % 60;
              return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
            };
            let etaLabel = `Generating images · ${idea.kfDone}/${idea.kfTotal}`;
            const startedAt = idea.kfStartedAt || 0;
            if (startedAt > 0 && idea.kfDone > 0) {
              const elapsed = Math.max(0, (Date.now() / 1000) - startedAt);
              const perItem = elapsed / idea.kfDone;
              const remain = fmtEta(perItem * (idea.kfTotal - idea.kfDone));
              if (remain) etaLabel += ` · ~${remain} left`;
            }
            return <WBProgressBar w={w} label={etaLabel} pct={pct} />;
          })()}
          {/* B6 (2026-06-03): the manual "skip" advance is a MOCK control — it
              desyncs the card from the real renderer. Only show it off-live. */}
          {!(window.SK && window.SK.isLive) && (
          <button data-no-swipe onClick={doNext}
            style={{
              marginTop: 10,
              width: '100%', height: 32,
              background: 'transparent',
              color: w.accent,
              border: `1px dashed ${w.accent}`,
              borderRadius: 6,
              fontFamily: '"Geist Mono", monospace', fontSize: 10.5,
              fontWeight: 600, letterSpacing: '0.06em',
              cursor: 'pointer',
              textTransform: 'uppercase',
            }}>[skip] mark ready now →</button>
          )}
        </div>
      )}

      {/* DONE — small action row with play + kebab (no "next" stage exists) */}
      {isDone && (
        <div style={{
          marginTop: 10, paddingTop: 10,
          borderTop: `1px solid ${w.ruleSoft}`,
          display: 'flex', gap: 6, position: 'relative',
        }}>
          {/* facts-slideshow-2026-06-21: no mp4 to play — hide Play (the Done body
              shows the title-slide thumb + per-platform copy instead). */}
          {!isSlideshow && (
          <button data-no-swipe onClick={() => setSheet && setSheet({ kind: 'video', ideaId: idea.id })}
            style={{
              flex: 1, height: 32,
              background: 'transparent',
              color: w.ink,
              border: `1px solid ${w.rule}`,
              borderRadius: 6,
              fontFamily: 'inherit', fontSize: 12, fontWeight: 500,
              cursor: 'pointer',
              display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
            }}>▶ <span>Play</span></button>
          )}
          <button data-no-swipe onClick={onMenuToggle}
            title="More"
            style={{
              width: 32, height: 32,
              background: 'transparent',
              color: w.inkDim,
              border: `1px solid ${w.rule}`,
              borderRadius: 6,
              fontFamily: 'inherit', fontSize: 16,
              cursor: 'pointer', lineHeight: 1,
            }}>⋯</button>
          {menuOpen && (
            <WBKebabMenu w={w} idea={idea} onClose={onMenuClose}
              onDelete={() => removeIdea(idea.id, { toast: 'Deleted.' })}
              onDuplicate={() => duplicateIdea(idea.id)}
              onChangeVoice={() => setSheet({ kind: 'voice', ideaId: idea.id })}
              onViewJson={() => setSheet({ kind: 'json', ideaId: idea.id })}
              onRegenTranscript={() => regenTranscript(idea, flashToast)}
              onMarkPosted={() => advanceIdea(idea.id,
                { stage: 'published', _markPosted: true, statusNote: 'posted', postedAt: 'just now' },
                { toast: 'Marked posted on TikTok + YouTube.' })} />
          )}
        </div>
      )}

      {/* Inline editor — hidden once the final video exists (publish-flow-
          2026-06-12): metadata/transcript/voice edits can't reach a rendered
          mp4 without a send-back, so offering them on Ready was a lie. */}
      {expanded && !isDone && idea.stage !== 'final' && <WBInlineEditor idea={idea} w={w} advanceIdea={advanceIdea} flashToast={flashToast} />}
    </article>
  );
}

// ──────────────── PROGRESS DOTS ────────────────
function WBProgressDots({ w, stage, postType }) {
  // slideshow-flow-2026-06-22 (Scott): slideshows have no Audio/Animation — show
  // their 5-step pipeline so the dots + N/N count are honest.
  const pipeline = postType === 'facts_slideshow' ? WB_PIPELINE_SLIDESHOW : WB_PIPELINE;
  const _stage = stage === 'preview' ? 'audio' : stage;  // legacy alias (video)
  const cur = pipeline.indexOf(_stage);
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 4,
    }}>
      {pipeline.map((s, i) => {
        const isPast = i < cur;
        const isCur = i === cur;
        return (
          <span key={s} style={{
            width: isCur ? 14 : 6,
            height: 6,
            borderRadius: isCur ? 3 : '50%',
            background: isPast ? w.ink : (isCur ? w.accent : 'transparent'),
            border: isPast || isCur ? 'none' : `1px solid ${w.rule}`,
            transition: 'all 200ms',
          }} />
        );
      })}
      <span style={{
        marginLeft: 8,
        fontSize: 9, fontWeight: 700,
        letterSpacing: '0.16em', textTransform: 'uppercase',
        color: w.inkDim,
      }}>{WB_STAGE_META[stage]?.label}</span>
      <div style={{ flex: 1 }} />
      <span style={{
        fontFamily: '"Geist Mono", monospace',
        fontSize: 9, color: w.inkFaint,
      }}>{(cur >= 0 ? cur : 0) + 1}/{pipeline.length}</span>
    </div>
  );
}

// ──────────────── PROGRESS BAR (in-flight) ────────────────
function WBProgressBar({ w, label, pct }) {
  // pct == null → indeterminate (live mode): real renders take minutes and we
  // have no % signal, so show a working stripe instead of a fake countdown.
  const indet = pct == null;
  const remaining = indet ? 0 : Math.max(0, Math.round((100 - pct) * 0.14));
  return (
    <div>
      <div style={{
        display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
        marginBottom: 6,
      }}>
        <span style={{
          fontSize: 10, fontWeight: 600,
          letterSpacing: '0.08em', textTransform: 'uppercase',
          color: w.accent,
        }}>{label}</span>
        {!indet && (
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 10, color: w.inkFaint,
          fontVariantNumeric: 'tabular-nums',
        }}>{pct}%</span>
        )}
      </div>
      <div style={{
        width: '100%', height: 4,
        background: w.bg,
        borderRadius: 2, overflow: 'hidden',
      }}>
        <div style={{
          width: indet ? '100%' : `${pct}%`, height: '100%',
          background: w.accent,
          backgroundImage: `repeating-linear-gradient(45deg, rgba(255,255,255,0.18) 0 4px, transparent 4px 8px)`,
          animation: 'wbBarStripe 1.6s linear infinite',
          transition: 'width 280ms cubic-bezier(.4,.7,.4,1)',
          opacity: indet ? 0.55 : 1,
        }} />
      </div>
      <div style={{
        marginTop: 6, fontSize: 10.5, color: w.inkFaint,
        fontFamily: '"Geist Mono", monospace',
      }}>
        Server is rendering — {indet ? 'updates every 15s' : (remaining > 0 ? `~${remaining}s remaining` : 'almost done')}
      </div>
    </div>
  );
}

// ──────────────── STAGE-SPECIFIC BODY ────────────────
function WBStageBody({ idea, w, advanceIdea, flashToast, setSheet }) {
  const stage = idea.stage;

  if (stage === 'pending') {
    // 2026-06-09: while the script is still being generated (temp stub or
    // server idea_status='generating'), DON'T render the engine picker — a
    // pick made now saves against a temp/half-written row and silently
    // vanishes (Scott's Telescope report). Show a clear wait state instead.
    if (idea._generating || idea.generating) {
      return (
        <div style={{
          fontSize: 12, color: w.inkDim,
          background: w.bg, padding: '10px 12px', borderRadius: 5,
          display: 'flex', alignItems: 'center', gap: 8,
        }}>
          <span style={{
            width: 7, height: 7, borderRadius: '50%',
            background: w.accent, flexShrink: 0,
            animation: 'wbBarStripe 1.2s ease-in-out infinite',
            boxShadow: `0 0 5px ${w.accent}`,
          }} />
          Writing the script… engine + voice unlock when it's done (~15s).
        </div>
      );
    }
    const blurb = (idea.transcript || '').slice(0, 160);
    return (
      <div>
        <p style={{
          fontSize: 12.5, color: w.inkDim, lineHeight: 1.5,
          margin: '0 0 10px',
          textWrap: 'pretty',
          borderLeft: `2px solid ${w.rule}`,
          paddingLeft: 8,
        }}>
          {blurb}{idea.transcript && idea.transcript.length > 160 ? '…' : ''}
        </p>
        {/* 2026-06-10 (Scott's flow correction): the Ideas stage picks the
            keyframe IMAGE generators + candidate counts — NOT the animation
            engine (that comes at the Audio stage, before Promote). */}
        <WBKeyframeEnginePicker idea={idea} w={w} advanceIdea={advanceIdea} flashToast={flashToast} />
        {/* 2026-06-10 (Scott: "didn't let me pick an art style"): the ART
            style drives keyframe aesthetics (renderer STYLE_PROMPT_TEXT).
            dev1 had this; dev2 never exposed it — renders defaulted by
            source (everything came out cartoon_2d). */}
        <div style={{ marginTop: 10 }}>
          <label style={{
            display: 'block', fontSize: 9, fontWeight: 700,
            letterSpacing: '0.12em', textTransform: 'uppercase',
            color: w.inkDim, marginBottom: 4,
          }}>Art style · keyframe look</label>
          <select data-no-swipe
            value={idea.artStyle || ''}
            onChange={e => advanceIdea(idea.id, { artStyle: e.target.value })}
            style={{
              width: '100%', background: w.raised,
              border: `1px solid ${w.rule}`, borderRadius: 5,
              padding: '6px 8px', fontFamily: 'inherit', fontSize: 12.5,
              color: w.ink, cursor: 'pointer', outline: 'none',
            }}>
            <option value="">(auto — picked by source/rotation)</option>
            <option value="cartoon_2d">Cartoon 2D — bold, vivid</option>
            <option value="watercolor_storybook">Watercolor storybook</option>
            <option value="painterly_cinematic">Painterly cinematic</option>
            <option value="photorealistic_cinematic">Photorealistic cinematic</option>
            <option value="anime">Anime</option>
            <option value="dark_comic">Dark comic</option>
            <option value="documentary_realism">Documentary realism</option>
          </select>
        </div>
      </div>
    );
  }

  if (stage === 'approved') {
    // 2026-06-09: the counts came from MOCK engine fixtures ("2 keyframes
    // across 1 engine" regardless of reality) — show plain text in live.
    const live = !!(window.SK && window.SK.isLive);
    return (
      <div style={{
        fontSize: 12, color: w.inkDim,
        background: w.bg,
        padding: '8px 10px',
        borderRadius: 5,
      }}>
        {live ? (
          <>Generating script, voice + keyframes on the render server.</>
        ) : (
          <>Generating <span style={{ color: w.ink, fontWeight: 600 }}>{idea.engines[0]?.title + idea.engines[0]?.scene}</span> keyframes across <span style={{ color: w.ink, fontWeight: 600 }}>{idea.engines.length}</span> engine{idea.engines.length !== 1 ? 's' : ''}.</>
        )}
      </div>
    );
  }

  if (stage === 'curating') {
    return <WBCurateBody idea={idea} w={w} advanceIdea={advanceIdea} flashToast={flashToast} />;
  }

  if (stage === 'preview' || stage === 'audio') {
    return <WBAudioBody idea={idea} w={w} advanceIdea={advanceIdea} setSheet={setSheet} />;
  }

  if (stage === 'animation') {
    return <WBAnimationBody idea={idea} w={w} advanceIdea={advanceIdea} flashToast={flashToast} />;
  }

  if (stage === 'final') {
    // facts-slideshow-2026-06-20: images-only finished card — slide grid +
    // downloads (no mp4, no platform copy). Fetches the composed stills
    // (videos/{id}/stills/{slot}.jpg via the no-auth public route) and
    // blob-saves them as {Title}_slide{n}.jpg for carousel upload.
    if (idea.postType === 'facts_slideshow') {
      const api = (window.SK && window.SK.api) || window.__SK_API || '';
      const nFacts = (idea.factsSpec && Array.isArray(idea.factsSpec.slides)) ? idea.factsSpec.slides.length
        : (idea.factsSpec && idea.factsSpec.slide_count ? Math.max(1, idea.factsSpec.slide_count - 1) : 6);
      const slots = ['title', ...Array.from({ length: nFacts }, (_, i) => String(i))];
      const safeTitle = (idea.title || idea.id).replace(/[^\w\- ]+/g, '').trim().replace(/\s+/g, '_') || 'slideshow';
      const stillURL = (slot) => api + '/public/stills/' + idea.id + '/' + slot + '.jpg';
      const dl = async (slot, n) => {
        try {
          const r = await fetch(stillURL(slot), { credentials: 'include' });
          if (!r.ok) throw new Error('http ' + r.status);
          const b = await r.blob();
          const u = URL.createObjectURL(b);
          const a = document.createElement('a'); a.href = u; a.download = safeTitle + '_slide' + n + '.jpg';
          document.body.appendChild(a); a.click(); a.remove();
          setTimeout(() => URL.revokeObjectURL(u), 2000);
        } catch (e) { if (window.flashToast) window.flashToast('Download failed for slide ' + n); }
      };
      const dlAll = async () => { for (let i = 0; i < slots.length; i++) { await dl(slots[i], i + 1); await new Promise(r => setTimeout(r, 350)); } };
      return (
        <div>
          <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: w.inkDim, marginBottom: 8 }}>
            Slideshow ready · {slots.length} slides · images only
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6, marginBottom: 10 }}>
            {slots.map((slot, i) => (
              <button key={slot} data-no-swipe onClick={() => dl(slot, i + 1)} title={'Download slide ' + (i + 1)}
                style={{ position: 'relative', padding: 0, border: `1px solid ${w.rule}`, borderRadius: 4, overflow: 'hidden', aspectRatio: '9/16', background: '#000', cursor: 'pointer' }}>
                <img src={stillURL(slot)} alt={'slide ' + (i + 1)} loading="lazy"
                  style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
                <span style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(20,17,11,0.6)', color: '#fff', fontSize: 8, fontFamily: '"Geist Mono", monospace', textAlign: 'center', padding: '1px 0' }}>⬇ {i + 1}</span>
              </button>
            ))}
          </div>
          <button data-no-swipe onClick={dlAll}
            style={{ width: '100%', height: 38, background: w.ink, color: '#fff', border: 'none', borderRadius: 7, fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' }}>
            ⬇ Download all {slots.length} slides
          </button>
          <div style={{ fontSize: 10, color: w.inkFaint, marginTop: 6, fontFamily: '"Geist Mono", monospace' }}>
            Saves {safeTitle}_slide1…{slots.length}.jpg — upload as a carousel.
          </div>
          {/* facts-slideshow-2026-06-21 (Scott): slideshows post like videos —
              per-platform caption (copy sheet) + Mark-posted toggle, same block
              as the video Ready card. All three marked → worker flips
              status='posted' → card moves to Done. */}
          <div style={{ marginTop: 14, paddingTop: 12, borderTop: `1px solid ${w.ruleSoft}` }}>
            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: w.inkDim, marginBottom: 8 }}>
              Post &amp; mark
            </div>
            {[['tiktok', 'TikTok', '#ff2a59'], ['youtube', 'YouTube', '#ff0000'], ['instagram', 'Instagram', '#e1306c']].map(([key, label, dot]) => {
              const isPosted = !!(idea.postedPlatforms || {})[key];
              return (
                <div key={key} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
                  <button data-no-swipe
                    onClick={() => setSheet && setSheet({ kind: 'platform', ideaId: idea.id, extra: key })}
                    style={{
                      flex: 1, height: 38,
                      display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
                      border: `1px solid ${w.rule}`, borderRadius: 6,
                      background: 'transparent', cursor: 'pointer',
                      fontFamily: 'inherit', fontSize: 12, fontWeight: 600, color: w.ink,
                    }}>
                    <span style={{ width: 7, height: 7, borderRadius: '50%', background: dot }} />
                    {label}
                  </button>
                  <button data-no-swipe
                    onClick={() => {
                      if (!window.SK || typeof window.SK.markPosted !== 'function') return;
                      const next = { ...(idea.postedPlatforms || {}), [key]: !isPosted };
                      const all = next.tiktok && next.youtube && next.instagram;
                      window.SK.markPosted(idea.id, key, !isPosted).catch(() => {});
                      advanceIdea(idea.id, all
                        ? { stage: 'published', _noVerb: true, postedPlatforms: next, statusNote: 'posted', postedAt: 'just now' }
                        : { _noVerb: true, postedPlatforms: next },
                        { toast: all ? 'All 3 platforms posted — moved to Done.' : `${label} marked ${!isPosted ? 'posted' : 'NOT posted'}.` });
                    }}
                    style={{
                      width: 116, height: 38,
                      border: `1px solid ${isPosted ? w.success : w.rule}`, borderRadius: 6,
                      background: isPosted ? w.success : 'transparent',
                      color: isPosted ? '#fff' : w.inkDim,
                      cursor: 'pointer',
                      fontFamily: 'inherit', fontSize: 11.5, fontWeight: 600,
                    }}>{isPosted ? '✓ Posted' : 'Mark posted'}</button>
                </div>
              );
            })}
          </div>
        </div>
      );
    }
    return (
      <div style={{ display: 'flex', gap: 10 }}>
        <button data-no-swipe onClick={() => setSheet && setSheet({ kind: 'video', ideaId: idea.id })}
          title="Play preview"
          style={{
            width: 56, aspectRatio: '9/16', flexShrink: 0,
            borderRadius: 4, overflow: 'hidden',
            border: `1px solid ${w.rule}`,
            position: 'relative',
            padding: 0, cursor: 'pointer',
            background: 'transparent',
          }}>
          <KeyframePlaceholder seed={idea.slug.charCodeAt(1)} label="" brighter />
          <span style={{
            position: 'absolute', inset: 0,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: '#fff', fontSize: 18,
            background: 'rgba(0,0,0,0.22)',
          }}>▶</span>
        </button>
        <div style={{ flex: 1 }}>
          <div style={{
            display: 'flex', flexWrap: 'wrap', gap: 4,
            marginBottom: 8,
          }}>
            {(idea.hashtags || []).map(t => (
              <span key={t} style={{
                fontSize: 10.5, color: w.accent,
                fontFamily: '"Geist Mono", monospace',
              }}>{t}</span>
            ))}
          </div>
          {/* publish-lock-2026-06-12 (Scott): Queue for publish locks the post.
              Before: small chips + volume sliders. After (readyToPost): the
              mix is baked — sliders go away; big platform buttons + a big
              Download button take their place. */}
          {!idea.readyToPost && (
          <div style={{
            display: 'flex', gap: 6, fontSize: 10.5, color: w.inkDim,
            fontFamily: '"Geist Mono", monospace',
          }}>
            {/* platform-chips-live-2026-06-12 (Scott: "if i click them it should
                change the hashtags for each... right now it shows neither"):
                these were inert spans — now they open the per-platform copy
                sheet (caption + hashtags, Generate/Regenerate), same as the
                Published card's TT/YT buttons. */}
            {[['tiktok', 'TikTok', '#ff2a59'], ['youtube', 'YouTube', '#ff0000'], ['instagram', 'Instagram', '#e1306c']].map(([key, label, dot]) => (
              <button key={key} data-no-swipe
                onClick={() => setSheet && setSheet({ kind: 'platform', ideaId: idea.id, extra: key })}
                style={{
                  display: 'inline-flex', alignItems: 'center', gap: 4,
                  border: `1px solid ${w.rule}`, padding: '1px 5px', borderRadius: 3,
                  background: 'transparent', cursor: 'pointer',
                  fontFamily: 'inherit', fontSize: 10.5, color: w.inkDim,
                }}>
                <span style={{ width: 5, height: 5, borderRadius: '50%', background: dot }} />
                {label} ↗
              </button>
            ))}
          </div>
          )}
          {/* 2026-06-10 gap C (Scott-approved): final volume tweaks WITHOUT a
              re-render — sliders auto-save the mix; Re-mux re-bakes the final
              video's audio (~$0.005, no new vendor calls). Hidden once the
              post is queued for publish (publish-lock-2026-06-12). */}
          {window.SK && window.SK.isLive && !idea.readyToPost && (
            <div style={{ marginTop: 10 }}>
              {['voice', 'bg', 'sfx'].map(k => (
                <WBSlider key={k} w={w}
                  label={k === 'bg' ? 'BG' : k.toUpperCase()}
                  value={(idea.mix && idea.mix[k] != null) ? idea.mix[k] : 1.0}
                  onChange={(v) => advanceIdea(idea.id, { mix: { ...(idea.mix || {}), [k]: v } })} />
              ))}
              <button data-no-swipe
                onClick={() => {
                  if (!window.SK || typeof window.SK.rebuildAudioMix !== 'function') return;
                  // remux-no-lag-2026-06-12 (Scott: "took a couple seconds for
                  // the page to send me to the assembling item"): flip to the
                  // in-flight card NOW via the optimistic patch; the _remux
                  // flag routes advanceIdea to rebuildAudioMix (revert on
                  // error). Same pattern as every other stage transition.
                  advanceIdea(idea.id, {
                    stage: 'approved', _remux: true,
                    statusNote: 'remuxing audio',
                    flightLabel: 'Re-mixing final audio (~1 min)',
                    _approvedAt: Date.now(),
                  }, { toast: 'Final re-mux queued (~1 min) — no re-render.' });
                }}
                style={{
                  marginTop: 6, height: 30, padding: '0 10px',
                  background: 'transparent', color: w.accent,
                  border: `1px solid ${w.rule}`, borderRadius: 5,
                  fontFamily: 'inherit', fontSize: 11.5, fontWeight: 600,
                  cursor: 'pointer',
                }}>↻ Apply mix to final video (~$0.005)</button>
            </div>
          )}
          {/* publish-lock-2026-06-12: LOCKED state — post is queued. Big
              per-platform copy buttons + a big download button. */}
          {idea.readyToPost && (
            <div style={{ marginTop: 4 }}>
              {/* complete-all-3-2026-06-12 (Scott): one row per platform —
                  copy sheet on the left, its own Mark-posted toggle on the
                  right. All three marked → worker flips status='posted' →
                  row moves to the collapsed Done section. */}
              {[['tiktok', 'TikTok', '#ff2a59'], ['youtube', 'YouTube', '#ff0000'], ['instagram', 'Instagram', '#e1306c']].map(([key, label, dot]) => {
                const isPosted = !!(idea.postedPlatforms || {})[key];
                return (
                  <div key={key} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
                    <button data-no-swipe
                      onClick={() => setSheet && setSheet({ kind: 'platform', ideaId: idea.id, extra: key })}
                      style={{
                        flex: 1, height: 38,
                        display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
                        border: `1px solid ${w.rule}`, borderRadius: 6,
                        background: 'transparent', cursor: 'pointer',
                        fontFamily: 'inherit', fontSize: 12, fontWeight: 600, color: w.ink,
                      }}>
                      <span style={{ width: 7, height: 7, borderRadius: '50%', background: dot }} />
                      {label}
                    </button>
                    <button data-no-swipe
                      onClick={() => {
                        if (!window.SK || typeof window.SK.markPosted !== 'function') return;
                        const next = { ...(idea.postedPlatforms || {}), [key]: !isPosted };
                        const all = next.tiktok && next.youtube && next.instagram;
                        window.SK.markPosted(idea.id, key, !isPosted).catch(() => {});
                        advanceIdea(idea.id, all
                          ? { stage: 'published', _noVerb: true, postedPlatforms: next, statusNote: 'posted', postedAt: 'just now' }
                          : { _noVerb: true, postedPlatforms: next },
                          { toast: all ? 'All 3 platforms posted — moved to Done.' : `${label} marked ${!isPosted ? 'posted' : 'NOT posted'}.` });
                      }}
                      style={{
                        width: 116, height: 38,
                        border: `1px solid ${isPosted ? w.success : w.rule}`, borderRadius: 6,
                        background: isPosted ? w.success : 'transparent',
                        color: isPosted ? '#fff' : w.inkDim,
                        cursor: 'pointer',
                        fontFamily: 'inherit', fontSize: 11.5, fontWeight: 600,
                      }}>{isPosted ? '✓ Posted' : 'Mark posted'}</button>
                  </div>
                );
              })}
              <button data-no-swipe
                onClick={async () => {
                  if (!window.SK) return;
                  const url = window.SK.videoUrl(idea.id);
                  if (typeof flashToast === 'function') flashToast('Preparing download…');
                  try {
                    const r = await fetch(url, { credentials: 'include' });
                    if (!r.ok) throw new Error('HTTP ' + r.status);
                    const b = await r.blob();
                    const obj = URL.createObjectURL(b);
                    const a = document.createElement('a');
                    a.href = obj;
                    a.download = (idea.title || idea.id).replace(/[^\w\- ]+/g, '').trim().replace(/\s+/g, '_') + '.mp4';
                    document.body.appendChild(a); a.click(); a.remove();
                    setTimeout(() => URL.revokeObjectURL(obj), 60000);
                  } catch (e) {
                    // fetch blocked (CORS/auth edge case) — open the stream in a
                    // new tab so the user can save from the player.
                    try { window.open(url, '_blank'); } catch (_) {}
                  }
                }}
                style={{
                  width: '100%', height: 40,
                  background: w.ink, color: '#fff',
                  border: 'none', borderRadius: 6,
                  fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
                  cursor: 'pointer',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
                }}>⬇ Download video</button>
            </div>
          )}
        </div>
      </div>
    );
  }

  if (stage === 'published') {
    // facts-slideshow-2026-06-21 (Scott): a posted slideshow has no video/views —
    // a compact Done view (title-slide thumb + posted date + per-platform copy)
    // instead of the video player + view counts.
    if (idea.postType === 'facts_slideshow') {
      const api = (window.SK && window.SK.api) || window.__SK_API || '';
      const thumb = api + '/public/stills/' + idea.id + '/title.jpg';
      return (
        <div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
            <img src={thumb} alt="title slide" loading="lazy"
              style={{ width: 44, aspectRatio: '9/16', objectFit: 'cover', borderRadius: 4, border: `1px solid ${w.rule}`, background: '#000' }} />
            <div>
              <div style={{ fontSize: 13, fontWeight: 700, color: w.ink }}>Slideshow · posted</div>
              <div style={{ fontSize: 10, color: w.inkFaint, fontFamily: '"Geist Mono", monospace', marginTop: 2 }}>posted {idea.postedAt}</div>
            </div>
          </div>
          <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
            {[['tiktok', 'TikTok', '#ff2a59'], ['youtube', 'YouTube', '#ff0000'], ['instagram', 'Instagram', '#e1306c']].map(([key, label, dot]) => (
              <button key={key} data-no-swipe onClick={() => setSheet && setSheet({ kind: 'platform', ideaId: idea.id, extra: key })}
                style={{ flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5, border: `1px solid ${w.rule}`, borderRadius: 4, padding: '4px 6px', background: 'transparent', cursor: 'pointer', fontFamily: '"Geist Mono", monospace', fontSize: 10.5, color: w.ink }}>
                <span style={{ width: 5, height: 5, borderRadius: '50%', background: dot }} />{label} ↗
              </button>
            ))}
          </div>
        </div>
      );
    }
    const tt = idea.posted?.tiktok;
    const yt = idea.posted?.youtube;
    const totalViews = (tt?.views || 0) + (yt?.views || 0);
    return (
      <div>
        <div style={{
          display: 'flex', alignItems: 'baseline', gap: 12,
        }}>
          <button data-no-swipe onClick={() => setSheet && setSheet({ kind: 'video', ideaId: idea.id })}
            style={{
              background: 'transparent', border: 'none',
              padding: 0, cursor: 'pointer', textAlign: 'left',
              fontFamily: 'inherit',
            }}>
            <div style={{
              fontSize: 22, fontWeight: 700,
              color: w.ink, lineHeight: 1,
              letterSpacing: '-0.02em',
              fontVariantNumeric: 'tabular-nums',
            }}>{formatN(totalViews)}</div>
            <div style={{
              fontSize: 9, fontWeight: 700,
              letterSpacing: '0.14em', textTransform: 'uppercase',
              color: w.inkFaint, marginTop: 3,
            }}>views · tap to play</div>
          </button>
          <div style={{ flex: 1 }} />
          <div style={{
            display: 'flex', flexDirection: 'column', gap: 4,
            alignItems: 'flex-end',
          }}>
            <button data-no-swipe onClick={() => setSheet && setSheet({ kind: 'platform', ideaId: idea.id, extra: 'tiktok' })}
              style={{
                background: 'transparent',
                border: `1px solid ${w.rule}`, borderRadius: 4,
                padding: '3px 8px',
                fontFamily: '"Geist Mono", monospace',
                fontSize: 10.5, color: w.ink,
                fontVariantNumeric: 'tabular-nums',
                cursor: 'pointer',
                display: 'inline-flex', alignItems: 'center', gap: 5,
              }}>
              <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#ff2a59' }} />
              TT {formatN(tt?.views || 0)} ↗
            </button>
            <button data-no-swipe onClick={() => setSheet && setSheet({ kind: 'platform', ideaId: idea.id, extra: 'youtube' })}
              style={{
                background: 'transparent',
                border: `1px solid ${w.rule}`, borderRadius: 4,
                padding: '3px 8px',
                fontFamily: '"Geist Mono", monospace',
                fontSize: 10.5, color: w.ink,
                fontVariantNumeric: 'tabular-nums',
                cursor: 'pointer',
                display: 'inline-flex', alignItems: 'center', gap: 5,
              }}>
              <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#ff0000' }} />
              YT {formatN(yt?.views || 0)} ↗
            </button>
          </div>
        </div>
        <div style={{
          marginTop: 8,
          fontSize: 10, color: w.inkFaint,
          fontFamily: '"Geist Mono", monospace',
        }}>posted {idea.postedAt}</div>
      </div>
    );
  }

  return null;
}

// ──────────────── INTERACTIVE BODIES ────────────────

// useLongPress — supports both quick-tap and long-press. We decide which
// happened on pointerup so it never fights with React's click handler.
// Usage: spread onto an element, and pass BOTH onTap and onLongPress.
function useLongPress(onLongPress, onTap, delay = 480) {
  const timer = React.useRef(null);
  const startPos = React.useRef({ x: 0, y: 0 });
  const fired = React.useRef(false);
  const moved = React.useRef(false);

  const cancel = () => {
    if (timer.current) { clearTimeout(timer.current); timer.current = null; }
  };

  return {
    onPointerDown: (e) => {
      if (e.pointerType === 'mouse' && e.button !== 0) return;
      fired.current = false;
      moved.current = false;
      startPos.current = { x: e.clientX, y: e.clientY };
      cancel();
      timer.current = setTimeout(() => {
        if (!moved.current) {
          fired.current = true;
          onLongPress(e);
        }
      }, delay);
    },
    onPointerMove: (e) => {
      const dx = Math.abs(e.clientX - startPos.current.x);
      const dy = Math.abs(e.clientY - startPos.current.y);
      if (dx > 8 || dy > 8) {
        moved.current = true;
        cancel();
      }
    },
    onPointerUp: (e) => {
      cancel();
      if (!fired.current && !moved.current) onTap(e);
    },
    onPointerLeave: cancel,
    onPointerCancel: cancel,
    onContextMenu: (e) => {
      e.preventDefault();
      fired.current = true;
      onLongPress(e);
    },
  };
}

// ──────────────── CURATE BODY (interactive accordion) ────────────────
// ISSUE 5 (2026-06-05): per-slide Ken Burns cost helpers. The post has ONE
// paid engine (idea.animationStyle); any slide can be flipped to free
// ken_burns_local in idea.sceneEngines to lower cost.
const PAID_PER_SLIDE_USD = 0.25;  // Runway $3 / 12 slides
function _wbSlotKey(scene, i) {
  return scene.key === 'title' ? 'title' : String(scene.idx != null ? scene.idx : i);
}
function _wbEngineForSlot(idea, slot) {
  const m = (idea && idea.sceneEngines) || {};
  return (m[slot] === 'ken_burns_local') ? 'ken_burns_local' : (idea.animationStyle || 'ken_burns_local');
}
function _wbCurateCost(idea, scenes) {
  let paid = 0, free = 0;
  scenes.forEach((s, i) => {
    if (_wbEngineForSlot(idea, _wbSlotKey(s, i)) === 'ken_burns_local') free++;
    else paid++;
  });
  return { paid, free, est: paid * PAID_PER_SLIDE_USD };
}

function WBCurateBody({ idea, w, advanceIdea, flashToast }) {
  // Scenes live on the idea so picks survive send-back/forward
  const scenes = idea.scenes || (() => {
    const s = makeScenes(idea);
    if (s[0]?.candidates[0]) s[0].pickedId = s[0].candidates[0].id;
    return s;
  })();

  // Auto-pick the first unpicked scene as the open row
  const firstUnpicked = scenes.findIndex(s => !s.pickedId);
  const [openIdx, setOpenIdx] = React.useState(firstUnpicked === -1 ? 0 : firstUnpicked);
  const [regenTarget, setRegenTarget] = React.useState(null); // { sceneIdx, candId }

  // ISSUE 5 (2026-06-05): per-slide Ken Burns toggle + live cost total.
  const postEngine = idea.animationStyle || 'ken_burns_local';
  const kbToggleable = postEngine !== 'ken_burns_local'; // pointless if the whole post is already free
  const cost = _wbCurateCost(idea, scenes);
  const toggleKB = (slot, makeKB) => {
    // Build a COMPLETE slot->engine map from the current effective engines, then
    // flip just this slot. Full map so the renderer/worker see every slide.
    const map = {};
    scenes.forEach((s, i) => { const k = _wbSlotKey(s, i); map[k] = _wbEngineForSlot(idea, k); });
    map[slot] = makeKB ? 'ken_burns_local' : postEngine;
    advanceIdea(idea.id, { sceneEngines: map });
  };

  // Persist scenes back to the idea
  const saveScenes = React.useCallback((next) => {
    advanceIdea(idea.id, { scenes: next });
  }, [advanceIdea, idea.id]);

  const pick = (sceneIdx, candId) => {
    const next = scenes.map((s, i) => i === sceneIdx ? { ...s, pickedId: candId } : s);
    saveScenes(next);
    // collapse this row, auto-advance to next unpicked
    const nextUnpicked = next.findIndex((s, i) => i > sceneIdx && !s.pickedId);
    setOpenIdx(nextUnpicked === -1 ? -1 : nextUnpicked);
  };

  const onRegen = (notes) => {
    // MOCK-ONLY since 2026-06-10 — in live mode WBRegenSheet commits straight
    // to /videos/{id}/regen-with-feedback (multi-engine, appended candidates)
    // and never calls this.
    if (!regenTarget) return;
    // For the mockup: append a freshly-seeded candidate at the end of that scene
    const { sceneIdx, candId } = regenTarget;
    const scene = scenes[sceneIdx];
    const srcCand = scene.candidates.find(c => c.id === candId);
    const candIdx = srcCand?.candIdx != null ? srcCand.candIdx : (scene.candidates.indexOf(srcCand) + 1);
    const engineIdx = srcCand?.engineIdx != null
      ? srcCand.engineIdx
      : Math.max(0, (idea.engines || []).findIndex(e => e.key === srcCand?.engine));
    const newCand = {
      engine: srcCand?.engine || 'regen',
      engineIdx,
      idx: scene.candidates.length,
      candIdx: candIdx,
      id: `regen_${sceneIdx}_${Date.now()}`,
      note: notes,
    };
    const next = scenes.map((s, i) => i === sceneIdx
      ? { ...s, candidates: [...s.candidates, newCand], pickedId: newCand.id }
      : s);
    saveScenes(next);
    setRegenTarget(null);
    flashToast && flashToast('Regenerating with new instructions…');
    // open the row so the user sees the new candidate appear
    setOpenIdx(sceneIdx);
  };

  const picked = scenes.filter(s => s.pickedId).length;
  const total = scenes.length;
  const pct = (picked / total) * 100;

  return (
    <div>
      {/* progress bar */}
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        marginBottom: 6,
      }}>
        <span style={{
          fontSize: 10, fontWeight: 700,
          letterSpacing: '0.10em', textTransform: 'uppercase',
          color: w.inkDim,
        }}>Scenes</span>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
          {/* ISSUE 5: live cost total — drops as slides flip to Ken Burns */}
          {kbToggleable && (
            <span style={{
              fontFamily: '"Geist Mono", monospace', fontSize: 10,
              color: cost.free > 0 ? w.success : w.inkDim,
              fontVariantNumeric: 'tabular-nums',
            }}>
              {cost.free > 0
                ? `Est $${cost.est.toFixed(2)} · ${cost.paid} paid + ${cost.free} free`
                : `Est $${cost.est.toFixed(2)}`}
            </span>
          )}
          <span style={{
            fontFamily: '"Geist Mono", monospace',
            fontSize: 10.5, fontWeight: 600,
            color: picked === total ? w.success : w.accent,
            fontVariantNumeric: 'tabular-nums',
          }}>{picked}/{total}</span>
        </div>
      </div>
      <div style={{
        height: 3, background: w.bg, borderRadius: 2,
        overflow: 'hidden', marginBottom: 10,
      }}>
        <div style={{
          width: `${pct}%`, height: '100%',
          background: picked === total ? w.success : w.accent,
          transition: 'width 220ms cubic-bezier(.2,.7,.2,1), background 200ms',
        }} />
      </div>

      {/* scene rows */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
        {scenes.map((scene, i) => (
          <WBSceneRow key={scene.key} w={w}
            idea={idea}
            scene={scene} sceneIdx={i}
            isOpen={openIdx === i}
            isTitle={scene.key === 'title'}
            onToggle={() => setOpenIdx(openIdx === i ? -1 : i)}
            onPick={(c) => pick(i, c)}
            onRegen={(candId) => setRegenTarget({ sceneIdx: i, candId })}
            kbToggleable={kbToggleable}
            onToggleKB={toggleKB} />
        ))}
      </div>

      {regenTarget && (
        <WBRegenSheet w={w}
          idea={idea}
          target={regenTarget}
          scene={scenes[regenTarget.sceneIdx]}
          onCancel={() => setRegenTarget(null)}
          onConfirm={onRegen}
          onCommitted={() => {
            setRegenTarget(null);
            flashToast && flashToast('Regenerating — new candidates will appear beside the old ones.');
          }} />
      )}
    </div>
  );
}

function WBSceneRow({ w, idea, scene, sceneIdx, isOpen, isTitle, onToggle, onPick, onRegen, kbToggleable, onToggleKB }) {
  const picked = scene.pickedId ? scene.candidates.find(c => c.id === scene.pickedId) : null;
  const num = isTitle ? 'T' : String(sceneIdx).padStart(2, '0');
  // Contract: still URLs key the title scene as 'title' and every body scene
  // as its NUMERIC 0-based index (scene.idx) — never the synthetic 'scene_N'
  // key. The worker stores R2 stills at {sceneKey}_eng{engIdx}_cand_{candIdx}.jpg.
  const sceneKey = isTitle ? 'title' : String(scene.idx != null ? scene.idx : sceneIdx);
  // ISSUE 5: is THIS slide overridden to free Ken Burns? (only when the post
  // engine is paid — otherwise the whole post is already free and the toggle hides)
  const _isKB = !!kbToggleable && _wbEngineForSlot(idea, sceneKey) === 'ken_burns_local';
  return (
    <div style={{
      background: isOpen ? w.bg : 'transparent',
      border: `1px solid ${isOpen ? w.rule : w.ruleSoft}`,
      borderRadius: 5, overflow: 'hidden',
      transition: 'background 140ms',
    }}>
      <button data-no-swipe onClick={onToggle} style={{
        width: '100%', padding: '6px 8px',
        display: 'flex', alignItems: 'center', gap: 8,
        background: 'transparent', border: 'none',
        cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
      }}>
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 9.5, fontWeight: 700,
          width: 18, color: scene.pickedId ? w.success : w.inkFaint,
        }}>{num}</span>
        <div style={{
          position: 'relative',
          width: 46, height: 64, borderRadius: 3, overflow: 'hidden',
          border: `1px solid ${scene.pickedId ? w.accent : w.rule}`, flexShrink: 0,
        }}>
          {(() => {
            // Show the picked candidate, or (when nothing picked yet) the first
            // candidate as a preview — so every row shows its keyframe (2026-06-04).
            const show = picked || (scene.candidates && scene.candidates[0]);
            if (!show) return <div style={{ width: '100%', height: '100%', background: w.paper }} />;
            return <SKCandidateImage
              src={window.SK ? window.SK.stillUrl(idea?.id, sceneKey, (show.engineIdx != null ? show.engineIdx : 0), (show.candIdx != null ? show.candIdx : 1)) : null}
              seed={(show.idx != null ? show.idx : 0) + sceneIdx * 4} brighter={!!picked} />;
          })()}
        </div>
        <span style={{
          flex: 1, minWidth: 0,
          fontSize: 11, color: w.ink,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{scene.text || scene.label}</span>
        {/* ISSUE 5: per-slide Ken Burns (free) toggle. stopPropagation so it
            doesn't open/close the row. Hidden when the whole post is free. */}
        {kbToggleable && (
          <span data-no-swipe role="button"
            onClick={(e) => { e.stopPropagation(); onToggleKB(sceneKey, !_isKB); }}
            title={_isKB ? 'Free Ken Burns — tap for paid AI motion' : 'Paid AI motion — tap for free Ken Burns'}
            style={{
              flexShrink: 0,
              fontFamily: '"Geist Mono", monospace', fontSize: 9, fontWeight: 700,
              padding: '2px 5px', borderRadius: 4, cursor: 'pointer', whiteSpace: 'nowrap',
              border: `1px solid ${_isKB ? w.success : w.rule}`,
              color: _isKB ? w.success : w.inkDim,
            }}>
            {_isKB ? 'KB·free' : '$0.25'}
          </span>
        )}
        {scene.pickedId && (
          <span style={{
            fontSize: 10, color: w.success,
            fontFamily: '"Geist Mono", monospace',
          }}>✓</span>
        )}
        <span style={{
          color: w.inkFaint, fontSize: 11,
          transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
          transition: 'transform 0.16s',
        }}>›</span>
      </button>

      {isOpen && (
        <div style={{ padding: '2px 8px 8px' }}>
          <div style={{
            fontSize: 9.5, color: w.inkFaint,
            fontFamily: '"Geist Mono", monospace',
            marginBottom: 5, letterSpacing: '0.04em',
          }}>tap to pick · long-press to regen</div>
          <div style={{
            display: 'grid',
            // Bigger keyframes: a lone candidate gets a large single tile; 2+
            // use 2 columns instead of the old cramped 3 (2026-06-04).
            gridTemplateColumns: (scene.candidates.length <= 1) ? 'minmax(0, 210px)' : 'repeat(2, 1fr)',
            gap: 6,
          }}>
            {scene.candidates.map((c, ci) => (
              <WBCandidate key={c.id}
                w={w} cand={c} sceneIdx={sceneIdx} candIdx={ci}
                idea={idea} sceneKey={sceneKey}
                isPicked={c.id === scene.pickedId}
                onPick={() => onPick(c.id)}
                onLongPress={() => onRegen(c.id)} />
            ))}
          </div>
          {/* prior-rounds-as-candidates-2026-06-13 (Scott: prior images should
              look + select exactly like current ones, labeled "Prior round", not
              the vendor). Same grid + tile size as the current candidates. Tap to
              USE one in the final render (renderer fetches the archived frame via
              adopt-prior-still-v2), tap again to undo. */}
          {Array.isArray(scene.prevCandidates) && scene.prevCandidates.length > 0 && (
            <div style={{ marginTop: 8 }}>
              <div style={{
                fontSize: 9.5, color: w.inkFaint,
                fontFamily: '"Geist Mono", monospace',
                marginBottom: 5, letterSpacing: '0.04em',
              }}>EARLIER ROUNDS · tap to use one in the video</div>
              <div style={{
                display: 'grid',
                gridTemplateColumns: (scene.prevCandidates.length <= 1) ? 'minmax(0, 210px)' : 'repeat(2, 1fr)',
                gap: 6,
              }}>
                {scene.prevCandidates.map((pc) => {
                  const prevKey = (pc.prefix || 'prev_') + pc.name;
                  const isUsing = scene.adoptedPrev === prevKey;
                  return (
                    <button key={prevKey} data-no-swipe
                      onClick={() => {
                        if (!(window.SK && window.SK.adoptPriorStill)) return;
                        const next = isUsing ? null : prevKey;
                        const p = window.SK.adoptPriorStill(idea?.id, sceneKey, next);
                        // honest-toast-2026-06-20: only claim success when the
                        // SERVER actually saved it. safeMut resolves to null (a
                        // no-op) when the page is running stale code (the old
                        // look-only bundle) or writes are disabled — the old code
                        // popped "Using…" anyway, a false done. The worker returns
                        // {ok:true, adopted} on a real save; null/empty = it never
                        // landed → tell the user to refresh instead of lying.
                        if (p && p.then) p.then((res) => {
                          if (typeof window.flashToast !== 'function') return;
                          const saved = !!(res && (res.ok || res.adopted !== undefined));
                          if (!saved) {
                            window.flashToast('Could not save the pick — this page looks out of date. Refresh and try again.');
                            return;
                          }
                          window.flashToast(next
                            ? `Using a prior-round image for ${isTitle ? 'the title card' : 'scene ' + (scene.idx + 1)}.`
                            : 'Back to the current pick.');
                        }).catch(() => {
                          if (typeof window.flashToast === 'function') window.flashToast('Could not set the image — try again.');
                        });
                      }}
                      style={{
                        position: 'relative', padding: 0, background: 'transparent',
                        border: isUsing ? `2px solid ${w.accent}` : `1px solid ${w.rule}`,
                        borderRadius: 3, aspectRatio: '9/16', overflow: 'hidden',
                        cursor: 'pointer', touchAction: 'manipulation',
                      }}>
                      <SKCandidateImage
                        src={(window.SK && window.SK.prevStillUrl) ? window.SK.prevStillUrl(idea?.id, pc.name, pc.prefix) : null}
                        seed={0} />
                      <span style={{
                        position: 'absolute', left: 0, right: 0, bottom: 0,
                        background: 'rgba(20,17,11,0.62)', color: '#fff',
                        fontFamily: '"Geist Mono", monospace', fontSize: 8, fontWeight: 600,
                        textAlign: 'center', padding: '1px 0', letterSpacing: '0.03em',
                        pointerEvents: 'none',
                      }}>Prior round{pc.round > 1 ? ` ·r${pc.round}` : ''}</span>
                      {isUsing && (
                        <span style={{
                          position: 'absolute', top: 2, right: 2,
                          width: 12, height: 12, borderRadius: '50%',
                          background: w.accent, color: '#fff', fontSize: 8, fontWeight: 700,
                          display: 'flex', alignItems: 'center', justifyContent: 'center',
                        }}>✓</span>
                      )}
                    </button>
                  );
                })}
              </div>
            </div>
          )}
          {/* prompt-visibility-2026-06-13 (Scott: can't read the full prompt to
              decide pick vs regen — only the truncated header line was visible).
              Show the full image + animation prompts here, wrapping. */}
          {((scene.keyframePrompt && scene.keyframePrompt.trim()) || (scene.motionPrompt && scene.motionPrompt.trim())) && (
            <div style={{ marginTop: 10, paddingTop: 8, borderTop: `1px dashed ${w.ruleSoft}` }}>
              {scene.keyframePrompt && scene.keyframePrompt.trim() && (
                <div style={{ marginBottom: 6 }}>
                  <div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: w.inkFaint, marginBottom: 2 }}>Image prompt</div>
                  <div style={{ fontSize: 11, color: w.inkDim, lineHeight: 1.45, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{scene.keyframePrompt.trim()}</div>
                </div>
              )}
              {scene.motionPrompt && scene.motionPrompt.trim() && (
                <div>
                  <div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: w.inkFaint, marginBottom: 2 }}>Animation prompt</div>
                  <div style={{ fontSize: 11, color: w.inkDim, lineHeight: 1.45, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{scene.motionPrompt.trim()}</div>
                </div>
              )}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// Real image with placeholder fallback. In live mode SK.stillUrl returns
// a real R2 url; in mock mode (or on 404) we fall back to the SVG strip.
function SKCandidateImage({ src, seed, label, brighter }) {
  const [failed, setFailed] = React.useState(false);
  if (!src || failed) {
    return <KeyframePlaceholder seed={seed} label={label || ''} brighter={brighter} />;
  }
  return (
    <img src={src} alt="" loading="lazy"
      onError={() => setFailed(true)}
      style={{
        position: 'absolute', inset: 0,
        width: '100%', height: '100%',
        objectFit: 'cover', display: 'block',
      }} />
  );
}

function WBCandidate({ w, cand, sceneIdx, candIdx, idea, sceneKey, isPicked, onPick, onLongPress }) {
  const handlers = useLongPress(onLongPress, onPick, 480);
  // Contract: candidate carries engineIdx (0-based) + candIdx (1-based) that
  // match the worker's still filename {sceneKey}_eng{engineIdx}_cand_{candIdx}.jpg.
  // Fall back to a derived index only if a legacy candidate lacks them.
  const engIdx = cand.engineIdx != null
    ? cand.engineIdx
    : Math.max(0, (idea?.engines || []).findIndex(e => e.key === cand.engine));
  const cIdx = cand.candIdx != null ? cand.candIdx : (candIdx + 1);
  const src = window.SK ? window.SK.stillUrl(idea?.id, sceneKey, engIdx, cIdx) : null;
  // curate-vendor-label-2026-06-13 (Scott: "how do i tell what cards are from
  // what vendors?"): badge each candidate with its image engine.
  const engLabel = kfEngineLabel((idea?.engineKeys || [])[engIdx]);
  return (
    <button data-no-swipe
      {...handlers}
      style={{
        position: 'relative', padding: 0,
        background: 'transparent',
        border: isPicked ? `2px solid ${w.accent}` : `1px solid ${w.rule}`,
        borderRadius: 3, aspectRatio: '9/16', overflow: 'hidden',
        cursor: 'pointer',
        touchAction: 'manipulation',
      }}>
      <SKCandidateImage src={src} seed={(cand.idx != null ? cand.idx : candIdx) + sceneIdx * 7 + ((cand.engine && cand.engine.charCodeAt(0)) || 0)} />
      {engLabel && (
        <span style={{
          position: 'absolute', left: 0, right: 0, bottom: 0,
          background: 'rgba(20,17,11,0.62)', color: '#fff',
          fontFamily: '"Geist Mono", monospace', fontSize: 8, fontWeight: 600,
          textAlign: 'center', padding: '1px 0', letterSpacing: '0.03em',
          pointerEvents: 'none',
        }}>{engLabel}{cand.candIdx ? ` ·${cand.candIdx}` : ''}</span>
      )}
      {cand.note && (
        <span title={`regen note: ${cand.note}`}
          style={{
            position: 'absolute', top: 2, left: 2,
            width: 10, height: 10, borderRadius: 2,
            background: w.amber, color: '#fff',
            fontSize: 7, fontWeight: 700,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontFamily: '"Geist Mono", monospace',
          }}>R</span>
      )}
      {isPicked && (
        <span style={{
          position: 'absolute', top: 2, right: 2,
          width: 12, height: 12, borderRadius: '50%',
          background: w.accent, color: '#fff',
          fontSize: 8, fontWeight: 700,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        }}>✓</span>
      )}
    </button>
  );
}

// ──────────────── REGEN SHEET (long-press target) ────────────────
// 2026-06-10 gap A (Scott-approved): regen a slide's keyframes with the SAME
// or a DIFFERENT image engine (up to 3, dev1 parity). Live mode drives the
// worker's regen-with-feedback endpoint: optional Haiku prompt-rewrite from
// notes (preview), then commit {prompt, engine_configs} — new candidates are
// APPENDED beside the old ones. Mock mode keeps the legacy local append.
// Regen endpoint accepts ONLY the engines in the worker's _KF_ENGINE_PRICES_USD
// (canonical keys) — offering others 400s "no valid engine" (robot catch #4).
const WB_KF_REGEN_ENGINES = [
  { key: 'luma_api_photon_flash', label: 'Photon Flash' },
  { key: 'runway_api_gen4_720p',  label: 'Runway Gen-4' },
  // imagen-regen-2026-06-22 (Scott): 3rd image engine (worker whitelists it +
  // renderer maps 'imagen' -> nano_banana_2 / imagen_client).
  { key: 'imagen',                label: 'Imagen 4' },
];
const WB_KF_REGEN_COSTS = { luma_api_photon_flash: 0.051, runway_api_gen4_720p: 0.050, imagen: 0.040 };

function WBRegenSheet({ w, idea, target, scene, onCancel, onConfirm, onCommitted }) {
  const live = !!(window.SK && window.SK.isLive);
  const [notes, setNotes] = React.useState('');
  const [engines, setEngines] = React.useState({ luma_api_photon_flash: { count: 1 } });
  // slideshow-regen-2026-06-22 (Scott): show the ORIGINAL prompt to compare/edit.
  // Slideshows don't carry keyframePrompt, so reconstruct it the same way the
  // renderer/worker do — title = the hook prompt; fact = its image_keywords.
  const _origPrompt = (() => {
    if (scene.keyframePrompt && scene.keyframePrompt.trim()) return scene.keyframePrompt.trim();
    if (idea.postType === 'facts_slideshow') {
      if (scene.key === 'title') return `A striking symbolic establishing scene representing: ${idea.title || ''}. ${idea.subtitle || ''}`.trim();
      const _sl = (idea.factsSpec && Array.isArray(idea.factsSpec.slides)) ? idea.factsSpec.slides[scene.idx] : null;
      if (_sl) return String(_sl.image_prompt || _sl.image_keywords || _sl.fact_text || '').trim();
    }
    return (scene.text && scene.text.trim()) || '';
  })();
  const [prompt, setPrompt] = React.useState(_origPrompt);
  const [phase, setPhase] = React.useState('edit'); // edit | rewriting | committing
  const sceneKey = scene.key === 'title' ? 'title' : String(scene.idx != null ? scene.idx : target.sceneIdx);
  // per-scene-anim-style-2026-06-20 (Scott): also let the user pick the
  // ANIMATION style for THIS scene when regenerating its image. Default = the
  // scene's current style (per-scene override if set, else the post's style).
  const _curStyle = (idea.sceneEngines && idea.sceneEngines[sceneKey])
    || idea.animationStyle || 'ken_burns_local';
  const [animStyle, setAnimStyle] = React.useState(_curStyle);
  const totalUsd = Object.entries(engines).reduce((s, [k, v]) => s + (WB_KF_REGEN_COSTS[k] || 0.03) * ((v && v.count) || 1), 0);

  const toggleEngine = (k) => setEngines(prev => {
    const next = { ...prev };
    if (next[k]) {
      if (Object.keys(next).length === 1) return prev; // keep at least one
      delete next[k];
    } else {
      if (Object.keys(next).length >= 3) return prev;  // worker max 3
      next[k] = { count: 1 };
    }
    return next;
  });

  const doRewrite = () => {
    if (!notes.trim()) return;
    setPhase('rewriting');
    window.SK_DATA.regenSceneWithFeedback(idea.id, { scene_idx: sceneKey, feedback_text: notes.trim() })
      .then(r => { if (r && r.rewritten_prompt) setPrompt(r.rewritten_prompt); setPhase('edit'); })
      .catch(e => { if (window.flashToast) window.flashToast((e && e.message) || 'Rewrite failed.'); setPhase('edit'); });
  };

  const doCommit = () => {
    if (!live) { onConfirm(notes); return; }       // mock: legacy local append
    if (!prompt.trim()) { if (window.flashToast) window.flashToast('Prompt is empty.'); return; }
    setPhase('committing');
    window.SK_DATA.regenSceneWithFeedback(idea.id, {
      scene_idx: sceneKey, confirm: true,
      prompt: prompt.trim(), engine_configs: engines,
    })
      .then(() => {
        // per-scene-anim-style-2026-06-20: persist the chosen animation style
        // for THIS scene (sceneEngines override) when it differs from current.
        if (animStyle !== _curStyle && window.SK && typeof window.SK.saveSceneEngines === 'function') {
          const m = { ...((idea.sceneEngines) || {}), [sceneKey]: animStyle };
          return Promise.resolve(window.SK.saveSceneEngines(idea.id, m)).catch(() => {});
        }
      })
      .then(() => { onCommitted && onCommitted(); })
      .catch(e => { if (window.flashToast) window.flashToast((e && e.message) || 'Regen failed.'); setPhase('edit'); });
  };

  const candIdxLabel = (scene.candidates.findIndex(c => c.id === target.candId) + 1);
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 28,
      background: 'rgba(20,17,11,0.45)',
      display: 'flex', alignItems: 'flex-end',
    }} onClick={onCancel}>
      <div onClick={e => e.stopPropagation()}
        style={{
          width: '100%',
          background: w.paper,
          borderTopLeftRadius: 16, borderTopRightRadius: 16,
          padding: '16px 18px 24px',
        }}>
        <div style={{
          width: 32, height: 4, borderRadius: 2,
          background: w.rule, margin: '0 auto 14px',
        }} />
        <div style={{
          fontSize: 10, fontWeight: 700,
          letterSpacing: '0.16em', textTransform: 'uppercase',
          color: w.inkFaint, marginBottom: 4,
        }}>{scene.label} · candidate {candIdxLabel}</div>
        <h3 style={{
          fontSize: 17, fontWeight: 700,
          letterSpacing: '-0.02em',
          color: w.ink, margin: '0 0 4px',
        }}>Regenerate this image</h3>
        <p style={{
          fontSize: 12.5, color: w.inkDim,
          margin: '0 0 10px', lineHeight: 1.45,
        }}>Notes are optional — “✦ Rewrite” turns them into a new prompt. Pick the engine(s) that generate the fresh candidates (added beside the old ones).</p>

        {/* slideshow-regen-2026-06-22 (Scott): the ORIGINAL prompt, read-only, so
            it can be compared against the edited one below. */}
        {_origPrompt && (
          <div style={{ marginBottom: 10 }}>
            <label style={{ display: 'block', fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: w.inkFaint, marginBottom: 3 }}>Original prompt (for comparison)</label>
            <div style={{ background: w.bg, border: `1px solid ${w.rule}`, borderRadius: 6, padding: '8px 10px', color: w.inkDim, fontFamily: '"Geist Mono", monospace', fontSize: 11, lineHeight: 1.4, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 90, overflowY: 'auto' }}>{_origPrompt}</div>
          </div>
        )}

        <textarea data-no-swipe value={notes} onChange={e => setNotes(e.target.value)}
          rows={2} autoFocus
          placeholder="e.g. more dramatic lighting · keep the river angle · no people"
          style={{
            width: '100%',
            background: w.bg,
            border: `1px solid ${w.rule}`,
            borderRadius: 6,
            padding: '8px 10px',
            color: w.ink, fontFamily: 'inherit',
            fontSize: 13, lineHeight: 1.45,
            outline: 'none', resize: 'none',
            marginBottom: 6,
          }} />
        {live && (
          <button data-no-swipe onClick={doRewrite}
            disabled={!notes.trim() || phase !== 'edit'}
            style={{
              marginBottom: 10, height: 30, padding: '0 10px',
              background: 'transparent', color: notes.trim() ? w.accent : w.inkFaint,
              border: `1px solid ${w.rule}`, borderRadius: 5,
              fontFamily: 'inherit', fontSize: 11.5, fontWeight: 600,
              cursor: notes.trim() && phase === 'edit' ? 'pointer' : 'default',
            }}>{phase === 'rewriting' ? 'Rewriting…' : '✦ Rewrite prompt from notes (~$0.001)'}</button>
        )}

        {live && (
          <>
            <label style={{
              display: 'block', fontSize: 9, fontWeight: 700,
              letterSpacing: '0.12em', textTransform: 'uppercase',
              color: w.inkFaint, marginBottom: 3,
            }}>Image prompt (editable)</label>
            <textarea data-no-swipe value={prompt} onChange={e => setPrompt(e.target.value)}
              rows={4}
              style={{
                width: '100%',
                background: w.bg, border: `1px solid ${w.rule}`,
                borderRadius: 6, padding: '8px 10px',
                color: w.ink, fontFamily: 'inherit',
                fontSize: 12, lineHeight: 1.4,
                outline: 'none', resize: 'none', marginBottom: 10,
              }} />

            <label style={{
              display: 'block', fontSize: 9, fontWeight: 700,
              letterSpacing: '0.12em', textTransform: 'uppercase',
              color: w.inkFaint, marginBottom: 4,
            }}>Engines (same or different · max 3)</label>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10 }}>
              {WB_KF_REGEN_ENGINES.map(e => {
                const on = !!engines[e.key];
                return (
                  <button key={e.key} data-no-swipe onClick={() => toggleEngine(e.key)}
                    style={{
                      background: on ? w.accent : 'transparent',
                      color: on ? '#fff' : w.inkDim,
                      border: `1px solid ${on ? w.accent : w.rule}`,
                      borderRadius: 100, padding: '4px 10px',
                      fontFamily: 'inherit', fontSize: 10.5, fontWeight: on ? 600 : 500,
                      cursor: 'pointer', whiteSpace: 'nowrap',
                    }}>{e.label}</button>
                );
              })}
            </div>

            <label style={{
              display: 'block', fontSize: 9, fontWeight: 700,
              letterSpacing: '0.12em', textTransform: 'uppercase',
              color: w.inkFaint, marginBottom: 4,
            }}>Animation style for this scene</label>
            <select data-no-swipe value={animStyle} onChange={e => setAnimStyle(e.target.value)}
              style={{
                width: '100%', marginBottom: 10,
                background: w.bg, border: `1px solid ${animStyle !== _curStyle ? w.accent : w.rule}`,
                borderRadius: 6, padding: '8px 10px',
                color: w.ink, fontFamily: 'inherit', fontSize: 12, outline: 'none',
              }}>
              {WB_VIDEO_ENGINES.map(en => (
                <option key={en.value} value={en.value}>
                  {en.label} · {en.cost}{en.value === _curStyle ? ' (current)' : ''}
                </option>
              ))}
            </select>
          </>
        )}

        <div style={{
          fontSize: 10.5, color: w.inkFaint,
          fontFamily: '"Geist Mono", monospace',
          marginBottom: 12,
        }}>
          {live
            ? `~$${totalUsd.toFixed(3)} · 1 new candidate per engine · old candidates kept`
            : '~$0.04 · ~12s · Photon Flash'}
        </div>

        <div style={{ display: 'flex', gap: 6 }}>
          <button data-no-swipe onClick={onCancel}
            style={{
              flex: 1, height: 38,
              background: 'transparent',
              color: w.ink,
              border: `1px solid ${w.rule}`,
              borderRadius: 6,
              fontFamily: 'inherit', fontSize: 12.5, fontWeight: 500,
              cursor: 'pointer',
            }}>Cancel</button>
          <button data-no-swipe
            onClick={doCommit}
            disabled={phase === 'committing'}
            style={{
              flex: 2, height: 38,
              background: w.accent, color: '#fff',
              border: 'none', borderRadius: 6,
              fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
              cursor: phase === 'committing' ? 'default' : 'pointer',
              opacity: phase === 'committing' ? 0.7 : 1,
            }}>{phase === 'committing' ? 'Queuing…' : 'Regenerate →'}</button>
        </div>
      </div>
    </div>
  );
}

// ──────────────── AUDIO BODY (interactive, auto-save) ────────────────
function WBAudioBody({ idea, w, advanceIdea, setSheet }) {
  const mix = idea.mix || { master: 1.0, voice: 1.15, bg: 0.50, sfx: 0.35 };
  const sfxCues = (mix.sfxCues || idea.mix?.sfxCues) ?? defaultSfxCues();
  const [openCueIdx, setOpenCueIdx] = React.useState(null);
  const [playing, setPlaying] = React.useState(false);

  // ── Hidden <audio> mixer: voice + bg + one per sfx cue
  const voiceRef = React.useRef(null);
  const bgRef = React.useRef(null);
  const sfxRefs = React.useRef([]);
  const SK = window.SK;

  const voiceVol = (mix.voice ?? 1.0) * (mix.master ?? 1.0);
  const bgVol    = (mix.bg    ?? 0.5) * (mix.master ?? 1.0);
  const sfxBus   = (mix.sfx   ?? 0.35) * (mix.master ?? 1.0);

  // preview-parity-2026-06-12 (Scott approved): the renderer's final mix
  // multiplies EVERY track by BASELINE_MASTER_GAIN (video-bot/config.py —
  // keep in sync) and lets gains exceed 1.0, then runs an alimiter. HTML5
  // el.volume caps at 1.0 and has no baseline, so the Audio tab played a
  // QUIETER, differently-balanced mix than the final (the "SFX sounds louder
  // than I remember" bug). Route every stem through WebAudio gain nodes with
  // the same math + a limiter. Falls back to capped el.volume if WebAudio is
  // unavailable or the element's source is CORS-tainted.
  const SK_BASELINE_MASTER_GAIN = 1.3;
  const audioGraph = React.useRef(null);
  const _graph = () => {
    let g = audioGraph.current;
    if (!g) {
      const Ctx = window.AudioContext || window.webkitAudioContext;
      if (!Ctx) return null;
      const ctx = new Ctx();
      const limiter = ctx.createDynamicsCompressor();
      limiter.threshold.value = -1; limiter.knee.value = 0; limiter.ratio.value = 20;
      limiter.attack.value = 0.003; limiter.release.value = 0.05;
      limiter.connect(ctx.destination);
      g = audioGraph.current = { ctx, limiter, nodes: new WeakMap() };
    }
    if (g.ctx.state === 'suspended') { try { g.ctx.resume(); } catch (e) {} }
    return g;
  };
  const setTrackGain = (el, rawGain) => {
    if (!el) return;
    const gain = Math.max(0, rawGain) * SK_BASELINE_MASTER_GAIN;
    const g = _graph();
    if (!g) { el.volume = Math.min(1, gain); return; }
    let n = g.nodes.get(el);
    if (!n) {
      try {
        const src = g.ctx.createMediaElementSource(el);
        const gn = g.ctx.createGain();
        src.connect(gn); gn.connect(g.limiter);
        n = { gain: gn };
        g.nodes.set(el, n);
        el.volume = 1; // the graph owns loudness from here on
      } catch (e) {
        // already-connected or tainted element — capped fallback
        el.volume = Math.min(1, gain);
        return;
      }
    }
    n.gain.gain.value = gain;
  };

  // bind volumes live whenever mix changes
  React.useEffect(() => {
    setTrackGain(voiceRef.current, voiceVol);
    setTrackGain(bgRef.current, bgVol);
    sfxRefs.current.forEach((el, i) => {
      const cue = sfxCues[i];
      setTrackGain(el, (cue?.volume ?? 0.4) * sfxBus);
    });
  }, [voiceVol, bgVol, sfxBus, sfxCues]);

  // 2026-06-09 preview-timing fix: previously EVERY track (voice included)
  // looped and every SFX cue fired together at 0:00, so the preview didn't
  // resemble the real mix. Now: voice plays once from 0, BG loops under it,
  // each SFX fires at its cue.start; voice ending stops the preview.
  const sfxTimers = React.useRef([]);
  const stopAll = React.useCallback(() => {
    sfxTimers.current.forEach(t => clearTimeout(t));
    sfxTimers.current = [];
    [voiceRef.current, bgRef.current, ...sfxRefs.current].filter(Boolean).forEach(t => {
      try { t.pause && t.pause(); } catch (e) {}
    });
  }, []);

  const playPause = () => {
    const next = !playing;
    setPlaying(next);
    if (!next) { stopAll(); return; }
    _graph(); // preview-parity: resume the AudioContext on the user's gesture
    const v = voiceRef.current, b = bgRef.current;
    try { if (v) { v.currentTime = 0; v.play && v.play().catch(() => {}); } } catch (e) {}
    try { if (b) { b.currentTime = 0; b.play && b.play().catch(() => {}); } } catch (e) {}
    sfxTimers.current = sfxCues.map((cue, i) => setTimeout(() => {
      const el = sfxRefs.current[i];
      try { if (el) { el.currentTime = 0; el.play && el.play().catch(() => {}); } } catch (e) {}
    }, Math.max(0, Number(cue.start) || 0) * 1000));
  };

  // one-player-live-mix-2026-06-11 (Scott: "two preview spots, neither played;
  // I want to hear mix changes live"): ONE video player (the SILENT slideshow —
  // same frames + burned captions as the baked preview, no audio track) drives
  // the stem mixer. Video play/pause/seek/end events keep voice/BG/SFX in
  // sync, so dragging any slider mid-play is heard instantly and the picture
  // still proves words↔images alignment.
  const videoRef = React.useRef(null);
  const [playhead, setPlayhead] = React.useState(0);
  const [vidDur, setVidDur] = React.useState(0);
  const scheduleSfxFrom = (fromT) => {
    sfxTimers.current.forEach(t => clearTimeout(t));
    sfxTimers.current = sfxCues.map((cue, i) => {
      const dt = (Math.max(0, Number(cue.start) || 0)) - fromT;
      if (dt < 0) return null;
      return setTimeout(() => {
        const el = sfxRefs.current[i];
        try { if (el) { el.currentTime = 0; el.play && el.play().catch(() => {}); } } catch (e) {}
      }, dt * 1000);
    }).filter(Boolean);
  };
  const onVideoPlay = () => {
    setPlaying(true);
    _graph(); // preview-parity: resume the AudioContext on the user's gesture
    const t = videoRef.current ? videoRef.current.currentTime : 0;
    try { if (voiceRef.current) { voiceRef.current.currentTime = t; voiceRef.current.play().catch(() => {}); } } catch (e) {}
    try { if (bgRef.current) { bgRef.current.play().catch(() => {}); } } catch (e) {}
    scheduleSfxFrom(t);
  };
  const onVideoPause = () => { setPlaying(false); stopAll(); };
  const onVideoSeeked = () => {
    const vd = videoRef.current;
    if (!vd) return;
    const t = vd.currentTime;
    try { if (voiceRef.current) voiceRef.current.currentTime = t; } catch (e) {}
    if (!vd.paused) {
      try { if (voiceRef.current) voiceRef.current.play().catch(() => {}); } catch (e) {}
      scheduleSfxFrom(t);
    }
  };
  const onVideoEnded = () => { setPlaying(false); stopAll(); };

  // Voice ending only stops the MOCK preview; in live the video element is
  // the master clock (it may run a tail past the last word).
  const onVoiceEnded = () => {
    if (window.SK && window.SK.isLive) return;
    setPlaying(false); stopAll();
  };

  // bg-pick-wipe-fix-2026-06-11: when the BG pick changes, the <audio> src
  // changes and the browser resets the element to paused at volume defaults.
  // Re-apply the bus volume and, if the mix is mid-play, start the new track.
  React.useEffect(() => {
    const b = bgRef.current;
    if (!b) return;
    setTrackGain(b, (mix.bg ?? 0.5) * (mix.master ?? 1.0));
    if (playing) { try { b.play && b.play().catch(() => {}); } catch (e) {} }
  }, [mix.bgLibrarySha]);

  // pause + clear timers on unmount
  React.useEffect(() => () => {
    sfxTimers.current.forEach(t => clearTimeout(t));
    [voiceRef.current, bgRef.current, ...sfxRefs.current].filter(Boolean).forEach(t => {
      try { t.pause && t.pause(); } catch (e) {}
    });
    // preview-parity: release the audio graph with the sheet
    try { if (audioGraph.current) audioGraph.current.ctx.close(); } catch (e) {}
    audioGraph.current = null;
  }, []);

  const patchMix = (patch) => advanceIdea(idea.id, { mix: { ...mix, sfxCues, ...patch } });
  const setBus = (k, v) => setBusLive(k, v);
  function setBusLive(k, v) {
    // optimistic: update track gain immediately, then commit to state
    if (k === 'voice') setTrackGain(voiceRef.current, v * (mix.master ?? 1.0));
    if (k === 'bg')    setTrackGain(bgRef.current,    v * (mix.master ?? 1.0));
    if (k === 'sfx') {
      sfxRefs.current.forEach((el, i) => {
        setTrackGain(el, (sfxCues[i]?.volume ?? 0.4) * v * (mix.master ?? 1.0));
      });
    }
    if (k === 'master') {
      setTrackGain(voiceRef.current, (mix.voice ?? 1.0) * v);
      setTrackGain(bgRef.current,    (mix.bg ?? 0.5) * v);
      sfxRefs.current.forEach((el, i) => {
        setTrackGain(el, (sfxCues[i]?.volume ?? 0.4) * (mix.sfx ?? 0.35) * v);
      });
    }
    patchMix({ [k]: v });
  }
  const setCue = (i, patch) => {
    const next = sfxCues.map((c, ix) => ix === i ? { ...c, ...patch } : c);
    // live volume
    if ('volume' in patch && sfxRefs.current[i]) {
      setTrackGain(sfxRefs.current[i], (patch.volume ?? 0.4) * sfxBus);
    }
    advanceIdea(idea.id, { mix: { ...mix, sfxCues: next } });
  };
  const addCue = () => {
    const next = [...sfxCues, {
      i: sfxCues.length,
      prompt: 'new sfx cue',
      start: 0, duration: 1.0, volume: 0.4,
    }];
    advanceIdea(idea.id, { mix: { ...mix, sfxCues: next } });
    setOpenCueIdx(next.length - 1);
  };
  const removeCue = (i) => {
    const next = sfxCues.filter((_, ix) => ix !== i);
    advanceIdea(idea.id, { mix: { ...mix, sfxCues: next } });
    if (openCueIdx === i) setOpenCueIdx(null);
  };

  const buses = [
    { key: 'voice', label: 'Voice' },
    { key: 'bg',    label: 'BG' },
    { key: 'sfx',   label: 'SFX' },
  ];
  return (
    <div>
      {/* hidden audio elements — sources omitted in mock mode so the
          browser doesn't fall back to fetching the page URL */}
      {/* 2026-06-09: voice plays ONCE (ending stops the preview); only BG loops;
          SFX are one-shots scheduled at their cue.start. */}
      {/* preview-parity-2026-06-12: crossOrigin=anonymous required or WebAudio
          mutes cross-origin media (auth rides the jwt query param, not cookies). */}
      {SK?.voiceAudioUrl(idea.id) ? <audio ref={voiceRef} crossOrigin="anonymous" src={SK.voiceAudioUrl(idea.id)} preload={(SK && SK.isLive) ? 'auto' : 'none'} onEnded={onVoiceEnded} /> : <audio ref={voiceRef} preload="none" onEnded={onVoiceEnded} />}
      {/* bg-pick-race-fix-2026-06-11 (3rd Scott report — "changed BG, didn't
          change"): /videos/{id}/audio/bg resolves the pick by READING D1 at
          fetch time, but the element refetches the instant the optimistic
          patch lands — BEFORE the library-set-bg POST has written D1 — so the
          worker served the previous track. Race eliminated: when a library
          track is picked, stream it DIRECTLY by sha via the library preview
          route (no D1 read involved). The baked /audio/bg file is only used
          when nothing is picked. */}
      {(() => {
        const _sha = mix.bgLibrarySha || null;
        const _bgSrc = (_sha && SK?.mediaUrl)
          ? SK.mediaUrl('/audio-library/preview/bg/' + _sha)
          : (SK?.bgAudioUrl ? SK.bgAudioUrl(idea.id) : null);
        return _bgSrc
          ? <audio ref={bgRef} crossOrigin="anonymous" src={_bgSrc} preload={(SK && SK.isLive) ? 'auto' : 'none'} loop />
          : <audio ref={bgRef} preload="none" loop />;
      })()}
      {sfxCues.map((cue, i) => {
        const url = SK?.sfxAudioUrl(idea.id, i);
        const setRef = el => sfxRefs.current[i] = el;
        return url
          ? <audio key={i} ref={setRef} crossOrigin="anonymous" src={url} preload={(SK && SK.isLive) ? 'auto' : 'none'} />
          : <audio key={i} ref={setRef}             preload="none" />;
      })}

      {/* one-player-live-mix-2026-06-11 (replaces the baked alignment video +
          separate black audio monitor + the paid $0.005 re-mux button): ONE
          player — silent slideshow video (same frames + burned captions, so
          alignment is still checkable) with voice/BG/SFX stems mixed live in
          the browser. Mix changes are FREE: sliders auto-save to the row and
          bake into the next render; only word changes cost (Save text +
          rebuild voice). */}
      {window.SK && window.SK.isLive && (
        <div style={{ marginBottom: 10 }}>
          <div style={{ display: 'flex', gap: 10 }}>
            {/* data-no-swipe: lets the native video scrubber work without the
                bottom-sheet stealing the horizontal drag (2026-06-11). */}
            <video ref={videoRef} controls playsInline preload="metadata" data-no-swipe
              src={window.SK.previewVideoUrl ? window.SK.previewVideoUrl(idea.id) : undefined}
              onPlay={onVideoPlay} onPause={onVideoPause} onSeeked={onVideoSeeked}
              onEnded={onVideoEnded}
              onTimeUpdate={() => { if (videoRef.current) setPlayhead(videoRef.current.currentTime); }}
              onLoadedMetadata={() => { if (videoRef.current) setVidDur(videoRef.current.duration || 0); }}
              style={{
                width: 170, aspectRatio: '9/16', flexShrink: 0,
                borderRadius: 6, background: '#000', objectFit: 'cover',
              }} />
            <div style={{
              flex: 1, display: 'flex', flexDirection: 'column', gap: 8,
              fontSize: 11.5, color: w.inkDim, lineHeight: 1.45,
            }}>
              <div>
                <span style={{ color: w.ink, fontWeight: 600 }}>Live mix:</span> press play,
                then drag any slider or move a cue — you hear it instantly.
                Captions are burned in, so check words landing on the right images here too.
              </div>
              <div style={{
                fontSize: 10, color: w.inkFaint,
                fontFamily: '"Geist Mono", monospace',
              }}>mix auto-saves · free · baked into the next render automatically{'\n'}word changes = “Save text + rebuild voice”</div>
            </div>
          </div>
          {vidDur > 0 && (
            <WBSfxTimeline w={w} dur={vidDur} playhead={playhead} cues={sfxCues}
              onMove={(i, newStart) => setCue(i, { start: newStart })}
              onTap={(i) => setOpenCueIdx(openCueIdx === i ? null : i)}
              onSeek={(t) => {
                const vd = videoRef.current;
                if (vd) { try { vd.currentTime = Math.max(0, Math.min(t, (vd.duration || t))); } catch (e) {} }
              }} />
          )}
          <div style={{
            marginTop: 4, fontSize: 9.5, color: w.inkFaint,
            fontFamily: '"Geist Mono", monospace',
          }}>tap or drag the bar to jump · drag a numbered chip to move that sound</div>
        </div>
      )}

      {/* 2026-06-10 (Scott's flow correction): the ANIMATION engine is picked
          HERE — after you've seen the keyframes, before the paid Promote.
          Saves scene_engines + idea_vendor; the Promote button reflects cost. */}
      <div style={{
        marginBottom: 10, padding: '8px 10px',
        background: w.bg, borderRadius: 5,
        border: `1px solid ${w.ruleSoft}`,
      }}>
        <label style={{
          display: 'block', fontSize: 9, fontWeight: 700,
          letterSpacing: '0.12em', textTransform: 'uppercase',
          color: w.inkDim, marginBottom: 4,
        }}>Animation engine · used by Promote</label>
        <select data-no-swipe
          value={WB_VIDEO_ENGINES.some(en => en.value === idea.animationStyle) ? idea.animationStyle : 'ken_burns_local'}
          onChange={e => advanceIdea(idea.id, { imageEngine: e.target.value, animationStyle: e.target.value })}
          style={{
            width: '100%', background: w.raised,
            border: `1px solid ${w.rule}`, borderRadius: 5,
            padding: '6px 8px', fontFamily: 'inherit', fontSize: 12.5,
            color: w.ink, cursor: 'pointer', outline: 'none',
          }}>
          {WB_VIDEO_ENGINES.map(c => (
            <option key={c.value} value={c.value}>{c.label} — {c.cost === 'free' ? 'free' : c.cost} · {c.blurb}</option>
          ))}
        </select>
        <div style={{
          marginTop: 5, fontSize: 10, color: w.inkFaint,
          fontFamily: '"Geist Mono", monospace', lineHeight: 1.5,
        }}>price = your {idea.sceneCount || 12} scenes + title, animated at that engine's per-clip rate · “off-peak” = the vendor's cheap hours, so those renders can queue instead of starting instantly</div>
      </div>

      {/* play/pause + library shortcut. one-player-live-mix-2026-06-11: in
          live mode the video element above is the only transport — the old ▶
          audio-only monitor was Scott's confusing "black tap-to-preview".
          Mock mode (no video) keeps it. */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        marginBottom: 10,
      }}>
        {!(window.SK && window.SK.isLive) && (
        <button data-no-swipe onClick={playPause}
          title={playing ? 'Pause mix' : 'Play mix'}
          style={{
            width: 30, height: 30, borderRadius: 6,
            background: playing ? w.accent : w.ink,
            color: '#fff', border: 'none', cursor: 'pointer',
            fontFamily: 'inherit', fontSize: 12,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            flexShrink: 0,
          }}>{playing ? '❚❚' : '▶'}</button>
        )}
        <span style={{
          flex: 1, fontSize: 10.5, color: w.inkFaint,
          fontFamily: '"Geist Mono", monospace',
        }}>
          {(window.SK && window.SK.isLive)
            ? (playing ? 'mixing live · sliders apply instantly' : 'press play on the video to monitor the mix')
            : (playing ? 'monitoring · 3-bus + cues' : 'tap to preview')}
        </span>
        <button data-no-swipe
          onClick={() => setSheet && setSheet({ kind: 'audio_library', ideaId: idea.id })}
          title="Browse SFX + BG library"
          style={{
            background: 'transparent', color: w.inkDim,
            border: `1px solid ${w.rule}`, borderRadius: 4,
            padding: '4px 8px', fontSize: 10.5,
            fontFamily: 'inherit', cursor: 'pointer',
          }}>⌗ Library</button>
      </div>

      {/* buses */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        {buses.map(b => (
          <WBSlider key={b.key} w={w}
            label={b.label}
            value={mix[b.key] ?? 1.0}
            onChange={(v) => setBus(b.key, v)} />
        ))}
      </div>

      {/* BG sound */}
      <div style={{
        marginTop: 10,
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '8px 10px',
        background: w.bg, borderRadius: 5,
        border: `1px solid ${w.ruleSoft}`,
        fontSize: 11, color: w.inkDim,
      }}>
        <span style={{
          width: 18, height: 18, borderRadius: 4,
          background: 'linear-gradient(135deg, #6b3a8a, #c46939)',
          color: '#fff', fontFamily: '"Geist Mono", monospace',
          fontSize: 11, fontWeight: 600,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          flexShrink: 0,
        }}>♪</span>
        <span style={{
          flex: 1, minWidth: 0,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
          fontFamily: '"Geist Mono", monospace', fontSize: 11,
          color: w.ink,
        }}>{idea.mix?.bgPrompt || 'low strings, slow march, foreboding'}</span>
        {/* bg-picker-2026-06-11: Edit opens the LIBRARY (BG tab, categories +
            search) — the free-text score-prompt sheet confused Scott as the
            default; it's now the "custom prompt" link inside the library. */}
        <button data-no-swipe
          onClick={() => setSheet && setSheet({ kind: 'audio_library', ideaId: idea.id, tab: 'bg' })}
          style={{
            background: 'transparent', color: w.inkDim,
            border: `1px solid ${w.rule}`, borderRadius: 4,
            padding: '2px 8px', fontSize: 10.5,
            fontFamily: 'inherit', cursor: 'pointer',
          }}>Edit</button>
      </div>

      {/* SFX cues */}
      <div style={{ marginTop: 10 }}>
        <div style={{
          display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
          marginBottom: 4,
        }}>
          <span style={{
            fontSize: 10, fontWeight: 700,
            letterSpacing: '0.10em', textTransform: 'uppercase',
            color: w.inkDim,
          }}>SFX cues</span>
          <button data-no-swipe onClick={addCue}
            style={{
              background: 'transparent', color: w.accent,
              border: 'none', padding: 0,
              fontFamily: '"Geist Mono", monospace',
              fontSize: 10.5, fontWeight: 600,
              cursor: 'pointer', letterSpacing: '0.02em',
            }}>+ add cue</button>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
          {sfxCues.length === 0 && (
            <div style={{
              padding: '8px 10px',
              border: `1px dashed ${w.ruleSoft}`,
              borderRadius: 4,
              color: w.inkFaint, fontSize: 11, fontStyle: 'italic',
            }}>No SFX cues yet — add one above.</div>
          )}
          {sfxCues.map((cue, i) => (
            <WBSfxRow key={i} w={w} num={i + 1} cue={cue}
              isOpen={openCueIdx === i}
              onToggle={() => setOpenCueIdx(openCueIdx === i ? null : i)}
              onChange={(p) => setCue(i, p)}
              onRemove={() => removeCue(i)} />
          ))}
        </div>
      </div>
    </div>
  );
}

function defaultSfxCues() {
  return [
    { prompt: 'wind sweep across snowy plain', start: 2.0,  duration: 1.6, volume: 0.32 },
    { prompt: 'horse hoof on hard ground',     start: 14.4, duration: 0.8, volume: 0.46 },
    { prompt: 'coin metal clink',              start: 28.0, duration: 0.5, volume: 0.38 },
    { prompt: 'distant trumpet, single note',  start: 44.5, duration: 2.0, volume: 0.42 },
  ];
}

function WBSfxRow({ w, num, cue, isOpen, onToggle, onChange, onRemove }) {
  const colors = [w.accent, w.success, w.amber, '#7a3a55', '#3a627a'];
  const tint = colors[(num - 1) % colors.length];
  const volPct = Math.round(cue.volume * 100);
  const meterPct = Math.min(100, (cue.volume / 1.5) * 100); // 150% max
  return (
    <div style={{
      background: isOpen ? w.bg : 'transparent',
      border: `1px solid ${isOpen ? w.rule : w.ruleSoft}`,
      borderRadius: 4, overflow: 'hidden',
    }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 6,
        padding: '5px 8px',
      }}>
        <button data-no-swipe onClick={onToggle}
          style={{
            flex: 1, minWidth: 0, padding: 0,
            display: 'flex', alignItems: 'center', gap: 6,
            background: 'transparent', border: 'none', cursor: 'pointer',
            fontFamily: 'inherit', textAlign: 'left',
          }}>
          <span style={{
            width: 18, height: 18, borderRadius: 3, background: tint, color: '#fff',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontFamily: '"Geist Mono", monospace',
            fontSize: 9, fontWeight: 700, flexShrink: 0,
          }}>{num}</span>
          <span style={{
            flex: 1, minWidth: 0,
            fontSize: 11, color: w.ink, lineHeight: 1.3,
            // cue-editor-legibility-2026-06-12 (Scott: "can't read what it
            // says, it gets cut off"): 2-line clamp instead of 1-line ellipsis.
            display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
            overflow: 'hidden',
          }}>{cue.prompt}</span>
          <span style={{
            fontFamily: '"Geist Mono", monospace',
            fontSize: 9.5, color: w.inkFaint,
            fontVariantNumeric: 'tabular-nums',
            whiteSpace: 'nowrap',
          }}>{cue.start.toFixed(1)}s</span>
        </button>

        {/* Inline volume slider — adjustable without expanding.
            cue-editor-legibility-2026-06-12: was 56x18px — too small to grab.
            Now 100x30 with the same meter styling. */}
        <div data-no-swipe style={{
          display: 'flex', alignItems: 'center', gap: 5,
          flexShrink: 0,
        }}>
          <div style={{
            position: 'relative', width: 100, height: 30,
            display: 'flex', alignItems: 'center',
          }}>
            {/* meter track */}
            <div style={{
              position: 'absolute', left: 0, right: 0, top: '50%',
              height: 4, marginTop: -2,
              background: w.ruleSoft, borderRadius: 2,
              pointerEvents: 'none',
            }} />
            {/* meter fill */}
            <div style={{
              position: 'absolute', left: 0, top: '50%',
              height: 4, marginTop: -2,
              width: `${meterPct}%`,
              background: cue.volume > 1 ? w.urgent : tint,
              borderRadius: 2,
              pointerEvents: 'none',
              transition: 'width 80ms linear',
            }} />
            {/* 100% reference tick */}
            <div style={{
              position: 'absolute', left: `${(1 / 1.5) * 100}%`,
              top: 2, bottom: 2, width: 1,
              background: w.rule,
              pointerEvents: 'none',
            }} />
            <input data-no-swipe type="range"
              min={0} max={1.5} step={0.01} value={cue.volume}
              onChange={e => onChange({ volume: parseFloat(e.target.value) })}
              onClick={e => e.stopPropagation()}
              style={{
                position: 'absolute', inset: 0,
                width: '100%', height: '100%',
                margin: 0, padding: 0,
                opacity: 0, cursor: 'pointer',
                WebkitAppearance: 'none',
              }} />
          </div>
          <span style={{
            fontFamily: '"Geist Mono", monospace',
            fontSize: 9.5,
            color: cue.volume > 1 ? w.urgent : w.inkFaint,
            fontVariantNumeric: 'tabular-nums',
            fontWeight: 600,
            width: 26, textAlign: 'right',
          }}>{volPct}</span>
        </div>

        <button data-no-swipe onClick={onToggle}
          style={{
            background: 'transparent', border: 'none',
            color: w.inkFaint, fontSize: 11, padding: '0 2px',
            cursor: 'pointer', fontFamily: 'inherit',
            transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
            transition: 'transform 0.16s',
            flexShrink: 0,
          }}>›</button>
      </div>
      {isOpen && (
        <div style={{
          padding: '6px 10px 8px', borderTop: `1px solid ${w.ruleSoft}`,
        }}>
          <label style={{
            display: 'block', fontSize: 9, fontWeight: 700,
            letterSpacing: '0.12em', textTransform: 'uppercase',
            color: w.inkFaint, marginBottom: 3,
          }}>Prompt</label>
          <textarea data-no-swipe rows={2}
            value={cue.prompt}
            onChange={e => onChange({ prompt: e.target.value })}
            style={{
              width: '100%', boxSizing: 'border-box', resize: 'vertical',
              background: w.raised, border: `1px solid ${w.rule}`,
              borderRadius: 4, padding: '5px 8px',
              fontFamily: 'inherit', fontSize: 12, lineHeight: 1.4,
              color: w.ink, outline: 'none',
              marginBottom: 8,
            }} />

          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
            <WBNumberField w={w} label="Start" suffix="s" step={0.1}
              value={cue.start} onChange={v => onChange({ start: v })} />
            <WBNumberField w={w} label="Dur" suffix="s" step={0.1}
              value={cue.duration} onChange={v => onChange({ duration: v })} />
          </div>

          <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
            <button data-no-swipe onClick={onRemove}
              style={{
                background: 'transparent',
                color: w.urgent,
                border: 'none', padding: '2px 4px',
                fontFamily: '"Geist Mono", monospace',
                fontSize: 10.5, fontWeight: 600,
                cursor: 'pointer', letterSpacing: '0.04em',
              }}>Remove cue</button>
          </div>
        </div>
      )}
    </div>
  );
}

function WBNumberField({ w, label, value, onChange, suffix, step = 1, pct = false }) {
  const display = pct ? Math.round(value * 100) : value.toFixed(1);
  const onInput = (v) => {
    const n = parseFloat(v);
    if (Number.isNaN(n)) return;
    onChange(pct ? n / 100 : n);
  };
  return (
    <div>
      <label style={{
        display: 'block', fontSize: 9, fontWeight: 700,
        letterSpacing: '0.12em', textTransform: 'uppercase',
        color: w.inkFaint, marginBottom: 3,
      }}>{label}</label>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 0,
        background: w.raised, border: `1px solid ${w.rule}`,
        borderRadius: 4, padding: '0 6px', height: 28,
      }}>
        <input data-no-swipe
          type="number" step={pct ? step : step} value={display}
          onChange={e => onInput(e.target.value)}
          style={{
            flex: 1, minWidth: 0,
            background: 'transparent', border: 'none',
            fontFamily: '"Geist Mono", monospace',
            fontSize: 12, color: w.ink, outline: 'none',
            padding: '0', textAlign: 'right',
            fontVariantNumeric: 'tabular-nums',
            appearance: 'textfield',
          }} />
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 11, color: w.inkFaint, marginLeft: 2,
        }}>{suffix}</span>
      </div>
    </div>
  );
}

function WBSlider({ w, label, value, onChange }) {
  const pct = Math.round(value * 100);
  return (
    <div data-no-swipe>
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        marginBottom: 3,
      }}>
        <span style={{
          fontSize: 11, fontWeight: 600,
          color: w.ink, letterSpacing: '-0.005em',
        }}>{label}</span>
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 10.5,
          color: pct > 100 ? w.urgent : w.inkDim,
          fontVariantNumeric: 'tabular-nums', fontWeight: 600,
        }}>{pct}%</span>
      </div>
      <input data-no-swipe type="range" min={0} max={2} step={0.01} value={value}
        onChange={e => onChange(parseFloat(e.target.value))}
        style={{
          width: '100%', height: 4,
          accentColor: w.accent, cursor: 'pointer',
        }} />
    </div>
  );
}

// Wired video-engine catalog (the renderer keys, REAL per-video costs).
// Veo 3.1 is intentionally absent — it is NOT wired in the worker.
// Single-select: ONE engine governs the whole video. Sorted LOW→HIGH by
// total per-video cost; the free Ken Burns stays first/default. `clip_len_s`
// is the per-scene clip length (null = stills, no i2v). Engine keys MUST
// match the rest of the app exactly (worker _FINALIZE_WIRED_VIDEO_ENGINES).
// ──────────────── SFX CUE TIMELINE (2026-06-11) ────────────────
// dev1 had draggable SFX cues on a strip under the player; this is the dev2
// take: full-width track, a playhead that tracks the video, one chip per cue.
//   - tap empty track  → seek the video there
//   - drag empty track → scrub the video
//   - drag a chip      → move its start time (commits on release → saveMix)
//   - tap a chip       → open that cue's editor below
// drag-rewrite-2026-06-11 (Scott: "can't move the slider / can't move chips"):
// the old version captured the pointer on the CHIP but listened for move/up on
// the TRACK — pointer-capture routes events to the chip, so the track handlers
// never fired and nothing moved. Now move/up live on window for the duration of
// a drag (the only reliable pattern inside the swipe-sheet). data-no-swipe +
// the parent's swipe-ignore list keep the bottom-sheet from stealing the drag.
function WBSfxTimeline({ w, dur, playhead, cues, onMove, onTap, onSeek }) {
  const trackRef = React.useRef(null);
  const [drag, setDrag] = React.useState(null); // {kind:'chip'|'seek', i?, start?, moved, grabT, origStart?}
  const dragRef = React.useRef(null);
  dragRef.current = drag;

  const tFromClientX = (clientX) => {
    const r = trackRef.current ? trackRef.current.getBoundingClientRect() : null;
    if (!r || !r.width) return 0;
    return Math.min(Math.max(0, (clientX - r.left) / r.width), 1) * dur;
  };

  // window-level move/up while a drag is active — survives pointer capture and
  // works for touch + mouse alike.
  React.useEffect(() => {
    if (!drag) return;
    const onMoveWin = (ev) => {
      const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
      const t = tFromClientX(clientX);
      const d = dragRef.current;
      if (!d) return;
      if (ev.cancelable) ev.preventDefault();
      if (d.kind === 'seek') {
        onSeek && onSeek(t);
        setDrag({ ...d, moved: true });
      } else {
        const ns = Math.min(Math.max(0, d.origStart + (t - d.grabT)), Math.max(0, dur - 0.3));
        setDrag({ ...d, start: ns, moved: d.moved || Math.abs(t - d.grabT) > 0.12 });
      }
    };
    const onUpWin = () => {
      const d = dragRef.current;
      if (d && d.kind === 'chip') {
        if (d.moved) onMove(d.i, Math.round(d.start * 10) / 10);
        else onTap(d.i);
      }
      setDrag(null);
    };
    window.addEventListener('pointermove', onMoveWin, { passive: false });
    window.addEventListener('pointerup', onUpWin);
    window.addEventListener('pointercancel', onUpWin);
    return () => {
      window.removeEventListener('pointermove', onMoveWin);
      window.removeEventListener('pointerup', onUpWin);
      window.removeEventListener('pointercancel', onUpWin);
    };
  }, [drag && drag.kind, drag && drag.i]);

  const chipDown = (i) => (e) => {
    e.preventDefault(); e.stopPropagation();
    const grabT = tFromClientX(e.clientX);
    const orig = Math.max(0, Number(cues[i]?.start) || 0);
    setDrag({ kind: 'chip', i, grabT, origStart: orig, start: orig, moved: false });
  };
  const trackDown = (e) => {
    // empty-track press = seek immediately, then allow scrubbing
    e.preventDefault();
    const t = tFromClientX(e.clientX);
    onSeek && onSeek(t);
    setDrag({ kind: 'seek', moved: false });
  };

  return (
    <div data-no-swipe ref={trackRef} onPointerDown={trackDown}
      style={{
        position: 'relative', height: 38, marginTop: 8,
        background: w.bg, border: `1px solid ${w.ruleSoft}`, borderRadius: 5,
        overflow: 'hidden', touchAction: 'none', cursor: 'pointer',
      }}>
      {/* playhead (with a fat grab cap so it reads as draggable) */}
      <div style={{
        position: 'absolute', top: 0, bottom: 0,
        left: `${Math.min(100, (dur ? (playhead / dur) : 0) * 100)}%`,
        width: 2, background: w.accent, opacity: 0.95, pointerEvents: 'none',
      }}>
        <div style={{
          position: 'absolute', top: -1, left: -4, width: 10, height: 6,
          borderRadius: 2, background: w.accent,
        }} />
      </div>
      {cues.map((cue, i) => {
        const isDragging = drag && drag.kind === 'chip' && drag.i === i;
        const start = isDragging ? drag.start : Math.max(0, Number(cue.start) || 0);
        const cw = Math.max(5, (Math.max(0.6, Number(cue.duration) || 1) / (dur || 1)) * 100);
        const left = Math.min(100 - cw, (start / (dur || 1)) * 100);
        return (
          <div key={i} data-no-swipe
            onPointerDown={chipDown(i)}
            title={`${cue.prompt || 'sfx'} @ ${start.toFixed(1)}s — drag to move · tap to edit`}
            style={{
              position: 'absolute', top: 4, bottom: 4,
              left: `${left}%`, width: `${cw}%`, minWidth: 22,
              background: isDragging ? w.accent : w.raised,
              color: isDragging ? '#fff' : w.inkDim,
              border: `1px solid ${isDragging ? w.accent : w.rule}`,
              borderRadius: 4, cursor: 'grab', touchAction: 'none',
              fontFamily: '"Geist Mono", monospace', fontSize: 9.5, fontWeight: 700,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              userSelect: 'none', overflow: 'hidden', zIndex: 2,
            }}>{i + 1}</div>
        );
      })}
    </div>
  );
}

// engine-blurbs-2026-06-11 (Scott: "users don't care about parallax — tell
// them what it's BEST AT, and explain off-peak"). Keep blurbs ≤ ~45 chars so
// the native <option> line stays readable. Costs are the whole-video estimate
// for the standard 12 scenes + title at that engine's per-clip rate.
const WB_VIDEO_ENGINES = [
  { value: 'ken_burns_local',           label: 'Ken Burns',            blurb: 'free slow pan/zoom — safe, no surprises',     cost: 'free',   clip_len_s: null, fast: true, motion: 'pan'   },
  { value: 'kling_v2_1_std_720p_5s',    label: 'Kling Std 5s',         blurb: 'best for people + animals, natural moves',    cost: '$1.63',  clip_len_s: 5,    motion: 'wave'  },
  { value: 'kling_v2_1_std_720p_10s',   label: 'Kling Std 10s',        blurb: 'same look, longer takes (fewer cuts)',        cost: '$1.75',  clip_len_s: 10,   motion: 'wave'  },
  { value: 'vidu_q3_turbo_offpeak',     label: 'Vidu Turbo (off-peak)',blurb: 'cheapest real motion; renders in discount hours so it can wait in line', cost: '$1.95', clip_len_s: 5, motion: 'pulse' },
  { value: 'luma_api_ray2flash_9s',     label: 'Luma Ray Flash 9s',    blurb: 'slow drifting camera, moody scenes, long takes', cost: '$3.08', clip_len_s: 9,   motion: 'flow'  },
  { value: 'luma_api_ray2flash',        label: 'Luma Ray Flash 5s',    blurb: 'slow drifting camera, moody/scenic shots',    cost: '$3.12',  clip_len_s: 5,    motion: 'flow'  },
  { value: 'runway_i2v_gen4_720p_3s',   label: 'Runway 3s',            blurb: 'punchy action in quick cuts',                 cost: '$3.15',  clip_len_s: 3,    motion: 'wave'  },
  { value: 'vidu_q3_pro_offpeak',       label: 'Vidu Pro (off-peak)',  blurb: 'sharper than Turbo; same discount-hours wait', cost: '$3.25', clip_len_s: 5,    motion: 'pulse' },
  { value: 'runway_i2v_gen4_720p_5s',   label: 'Runway 5s',            blurb: 'best for action — battles, storms, fire',     cost: '$3.25',  clip_len_s: 5,    motion: 'wave'  },
  { value: 'kling_v2_1_pro_1080p_5s',   label: 'Kling Pro 5s',         blurb: 'sharpest faces/people, full-HD',              cost: '$3.25',  clip_len_s: 5,    motion: 'wave'  },
  { value: 'runway_i2v_gen4_720p_10s',  label: 'Runway 10s',           blurb: 'action look, longer takes',                   cost: '$3.50',  clip_len_s: 10,   motion: 'wave'  },
  { value: 'kling_v2_1_pro_1080p_10s',  label: 'Kling Pro 10s',        blurb: 'sharpest + longest takes, full-HD',           cost: '$3.50',  clip_len_s: 10,   motion: 'wave'  },
];

// ──────────────── KEYFRAME IMAGE ENGINE PICKER (Ideas stage, 2026-06-10) ────────────────
// dev1 parity (Scott's flow correction): at the Ideas stage you choose the
// IMAGE generators that produce keyframe candidates — multi-select, with
// per-title and per-scene candidate counts. The ANIMATION engine is chosen
// later (Audio/A-V stage, before Promote). Keys MUST be in the worker's
// validKeyframeVendors set; persisted via /ideas/{id}/save-edits
// {engine_configs: {key: {title: N, scene: N}}} — renderer already honors it.
// CANONICAL renderer engine keys (robot catches #3/#4, 2026-06-10): the
// renderer selects engines from keyframe_vendor and looks counts up in
// keyframe_engine_configs by the SAME key. Short aliases like 'photon_flash'
// pass save-edits validation but never match the renderer's engine loop
// ('luma_api_photon_flash'), so the config was silently ignored (1/1 counts).
const WB_KEYFRAME_ENGINES = [
  { key: 'luma_api_photon_flash', label: 'Photon Flash', blurb: 'fast · painterly',      cost: '~$0.03/img' },
  { key: 'imagen',                label: 'Imagen 4',     blurb: 'Google · high realism', cost: '~$0.04/img' },
  { key: 'runway_api_gen4_720p',  label: 'Runway Gen-4', blurb: 'best at people',        cost: '~$0.05/img' },
];
// Legacy short keys saved before the canonical-key fix.
const WB_KF_LEGACY_KEYS = { photon_flash: 'luma_api_photon_flash', runway_api_gen4_turbo: 'runway_api_gen4_720p' };

// curate-vendor-label-2026-06-13: canonical engine key -> friendly vendor name
// for the Curate candidate badges. Hoisted (function decl) so WBCandidate,
// defined earlier in the file, can call it. Unknown keys (e.g. nano_banana_2)
// degrade to a cleaned-up version of the key instead of breaking.
function kfEngineLabel(key) {
  if (!key) return '';
  const canon = WB_KF_LEGACY_KEYS[key] || key;
  const m = WB_KEYFRAME_ENGINES.find(e => e.key === canon);
  if (m) return m.label;
  return String(canon)
    .replace(/^luma_api_/, '').replace(/^runway_api_/, '')
    .replace(/_/g, ' ')
    .replace(/\bgen4 720p\b/i, 'Gen-4')
    .replace(/\bnano banana 2\b/i, 'Nano-Banana')
    .replace(/\b\w/g, c => c.toUpperCase())
    .trim();
}

function WBKeyframeEnginePicker({ idea, w, advanceIdea, flashToast }) {
  // Renderer default when nothing saved: Photon Flash, 1 title + 1 per scene.
  const saved = {};
  Object.entries(idea.engineConfigs || {}).forEach(([k, v]) => { saved[WB_KF_LEGACY_KEYS[k] || k] = v; });
  const active = Object.keys(saved).length ? saved : { luma_api_photon_flash: { title: 1, scene: 1 } };

  // RACE FIX (2026-06-10, Scott: "picked 3 engines, only 2 saved"): rapid
  // toggles each computed `next` from the PROP rendered before the previous
  // click's state landed — the last save silently dropped earlier additions
  // (same stale-state class as the advanceIdea race). Track the live map in
  // a ref so every click builds on the latest, not the last-rendered, state.
  const activeRef = React.useRef(active);
  React.useEffect(() => { activeRef.current = active; });

  const save = (next) => {
    activeRef.current = next;
    advanceIdea(idea.id, { engineConfigs: next }); // optimistic local mirror
    if (window.SK && window.SK.isLive && window.SK_DATA && typeof window.SK_DATA.saveEdits === 'function') {
      // keyframe_vendors picks WHICH engines run; engine_configs sets counts.
      window.SK_DATA.saveEdits(idea.id, { engine_configs: next, keyframe_vendors: Object.keys(next) })
        .catch(e => { if (typeof window.flashToast === 'function') window.flashToast((e && e.message) || 'Engine config save failed.'); });
    }
  };
  const toggle = (key) => {
    const next = { ...activeRef.current };
    if (next[key]) {
      if (Object.keys(next).length === 1) { flashToast && flashToast('Keep at least one image engine.'); return; }
      delete next[key];
    } else {
      next[key] = { title: 1, scene: 1 };
    }
    save(next);
  };
  const bump = (key, field, delta) => {
    const cur = activeRef.current[key];
    if (!cur) return;
    const v = Math.max(1, Math.min(10, (Number(cur[field]) || 1) + delta));
    save({ ...activeRef.current, [key]: { ...cur, [field]: v } });
  };
  const Stepper = ({ label, value, onMinus, onPlus }) => (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
      <span style={{ fontSize: 9, color: w.inkFaint, textTransform: 'uppercase', letterSpacing: '0.08em' }}>{label}</span>
      <button data-no-swipe onClick={(e) => { e.stopPropagation(); onMinus(); }} style={{
        width: 18, height: 18, borderRadius: 3, background: w.raised, border: `1px solid ${w.rule}`,
        color: w.ink, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, lineHeight: 1, padding: 0,
      }}>−</button>
      <span style={{ fontFamily: '"Geist Mono", monospace', fontSize: 11, fontWeight: 600, minWidth: 14, textAlign: 'center', color: w.ink }}>{value}</span>
      <button data-no-swipe onClick={(e) => { e.stopPropagation(); onPlus(); }} style={{
        width: 18, height: 18, borderRadius: 3, background: w.raised, border: `1px solid ${w.rule}`,
        color: w.ink, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, lineHeight: 1, padding: 0,
      }}>+</button>
    </span>
  );

  return (
    <div>
      <div style={{
        fontSize: 9, fontWeight: 700,
        letterSpacing: '0.12em', textTransform: 'uppercase',
        color: w.inkDim, marginBottom: 5,
      }}>Keyframe image engines · candidates per slide</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
        {WB_KEYFRAME_ENGINES.map(e => {
          const cfg = active[e.key];
          const on = !!cfg;
          return (
            <div key={e.key} style={{
              background: on ? w.bg : 'transparent',
              border: `1px solid ${on ? w.accent : w.rule}`,
              borderRadius: 5, padding: '7px 9px',
            }}>
              <div data-no-swipe role="button" onClick={() => toggle(e.key)}
                style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
                <span style={{
                  width: 14, height: 14, borderRadius: 3, flexShrink: 0,
                  border: `1.5px solid ${on ? w.accent : w.rule}`,
                  background: on ? w.accent : 'transparent',
                  color: '#fff', fontSize: 9, fontWeight: 700,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                }}>{on ? '✓' : ''}</span>
                <span style={{ fontSize: 12.5, fontWeight: 600, color: w.ink }}>{e.label}</span>
                <span style={{ fontSize: 10, color: w.inkFaint }}>{e.blurb}</span>
                <span style={{ flex: 1 }} />
                <span style={{ fontFamily: '"Geist Mono", monospace', fontSize: 9.5, color: w.inkDim }}>{e.cost}</span>
              </div>
              {on && (
                <div style={{ display: 'flex', gap: 14, marginTop: 6, paddingLeft: 22 }}>
                  <Stepper label="title" value={cfg.title || 1}
                    onMinus={() => bump(e.key, 'title', -1)} onPlus={() => bump(e.key, 'title', +1)} />
                  <Stepper label="per scene" value={cfg.scene || 1}
                    onMinus={() => bump(e.key, 'scene', -1)} onPlus={() => bump(e.key, 'scene', +1)} />
                </div>
              )}
            </div>
          );
        })}
      </div>
      <div style={{
        marginTop: 6, fontSize: 10, color: w.inkFaint,
        fontFamily: '"Geist Mono", monospace',
      }}>
        → animation engine is chosen later, at the Audio stage, before Promote
      </div>
    </div>
  );
}

// ──────────────── IMAGE / VIDEO ENGINE PICKER (Ideas stage — C2) ────────────────
// One engine for the whole video, chosen up front. Persists via
// saveSceneEngines (scene_engines_json), and mirrors animationStyle locally so
// the later Animation stage shows the same selection. Real costs, no Veo.
function WBImageEnginePicker({ idea, w, advanceIdea }) {
  const current = idea.animationStyle || 'ken_burns_local';
  const pick = (v) => {
    if (v === current) return;
    // Route to saveSceneEngines (via imageEngine) AND keep animationStyle in
    // sync for display — one engine governs the entire video.
    advanceIdea(idea.id, { imageEngine: v, animationStyle: v });
  };
  return (
    <div>
      <div style={{
        fontSize: 9, fontWeight: 700,
        letterSpacing: '0.12em', textTransform: 'uppercase',
        color: w.inkDim, marginBottom: 5,
      }}>Video engine</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
        {WB_VIDEO_ENGINES.map(c => {
          const active = c.value === current;
          return (
            <button key={c.value} data-no-swipe onClick={() => pick(c.value)}
              style={{
                width: '100%',
                background: active ? w.bg : 'transparent',
                border: `1px solid ${active ? w.accent : w.rule}`,
                borderRadius: 5,
                padding: '7px 9px',
                cursor: 'pointer',
                fontFamily: 'inherit', textAlign: 'left',
                display: 'flex', alignItems: 'center', gap: 10,
              }}>
              <div style={{
                width: 36, height: 28,
                borderRadius: 3, overflow: 'hidden',
                background: w.ink, position: 'relative', flexShrink: 0,
              }}>
                <WBMotionPreview w={w} kind={c.motion} active={active} />
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
                  <span style={{
                    fontSize: 12.5, fontWeight: 600,
                    color: w.ink, letterSpacing: '-0.005em',
                  }}>{c.label}</span>
                  <span style={{ fontSize: 10, color: w.inkFaint }}>{c.blurb}</span>
                </div>
                <div style={{
                  marginTop: 1,
                  fontFamily: '"Geist Mono", monospace',
                  fontSize: 9.5,
                  color: c.fast ? w.success : w.inkDim,
                  fontVariantNumeric: 'tabular-nums',
                }}>{c.fast ? 'instant · ' : ''}{c.cost}{!c.fast ? ' · full render' : ''}</div>
              </div>
              <span style={{
                width: 14, height: 14, borderRadius: '50%',
                border: `1.5px solid ${active ? w.accent : w.rule}`,
                background: active ? w.accent : 'transparent',
                color: '#fff', fontSize: 8, fontWeight: 700,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                flexShrink: 0,
              }}>{active ? '✓' : ''}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ──────────────── ANIMATION BODY = CLIP REVIEW (2026-06-10) ────────────────
// dev1-parity (Scott's flow correction): by the time a row reaches Animation,
// the clips are RENDERED (Promote already happened at Audio). This stage is
// for REVIEWING each animated clip and regenerating any that look wrong —
// not for picking an engine (that moved to the Audio stage). Mirrors dev1's
// Animate tab: per-slot <video> from /videos/{id}/clips/{idx} + ↻ regen
// (archives the old clip server-side, re-renders just that scene).
function WBAnimationBody({ idea, w, advanceIdea, flashToast }) {
  const count = idea.sceneCount
    || (Array.isArray(idea.scenes) ? idea.scenes.filter(s => s.key !== 'title').length : 0)
    || 12;
  const slots = ['title', ...Array.from({ length: count }, (_, i) => String(i))];
  const done = idea.clipsDone || [];
  const eng = WB_VIDEO_ENGINES.find(c => c.value === (idea.animationStyle || 'ken_burns_local'));
  const [regenning, setRegenning] = React.useState({});

  const regen = (slot) => {
    if (!window.SK || typeof window.SK.regenClip !== 'function') return;
    setRegenning(p => ({ ...p, [slot]: true }));
    window.SK.regenClip(idea.id, slot)
      .then(() => { flashToast && flashToast(`Clip ${slot === 'title' ? 'T' : slot} queued — re-rendering…`); })
      .catch(() => { setRegenning(p => ({ ...p, [slot]: false })); });
  };

  return (
    <div>
      <div style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        marginBottom: 6,
      }}>
        <span style={{
          fontSize: 10, fontWeight: 700,
          letterSpacing: '0.10em', textTransform: 'uppercase',
          color: w.inkDim,
        }}>Animated clips · {eng ? eng.label : '—'}</span>
        <span style={{
          fontFamily: '"Geist Mono", monospace', fontSize: 10.5, fontWeight: 600,
          color: done.length >= slots.length ? w.success : w.accent,
          fontVariantNumeric: 'tabular-nums',
        }}>{done.length}/{slots.length}</span>
      </div>
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6,
      }}>
        {slots.map(slot => {
          const ready = done.includes(slot);
          const busy = !!regenning[slot];
          return (
            <div key={slot} style={{
              border: `1px solid ${w.ruleSoft}`, borderRadius: 5,
              padding: 4, background: w.bg,
            }}>
              <div style={{
                fontFamily: '"Geist Mono", monospace', fontSize: 9, fontWeight: 700,
                color: ready ? w.success : w.inkFaint, marginBottom: 3,
                display: 'flex', justifyContent: 'space-between', alignItems: 'center',
              }}>
                <span>{slot === 'title' ? 'T' : slot.padStart(2, '0')}</span>
                <button data-no-swipe title="Regenerate this clip"
                  onClick={() => regen(slot)}
                  disabled={!ready || busy}
                  style={{
                    background: 'transparent', border: `1px solid ${w.rule}`,
                    borderRadius: 3, color: busy ? w.inkFaint : w.inkDim,
                    fontFamily: 'inherit', fontSize: 9, padding: '1px 5px',
                    cursor: ready && !busy ? 'pointer' : 'default',
                    opacity: ready && !busy ? 1 : 0.5,
                  }}>{busy ? 'queued' : '↻'}</button>
              </div>
              {ready ? (
                <video controls playsInline preload="metadata"
                  src={window.SK && window.SK.clipUrl ? window.SK.clipUrl(idea.id, slot) : undefined}
                  style={{
                    width: '100%', aspectRatio: '9/16', display: 'block',
                    borderRadius: 3, background: '#000', objectFit: 'cover',
                  }} />
              ) : (
                <div style={{
                  width: '100%', aspectRatio: '9/16', borderRadius: 3,
                  background: w.paper, border: `1px dashed ${w.rule}`,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  fontSize: 9, color: w.inkFaint,
                  fontFamily: '"Geist Mono", monospace', textAlign: 'center',
                }}>rendering…</div>
              )}
            </div>
          );
        })}
      </div>
      <div style={{
        marginTop: 8, fontSize: 10, color: w.inkFaint,
        fontFamily: '"Geist Mono", monospace',
      }}>
        ↻ re-renders ONE clip with the same engine (~1/12 of the render cost) ·
        “Finalise animation” composites the final video from these clips (free)
      </div>
    </div>
  );
}

// Mini motion-preview tile — animated CSS to evoke each style
function WBMotionPreview({ w, kind, active }) {
  const common = {
    position: 'absolute', inset: 0,
  };
  if (kind === 'pan') {
    // Ken Burns — wide gradient that slowly pans
    return (
      <div style={{
        ...common,
        background: 'linear-gradient(90deg, #3b5a7a, #6b4a3a, #3b5a7a)',
        backgroundSize: '300% 100%',
        animation: 'wbPan 3.6s linear infinite',
      }} />
    );
  }
  if (kind === 'wave') {
    return (
      <div style={common}>
        <div style={{
          position: 'absolute', inset: 0,
          background: 'linear-gradient(135deg, #4a5a7a 0%, #7a4a5a 100%)',
        }} />
        <div style={{
          position: 'absolute', inset: 0,
          background: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 3px, transparent 3px 9px)',
          animation: 'wbWave 1.4s linear infinite',
        }} />
      </div>
    );
  }
  if (kind === 'flow') {
    return (
      <div style={common}>
        <div style={{
          position: 'absolute', inset: 0,
          background: 'radial-gradient(circle at 30% 50%, #5a7a8a 0%, #3a4a6a 60%, #2a2a3a 100%)',
        }} />
        <div style={{
          position: 'absolute', top: '40%', left: '-30%',
          width: '60%', height: '20%',
          background: 'radial-gradient(ellipse, rgba(255,255,255,0.42) 0%, transparent 70%)',
          animation: 'wbFlow 2.4s ease-in-out infinite',
        }} />
      </div>
    );
  }
  if (kind === 'pulse') {
    return (
      <div style={common}>
        <div style={{
          position: 'absolute', inset: 0,
          background: 'linear-gradient(180deg, #2a2a3a, #5a3a3a)',
        }} />
        <div style={{
          position: 'absolute', top: '50%', left: '50%',
          width: '60%', height: '60%',
          transform: 'translate(-50%, -50%)',
          borderRadius: '50%',
          background: 'radial-gradient(circle, rgba(255,200,150,0.7) 0%, transparent 70%)',
          animation: 'wbPulse 1.6s ease-in-out infinite',
        }} />
      </div>
    );
  }
  return null;
}

// ──────────────── EMPTY ────────────────
function WBEmpty({ w, bucket }) {
  const m = {
    you:    { title: 'Inbox zero.',   sub: 'Nothing waiting on you right now.' },
    flight: { title: 'No background jobs.', sub: 'When you approve an idea, the renderer claims it here.' },
    done:   { title: 'No live videos yet.', sub: 'Published shorts land here.' },
  }[bucket] || { title: 'Empty.', sub: '' };
  return (
    <div style={{
      padding: '48px 16px', textAlign: 'center',
    }}>
      <div style={{
        fontSize: 15, fontWeight: 600, color: w.ink,
        marginBottom: 4,
      }}>{m.title}</div>
      <div style={{
        fontSize: 12, color: w.inkDim, lineHeight: 1.5,
        maxWidth: 240, margin: '0 auto',
      }}>{m.sub}</div>
    </div>
  );
}

// ──────────────── KEBAB MENU ────────────────
function WBKebabMenu({ w, idea, onClose, onDelete, onDuplicate, onChangeVoice, onViewJson, onRegenTranscript, onMarkPosted }) {
  React.useEffect(() => {
    const handler = (e) => {
      if (!e.target.closest('[data-wb-menu]')) onClose();
    };
    setTimeout(() => document.addEventListener('click', handler), 0);
    return () => document.removeEventListener('click', handler);
  }, [onClose]);

  const live = !!(window.SK && window.SK.isLive);
  // publish-flow-2026-06-12 (Scott): the final video is rendered — regenerating
  // the transcript or changing the voice here can't take effect without a full
  // send-back, and "Mark posted" is only honest AFTER publish prep ran.
  const locked = idea.stage === 'final' || idea.stage === 'published';
  const items = [
    // complete-all-3-2026-06-12: the kebab "Mark posted (TT + YT)" is gone —
    // posting is per-platform on the locked Ready card now.
    // facts-slideshow-2026-06-20 (Scott): no narration/voice for slideshows.
    ...((!locked && idea.postType !== 'facts_slideshow') ? [
      { label: 'Regenerate transcript', onClick: () => { onClose(); onRegenTranscript && onRegenTranscript(); } },
      { label: 'Change voice…',         onClick: () => { onClose(); onChangeVoice && onChangeVoice(); } },
    ] : []),
    // Duplicate has no server verb — the local copy vanished on the next poll
    // in live mode, so it's mock-only now (2026-06-09).
    ...(!live ? [{ label: 'Duplicate', onClick: () => { onClose(); onDuplicate && onDuplicate(); } }] : []),
    { label: 'View raw JSON',         onClick: () => { onClose(); onViewJson && onViewJson(); } },
    { divider: true },
    { label: 'Delete', danger: true,  onClick: () => { onClose(); onDelete && onDelete(); } },
  ];
  return (
    <div data-wb-menu style={{
      position: 'absolute', right: 0, bottom: 42,
      background: w.raised,
      border: `1px solid ${w.rule}`,
      borderRadius: 8,
      boxShadow: '0 12px 28px -8px rgba(20,17,11,0.22)',
      padding: 4,
      minWidth: 180,
      zIndex: 20,
    }}>
      {items.map((it, i) => it.divider ? (
        <div key={i} style={{ height: 1, background: w.ruleSoft, margin: '4px 6px' }} />
      ) : (
        <button key={i} data-no-swipe onClick={it.onClick}
          style={{
            display: 'block', width: '100%', textAlign: 'left',
            background: 'transparent', border: 'none',
            padding: '7px 10px', borderRadius: 5,
            fontFamily: 'inherit', fontSize: 12,
            color: it.danger ? w.urgent : w.ink,
            cursor: 'pointer',
          }}
          onMouseEnter={e => e.currentTarget.style.background = w.bg}
          onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
          {it.label}
        </button>
      ))}
    </div>
  );
}

// ──────────────── INLINE EDITOR ────────────────
function WBInlineEditor({ idea, w, advanceIdea, flashToast }) {
  const [title, setTitle] = React.useState(idea.title);
  const [subtitle, setSubtitle] = React.useState(idea.subtitle || '');
  const [transcript, setTranscript] = React.useState(idea.transcript || '');
  const [voiceId, setVoiceId] = React.useState(idea.voiceId);

  // 2026-06-09 stale-overwrite fix: the editor seeded from the idea ONCE, so a
  // server-side change (e.g. "Regenerate transcript") never reached an open
  // editor — and the next keystroke auto-saved the stale full object back over
  // it. Adopt server changes per-field, but only for fields the user hasn't
  // locally diverged on (local still equals the last-seen server value).
  const serverRef = React.useRef({
    title: idea.title, subtitle: idea.subtitle || '',
    transcript: idea.transcript || '', voiceId: idea.voiceId,
  });
  React.useEffect(() => {
    const prev = serverRef.current;
    const next = {
      title: idea.title, subtitle: idea.subtitle || '',
      transcript: idea.transcript || '', voiceId: idea.voiceId,
    };
    if (next.title !== prev.title && title === prev.title) setTitle(next.title);
    if (next.subtitle !== prev.subtitle && subtitle === prev.subtitle) setSubtitle(next.subtitle);
    if (next.transcript !== prev.transcript && transcript === prev.transcript) setTranscript(next.transcript);
    if (next.voiceId !== prev.voiceId && voiceId === prev.voiceId) setVoiceId(next.voiceId);
    serverRef.current = next;
  }, [idea.title, idea.subtitle, idea.transcript, idea.voiceId]);

  // Auto-save (debounced) — no Save button. Skips first render.
  // 2026-06-10 (robot catch #5): /ideas/{id}/save-edits only matches
  // Ideas-stage rows — for Audio/preview rows it 404s, so the auto-save can
  // NEVER work there. At those stages edits stay local and persist via the
  // explicit "Save text + rebuild voice" button (which now carries voice_id).
  const _saveViaRebuildOnly = idea.stage === 'audio' || idea.stage === 'preview';
  const first = React.useRef(true);
  React.useEffect(() => {
    if (first.current) { first.current = false; return; }
    if (_saveViaRebuildOnly) return;
    const t = setTimeout(() => {
      advanceIdea(idea.id, { title, subtitle, transcript, voiceId });
    }, 350);
    return () => clearTimeout(t);
  }, [title, subtitle, transcript, voiceId, advanceIdea, idea.id, _saveViaRebuildOnly]);

  const inputStyle = {
    width: '100%',
    background: w.raised,
    border: `1px solid ${w.rule}`,
    borderRadius: 5,
    padding: '7px 9px',
    color: w.ink,
    fontFamily: 'inherit',
    fontSize: 12.5, lineHeight: 1.45,
    outline: 'none',
    resize: 'none',
  };
  const labelStyle = {
    display: 'block',
    fontSize: 9, fontWeight: 700,
    letterSpacing: '0.14em', textTransform: 'uppercase',
    color: w.inkDim, marginBottom: 4,
  };

  return (
    <div style={{
      marginTop: 14, paddingTop: 14,
      borderTop: `1px dashed ${w.rule}`,
    }}>
      <div style={{
        display: 'flex', justifyContent: 'space-between',
        alignItems: 'baseline', marginBottom: 8,
      }}>
        <span style={{
          fontSize: 10, fontWeight: 700,
          letterSpacing: '0.14em', textTransform: 'uppercase',
          color: w.inkDim,
        }}>Edit metadata</span>
        <span style={{
          fontFamily: '"Geist Mono", monospace',
          fontSize: 9.5, color: w.inkFaint,
        }}>auto-saves</span>
      </div>

      <label style={labelStyle}>Title</label>
      <input data-no-swipe value={title} onChange={e => setTitle(e.target.value)} style={{ ...inputStyle, marginBottom: 10 }} />

      <label style={labelStyle}>Subtitle</label>
      <textarea data-no-swipe value={subtitle} onChange={e => setSubtitle(e.target.value)} rows={2} style={{ ...inputStyle, marginBottom: 10 }} />

      {/* facts-slideshow-2026-06-20 (Scott): slideshows have NO narration/voice. */}
      {idea.postType !== 'facts_slideshow' && (<>
      <label style={labelStyle}>Transcript</label>
      <textarea data-no-swipe value={transcript} onChange={e => setTranscript(e.target.value)} rows={5} style={{ ...inputStyle, marginBottom: 10 }} />

      <label style={labelStyle}>Voice</label>
      <div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
        <select data-no-swipe value={voiceId} onChange={e => setVoiceId(e.target.value)}
          style={{ ...inputStyle, flex: 1, appearance: 'none', backgroundImage: 'none', cursor: 'pointer' }}>
          {VOICES.map(v => (
            <option key={v.id} value={v.id}>{v.name} — {v.category} · {v.accent}</option>
          ))}
        </select>
        {/* 2026-06-09: hear the selected voice right where it's picked — the
            sample only existed (dead) in the kebab Voice sheet before. */}
        <button data-no-swipe type="button"
          onClick={() => {
            const v = VOICES.find(x => x.id === voiceId);
            const key = (v && v.elevenId) || voiceId;
            if (!window.SK || !window.SK.mediaUrl) return;
            if (!window.__skVoiceSampleEl) window.__skVoiceSampleEl = new Audio();
            const el = window.__skVoiceSampleEl;
            if (!el.paused && el.dataset.vid === voiceId) { el.pause(); return; }
            el.onerror = () => { if (typeof window.flashToast === 'function') window.flashToast('No sample available for this voice yet.'); };
            el.src = window.SK.mediaUrl('/voice-sample/' + encodeURIComponent(key));
            el.dataset.vid = voiceId;
            try { el.play && el.play().catch(() => {}); } catch (e) {}
          }}
          title="Hear a sample of this voice"
          style={{
            flexShrink: 0, width: 40,
            background: w.bg, color: w.ink,
            border: `1px solid ${w.rule}`, borderRadius: 5,
            fontFamily: 'inherit', fontSize: 13, cursor: 'pointer',
          }}>▶</button>
      </div>
      </>)}

      {/* H5 (2026-06-03): text edits auto-save, but the VOICE only regenerates
          on an explicit rebuild. In the Audio stage (worker build_status='preview')
          this commits the current text + re-synthesises the narration via
          save-text-and-rebuild. Only meaningful once a preview render exists. */}
      {(idea.stage === 'audio' || idea.stage === 'preview') && (
        <button data-no-swipe
          onClick={() => {
            if (window.SK && typeof window.SK.saveTextAndRebuild === 'function') {
              // voice_id included (2026-06-10) — the only path that persists a
              // voice change at this stage; the rebuild re-synthesizes with it.
              const p = window.SK.saveTextAndRebuild(idea.id, { title, subtitle, transcript, voice_id: voiceId });
              if (p && typeof p.then === 'function') {
                p.then(() => flashToast && flashToast('Rebuilding voice…'))
                 .catch(() => {});
              } else {
                flashToast && flashToast('Rebuilding voice…');
              }
            } else {
              flashToast && flashToast('Rebuilding voice…');
            }
          }}
          style={{
            marginTop: 12,
            width: '100%', height: 34,
            background: w.accent, color: '#fff',
            border: 'none', borderRadius: 6,
            fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
            cursor: 'pointer',
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
          }}>
          ↻ Save text + rebuild voice
        </button>
      )}
    </div>
  );
}

// ──────────────── FAB ────────────────
function WBFab({ w, onClick }) {
  return (
    <button data-no-swipe onClick={onClick}
      title="Seed a new idea"
      style={{
        position: 'absolute',
        bottom: 22, right: 18,
        width: 52, height: 52,
        borderRadius: 14,
        background: w.ink, color: '#fff',
        border: 'none', cursor: 'pointer',
        fontFamily: 'inherit',
        fontSize: 22, fontWeight: 300, lineHeight: 1,
        boxShadow: '0 12px 24px -6px rgba(20,17,11,0.32)',
        zIndex: 9,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>+</button>
  );
}

// ──────────────── TOAST ────────────────
function WBToast({ toast, w }) {
  if (!toast) return null;
  return (
    <div style={{
      position: 'absolute', left: 16, right: 88, bottom: 28,
      background: w.ink, color: '#fff',
      borderRadius: 8,
      padding: '10px 14px',
      fontSize: 12.5,
      fontFamily: 'inherit',
      boxShadow: '0 14px 30px rgba(20,17,11,0.34)',
      zIndex: 10,
      display: 'flex', alignItems: 'center', gap: 8,
    }}>
      <span style={{
        width: 6, height: 6, borderRadius: '50%',
        background: w.accent,
        boxShadow: `0 0 5px ${w.accent}`,
      }} />
      <span>{toast.text}</span>
    </div>
  );
}

// ──────────────── NEW IDEA SHEET ────────────────
function WBNewSheet({ w, onClose, onCreate }) {
  const [transcript, setTranscript] = React.useState('');
  // facts-slideshow-2026-06-20: post type + slide count.
  const [postType, setPostType] = React.useState('ai_video');
  const [slideCount, setSlideCount] = React.useState(6);
  const isSlideshow = postType === 'facts_slideshow' || postType === 'random_slideshow';
  const isRandom = postType === 'random_slideshow';

  const inputStyle = {
    width: '100%',
    background: w.bg,
    border: `1px solid ${w.rule}`,
    borderRadius: 6,
    padding: '8px 10px',
    color: w.ink,
    fontFamily: 'inherit',
    fontSize: 13, lineHeight: 1.45,
    outline: 'none',
    resize: 'none',
  };
  const labelStyle = {
    display: 'block',
    fontSize: 9, fontWeight: 700,
    letterSpacing: '0.14em', textTransform: 'uppercase',
    color: w.inkDim, marginBottom: 5,
  };

  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 30,
      background: 'rgba(20,17,11,0.32)',
      display: 'flex', alignItems: 'flex-end',
    }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()}
        style={{
          width: '100%',
          background: w.paper,
          borderTopLeftRadius: 16, borderTopRightRadius: 16,
          padding: '18px 18px 28px',
          maxHeight: '88%', overflow: 'auto',
        }}>
        <div style={{
          width: 32, height: 4, borderRadius: 2,
          background: w.rule,
          margin: '0 auto 14px',
        }} />
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
          marginBottom: 14,
        }}>
          <h3 style={{
            fontSize: 17, fontWeight: 700,
            letterSpacing: '-0.02em',
            color: w.ink, margin: 0,
          }}>Seed a new idea</h3>
          <button data-no-swipe onClick={onClose}
            style={{
              background: 'transparent', border: 'none',
              color: w.inkDim, fontSize: 18, cursor: 'pointer',
              fontFamily: 'inherit', padding: 0,
            }}>✕</button>
        </div>

        {/* facts-slideshow-2026-06-20: post-type toggle. Slideshow reuses the
            video pipeline up to Curate, then composes fact text over the picked
            images (images-only, no voice/animate). */}
        <label style={labelStyle}>Post type</label>
        <div style={{ display: 'flex', gap: 6, marginBottom: 14 }}>
          {[['ai_video', 'AI Video'], ['facts_slideshow', 'Slideshow'], ['random_slideshow', 'Random Slideshow']].map(([v, lbl]) => (
            <button key={v} data-no-swipe onClick={() => setPostType(v)}
              style={{
                flex: 1, height: 38, padding: '0 4px',
                background: postType === v ? w.ink : 'transparent',
                color: postType === v ? '#fff' : w.inkDim,
                border: `1px solid ${postType === v ? w.ink : w.rule}`,
                borderRadius: 7, fontFamily: 'inherit', fontSize: 11, fontWeight: postType === v ? 600 : 500,
                cursor: 'pointer', lineHeight: 1.1,
              }}>{lbl}</button>
          ))}
        </div>

        {!isRandom && (<>
          <label style={labelStyle}>{isSlideshow ? 'Slideshow topic' : 'Topic / idea'}</label>
          <textarea data-no-swipe value={transcript} onChange={e => setTranscript(e.target.value)}
            rows={3} placeholder={isSlideshow ? 'e.g. deep sea creatures' : 'e.g. the size of the universe'}
            style={{ ...inputStyle, marginBottom: 6 }} />
        </>)}
        <div style={{ fontSize: 11, color: w.inkDim, marginBottom: 12 }}>
          {isRandom ? 'No topic needed — a random trivia theme + facts are chosen for you.'
            : isSlideshow ? 'Give a topic — facts + images are generated; you pick the best image per slide in Curate.'
            : 'Just give a topic — title, script, source, and voice are all chosen for you.'}
        </div>

        {isSlideshow && (
          <div style={{ marginBottom: 12 }}>
            <label style={labelStyle}>Number of facts (slides)</label>
            <div style={{ display: 'flex', gap: 6 }}>
              {[4, 5, 6, 7, 8].map(n => (
                <button key={n} data-no-swipe onClick={() => setSlideCount(n)}
                  style={{
                    width: 42, height: 34,
                    background: slideCount === n ? w.accent : 'transparent',
                    color: slideCount === n ? '#fff' : w.inkDim,
                    border: `1px solid ${slideCount === n ? w.accent : w.rule}`,
                    borderRadius: 7, fontFamily: 'inherit', fontSize: 12, fontWeight: 600, cursor: 'pointer',
                  }}>{n}</button>
              ))}
            </div>
          </div>
        )}

        <div style={{ display: 'flex', gap: 8, marginTop: 18 }}>
          <button data-no-swipe onClick={onClose}
            style={{
              flex: 1, height: 40,
              background: 'transparent',
              color: w.ink,
              border: `1px solid ${w.rule}`,
              borderRadius: 7,
              fontFamily: 'inherit', fontSize: 13, fontWeight: 500,
              cursor: 'pointer',
            }}>Cancel</button>
          <button data-no-swipe
            onClick={() => onCreate({ prompt: transcript.trim(), postType, mode: isRandom ? 'random' : 'themed', slideCount })}
            disabled={!isRandom && !transcript.trim()}
            style={{
              flex: 2, height: 40,
              background: (isRandom || transcript.trim()) ? w.ink : w.rule,
              color: '#fff',
              border: 'none',
              borderRadius: 7,
              fontFamily: 'inherit', fontSize: 13, fontWeight: 600,
              cursor: (isRandom || transcript.trim()) ? 'pointer' : 'not-allowed',
            }}>{isSlideshow ? 'Generate slideshow →' : 'Generate idea →'}</button>
        </div>
      </div>
    </div>
  );
}

// ──────────────── OVERFLOW MENU ────────────────
function WBMenu({ w, onClose, onPick }) {
  // 2026-06-09: Deploy log / Settings / Switch-to-Prod are mock-only sheets
  // (hardcoded shas, dead toggles, fake renderer status) — hidden in live so
  // they can't be mistaken for real state. Sign out hits the real CF Access
  // logout in live.
  const live = !!(window.SK && window.SK.isLive);
  const items = [
    { key: 'library',  label: 'Library',        icon: '⌗' },
    { key: 'audio_library', label: 'Audio library', icon: '♪' },
    { key: 'log',      label: 'Audit log',      icon: '≡' },
    ...(!live ? [
      { key: 'deploy',   label: 'Deploy log',     icon: '◷' },
      { key: 'settings', label: 'Settings',       icon: '⚙' },
    ] : []),
    { divider: true },
    ...(!live ? [{ key: 'prod', label: 'Switch to Prod', icon: '↗' }] : []),
    { key: 'signout',  label: 'Sign out',       icon: '←' },
  ];
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 30,
      background: 'rgba(20,17,11,0.30)',
      display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
      paddingTop: 60, paddingRight: 16,
    }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()}
        style={{
          width: 200,
          background: w.raised,
          border: `1px solid ${w.rule}`,
          borderRadius: 10,
          boxShadow: '0 14px 30px rgba(20,17,11,0.22)',
          padding: 6,
        }}>
        {items.map((it, i) => it.divider ? (
          <div key={i} style={{ height: 1, background: w.ruleSoft, margin: '4px 6px' }} />
        ) : (
          <button key={i} data-no-swipe
            onClick={() => {
              if (it.key === 'signout') {
                onClose();
                // Real CF Access logout in live (2026-06-09); no-op in mock.
                if (window.SK && window.SK.isLive) window.location.href = '/cdn-cgi/access/logout';
                return;
              }
              onPick(it.key);
            }}
            style={{
              display: 'flex', alignItems: 'center', gap: 10,
              width: '100%', textAlign: 'left',
              background: 'transparent', border: 'none',
              padding: '8px 10px', borderRadius: 6,
              fontFamily: 'inherit', fontSize: 12.5,
              color: w.ink, cursor: 'pointer',
            }}
            onMouseEnter={e => e.currentTarget.style.background = w.bg}
            onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
            <span style={{
              width: 14, color: w.inkFaint, fontFamily: '"Geist Mono", monospace',
              fontSize: 11, textAlign: 'center',
            }}>{it.icon}</span>
            <span>{it.label}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

Object.assign(window, {
  WorkbenchShell,
  WB_STAGE_META, WB_PIPELINE, bucketFor,
  workbenchPalette,
});

// Inject stripe-animation keyframes once
(function injectKeyframes() {
  if (document.getElementById('wb-keyframes')) return;
  const style = document.createElement('style');
  style.id = 'wb-keyframes';
  style.textContent = `
    @keyframes wbBarStripe {
      0% { background-position: 0 0; }
      100% { background-position: 16px 0; }
    }
    @keyframes wbPan {
      0%   { background-position: 0% 50%; }
      100% { background-position: 100% 50%; }
    }
    @keyframes wbWave {
      0%   { background-position: 0 0; }
      100% { background-position: 24px 0; }
    }
    @keyframes wbFlow {
      0%   { transform: translateX(0); opacity: 0.4; }
      50%  { transform: translateX(140%); opacity: 1; }
      100% { transform: translateX(280%); opacity: 0.4; }
    }
    @keyframes wbPulse {
      0%, 100% { transform: translate(-50%, -50%) scale(0.85); opacity: 0.65; }
      50%      { transform: translate(-50%, -50%) scale(1.10); opacity: 1; }
    }
  `;
  document.head.appendChild(style);
})();
