// Scenes.jsx — multi-scene management for Block Builder
//
// A "scene" snapshots the editable state of the builder:
//   { id, name, kind, blocks, accent, externals, canvas, github? }
// Scenes live in localStorage under "bb.scenes.v1" and are auto-saved
// whenever any of the underlying state changes.
//
// Exports (attached to window):
//   useScenes()               → React hook returning { scenes, activeId,
//                               active, setActiveId, createManual,
//                               createFromGithub, renameScene, deleteScene,
//                               patchActive }
//   <SceneTabs ... />         → top-bar pill tabs + "+" with popdown menu
//   <SceneTransitions ... />  → small "scene transitions" panel shown
//                               above Canvas/Layers/Export when >1 scene
//
// The hook is the single source of truth: App.jsx mirrors `active.*`
// into its own state on scene-switch, and pipes state changes back
// through patchActive(...) so persistence is automatic.

const { useState: _sUseState, useEffect: _sUseEffect, useRef: _sUseRef, useMemo: _sUseMemo, useCallback: _sUseCallback } = React;

// localStorage keys are namespaced by auth mode + userId so guest sessions
// can never see a previous OAuth user's cached scenes (and two different
// OAuth users on the same browser stay isolated from each other too).
//   guest:  bb.scenes.v1::guest
//   oauth:  bb.scenes.v1::user.<userId>
//   bypass: bb.scenes.v1::user.local-dev
function _ns() {
  const mode = window.BB_AUTH_MODE;
  if (mode === 'guest') return 'guest';
  const userId = window.BB_USER?.user || (mode === 'bypass' ? 'local-dev' : 'anonymous');
  return `user.${userId}`;
}
function _key(base) { return `bb.${base}.v1::${_ns()}`; }
const SCENES_KEY      = () => _key('scenes');
const ACTIVE_KEY      = () => _key('scenes.active');
const TRANSITIONS_KEY = () => _key('scenes.transitions');

// One-time migration from the legacy un-namespaced keys (v=11 and earlier)
// AND from the buggy v=13 user.anonymous namespace (which was created when the
// migration accidentally ran before AuthGate set BB_AUTH_MODE). We only
// migrate INTO an authenticated namespace — never into 'guest', so a previous
// user's local cache doesn't leak into a guest session.
function _migrateLegacyKeysOnce() {
  try {
    if (window.BB_AUTH_MODE === 'guest' || !window.BB_AUTH_MODE) return;
    if (sessionStorage.getItem('bb.legacy.migrated.v2') === _ns()) return;
    const sourceNamespaces = ['', '::user.anonymous']; // legacy + buggy intermediate
    for (const src of sourceNamespaces) {
      for (const base of ['scenes', 'scenes.active', 'scenes.transitions']) {
        const oldK = src ? `bb.${base}.v1${src}` : `bb.${base}.v1`;
        const newK = _key(base);
        if (oldK === newK) continue;
        const v = localStorage.getItem(oldK);
        if (v != null && localStorage.getItem(newK) == null) {
          localStorage.setItem(newK, v);
        }
        localStorage.removeItem(oldK);
      }
    }
    sessionStorage.setItem('bb.legacy.migrated.v2', _ns());
  } catch {}
}

// ----- default scene factory -----
// First-run default canvas is 3x3x3 — gives new users (guest + freshly-signed-
// in) the more interesting playground out of the box. Once they pick a size
// (or otherwise edit the scene) it persists through patchActive → setScenes →
// localStorage / Harper, so future visits show whatever they last left.
// ============================================================================
// GitHub ZIP → repo-summary client-side processor
// ============================================================================
//
// The /SceneAgent endpoint accepts EITHER a public GitHub URL (server fetches
// via REST API) OR a pre-built `summary` object in the same shape that
// fetchRepoSummary() emits server-side. This block handles the second mode:
// the user drops a downloaded GitHub repo ZIP, we unpack it in the browser,
// apply the same file-selection rules the server uses, and POST the resulting
// summary directly so the agent skips its GitHub fetch step.
//
// Selection rules / caps below are mirrored from
// resources/lib/githubFetch.js — keep both copies in sync.
// ----------------------------------------------------------------------------

let _jszipPromise = null;
function _loadJSZip() {
  if (_jszipPromise) return _jszipPromise;
  if (window.JSZip) {
    _jszipPromise = Promise.resolve(window.JSZip);
    return _jszipPromise;
  }
  _jszipPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
    s.crossOrigin = 'anonymous';
    s.referrerPolicy = 'no-referrer';
    s.onload = () => (window.JSZip ? resolve(window.JSZip) : reject(new Error('JSZip did not load')));
    s.onerror = () => reject(new Error('Failed to fetch JSZip from CDN'));
    document.head.appendChild(s);
  });
  return _jszipPromise;
}

// GitHub zip downloads have a single top-level directory like
// `<repo>-<branch>` or `<repo>-<sha>`. Strip it so paths line up with what
// the server-side selection rules expect. Returns the stripped files map and
// the original top-dir name.
function _stripZipTopDir(zipFiles) {
  const all = Object.entries(zipFiles).filter(([, e]) => !e.dir);
  if (!all.length) return { files: {}, topDir: '' };
  const firstSegments = new Set(all.map(([p]) => p.split('/')[0]));
  if (firstSegments.size !== 1) return { files: Object.fromEntries(all), topDir: '' };
  const topDir = [...firstSegments][0];
  const out = {};
  for (const [p, entry] of all) {
    const stripped = p.startsWith(topDir + '/') ? p.slice(topDir.length + 1) : p;
    if (stripped) out[stripped] = entry;
  }
  return { files: out, topDir };
}

// Heuristic: split the GitHub-style top-dir name "<repo>-<branch-or-sha>"
// into repo + branch. Falls back to using the full name as the repo when
// the branch can't be guessed.
function _parseRepoFromDirName(dirName) {
  const fallback = (r) => ({ owner: '(uploaded)', repo: r || 'repo', defaultBranch: 'main' });
  if (!dirName) return fallback(null);
  for (const branch of ['main', 'master', 'develop', 'trunk']) {
    if (dirName.endsWith('-' + branch)) {
      return { owner: '(uploaded)', repo: dirName.slice(0, -(branch.length + 1)), defaultBranch: branch };
    }
  }
  const sha = dirName.match(/^(.+)-([a-f0-9]{7,40})$/);
  if (sha) return { owner: '(uploaded)', repo: sha[1], defaultBranch: sha[2] };
  return fallback(dirName);
}

const _ZIP_MAX_FILE_BYTES = 16_000;
const _ZIP_MAX_README_BYTES = 8_000;
const _ZIP_HEAD_LINES = 50;
const _ZIP_PACKAGE_JSON_LIMIT = 8;
const _ZIP_CONFIG_YAML_LIMIT = 6;
const _ZIP_SCHEMA_LIMIT = 6;
const _ZIP_RESOURCE_LIMIT = 8;
const _ZIP_TREE_LIMIT = 200;
const _ZIP_SKIP_DIRS = /(^|\/)(node_modules|vendor|dist|build|\.next|coverage|\.cache)(\/|$)/;

function _zipHeadLines(text, n = _ZIP_HEAD_LINES) {
  if (!text) return text;
  const lines = text.split(/\r?\n/);
  if (lines.length <= n) return text;
  return lines.slice(0, n).join('\n') + `\n…(${lines.length - n} more lines truncated)…`;
}

function _zipByDepth(a, b) {
  const da = a.split('/').length, db = b.split('/').length;
  if (da !== db) return da - db;
  return a.localeCompare(b);
}

async function _zipReadText(entry, byteCap = _ZIP_MAX_FILE_BYTES) {
  const buf = await entry.async('uint8array');
  const slice = buf.byteLength > byteCap ? buf.slice(0, byteCap) : buf;
  return new TextDecoder('utf-8', { fatal: false }).decode(slice);
}

// Build the same summary shape that resources/lib/githubFetch.js emits.
// Throws on malformed zips.
async function buildZipSummary(zipArrayBuffer) {
  const JSZip = await _loadJSZip();
  const zip = await JSZip.loadAsync(zipArrayBuffer);
  const { files, topDir } = _stripZipTopDir(zip.files);
  const { owner, repo, defaultBranch } = _parseRepoFromDirName(topDir);

  const has = (p) => p in files;
  const allPaths = Object.keys(files).filter((p) => !_ZIP_SKIP_DIRS.test(p));

  const readmePath = ['README.md', 'README.MD', 'Readme.md', 'readme.md'].find(has);
  const readme = readmePath ? await _zipReadText(files[readmePath], _ZIP_MAX_README_BYTES) : null;

  const packageJsonPaths = allPaths
    .filter((p) => /(^|\/)package\.json$/.test(p))
    .sort(_zipByDepth)
    .slice(0, _ZIP_PACKAGE_JSON_LIMIT);
  const configYamlPaths = allPaths
    .filter((p) => /(^|\/)config\.ya?ml$/i.test(p))
    .sort(_zipByDepth)
    .slice(0, _ZIP_CONFIG_YAML_LIMIT);
  const schemaPaths = allPaths
    .filter((p) =>
      /\.graphql$/i.test(p) &&
      (/(^|\/)schemas\/[^/]+\.graphql$/i.test(p) ||
       /(^|\/)schema\.graphql$/i.test(p) ||
       /(^|\/)multi-tenant-schema\.graphql$/i.test(p) ||
       /(^|\/)packages\//.test(p))
    )
    .sort(_zipByDepth)
    .slice(0, _ZIP_SCHEMA_LIMIT);
  const resourcePaths = allPaths
    .filter((p) =>
      /\.(js|ts|mjs|cjs)$/i.test(p) &&
      /(^|\/)resources\/[^/]+\.(js|ts|mjs|cjs)$/i.test(p)
    )
    .sort(_zipByDepth)
    .slice(0, _ZIP_RESOURCE_LIMIT);

  const [pjBodies, cfgBodies, schemaBodies, resourceBodies] = await Promise.all([
    Promise.all(packageJsonPaths.map((p) => _zipReadText(files[p]))),
    Promise.all(configYamlPaths.map((p) => _zipReadText(files[p]))),
    Promise.all(schemaPaths.map((p) => _zipReadText(files[p]))),
    Promise.all(resourcePaths.map((p) => _zipReadText(files[p]))),
  ]);

  const packageJsons = packageJsonPaths.map((path, i) => {
    let parsed = null;
    try { parsed = JSON.parse(pjBodies[i]); } catch (_) { /* skip malformed */ }
    return { path, parsed };
  }).filter((p) => p.parsed);

  const harperConfigs = configYamlPaths.map((path, i) => {
    const yaml = cfgBodies[i];
    return yaml ? { path, yaml } : null;
  }).filter(Boolean);

  const schemas = schemaPaths.map((path, i) => ({ path, head: _zipHeadLines(schemaBodies[i] || '') }));
  const resources = resourcePaths.map((path, i) => ({ path, head: _zipHeadLines(resourceBodies[i] || '') }));

  const rootPj = packageJsons.find((p) => p.path === 'package.json');
  const packageJson = rootPj?.parsed || null;
  const firstHarperConfig =
    harperConfigs.find((c) => /^\s*(rest|jsResource|graphqlSchema|loadEnv)\s*:|@harperfast\//m.test(c.yaml || '')) ||
    harperConfigs[0] ||
    null;
  const harperConfig = firstHarperConfig ? { yaml: firstHarperConfig.yaml, path: firstHarperConfig.path } : null;

  const tree = allPaths.slice(0, _ZIP_TREE_LIMIT);

  return {
    owner, repo, defaultBranch,
    meta: {
      full_name: `${owner}/${repo}`,
      description: packageJson?.description || null,
      language: null,
      topics: [],
      stargazers: null,
      homepage: packageJson?.homepage || null,
    },
    readme,
    packageJson,
    packageJsons,
    harperConfig,
    harperConfigs,
    schemas,
    resources,
    tree,
  };
}

function _defaultScene(name = 'Scene 1') {
  return {
    id: 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7),
    name,
    kind: 'manual',
    canvas: { W: 3, D: 3, H: 3, logo: true, floorDisk: false },
    blocks: [
      { id: 'seed-1', color: 'purple',  type: 'solid', label: 'Cache', gx: 0, gy: 0, gz: 0, dx: 1, dy: 1, dz: 1 },
      { id: 'seed-2', color: 'magenta', type: 'solid', label: 'API',   gx: 0, gy: 0, gz: 1, dx: 1, dy: 1, dz: 1 },
      { id: 'seed-3', color: 'blue',    type: 'solid', label: 'NoSQL', gx: 1, gy: 0, gz: 0, dx: 1, dy: 1, dz: 1 },
    ],
    accent: null,
    externals: [],
    github: null,
  };
}

// ---- Data sanitizer ----
// Older builds (and the now-removed mock auto-agent) sometimes wrote scenes
// with colors that aren't in the current PALETTE — 'teal', 'amber', or other
// stray values. The renderer already falls back to a safe color, but we also
// coerce on load so bad values don't quietly survive into Harper / localStorage.
const _PRIMARY_COLORS = new Set(['magenta', 'purple', 'blue', 'green']);
const _EXTERNAL_COLORS = new Set(['slate', 'slateNeutral', 'slateCool']);
function _sanitizeScene(s) {
  if (!s || typeof s !== 'object') return s;
  const blocks = Array.isArray(s.blocks)
    ? s.blocks.map((b) => (_PRIMARY_COLORS.has(b?.color) ? b : { ...b, color: 'green' }))
    : s.blocks;
  let accent = s.accent;
  if (accent && !_PRIMARY_COLORS.has(accent.color)) accent = { ...accent, color: 'green' };
  const externals = Array.isArray(s.externals)
    ? s.externals.map((e) => (_EXTERNAL_COLORS.has(e?.color) ? e : { ...e, color: 'slate' }))
    : s.externals;
  return { ...s, blocks, accent, externals };
}

function _emptyScene(name) {
  return {
    id: 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7),
    name,
    kind: 'manual',
    // Match _defaultScene — new scenes start at 3x3x3 unless the user
    // explicitly resizes via the Canvas panel.
    canvas: { W: 3, D: 3, H: 3, logo: true, floorDisk: false },
    blocks: [],
    accent: null,
    externals: [],
    github: null,
  };
}

function _loadScenes() {
  try {
    const raw = localStorage.getItem(SCENES_KEY());
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed) || parsed.length === 0) return null;
    // Sanitize on the way in: stray colors from old builds get coerced to
    // a safe value so they can't crash the renderer.
    return parsed.map(_sanitizeScene);
  } catch (e) { return null; }
}
function _loadActive() {
  try { return localStorage.getItem(ACTIVE_KEY()) || null; } catch { return null; }
}
function _saveScenes(scenes) {
  try { localStorage.setItem(SCENES_KEY(), JSON.stringify(scenes)); } catch {}
}
function _saveActive(id) {
  try { localStorage.setItem(ACTIVE_KEY(), id || ''); } catch {}
}

// ---- The hook ----
function useScenes() {
  // Run the legacy-key migration before initial state load so the lazy
  // useState initializers below see the migrated values for this user.
  _migrateLegacyKeysOnce();
  const [scenes, _setScenesRaw] = _sUseState(() => _loadScenes() || [_defaultScene()]);
  const [activeId, _setActiveId] = _sUseState(() => {
    const stored = _loadActive();
    const list = _loadScenes() || [];
    if (stored && list.find((s) => s.id === stored)) return stored;
    return (list[0] && list[0].id) || null;
  });

  // ----- Undo / redo history -----
  // We snapshot { scenes, activeId } before each user-initiated mutation
  // and coalesce snapshots that arrive within 300ms (so a drag or a fast
  // sequence of nudges becomes a single undo step). The stack is capped at
  // 50 entries so memory stays bounded even on long sessions.
  const HISTORY_LIMIT = 50;
  const COALESCE_MS = 300;
  const _historyRef = _sUseRef({
    past: [],
    future: [],
    coalesceTimer: null,
    applying: false, // true while undo/redo is being applied -> skip snapshotting
  });
  const _activeIdRef = _sUseRef(activeId);
  _sUseEffect(() => { _activeIdRef.current = activeId; }, [activeId]);
  const [_historyEpoch, _setHistoryEpoch] = _sUseState(0);

  // Per-scene "thinking" map — what the AI is currently doing for an
  // analyzing scene. Drives the cycling chip text in SceneTabs while a
  // /SceneAgent request is in flight. Ephemeral (NOT persisted), keyed by
  // scene id, value is a short string the chip will truncate.
  const [_thinking, _setThinking] = _sUseState({});
  const _setThinkingFor = _sUseCallback((id, text) => {
    _setThinking((m) => ({ ...m, [id]: text }));
  }, []);
  const _clearThinkingFor = _sUseCallback((id) => {
    _setThinking((m) => {
      if (!(id in m)) return m;
      const next = { ...m };
      delete next[id];
      return next;
    });
  }, []);

  // History-aware setScenes. Pass { skipHistory: true } to bypass snapshots
  // (used for non-user-initiated updates like loading from Harper).
  const setScenes = _sUseCallback((updater, opts) => {
    _setScenesRaw((prev) => {
      const h = _historyRef.current;
      if (!h.applying && !opts?.skipHistory) {
        if (h.coalesceTimer) {
          clearTimeout(h.coalesceTimer);
        } else {
          h.past.push({ scenes: prev, activeId: _activeIdRef.current });
          if (h.past.length > HISTORY_LIMIT) h.past.shift();
          h.future = [];
        }
        h.coalesceTimer = setTimeout(() => { h.coalesceTimer = null; }, COALESCE_MS);
      }
      return typeof updater === 'function' ? updater(prev) : updater;
    });
  }, []);

  const undo = _sUseCallback(() => {
    const h = _historyRef.current;
    if (!h.past.length) return;
    if (h.coalesceTimer) { clearTimeout(h.coalesceTimer); h.coalesceTimer = null; }
    const snapshot = h.past.pop();
    _setScenesRaw((curr) => {
      h.future.push({ scenes: curr, activeId: _activeIdRef.current });
      if (h.future.length > HISTORY_LIMIT) h.future.shift();
      return snapshot.scenes;
    });
    _setActiveId(snapshot.activeId);
    h.applying = true;
    setTimeout(() => { h.applying = false; }, 0);
    _setHistoryEpoch((n) => n + 1);
  }, []);

  const redo = _sUseCallback(() => {
    const h = _historyRef.current;
    if (!h.future.length) return;
    if (h.coalesceTimer) { clearTimeout(h.coalesceTimer); h.coalesceTimer = null; }
    const snapshot = h.future.pop();
    _setScenesRaw((curr) => {
      h.past.push({ scenes: curr, activeId: _activeIdRef.current });
      if (h.past.length > HISTORY_LIMIT) h.past.shift();
      return snapshot.scenes;
    });
    _setActiveId(snapshot.activeId);
    h.applying = true;
    setTimeout(() => { h.applying = false; }, 0);
    _setHistoryEpoch((n) => n + 1);
  }, []);

  // Persist scenes & activeId on change — localStorage (instant) + Harper (durable)
  const _harperSaveTimer = _sUseRef(null);
  _sUseEffect(() => { _saveScenes(scenes); }, [scenes]);
  _sUseEffect(() => { _saveActive(activeId); }, [activeId]);

  // Debounced sync to Harper via WebSocket — only when an authenticated session
  // (or local AUTH_BYPASS) has flipped on the BB_HARPER_ENABLED flag.
  // In guest mode we stay localStorage-only, so this effect short-circuits.
  _sUseEffect(() => {
    if (!window.HarperSync || !window.BB_HARPER_ENABLED) return;
    clearTimeout(_harperSaveTimer.current);
    _harperSaveTimer.current = setTimeout(() => {
      scenes.forEach((s, i) => window.HarperSync.saveScene(s, i));
      window.HarperSync.saveAppState(activeId, null);
    }, 600);
  }, [scenes, activeId]);

  // On mount: try loading from Harper (overwrites localStorage if Harper has data)
  const _harperLoaded = _sUseRef(false);
  _sUseEffect(() => {
    if (_harperLoaded.current || !window.HarperSync || !window.BB_HARPER_ENABLED) return;
    _harperLoaded.current = true;
    (async () => {
      try {
        const harperScenesRaw = await window.HarperSync.loadScenes();
        const harperScenes = Array.isArray(harperScenesRaw) ? harperScenesRaw.map(_sanitizeScene) : harperScenesRaw;
        if (harperScenes && harperScenes.length > 0) {
          // Initial Harper load isn't a user action — don't push it onto
          // the undo stack (otherwise the user's first Cmd+Z would wipe
          // the just-loaded scenes back to the default seed scene).
          setScenes(harperScenes, { skipHistory: true });
          const appState = await window.HarperSync.loadAppState();
          if (appState?.activeSceneId) {
            const exists = harperScenes.find(s => s.id === appState.activeSceneId);
            if (exists) _setActiveId(appState.activeSceneId);
          }
        } else {
          // First run: seed Harper with localStorage data
          const local = _loadScenes();
          if (local && local.length > 0) {
            local.forEach((s, i) => window.HarperSync.saveScene(s, i));
          }
        }
        // Open WebSocket for ongoing sync
        window.HarperSync.connect();
      } catch (e) {
        console.warn('[Scenes] Harper load failed, using localStorage', e);
      }
    })();
  }, []);

  // If activeId points at a missing scene, fall back to first.
  _sUseEffect(() => {
    if (!scenes.length) return;
    if (!scenes.find((s) => s.id === activeId)) {
      _setActiveId(scenes[0].id);
    }
  }, [scenes, activeId]);

  const active = _sUseMemo(
    () => scenes.find((s) => s.id === activeId) || scenes[0] || null,
    [scenes, activeId]
  );

  const setActiveId = _sUseCallback((id) => _setActiveId(id), []);

  const patchActive = _sUseCallback((patch) => {
    setScenes((ss) =>
      ss.map((s) => (s.id === activeId ? { ...s, ...patch } : s))
    );
  }, [activeId]);

  const renameScene = _sUseCallback((id, name) => {
    setScenes((ss) => ss.map((s) => (s.id === id ? { ...s, name } : s)));
  }, []);

  const deleteScene = _sUseCallback((id) => {
    setScenes((ss) => {
      if (ss.length <= 1) return ss; // never delete the last
      const idx = ss.findIndex((s) => s.id === id);
      const next = ss.filter((s) => s.id !== id);
      // If we deleted the active scene, jump to neighbour
      if (id === activeId) {
        const fallback = next[Math.max(0, idx - 1)] || next[0];
        if (fallback) _setActiveId(fallback.id);
      }
      // Sync delete to Harper (only in authenticated/bypass mode; guests are localStorage-only)
      if (window.HarperSync && window.BB_HARPER_ENABLED) window.HarperSync.deleteScene(id);
      return next;
    });
  }, [activeId]);

  const createManual = _sUseCallback((name) => {
    const s = _emptyScene(name);
    setScenes((ss) => [...ss, s]);
    _setActiveId(s.id);
    return s;
  }, []);

  const replicateScene = _sUseCallback(() => {
    const source = scenes.find((s) => s.id === activeId);
    if (!source) return null;
    // Skip while the agent is still filling in blocks/canvas — the copy
    // would be empty.
    if (source.github && source.github.status === 'analyzing') return null;

    // Strip a trailing ".<digits>" from the source name so replicating
    // "Foo.1" yields "Foo.2", not "Foo.1.1".
    const m = source.name.match(/^(.+)\.(\d+)$/);
    const root = m ? m[1] : source.name;
    const taken = new Set(scenes.map((s) => s.name));
    let n = 1;
    while (taken.has(`${root}.${n}`)) n++;
    const newName = `${root}.${n}`;

    const copy = {
      ...source,
      id: 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7),
      name: newName,
      kind: 'manual',
      github: null,
      blocks: (source.blocks || []).map((b) => ({ ...b })),
      accent: source.accent ? { ...source.accent } : null,
      externals: (source.externals || []).map((e) => ({ ...e })),
      canvas: { ...(source.canvas || { W: 3, D: 3, H: 3, logo: true, floorDisk: false }) },
    };

    setScenes((ss) => {
      const idx = ss.findIndex((s) => s.id === source.id);
      if (idx < 0) return [...ss, copy];
      const next = ss.slice();
      next.splice(idx + 1, 0, copy);
      return next;
    });
    _setActiveId(copy.id);
    return copy;
  }, [scenes, activeId]);

  // Reorder by moving the scene at fromIdx to position toIdx. Both are
  // 0-indexed positions in the live `scenes` array. The hook is the source
  // of truth for animation playback order, so reordering chips here also
  // reorders Animation Preview legs automatically.
  const reorderScenes = _sUseCallback((fromIdx, toIdx) => {
    setScenes((ss) => {
      if (fromIdx === toIdx) return ss;
      if (fromIdx < 0 || fromIdx >= ss.length) return ss;
      const clamped = Math.max(0, Math.min(ss.length - 1, toIdx));
      const next = ss.slice();
      const [moved] = next.splice(fromIdx, 1);
      next.splice(clamped, 0, moved);
      return next;
    });
  }, []);

  const createFromGithub = _sUseCallback(async (input) => {
    // `input` is either a string (legacy URL form), { url }, or
    // { summary, sourceLabel } where summary was pre-built client-side
    // from a downloaded GitHub ZIP.
    const isObj = input && typeof input === 'object';
    const repoUrl = typeof input === 'string' ? input : (isObj ? input.url : null);
    const preBuiltSummary = isObj ? input.summary : null;
    const sourceLabel = isObj ? input.sourceLabel : null;

    // Placeholder name; the agent picks the real name and we patch it in
    // when the response lands. While in flight, the chip ignores `name`
    // and shows the rotating thinking text from the _thinking map.
    const s = {
      ..._emptyScene(''),
      kind: 'auto',
      github: preBuiltSummary
        ? { source: 'zip', label: sourceLabel || 'uploaded.zip', status: 'analyzing', startedAt: Date.now() }
        : { url: repoUrl, status: 'analyzing', startedAt: Date.now() },
    };
    setScenes((ss) => [...ss, s]);
    _setActiveId(s.id);

    // Cycle through stage labels in the chip (≤16 chars each) while the
    // server-side fetch + Claude call + validation runs. Each stage stays
    // for ~1.6s; once we've hit the last stage we just stay there until
    // the response arrives. ZIP uploads skip the "Fetching repo" stage
    // because the summary already includes the file contents.
    const STAGES = preBuiltSummary
      ? ['Reading code', 'Finding intent', 'Picking blocks', 'Sizing canvas', 'Validating']
      : ['Fetching repo', 'Reading code', 'Finding intent', 'Picking blocks', 'Sizing canvas', 'Validating'];
    let stageIdx = 0;
    _setThinkingFor(s.id, STAGES[0]);
    const stageTimer = setInterval(() => {
      stageIdx = Math.min(stageIdx + 1, STAGES.length - 1);
      _setThinkingFor(s.id, STAGES[stageIdx]);
    }, 1600);

    (async () => {
      try {
        const res = await fetch('/SceneAgent', {
          method: 'POST',
          credentials: 'include',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(preBuiltSummary ? { summary: preBuiltSummary } : { url: repoUrl }),
        });
        const body = await res.json().catch(() => ({}));
        if (!res.ok) {
          // Harper returns RFC 9457 Problem Details on errors:
          //   { type, code, title, status, detail, instance }
          const title = body?.title || body?.message || `Agent failed (HTTP ${res.status})`;
          const hint = body?.detail?.hint;
          throw new Error(hint ? `${title} ${hint}` : title);
        }
        const { name: agentName, canvas, blocks, accent, accentSuggestions, externals } = body;
        const finalName = (typeof agentName === 'string' && agentName.trim()) || 'Auto scene';
        setScenes((ss) =>
          ss.map((sc) =>
            sc.id === s.id
              ? {
                  ...sc,
                  name: finalName,
                  canvas, blocks, accent, accentSuggestions, externals,
                  github: { ...sc.github, status: 'ready', completedAt: Date.now() },
                }
              : sc
          )
        );
        // The new scene was already active when the placeholder mounted, so
        // _activeSceneId hasn't changed — App's mirror effect won't re-fire on
        // its own. Bump _historyEpoch to force the mirror to repopulate
        // canvas/blocks/accent/externals from the now-filled-in active scene.
        _setHistoryEpoch((n) => n + 1);
      } catch (err) {
        setScenes((ss) =>
          ss.map((sc) =>
            sc.id === s.id
              ? { ...sc, name: 'Error', github: { ...sc.github, status: 'error', error: err.message || String(err) } }
              : sc
          )
        );
      } finally {
        clearInterval(stageTimer);
        _clearThinkingFor(s.id);
      }
    })();
    return s;
  }, [_setThinkingFor, _clearThinkingFor]);

  return {
    scenes, activeId, active,
    setActiveId, patchActive, renameScene, deleteScene,
    createManual, createFromGithub, replicateScene, reorderScenes,
    undo, redo, historyEpoch: _historyEpoch,
    thinking: _thinking,
  };
}

// (The deterministic placeholder _runMockAgent that used to live here is
// gone — `createFromGithub` now POSTs to /SceneAgent which does the real
// thing. See resources/sceneAgent.js + resources/lib/*. Git history has
// the old implementation if you ever need it back as a fallback.)

// =====================================================================
//                              UI
// =====================================================================

// ---- Scene tabs (header) ----
function SceneTabs({
  scenes, activeId, thinking, onActivate, onCreate, onRename, onDelete, onReorder,
}) {
  const [menuOpen, setMenuOpen] = _sUseState(false);
  const [editingId, setEditingId] = _sUseState(null);
  // Drag-reorder state. dragId = id of the chip being dragged; overId =
  // id of the chip currently hovered (the "drop target"). Both are null
  // when no drag is in progress.
  const [dragId, setDragId] = _sUseState(null);
  const [overId, setOverId] = _sUseState(null);
  const plusRef = _sUseRef(null);

  // Close popdown on outside click / Esc
  _sUseEffect(() => {
    if (!menuOpen) return;
    const onDown = (e) => {
      if (plusRef.current && plusRef.current.contains(e.target)) return;
      const menu = document.getElementById('scene-create-menu');
      if (menu && menu.contains(e.target)) return;
      setMenuOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false); };
    document.addEventListener('mousedown', onDown);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDown);
      document.removeEventListener('keydown', onKey);
    };
  }, [menuOpen]);

  return (
    <div className="scene-tabs" role="tablist" aria-label="Scenes">
      {scenes.map((s) => {
        const active = s.id === activeId;
        const editing = editingId === s.id;
        const status = s.github?.status;
        const isDragging = dragId === s.id;
        const isOver = overId === s.id && dragId && dragId !== s.id;
        // While the agent is analyzing, the chip shows a rotating
        // "thinking" string (≤16 chars) instead of the scene name.
        const thinkText = thinking && thinking[s.id];
        const displayName = (status === 'analyzing' && thinkText)
          ? thinkText.slice(0, 16)
          : s.name;
        return (
          <div
            key={s.id}
            role="tab"
            aria-selected={active}
            draggable={!editing}
            className={`scene-tab ${active ? 'active' : ''} ${s.kind === 'auto' ? 'auto' : ''} ${isDragging ? 'dragging' : ''} ${isOver ? 'drop-target' : ''}`}
            onClick={() => onActivate(s.id)}
            onDoubleClick={() => setEditingId(s.id)}
            onDragStart={(e) => {
              if (editing) { e.preventDefault(); return; }
              setDragId(s.id);
              try {
                e.dataTransfer.effectAllowed = 'move';
                e.dataTransfer.setData('text/plain', s.id);
              } catch (_) {}
            }}
            onDragEnter={(e) => {
              if (!dragId || dragId === s.id) return;
              e.preventDefault();
              setOverId(s.id);
            }}
            onDragOver={(e) => {
              if (!dragId) return;
              e.preventDefault();
              if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
              if (overId !== s.id && dragId !== s.id) setOverId(s.id);
            }}
            onDragLeave={(e) => {
              if (overId === s.id) setOverId(null);
            }}
            onDrop={(e) => {
              e.preventDefault();
              if (!dragId || dragId === s.id) {
                setDragId(null); setOverId(null); return;
              }
              const fromIdx = scenes.findIndex((x) => x.id === dragId);
              const toIdx = scenes.findIndex((x) => x.id === s.id);
              if (fromIdx >= 0 && toIdx >= 0 && onReorder) onReorder(fromIdx, toIdx);
              setDragId(null);
              setOverId(null);
            }}
            onDragEnd={() => { setDragId(null); setOverId(null); }}
            title={s.kind === 'auto' ? `Auto · ${s.github?.url || ''}` : 'Click to switch · drag to reorder · double-click to rename'}
          >
            {s.kind === 'auto' && (
              <span className={`scene-tab-dot ${status || ''}`} aria-hidden="true" />
            )}
            {editing ? (
              <input
                autoFocus
                className="scene-tab-input"
                defaultValue={s.name}
                maxLength={28}
                onClick={(e) => e.stopPropagation()}
                onBlur={(e) => { onRename(s.id, e.target.value.trim() || s.name); setEditingId(null); }}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') e.target.blur();
                  if (e.key === 'Escape') setEditingId(null);
                }}
              />
            ) : (
              <span className={`scene-tab-name ${status === 'analyzing' && thinkText ? 'thinking' : ''}`}>{displayName}</span>
            )}
            {scenes.length > 1 && active && !editing && (
              <button
                type="button"
                className="scene-tab-close"
                aria-label="Delete scene"
                title="Delete scene"
                onClick={(e) => {
                  e.stopPropagation();
                  if (confirm(`Delete scene "${s.name}"?`)) onDelete(s.id);
                }}
              >×</button>
            )}
          </div>
        );
      })}
      <button
        ref={plusRef}
        type="button"
        className={`scene-tab-plus ${menuOpen ? 'open' : ''}`}
        onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v); }}
        aria-label="Add scene"
        aria-expanded={menuOpen}
      >
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
          <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
        </svg>
      </button>
      {menuOpen && (
        <SceneCreateMenu
          anchorRef={plusRef}
          onClose={() => setMenuOpen(false)}
          onManual={() => {
            const next = `Scene ${scenes.length + 1}`;
            onCreate({ kind: 'manual', name: next });
            setMenuOpen(false);
          }}
          onAutomated={({ url, summary, sourceLabel }) => {
            // The agent picks the scene name; no user-supplied name needed.
            onCreate({ kind: 'auto', url, summary, sourceLabel });
            setMenuOpen(false);
          }}
          onReplicate={() => {
            onCreate({ kind: 'replicate' });
            setMenuOpen(false);
          }}
        />
      )}
    </div>
  );
}

function SceneCreateMenu({ anchorRef, onClose, onManual, onAutomated, onReplicate }) {
  const [pos, setPos] = _sUseState(null);
  const [tab, setTab] = _sUseState('choose'); // 'choose' | 'github'
  const [url, setUrl] = _sUseState('');
  // Optional ZIP upload as an alternative to a public GitHub URL. Holds the
  // raw File so we can read it on Generate; cleared with the × button.
  const [zipFile, setZipFile] = _sUseState(null);
  const [zipBusy, setZipBusy] = _sUseState(false);
  const [zipError, setZipError] = _sUseState(null);
  const [dragOver, setDragOver] = _sUseState(false);
  const fileInputRef = _sUseRef(null);
  const ref = _sUseRef(null);
  // Automated scene generation calls a paid API on the server, so it's
  // gated behind a real OAuth session. Guests see the option but can't
  // pick it. The server's /SceneAgent endpoint also requires a session,
  // so DevTools tampering can't sneak past — this is purely a UX hint.
  const isGuest = window.BB_AUTH_MODE === 'guest';

  _sUseEffect(() => {
    const measure = () => {
      const a = anchorRef.current;
      if (!a) return;
      const r = a.getBoundingClientRect();
      setPos({ top: r.bottom + 8, left: r.left });
    };
    measure();
    window.addEventListener('resize', measure);
    return () => window.removeEventListener('resize', measure);
  }, [anchorRef]);

  const validUrl = /^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+/.test(url.trim());

  return ReactDOM.createPortal(
    <div
      ref={ref}
      id="scene-create-menu"
      className="scene-create-menu"
      style={{ position: 'fixed', top: pos ? pos.top : -9999, left: pos ? pos.left : -9999, visibility: pos ? 'visible' : 'hidden' }}
      onMouseDown={(e) => e.stopPropagation()}
    >
      {tab === 'choose' && (
        <div className="scene-create-grid">
          <button type="button" className="scene-create-card" onClick={onManual}>
            <span className="scene-create-icon" aria-hidden="true">
              <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 3 L20 7.5 L20 16.5 L12 21 L4 16.5 L4 7.5 Z"/>
                <path d="M12 3 L12 12 M4 7.5 L12 12 L20 7.5"/>
              </svg>
            </span>
            <span className="scene-create-title">Manual scene</span>
            <span className="scene-create-sub">Start with an empty canvas.</span>
          </button>
          <button
            type="button"
            className={`scene-create-card ${isGuest ? 'locked' : ''}`}
            disabled={isGuest}
            aria-disabled={isGuest}
            title={isGuest ? 'This feature is only available to logged in users.' : ''}
            onClick={(e) => {
              if (isGuest) { e.preventDefault(); e.stopPropagation(); return; }
              setTab('github');
            }}
          >
            <span className="scene-create-icon" aria-hidden="true">
              {isGuest ? (
                <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                  <rect x="5" y="11" width="14" height="9" rx="1.5"/>
                  <path d="M8 11V8a4 4 0 0 1 8 0v3"/>
                  <circle cx="12" cy="15.5" r="1.2" fill="currentColor"/>
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M9 19c-4.3 1.4-4.3-2.5-6-3M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6 0-1.2-.5-2.4-1.3-3.2.4-1.2.4-2.5-.2-3.7 0 0-1.1-.3-3.5 1.3a12 12 0 0 0-6 0C6.6 1.3 5.5 1.6 5.5 1.6a4 4 0 0 0-.2 3.7A4.6 4.6 0 0 0 4 8.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/>
                </svg>
              )}
            </span>
            <span className="scene-create-title">Automated</span>
            <span className="scene-create-sub">
              {isGuest
                ? 'Sign in to enable. This feature is only available to logged in users.'
                : 'Agent reads a Harper repo and assembles blocks.'}
            </span>
          </button>
          <button type="button" className="scene-create-card" onClick={onReplicate}>
            <span className="scene-create-icon" aria-hidden="true">
              <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                <rect x="9" y="9" width="11" height="11" rx="2"/>
                <path d="M5 15V6a2 2 0 0 1 2-2h9"/>
              </svg>
            </span>
            <span className="scene-create-title">Replicate current</span>
            <span className="scene-create-sub">Duplicate the active scene.</span>
          </button>
        </div>
      )}
      {tab === 'github' && (
        <div className="scene-create-form">
          <div className="scene-create-form-title">From a Harper repo</div>
          <input
            autoFocus
            className="scene-create-input"
            placeholder="https://github.com/owner/repo"
            value={url}
            onChange={(e) => { setUrl(e.target.value); if (zipError) setZipError(null); }}
            disabled={!!zipFile || zipBusy}
          />

          {/* OR divider + ZIP upload alternative. Useful for private repos
              and when GitHub rate-limits get in the way; the unzip happens
              entirely client-side and we POST a pre-built summary so the
              server skips its REST API fetch. */}
          <div className="scene-create-or"><span>OR</span></div>
          <input
            ref={fileInputRef}
            type="file"
            accept=".zip,application/zip,application/x-zip-compressed"
            style={{ display: 'none' }}
            onChange={(e) => {
              const f = e.target.files && e.target.files[0];
              if (f) { setZipFile(f); setZipError(null); }
              e.target.value = '';
            }}
          />
          {!zipFile ? (
            <div
              className={`scene-create-dropzone ${dragOver ? 'over' : ''}`}
              onClick={() => fileInputRef.current && fileInputRef.current.click()}
              onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
              onDragLeave={() => setDragOver(false)}
              onDrop={(e) => {
                e.preventDefault();
                setDragOver(false);
                const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
                if (!f) return;
                if (!/\.zip$/i.test(f.name) && f.type !== 'application/zip' && f.type !== 'application/x-zip-compressed') {
                  setZipError('Please drop a .zip file from GitHub.');
                  return;
                }
                setZipFile(f);
                setZipError(null);
              }}
              role="button"
              tabIndex={0}
              onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInputRef.current && fileInputRef.current.click(); } }}
            >
              <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                <polyline points="17 8 12 3 7 8" />
                <line x1="12" y1="3" x2="12" y2="15" />
              </svg>
              <div className="scene-create-dropzone-primary">Drop a GitHub repo ZIP</div>
              <div className="scene-create-dropzone-secondary">or click to choose a file</div>
            </div>
          ) : (
            <div className="scene-create-zipchip" title={zipFile.name}>
              <span className="scene-create-zipchip-name">{zipFile.name}</span>
              <span className="scene-create-zipchip-size">{Math.max(1, Math.round(zipFile.size / 1024))} KB</span>
              <button
                type="button"
                className="scene-create-zipchip-x"
                aria-label="Remove file"
                onClick={() => { setZipFile(null); setZipError(null); }}
                disabled={zipBusy}
              >×</button>
            </div>
          )}
          {zipError && <div className="scene-create-form-error">{zipError}</div>}

          <div className="scene-create-form-hint">
            {zipFile
              ? 'The agent will analyze the contents of this ZIP — owner, name and structure are derived from the archive.'
              : 'The agent inspects the repo, picks a scene name and the blocks that capture its architecture, and arranges them to fill the canvas with readable labels.'}
          </div>
          <div className="scene-create-form-actions">
            <button type="button" className="scene-create-btn-ghost" onClick={() => setTab('choose')} disabled={zipBusy}>Back</button>
            <button
              type="button"
              className="scene-create-btn"
              disabled={zipBusy || (!zipFile && !validUrl)}
              onClick={async () => {
                if (zipFile) {
                  setZipBusy(true);
                  setZipError(null);
                  try {
                    const buf = await zipFile.arrayBuffer();
                    const summary = await buildZipSummary(buf);
                    onAutomated({ summary, sourceLabel: zipFile.name });
                  } catch (e) {
                    setZipError(`Couldn't read that ZIP: ${(e && e.message) ? e.message : String(e)}`);
                    setZipBusy(false);
                  }
                  // On success the menu closes; no need to reset zipBusy.
                  return;
                }
                onAutomated({ url: url.trim() });
              }}
            >{zipBusy ? 'Reading ZIP…' : 'Generate'}</button>
          </div>
        </div>
      )}
    </div>,
    document.body
  );
}

// ---- Scene transitions panel ----
//
// Sits above the right-rail when there's >1 scene. Initially minimal —
// a single header row showing "Scene Transitions" + the from→to picker.
// Expanded view exposes per-transition timing/easing + Webflow export.
// Bottom-of-rail "// Export" panel. Originally just an Animation control
// surface; now houses TWO subsections:
//   STATIC   — caller-supplied content (the PNG / SVG export buttons live
//              in App.jsx so they own the export state; we just render
//              whatever node is passed via `staticContent`).
//   ANIMATED — the Preview-transitions button when 2+ scenes exist; an
//              empty-state hint when there's only one. Will host real
//              animated-export controls (GIF / MP4 / Lottie) later.
function SceneTransitions({ scenes, activeId, transitions, setTransitions, onPreview, onDisassembly, disassemblyProgress, onOpenChange, staticContent, onExportWebflow, onExportWebflowBeta }) {
  // Accordion state for the three subsections — at most one open at a time.
  // Null = all collapsed (default on first render). The parent "// Export"
  // panel itself no longer collapses; only the subsections do.
  const [expandedSection, setExpandedSection] = _sUseState(null);
  const isStaticOpen = expandedSection === 'static';
  const isAnimatedOpen = expandedSection === 'animated';
  const isWebflowOpen = expandedSection === 'webflow';
  const [webflowState, setWebflowState] = (window.useWebflowExport || (() => [null, () => {}]))(scenes);
  const [webflowBusy, setWebflowBusy] = _sUseState(false);
  const [webflowBetaBusy, setWebflowBetaBusy] = _sUseState(false);
  // Mirror "any subsection expanded" to the parent so the right rail can
  // resize the same way it used to when the whole panel was collapsible.
  _sUseEffect(() => { if (onOpenChange) onOpenChange(expandedSection !== null); }, [expandedSection]);
  const otherScenes = scenes.filter((s) => s.id !== activeId);
  const fromScene = scenes.find((s) => s.id === activeId);

  // Publish the panel's actual rendered height to a CSS var on <html>
  // so the right-rail can shrink by exactly that amount + gap, keeping
  // the visual gap constant whether the panel is collapsed or open.
  const panelRef = _sUseRef(null);
  const _pushPanelHeight = _sUseCallback(() => {
    const el = panelRef.current;
    if (!el) return;
    const h = el.offsetHeight;
    document.documentElement.style.setProperty('--bb-anim-panel-h', h + 'px');
    document.body.style.setProperty('--bb-anim-panel-h', h + 'px');
    const rail = document.querySelector('.right-rail.with-transitions');
    if (rail) rail.style.bottom = `calc(20px + ${h}px + 12px)`;
  }, []);
  _sUseEffect(() => {
    const el = panelRef.current;
    if (!el) return;
    _pushPanelHeight();
    const ro = new ResizeObserver(() => _pushPanelHeight());
    ro.observe(el);
    return () => {
      ro.disconnect();
      document.documentElement.style.removeProperty('--bb-anim-panel-h');
      document.body.style.removeProperty('--bb-anim-panel-h');
      const rail = document.querySelector('.right-rail.with-transitions');
      if (rail) rail.style.bottom = '';
    };
  }, [_pushPanelHeight]);
  // Also push on every render so a state change (e.g. open toggle, new
  // transition row) immediately reconciles the rail height.
  _sUseEffect(() => {
    // Wait for layout — content additions take a frame.
    const id = requestAnimationFrame(() => _pushPanelHeight());
    return () => cancelAnimationFrame(id);
  });

  const ensureFor = (toId) => {
    const key = `${activeId}->${toId}`;
    return transitions[key] || { duration: 600, easing: 'ease-in-out', kind: 'morph' };
  };
  const setFor = (toId, patch) => {
    const key = `${activeId}->${toId}`;
    setTransitions({ ...transitions, [key]: { ...ensureFor(toId), ...patch } });
  };

  // One of the subsection toggles. Picking a section both expands it AND
  // collapses the other, giving an accordion feel. Clicking the open
  // section closes it (collapses everything).
  const toggleSection = (name) => {
    setExpandedSection((cur) => (cur === name ? null : name));
  };

  // Caret SVG — same triangle the right rail uses, rotated 90° via CSS
  // when its containing section is closed.
  const caret = (
    <span className="scene-transitions-sub-caret" aria-hidden="true">
      <svg viewBox="0 0 12 12" width="12" height="12">
        <polyline points="3,4.5 6,8 9,4.5" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    </span>
  );

  return (
    <div ref={panelRef} className={`scene-transitions ${expandedSection !== null ? 'open' : ''}`}>
      {/* Panel title — no longer a button. The // Export header is now a
          static label and the only collapsing happens in the two
          subsections below (accordion: at most one open at a time). */}
      <div className="scene-transitions-header static">
        <span className="scene-transitions-title">Export</span>
      </div>
      <div className="scene-transitions-body">
        {/* STATIC subsection — caller-supplied content (the PNG / SVG /
            crop controls). Header is teal-styled to match the right rail's
            section headers (Canvas, Layers). */}
        <div className={`scene-transitions-sub ${isStaticOpen ? 'open' : 'closed'}`}>
          <button
            type="button"
            className="scene-transitions-sub-header"
            onClick={() => toggleSection('static')}
            aria-expanded={isStaticOpen}
          >
            <span className="scene-transitions-sub-title">Static</span>
            {caret}
          </button>
          {isStaticOpen && (
            <div className="scene-transitions-sub-body scene-transitions-static">
              {staticContent}
            </div>
          )}
        </div>

        {/* ANIMATED subsection — preview button when 2+ scenes; placeholder
            otherwise. Will host GIF / MP4 / Lottie export later. */}
        <div className={`scene-transitions-sub ${isAnimatedOpen ? 'open' : 'closed'}`}>
          <button
            type="button"
            className="scene-transitions-sub-header"
            onClick={() => toggleSection('animated')}
            aria-expanded={isAnimatedOpen}
          >
            <span className="scene-transitions-sub-title">Animated</span>
            {caret}
          </button>
          {isAnimatedOpen && (
            <div className="scene-transitions-sub-body scene-transitions-animated">
              <button
                type="button"
                className="scene-transitions-export scene-transitions-disassembly"
                onClick={() => { if (onDisassembly) onDisassembly(); }}
                disabled={disassemblyProgress != null}
                title="Play the preview animation — scene disassembles, holds, and reassembles."
              >
                {disassemblyProgress != null && (
                  <span
                    className="scene-transitions-progress"
                    style={{ width: `${Math.max(0, Math.min(1, disassemblyProgress)) * 100}%` }}
                  />
                )}
                <span className="scene-transitions-export-content">
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                    <polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none" />
                  </svg>
                  Preview
                </span>
              </button>
            </div>
          )}
        </div>

        {/* WEBFLOW subsection — produces a self-contained <div>+<style>+
            <script> bundle that can be pasted into a Webflow Custom Code
            embed. The bundle drives an SVG block animation in sync with
            Webflow tabs that carry the configured custom attribute. */}
        <div className={`scene-transitions-sub ${isWebflowOpen ? 'open' : 'closed'}`}>
          <button
            type="button"
            className="scene-transitions-sub-header"
            onClick={() => toggleSection('webflow')}
            aria-expanded={isWebflowOpen}
          >
            <span className="scene-transitions-sub-title">Webflow</span>
            {caret}
          </button>
          {isWebflowOpen && webflowState && (
            <div className="scene-transitions-sub-body scene-transitions-webflow">
              <div className="builder-label export-section-label">Tab attribute</div>
              <input
                type="text"
                className="webflow-attr-input"
                value={webflowState.attrName || ''}
                onChange={(e) => setWebflowState({ ...webflowState, attrName: e.target.value || 'block-scene' })}
                placeholder="block-scene"
                spellCheck={false}
                title="The custom attribute name on each Webflow tab. Each tab carries a value matching one of the scene names below."
              />

              <div className="builder-label export-section-label">Scenes to export</div>
              <div className="webflow-scenes-list">
                {(webflowState.scenes || []).map((row, idx) => {
                  const live = scenes.find((s) => s.id === row.id);
                  if (!live) return null;
                  return (
                    <div key={row.id} className={`webflow-scene-row ${row.include ? 'on' : 'off'}`}>
                      <label className="webflow-scene-check">
                        <input
                          type="checkbox"
                          checked={!!row.include}
                          onChange={(e) => {
                            const next = (webflowState.scenes || []).slice();
                            next[idx] = { ...row, include: e.target.checked };
                            setWebflowState({ ...webflowState, scenes: next });
                          }}
                        />
                        <span className="webflow-scene-label" title={live.name}>{live.name}</span>
                      </label>
                      <input
                        type="text"
                        className="webflow-scene-name"
                        value={row.name}
                        onChange={(e) => {
                          const next = (webflowState.scenes || []).slice();
                          next[idx] = { ...row, name: e.target.value };
                          setWebflowState({ ...webflowState, scenes: next });
                        }}
                        placeholder="scene-id"
                        spellCheck={false}
                        title="The value to set on the matching Webflow tab's custom attribute."
                      />
                    </div>
                  );
                })}
              </div>

              {/* Embed fonts toggle. Off ships a much smaller bundle and
                  relies on the host page already loading Ubuntu (most
                  Webflow projects do). On embeds one shared @font-face
                  block at the bundle level. */}
              <label className="webflow-embed-fonts" title="Off: smaller embed, relies on the page loading Ubuntu (recommended for Webflow sites that already include the font). On: bundle includes one shared copy of the fonts.">
                <input
                  type="checkbox"
                  checked={webflowState.embedFonts !== false}
                  onChange={(e) => setWebflowState({ ...webflowState, embedFonts: e.target.checked })}
                />
                <span className="webflow-embed-fonts-label">Embed fonts in bundle</span>
              </label>
              <div className="webflow-embed-fonts-help">
                Off ships much smaller; relies on the page loading Ubuntu (Webflow projects usually do).
              </div>

              <div className="builder-label export-section-label">Logo URL (SVG)</div>
              <input
                type="text"
                className="webflow-attr-input webflow-logo-url-input"
                value={webflowState.logoUrl ?? ''}
                onChange={(e) => setWebflowState({ ...webflowState, logoUrl: e.target.value })}
                placeholder="https://… (empty = no logo)"
                spellCheck={false}
                title="External SVG URL for the logo. Replaces the inlined base64 logo to keep the bundle small. Leave empty to omit the logo."
              />
              <div className="webflow-embed-fonts-help">
                Uses an external URL instead of embedding the logo. Leave empty to omit.
              </div>

              <button
                type="button"
                className="scene-transitions-export scene-transitions-webflow-export"
                disabled={webflowBusy || !(webflowState.scenes || []).some((s) => s.include)}
                onClick={async () => {
                  if (!onExportWebflow) return;
                  setWebflowBusy(true);
                  try { await onExportWebflow({
                    attrName: webflowState.attrName || 'block-scene',
                    scenes: webflowState.scenes || [],
                    embedFonts: webflowState.embedFonts !== false,
                    logoUrl: (webflowState.logoUrl || '').trim(),
                  }); }
                  finally { setWebflowBusy(false); }
                }}
                title="Generate a Webflow-ready embed snippet for the selected scenes."
              >
                <span className="scene-transitions-export-content">
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M4 7 L8 3 L20 3 L20 21 L4 21 Z" />
                    <path d="M8 3 L8 7 L4 7" />
                    <path d="M8 12 L11 15 L16 10" />
                  </svg>
                  {webflowBusy ? 'Generating…' : 'Export Webflow Embed'}
                </span>
              </button>

              <button
                type="button"
                className="scene-transitions-export scene-transitions-webflow-export scene-transitions-beta-export"
                disabled={webflowBetaBusy || !(webflowState.scenes || []).some((s) => s.include)}
                onClick={async () => {
                  if (!onExportWebflowBeta) return;
                  setWebflowBetaBusy(true);
                  try { await onExportWebflowBeta({
                    attrName: webflowState.attrName || 'block-scene',
                    scenes: webflowState.scenes || [],
                    embedFonts: webflowState.embedFonts !== false,
                    logoUrl: (webflowState.logoUrl || '').trim(),
                  }); }
                  finally { setWebflowBetaBusy(false); }
                }}
                title="Size-optimized export (under 50K chars). Same visual output."
              >
                <span className="scene-transitions-export-content">
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M4 7 L8 3 L20 3 L20 21 L4 21 Z" />
                    <path d="M8 3 L8 7 L4 7" />
                    <path d="M8 12 L11 15 L16 10" />
                  </svg>
                  {webflowBetaBusy ? 'Generating…' : 'Beta Export'}
                </span>
              </button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// Build a portable HTML snippet that Webflow accepts (Custom Code embed).
// Renders an <iframe srcdoc> wouldn't be ideal — instead we ship a
// self-contained <div> + <style> + <script> block that disassembles the
// current scene, swaps to the next, and re-assembles it. The animation
// is driven by reading the active Webflow tab's `block-scene` attribute
// (configurable). Auto-advance plays one full cycle then stops; any
// manual tab click cancels auto-advance permanently.
//
// Inputs:
//   attrName        — DOM attribute on each Webflow tab carrying the scene id
//   sceneSnapshots  — [{ name, svg }] in the order they should auto-advance
//   embedFonts      — when true, emit one shared <style>{ @font-face… }
//                     block; when false, omit fonts and rely on the host
//                     page's webfonts (and print a hint comment showing
//                     the matching Google Fonts <link>).
//   fontFaceCss     — optional pre-built @font-face CSS string. If absent
//                     and embedFonts is true, no font CSS will be emitted.
//
// Output: a string containing <div> + <style> + <script>.
function buildWebflowExport({ attrName = 'block-scene', sceneSnapshots = [], embedFonts = true, fontFaceCss = '', logoUrl = '', canvasDims = null }) {
  if (!sceneSnapshots.length) {
    return '<!-- Block Builder · Webflow embed: no scenes selected -->';
  }

  // Hoist the logo across scenes. Each captured SVG carries an
  // <image id="bb-logo-img" href="data:image/...;base64,…"/> in its <defs>
  // (see App.jsx render). We strip every copy from the per-scene SVGs.
  // If logoUrl is provided, we rebuild the shared <image> using that URL
  // (massively smaller than the inlined base64 — keeps the bundle under
  // Webflow's 50,000-char embed limit). If logoUrl is empty, no shared
  // logo is emitted and the per-scene <use> references are stripped too.
  const useExternalUrl = !!logoUrl;
  let capturedLogoAttrs = null; // dimension/preserveAspectRatio attrs from the first matched logo
  const cleanSvg = (raw) => {
    let s = raw.replace(/^<\?xml[^?]*\?>\s*/i, '');
    // Remove (and remember) the logo template <image>. Match either
    // self-closing or paired form, with attributes in any order.
    s = s.replace(
      /<image\b([^>]*?)\bid="bb-logo-img"([^>]*?)(\/>|>\s*<\/image>)/gi,
      (_match, before, after) => {
        if (capturedLogoAttrs == null) {
          // Save attrs from the first matched logo so we can reconstruct
          // the shared tag with the same width/height/preserveAspectRatio.
          capturedLogoAttrs = (before + after);
        }
        return ''; // strip from the per-scene SVG
      }
    );
    if (useExternalUrl) {
      // Rewrite use-references to point at the shared id.
      s = s.replace(/href="#bb-logo-img"/g, 'href="#bb-shared-logo"');
      s = s.replace(/xlink:href="#bb-logo-img"/g, 'href="#bb-shared-logo"');
    } else {
      // No logo: drop every <use> that referenced it so we don't leave
      // dangling references in the DOM. Match self-closing and paired
      // forms; covers both the visible logo and its drop-shadow clone.
      s = s.replace(/<use\b[^>]*?(?:xlink:)?href="#bb-logo-img"[^>]*?(?:\/>|>\s*<\/use>)/gi, '');
    }
    // Strip the per-svg width/height so CSS-driven sizing wins, and add
    // preserveAspectRatio + display:block.
    s = s.replace(/<svg\b([^>]*)>/i, (_, attrs) => {
      const stripped = attrs
        .replace(/\swidth="[^"]*"/i, '')
        .replace(/\sheight="[^"]*"/i, '');
      return `<svg${stripped} preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block">`;
    });
    return s;
  };

  // Run cleanSvg on every scene first so capturedLogoAttrs is populated
  // before we assemble the bundle.
  let cleanedScenes = sceneSnapshots.map((s) => ({
    name: s.name,
    svg: cleanSvg(s.svg),
  }));

  // ----- Bundle-shrink pipeline (each pass is a pure string transform) -----

  // Step 1: round verbose decimals (e.g. 33.7651568331912 → 33.77). Excludes
  // scientific notation. Saves ~30% of bundle size on its own.
  const roundDecimalsInSvg = (s) => s.replace(/(-?\d+\.\d{3,})(?![eE\d])/g, (m) => {
    const n = parseFloat(m);
    return Number.isFinite(n) ? Number(n.toFixed(2)).toString() : m;
  });

  // Step 2: strip presentation-only declarations from inline styles that have
  // no purpose in a static embed. Removes `pointer-events:` and `cursor:` from
  // any style attribute (whether standalone or combined with other declarations);
  // empties out `style=""` afterward so the attribute itself can be dropped.
  const stripInlineEventStyles = (s) => s.replace(
    /\sstyle="([^"]*)"/g,
    (full, body) => {
      const cleaned = body
        .replace(/(?:^|\s*;\s*)pointer-events\s*:[^;"]*(?=;|$)/g, '')
        .replace(/(?:^|\s*;\s*)cursor\s*:[^;"]*(?=;|$)/g, '')
        .replace(/^\s*;+/, '')
        .replace(/;+\s*$/, '')
        .trim();
      return cleaned ? ` style="${cleaned}"` : '';
    }
  );

  // Step 3: replace the recurring 5-attribute stroke block with a CSS class.
  let strokeClassUsed = false;
  const applyStrokeClass = (s) => s.replace(
    /\sstroke="#8b8170"\s+stroke-opacity="0\.9"\s+stroke-width="3"\s+stroke-linecap="round"\s+stroke-linejoin="round"/g,
    () => { strokeClassUsed = true; return ' class="bb-s1"'; }
  );

  // Step 3b: replace the per-block top-edge polyline attribute set with a class.
  let edgeClassUsed = false;
  const applyEdgeClass = (s) => s.replace(
    /\sfill="none"\s+stroke="rgba\(255,255,255,0\.12\)"\s+stroke-width="1"/g,
    () => { edgeClassUsed = true; return ' class="bb-edge"'; }
  );

  // Step 3b-2: replace the recurring shadow polygon attribute set with a class.
  // Each block has a sibling shadow at unique coords but identical fill/filter.
  let shadowClassUsed = false;
  const applyShadowClass = (s) => s.replace(
    /\sfill="rgba\(0,\s*0,\s*0,\s*0\.32\)"\s+style="filter:\s*blur\(1\.5px\)"/g,
    () => { shadowClassUsed = true; return ' class="bb-shadow"'; }
  );

  // Step 3c: drop redundant style="fill: rgb(...)" — text elements duplicate
  // the fill color in both `style` and `fill=`; the SVG fill attribute wins.
  const stripStyleFill = (s) => s.replace(/\sstyle="fill:\s*rgb\([^)]+\)\s*;?\s*"/g, '');

  // Step 3d: hoist .block-label common attributes to a CSS rule. Each
  // <text class="block-label"> declares the same font-family, font-weight,
  // text-anchor, and fill — ~110 chars per text × 60+ texts per scene set.
  let textClassUsed = false;
  const isBlockLabel = (a, b) => a.includes('class="block-label"') || b.includes('class="block-label"');
  const applyTextClass = (s) => {
    let out = s;
    // Strip font-family
    out = out.replace(/<text\b([^>]*?)\sfont-family="[^"]*"([^>]*?)>/g, (m, a, b) => {
      if (isBlockLabel(a, b)) { textClassUsed = true; return `<text${a}${b}>`; }
      return m;
    });
    // Strip font-weight (always 700 on block-label)
    out = out.replace(/<text\b([^>]*?)\sfont-weight="[^"]*"([^>]*?)>/g, (m, a, b) => {
      if (isBlockLabel(a, b)) return `<text${a}${b}>`;
      return m;
    });
    // Strip text-anchor="middle" (always centered on block-label)
    out = out.replace(/<text\b([^>]*?)\stext-anchor="middle"([^>]*?)>/g, (m, a, b) => {
      if (isBlockLabel(a, b)) return `<text${a}${b}>`;
      return m;
    });
    // Strip fill="#ffffff" (always white on block-label)
    out = out.replace(/<text\b([^>]*?)\sfill="#ffffff"([^>]*?)>/g, (m, a, b) => {
      if (isBlockLabel(a, b)) return `<text${a}${b}>`;
      return m;
    });
    return out;
  };

  // Step 4: hoist ALL <defs> blocks (top-level AND nested inside scene <g>
  // wrappers — e.g. linearGradient defs for container walls). Dedupe child
  // entries by string equality. Children of <defs> never nest, so child
  // matching with a flat regex is safe.
  const hoistDefs = (scenes) => {
    const seen = new Set();
    const ordered = [];
    const childRe = /<(?:linearGradient|radialGradient|filter|pattern|clipPath|mask|symbol|marker|image|style)\b[^>]*(?:\/>|>[\s\S]*?<\/(?:linearGradient|radialGradient|filter|pattern|clipPath|mask|symbol|marker|image|style)>)/g;
    const stripped = scenes.map((sc) => ({
      name: sc.name,
      svg: sc.svg.replace(/<defs\b[^>]*>([\s\S]*?)<\/defs>/g, (_full, body) => {
        const children = body.match(childRe);
        if (children && children.length) {
          for (const child of children) {
            if (!seen.has(child)) { seen.add(child); ordered.push(child); }
          }
        } else if (body.trim()) {
          if (!seen.has(body)) { seen.add(body); ordered.push(body); }
        }
        return '';
      }),
    }));
    return { scenes: stripped, defsHtml: ordered.join('') };
  };

  // Step 6: hoist block geometry. Each block is a <g data-bb-block-id="..."
  // transform="translate(...)"> wrapping 3 colored polygons + an outline +
  // a matrix-rotated <text> label group. The geometry repeats heavily across
  // blocks of the same (dimension, color); the labels do not. We split the
  // label group out before hashing so two identically-shaped blocks with
  // different labels share a single <symbol>. The label group goes back on
  // the per-instance side of the <use>.
  const djb2 = (str) => {
    let h = 5381;
    for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
    return (h >>> 0).toString(36);
  };
  // Depth-counting scanner: given an offset right after a <g ...> opening
  // tag, return the offset right after the matching </g>. Handles arbitrary
  // nesting; returns -1 if unbalanced.
  const findMatchingGEnd = (svg, startAfterOpen) => {
    let depth = 1;
    const re = /<\/?g\b/g;
    re.lastIndex = startAfterOpen;
    while (depth > 0) {
      const m = re.exec(svg);
      if (!m) return -1;
      if (m[0] === '</g') {
        depth--;
        if (depth === 0) {
          const close = svg.indexOf('>', m.index);
          return close === -1 ? -1 : close + 1;
        }
      } else {
        const tagEnd = svg.indexOf('>', m.index);
        if (tagEnd === -1) return -1;
        // Self-closing <g .../> (rare but defensive)
        if (svg[tagEnd - 1] !== '/') depth++;
      }
    }
    return -1;
  };
  // Pull <g transform="matrix(...)"><text>...</text>[<text>...</text>]</g>
  // groups out of block inner content. Matrix wrappers contain only text +
  // whitespace (no nested <g>), so a non-greedy match is safe.
  const extractLabelGroups = (inner) => {
    const texts = [];
    const re = /<g\s+transform="matrix\([^"]+\)"[^>]*>(?:\s*<text\b[^>]*>[\s\S]*?<\/text>\s*)+<\/g>/g;
    const geometry = inner.replace(re, (m) => { texts.push(m); return ''; });
    return { geometry: geometry.trim(), texts: texts.join('') };
  };
  const hoistBlockSymbols = (scenes) => {
    const innerToHash = new Map();
    const symbols = [];
    const stripped = scenes.map((sc) => {
      const svg = sc.svg;
      let result = '';
      let cursor = 0;
      const openRe = /<g\s+[^>]*\bdata-bb-block-id="[^"]+"[^>]*>/g;
      let m;
      while ((m = openRe.exec(svg)) !== null) {
        const openStart = m.index;
        const openEnd = m.index + m[0].length;
        const closeAfter = findMatchingGEnd(svg, openEnd);
        if (closeAfter === -1) continue;
        // closeAfter is right after the matching </g>; its start is closeAfter - 4.
        const innerEnd = closeAfter - 4;
        const inner = svg.substring(openEnd, innerEnd);
        const innerTrim = inner.trim();
        if (!innerTrim) continue;
        const attrs = m[0];
        const idMatch = attrs.match(/\bdata-bb-block-id="([^"]+)"/);
        const tfMatch = attrs.match(/\btransform="([^"]+)"/);
        if (!idMatch) continue;
        const id = idMatch[1];
        const transform = tfMatch ? tfMatch[1] : '';

        // Separate label group(s) from geometry — labels vary per block,
        // geometry repeats across blocks of the same dimension + color.
        const { geometry, texts } = extractLabelGroups(innerTrim);
        const geomKey = geometry || innerTrim;

        let hash = innerToHash.get(geomKey);
        if (!hash) {
          hash = djb2(geomKey);
          innerToHash.set(geomKey, hash);
          symbols.push(`<symbol id="bb-b${hash}" overflow="visible">${geomKey}</symbol>`);
        }
        const tfAttr = transform ? ` transform="${transform}"` : '';
        const replacement = `<g data-bb-block-id="${id}"${tfAttr}><use href="#bb-b${hash}"/>${texts}</g>`;

        result += svg.substring(cursor, openStart) + replacement;
        cursor = closeAfter;
        openRe.lastIndex = closeAfter;
      }
      result += svg.substring(cursor);
      return { name: sc.name, svg: result };
    });
    return { scenes: stripped, symbolsHtml: symbols.join('') };
  };

  // Step 5: hoist canvas chrome — polygons/polylines/lines that serialize
  // identically across every scene. Each common shape becomes a <symbol>;
  // <use href="#bb-cN"/> refs are extracted into a persistent canvas layer
  // (rendered ONCE at the bundle level above scenes) instead of being placed
  // back into each scene. This lets the canvas stay visible during scene
  // transitions and animate independently (shrink on exit, grow on entry).
  // Includes <line> so canvas accent connectors dedupe.
  const hoistChromeShapes = (scenes) => {
    if (scenes.length < 2) return { scenes, symbolsHtml: '', canvasInner: '' };
    const SHAPE_RE = /<(?:polygon|polyline|path|line)\b[^/>]*\/>/g;
    const sceneShapes = scenes.map((sc) => sc.svg.match(SHAPE_RE) || []);
    const seen = new Set();
    const common = [];
    for (const shape of sceneShapes[0]) {
      if (shape.length < 40 || seen.has(shape)) continue;
      if (shape.includes('class="bb-s1"')) continue;
      seen.add(shape);
      let inAll = true;
      for (let i = 1; i < sceneShapes.length; i++) {
        if (!sceneShapes[i].includes(shape)) { inAll = false; break; }
      }
      if (inAll) common.push(shape);
    }
    if (!common.length) return { scenes, symbolsHtml: '', canvasInner: '' };
    const shapeToId = new Map();
    const symbols = [];
    common.forEach((shape, i) => {
      const id = `bb-c${i}`;
      shapeToId.set(shape, id);
      symbols.push(`<symbol id="${id}" overflow="visible">${shape}</symbol>`);
    });
    // Phase 1: replace each shape with a <use> ref, scene-by-scene
    const stripped = scenes.map((sc) => {
      let svg = sc.svg;
      for (const [shape, id] of shapeToId) {
        const useTag = `<use href="#${id}"/>`;
        while (svg.includes(shape)) svg = svg.replace(shape, useTag);
      }
      return { name: sc.name, svg };
    });
    // Phase 2: extract chrome <use> refs from scene 0 (preserves order/count) —
    // those become the persistent canvas layer's content.
    const canvasInner = (stripped[0].svg.match(/<use href="#bb-c\d+"\/>/g) || []).join('');
    // Phase 3: strip ALL chrome <use> refs from every scene; the canvas layer
    // renders them once, persistently.
    const finalScenes = stripped.map((sc) => ({
      name: sc.name,
      svg: sc.svg.replace(/<use href="#bb-c\d+"\/>/g, ''),
    }));
    return { scenes: finalScenes, symbolsHtml: symbols.join(''), canvasInner };
  };

  // Step 7: conservative JS controller minifier. Strips comments + leading
  // whitespace; keeps newlines (so ASI doesn't break) and variable names.
  const minifyJs = (src) => {
    let s = src.replace(/\/\*[\s\S]*?\*\//g, '');
    const stripLineComment = (line) => {
      let inStr = null;
      for (let i = 0; i < line.length; i++) {
        const c = line[i];
        if (inStr) {
          if (c === '\\') { i++; continue; }
          if (c === inStr) inStr = null;
        } else {
          if (c === '"' || c === "'" || c === '`') inStr = c;
          else if (c === '/' && line[i + 1] === '/') return line.slice(0, i);
        }
      }
      return line;
    };
    const out = [];
    for (const ln of s.split('\n')) {
      const t = stripLineComment(ln).replace(/^\s+/, '').replace(/\s+$/, '');
      if (t) out.push(t);
    }
    return out.join('\n');
  };

  // Run the per-scene passes (1, 2, 3, 3b, 3b-2, 3c, 3d).
  cleanedScenes = cleanedScenes.map((sc) => {
    let svg = sc.svg;
    svg = roundDecimalsInSvg(svg);
    svg = stripInlineEventStyles(svg);
    svg = stripStyleFill(svg);
    svg = applyStrokeClass(svg);
    svg = applyEdgeClass(svg);
    svg = applyShadowClass(svg);
    svg = applyTextClass(svg);
    return { name: sc.name, svg };
  });

  // Unify viewBox across all scenes BEFORE hoisting passes. Build-area mode
  // produces a per-scene bbox that varies if the accent slot is present in
  // some scenes but not others — different viewBoxes mean different scales
  // when fitted into a fixed-aspect container, which makes the floor jump
  // when switching tabs. Compute the union bbox and apply it everywhere so
  // all scenes (and the canvas layer derived from scene 0) share one viewBox.
  if (cleanedScenes.length > 1) {
    let minX = Infinity, minY = Infinity, maxR = -Infinity, maxB = -Infinity;
    const parsed = cleanedScenes.map((sc) => {
      const m = sc.svg.match(/viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+([\d.]+)\s+([\d.]+)"/);
      if (!m) return null;
      const x = parseFloat(m[1]), y = parseFloat(m[2]), w = parseFloat(m[3]), h = parseFloat(m[4]);
      if (x < minX) minX = x;
      if (y < minY) minY = y;
      if (x + w > maxR) maxR = x + w;
      if (y + h > maxB) maxB = y + h;
      return true;
    });
    if (parsed.every(Boolean)) {
      const unionVb = `${minX} ${minY} ${maxR - minX} ${maxB - minY}`;
      cleanedScenes = cleanedScenes.map((sc) => ({
        name: sc.name,
        svg: sc.svg.replace(/viewBox="[^"]+"/, `viewBox="${unionVb}"`),
      }));
    }
  }

  // Decide whether the bundle uses the dynamic shared canvas layer (chrome
  // rendered fresh from CANVAS_DIMS each frame) or the legacy static-hoist
  // path. Determined here so cross-scene hoisting passes can branch on it.
  const useDynamicCanvas = canvasDims && canvasDims.W != null && canvasDims.gridCenter != null;

  // Unify each scene's inner <g transform="translate(...)"> AND viewBox to
  // the unified gridCenter (computed at export time from max W/D/H across
  // selected scenes). Iso projections of (gx,gy,gz) are independent of
  // canvas H, so rewriting only the gridCenter offset shifts every scene's
  // content onto the same floor anchor. The viewBox is recomputed from the
  // unified canvas + worst-case external footprint so the shifted content
  // is fully visible (SVG clips to its viewBox by default).
  if (useDynamicCanvas) {
    const gc = canvasDims.gridCenter;
    const unifiedTf = `translate(${(-gc.x).toFixed(2)},${(-gc.y).toFixed(2)})`;

    // Compute unified viewBox in SVG coords (after gridCenter shift).
    // Mirrors Export.jsx _buildAreaSvgBBox but at the unified dims.
    const _CL = 0.16, _PED = 0.16, _FB = 0.12, _EXT = 3, _MAX_EXT = 2, _PAD = 24;
    const _UNIT = 46, _COS = Math.cos((22.5 * Math.PI) / 180), _SIN = Math.sin((22.5 * Math.PI) / 180);
    const _iso = (x, y, z) => ({ x: (x - y) * _UNIT * _COS, y: (x + y) * _UNIT * _SIN - z * _UNIT });
    const W = canvasDims.W, D = canvasDims.D, H = canvasDims.H, accent = canvasDims.accentSlotH || 0;
    const Wf = W + _FB, Df = D + _FB;
    const pts = [];
    for (const x of [0, Wf]) for (const y of [0, Df])
      for (const z of [-_PED, H + _CL + accent]) pts.push({ x, y, z });
    for (const x of [-_EXT - _MAX_EXT, -_EXT]) for (const y of [0, D])
      for (const z of [0, _MAX_EXT]) pts.push({ x, y, z });
    for (const x of [0, W]) for (const y of [-_EXT - _MAX_EXT, -_EXT])
      for (const z of [0, _MAX_EXT]) pts.push({ x, y, z });
    for (const x of [W + _EXT, W + _EXT + _MAX_EXT]) for (const y of [0, D])
      for (const z of [0, _MAX_EXT]) pts.push({ x, y, z });
    for (const x of [0, W]) for (const y of [D + _EXT, D + _EXT + _MAX_EXT])
      for (const z of [0, _MAX_EXT]) pts.push({ x, y, z });
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const p of pts) {
      const sp = _iso(p.x, p.y, p.z);
      const sx = sp.x - gc.x, sy = sp.y - gc.y;
      if (sx < minX) minX = sx; if (sy < minY) minY = sy;
      if (sx > maxX) maxX = sx; if (sy > maxY) maxY = sy;
    }
    const vbX = (minX - _PAD).toFixed(2);
    const vbY = (minY - _PAD).toFixed(2);
    const vbW = (maxX - minX + _PAD * 2).toFixed(2);
    const vbH = (maxY - minY + _PAD * 2).toFixed(2);
    const unifiedVb = `${vbX} ${vbY} ${vbW} ${vbH}`;

    cleanedScenes = cleanedScenes.map((sc) => ({
      name: sc.name,
      svg: sc.svg
        .replace(/<g\s+transform="translate\([^)]+\)"\s*>/, `<g transform="${unifiedTf}">`)
        .replace(/viewBox="[^"]+"/, `viewBox="${unifiedVb}"`),
    }));
  }

  // Run the cross-scene hoisting passes (4, 6, 5 — order matters).
  const defsRes = hoistDefs(cleanedScenes);
  cleanedScenes = defsRes.scenes;
  const blocksRes = hoistBlockSymbols(cleanedScenes);
  cleanedScenes = blocksRes.scenes;
  // Skip chrome hoisting when the dynamic canvas layer is the chrome source
  // — its output (canvasInner / chrome symbols) would be orphaned bytes.
  const chromeRes = useDynamicCanvas
    ? { scenes: cleanedScenes, symbolsHtml: '', canvasInner: '' }
    : hoistChromeShapes(cleanedScenes);
  cleanedScenes = chromeRes.scenes;

  // Step 8: extract connector polylines (class="bb-s1") into a per-scene back
  // layer. In the live preview, the canvas pedestal/floor/back walls (drawn
  // AFTER connectors in the same SVG) occlude the inner portion of each
  // connector that runs through the canvas volume. In the embed, canvas chrome
  // lives in a separate persistent layer (.bb-canvas), so we pull connectors
  // out of the per-scene SVG and place them in a parallel .bb-scene-conn layer
  // that sits behind .bb-canvas. The portion of the connector that runs
  // through the canvas footprint is then occluded by the canvas walls/floor,
  // matching the live preview.
  const extractConnectorLayers = (scenes) => {
    const connRe = /<polyline\b[^>]*\bclass="bb-s1"[^>]*\/>/g;
    return scenes.map((sc) => {
      const matches = sc.svg.match(connRe) || [];
      if (!matches.length) return { name: sc.name, svg: sc.svg, connHtml: '' };
      const stripped = sc.svg.replace(connRe, '');
      const vbMatch = sc.svg.match(/viewBox="([^"]+)"/);
      const innerGMatch = sc.svg.match(/<g\s+transform="([^"]+)"\s*>/);
      const viewBox = vbMatch ? vbMatch[1] : '';
      const innerTfAttr = innerGMatch ? ` transform="${innerGMatch[1]}"` : '';
      const connHtml = viewBox
        ? `<svg viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block">` +
          `<g${innerTfAttr}>${matches.join('')}</g>` +
          `</svg>`
        : '';
      return { name: sc.name, svg: stripped, connHtml };
    });
  };
  const connRes = extractConnectorLayers(cleanedScenes);
  cleanedScenes = connRes.map((s) => ({ name: s.name, svg: s.svg }));

  // Step 8b: extract back-side external <g data-bb-back="1">…</g> wrappers into
  // a per-scene .bb-scene-back-ext layer that lives BEHIND .bb-canvas (the
  // hoisted canvas chrome). In the live preview, App.jsx renders back-side
  // externals BEFORE ContainerBack so the canvas walls occlude them; in the
  // embed, all externals would otherwise sit at z-index 2 inside .bb-scene
  // and visually float in front of the canvas. Pulling them into a layer at
  // z-index -1 puts the canvas back walls back on top of them where they
  // overlap. Block animations still find these elements because extsOf()
  // unions [data-bb-ext] across .bb-scene + .bb-scene-back-ext.
  const extractBalancedGs = (svg, attr) => {
    const startRe = new RegExp(`<g\\b[^>]*\\b${attr}[^>]*>`, 'g');
    const ranges = [];
    let m;
    while ((m = startRe.exec(svg)) !== null) {
      let depth = 1;
      let i = m.index + m[0].length;
      while (depth > 0 && i < svg.length) {
        const nextOpen = svg.indexOf('<g', i);
        const nextClose = svg.indexOf('</g>', i);
        if (nextClose === -1) break;
        if (nextOpen !== -1 && nextOpen < nextClose) {
          const ch = svg.charAt(nextOpen + 2);
          if (ch === ' ' || ch === '>' || ch === '\t' || ch === '\n' || ch === '/') depth++;
          i = nextOpen + 2;
        } else {
          depth--;
          i = nextClose + 4;
        }
      }
      if (depth === 0) ranges.push({ start: m.index, end: i });
    }
    if (!ranges.length) return { matches: [], stripped: svg };
    let stripped = svg;
    const matches = [];
    for (let r = ranges.length - 1; r >= 0; r--) {
      matches.unshift(stripped.substring(ranges[r].start, ranges[r].end));
      stripped = stripped.substring(0, ranges[r].start) + stripped.substring(ranges[r].end);
    }
    return { matches, stripped };
  };
  const extractBackExtLayers = (scenes) => scenes.map((sc) => {
    const { matches, stripped } = extractBalancedGs(sc.svg, 'data-bb-back="1"');
    if (!matches.length) return { name: sc.name, svg: sc.svg, backHtml: '' };
    const vbMatch = sc.svg.match(/viewBox="([^"]+)"/);
    const innerGMatch = sc.svg.match(/<g\s+transform="([^"]+)"\s*>/);
    const viewBox = vbMatch ? vbMatch[1] : '';
    const innerTfAttr = innerGMatch ? ` transform="${innerGMatch[1]}"` : '';
    const backHtml = viewBox
      ? `<svg viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block">` +
        `<g${innerTfAttr}>${matches.join('')}</g>` +
        `</svg>`
      : '';
    return { name: sc.name, svg: stripped, backHtml };
  });
  const backRes = extractBackExtLayers(cleanedScenes);
  cleanedScenes = backRes.map((s) => ({ name: s.name, svg: s.svg }));

  // Step 9: when the dynamic canvas layer is the sole source of canvas chrome
  // (dynamic mode), strip the per-scene Container chrome so it isn't
  // drawn twice. Without this, scenes with different H or accent-slot heights
  // leave their walls/lines in the per-scene SVG (because hoistChromeShapes
  // only dedupes shapes COMMON to all scenes); the dynamic layer then paints
  // a second canvas on top, producing the visible duplicate. Container.jsx
  // marks its root <g>s with data-bb-chrome="back"/"front" — both <g>s have
  // no nested <g>s, so a non-greedy match is safe.
  const stripContainerChrome = (svg) => svg.replace(
    /<g\b[^>]*\bdata-bb-chrome="[^"]+"[^>]*>[\s\S]*?<\/g>/g,
    ''
  );
  if (useDynamicCanvas) {
    cleanedScenes = cleanedScenes.map((sc) => ({ name: sc.name, svg: stripContainerChrome(sc.svg) }));
  }
  const connLayerHasContent = connRes.some((s) => s.connHtml);
  const connLayerHtml = connLayerHasContent
    ? connRes.map((s) => (
        s.connHtml
          ? `  <div class="bb-scene-conn" data-scene="${escapeAttr(s.name)}" aria-hidden="true">\n    ${s.connHtml}\n  </div>`
          : ''
      )).filter(Boolean).join('\n')
    : '';
  const backLayerHasContent = backRes.some((s) => s.backHtml);
  const backLayerHtml = backLayerHasContent
    ? backRes.map((s) => (
        s.backHtml
          ? `  <div class="bb-scene-back-ext" data-scene="${escapeAttr(s.name)}" aria-hidden="true">\n    ${s.backHtml}\n  </div>`
          : ''
      )).filter(Boolean).join('\n')
    : '';

  // Build the shared <image> tag using the external URL while preserving
  // the dimensional attrs from the captured base64 image. Replace any
  // href/xlink:href found in the captured attrs with the new URL.
  let sharedLogoTag = null;
  if (useExternalUrl) {
    const attrsBase = capturedLogoAttrs != null
      ? capturedLogoAttrs
        .replace(/\s(?:xlink:)?href="[^"]*"/g, '')
      : '';
    sharedLogoTag = `<image id="bb-shared-logo"${attrsBase} href="${escapeAttr(logoUrl)}"/>`;
  }

  // Compose the shared <defs> container with logo + hoisted defs + symbols.
  const sharedDefsParts = [];
  if (sharedLogoTag) sharedDefsParts.push(sharedLogoTag);
  if (defsRes.defsHtml) sharedDefsParts.push(defsRes.defsHtml);
  if (blocksRes.symbolsHtml) sharedDefsParts.push(blocksRes.symbolsHtml);
  if (chromeRes.symbolsHtml) sharedDefsParts.push(chromeRes.symbolsHtml);
  const sharedDefs = sharedDefsParts.length
    ? `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>${sharedDefsParts.join('')}</defs></svg>\n\n`
    : '';

  // Optional shared <style> with @font-face. When embedFonts is false,
  // emit a comment with the recommended Google Fonts <link> instead.
  const fontStyle = embedFonts && fontFaceCss
    ? `<style>\n${fontFaceCss}\n</style>\n`
    : '';
  const fontHint = !embedFonts
    ? [
      '<!-- Fonts are NOT embedded. Add this <link> to your Webflow site',
      '     <head> (Project Settings → Custom Code → Head Code) so scene',
      '     labels render in Ubuntu: -->',
      '<!-- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> -->',
      '<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"> -->',
      '',
    ].join('\n')
    : '';

  // Build the persistent canvas layer if the chrome hoist produced any
  // shared shapes. The layer mirrors the scene's <svg> + outer <g> structure
  // so the chrome <use> refs render at the same coordinate position they
  // would inside a scene. Wrapped in an extra .bb-canvas-scale div so the
  // shrink/grow animation has a clean transform target without conflicting
  // with the SVG <g>'s own transform attribute.
  // When canvasDims is provided, the JS controller will render the canvas
  // chrome dynamically per frame (replicates the live preview's wall-shrink
  // animation exactly — walls collapse toward the floor while the floor
  // stays put). The static canvas-layer SVG starts empty and is populated
  // by the controller on init. (useDynamicCanvas was determined earlier.)
  let canvasLayerHtml = '';
  if ((chromeRes.canvasInner || useDynamicCanvas) && cleanedScenes.length) {
    const scene0 = cleanedScenes[0].svg || '';
    const vbMatch = scene0.match(/viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+([\d.]+)\s+([\d.]+)"/);
    const innerGMatch = scene0.match(/<g\s+transform="([^"]+)"\s*>/);
    const viewBox = vbMatch ? `${vbMatch[1]} ${vbMatch[2]} ${vbMatch[3]} ${vbMatch[4]}` : '';
    const innerTf = innerGMatch ? innerGMatch[1] : '';
    if (viewBox) {
      // In dynamic mode the controller fills <g class="bb-canvas-inner"> on
      // init and animates wall-height morphs between scenes. In static mode
      // we emit the deduped chrome <use> refs as before.
      const innerContent = useDynamicCanvas
        ? `<g class="bb-canvas-inner"${innerTf ? ` transform="${innerTf}"` : ''}></g>`
        : `<g${innerTf ? ` transform="${innerTf}"` : ''}>${chromeRes.canvasInner}</g>`;
      // Static fallback uses scaleY transform-origin at the bottom of the
      // div; not used in dynamic mode (chrome is rendered fresh each frame).
      const scaleStyle = useDynamicCanvas ? '' : ` style="transform-origin:50% 100%"`;
      canvasLayerHtml =
        `<div class="bb-canvas" aria-hidden="true">\n` +
        `  <div class="bb-canvas-scale"${scaleStyle}>\n` +
        `    <svg viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block">\n` +
        `      ${innerContent}\n` +
        `    </svg>\n` +
        `  </div>\n` +
        `</div>\n`;
      // Front-of-blocks layer (dynamic mode only): the front post + the two
      // top edges meeting at the front corner. Lives in a separate div with
      // higher z-index than scenes so it renders ABOVE blocks.
      if (useDynamicCanvas) {
        canvasLayerHtml +=
          `<div class="bb-canvas-front" aria-hidden="true">\n` +
          `  <svg viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block">\n` +
          `    <g class="bb-canvas-inner-front"${innerTf ? ` transform="${innerTf}"` : ''}></g>\n` +
          `  </svg>\n` +
          `</div>\n`;
      }
    }
  }

  const sceneHtml = cleanedScenes.map((s) => (
    `  <div class="bb-scene" data-scene="${escapeAttr(s.name)}" aria-hidden="true">\n` +
    `${s.svg.split('\n').map((l) => '    ' + l).join('\n')}\n` +
    `  </div>`
  )).join('\n');

  const sceneOrder = JSON.stringify(sceneSnapshots.map((s) => s.name));

  // The controller. Kept inside an IIFE so it's safe to paste multiple
  // embeds (different attrName) on one page; each instance is bound to
  // its own .bb-stage. The script auto-runs on DOMContentLoaded and
  // exposes a single API on window.BlockBuilder pointing at the most
  // recently initialized stage (sufficient for typical Webflow pages).
  // Single shared canvas dims (one stage for all acts). Plus a per-scene
  // accent-presence map — that's the only per-scene data the controller
  // needs to pick a target wall height (standard vs. with-accent).
  const canvasDimsJson = useDynamicCanvas ? JSON.stringify(canvasDims) : 'null';
  const sceneAccentMap = {};
  sceneSnapshots.forEach((s) => { sceneAccentMap[s.name] = !!s.hasAccent; });
  const sceneAccentJson = JSON.stringify(sceneAccentMap);
  const script = `
(function () {
  var ATTR = ${JSON.stringify(attrName)};
  var ORDER = ${sceneOrder};
  var DURATION_DISASSEMBLE = 900;   // ms — match in-app exit timing roughly
  var DURATION_ASSEMBLE    = 900;   // ms — entry
  var AUTO_INTERVAL_MS     = 4500;  // dwell on each scene during auto-advance
  var BLOCK_LIFT_PX        = 340;   // how far blocks fly up on exit (matches in-app)
  var STAGGER_MS           = 280;   // total stagger window across all blocks
  // One shared canvas (the "stage") for all acts. The floor is anchored at
  // a single screen Y derived from the unified gridCenter — never moves.
  // Each act differs only in whether it has an accent block, which extends
  // the wall height by accentSlotH. WALL_LOW is the mid-transition state
  // (walls fully collapsed, only floor + pedestal visible).
  var CANVAS_DIMS = ${canvasDimsJson};
  var SCENE_ACCENT = ${sceneAccentJson};
  var HAS_DIMS = !!(CANVAS_DIMS && CANVAS_DIMS.W != null);
  var WALL_LOW = 0;
  function targetHeightFor(id) {
    if (!HAS_DIMS) return 0;
    var base = SCENE_ACCENT[id]
      ? CANVAS_DIMS.H + (CANVAS_DIMS.accentSlotH || 0)
      : CANVAS_DIMS.H;
    return base + CEIL_LIFT;
  }
  // Iso projection constants, matching Block.jsx (UNIT, COS, SIN @ 22.5°).
  var ISO_UNIT = 46;
  var ISO_COS = Math.cos((22.5 * Math.PI) / 180);
  var ISO_SIN = Math.sin((22.5 * Math.PI) / 180);
  var CEIL_LIFT = 0.16, PEDESTAL = 0.16, FB = 0.12;
  var TEAL = '#2dd4a0', TEAL_LIGHT = '#7df0c4', TEAL_DEEP = '#14b88a';
  function bbIso(x, y, z) {
    return { x: (x - y) * ISO_UNIT * ISO_COS, y: (x + y) * ISO_UNIT * ISO_SIN - z * ISO_UNIT };
  }
  // Compute the 12 container corners (floor, ceiling, pedestal underside) for
  // a given wall top z. Caller passes the actual top z; 0 = walls fully
  // collapsed (only floor + pedestal visible). Corners are in iso screen
  // coords; the gridCenter shift is applied separately at render time.
  function bbCorners(W, D, wallTopZ) {
    var Wf = W + FB, Df = D + FB;
    return {
      bBack: bbIso(0, 0, 0), bRight: bbIso(Wf, 0, 0), bLeft: bbIso(0, Df, 0), bFront: bbIso(Wf, Df, 0),
      tBack: bbIso(0, 0, wallTopZ), tRight: bbIso(Wf, 0, wallTopZ), tLeft: bbIso(0, Df, wallTopZ), tFront: bbIso(Wf, Df, wallTopZ),
      pBack: bbIso(0, 0, -PEDESTAL), pRight: bbIso(Wf, 0, -PEDESTAL), pLeft: bbIso(0, Df, -PEDESTAL), pFront: bbIso(Wf, Df, -PEDESTAL)
    };
  }
  function bbPolyStr() {
    var s = '';
    for (var i = 0; i < arguments.length; i++) {
      if (i) s += ' ';
      s += arguments[i].x + ',' + arguments[i].y;
    }
    return s;
  }
  // Compute the chrome corners (with gridCenter shift) for a given wall top
  // z. The gridCenter is the unified one — all acts share it, so the floor's
  // screen position is constant regardless of wall height.
  function bbChromeCorners(wallTopZ) {
    if (!HAS_DIMS) return null;
    var W = CANVAS_DIMS.W, D = CANVAS_DIMS.D;
    var gc = CANVAS_DIMS.gridCenter || { x: 0, y: 0 };
    var C = bbCorners(W, D, wallTopZ);
    var k; for (k in C) C[k] = { x: C[k].x - gc.x, y: C[k].y - gc.y };
    return C;
  }
  function bbLine(a, b, op, sw, color) {
    return '<line x1="' + a.x + '" y1="' + a.y + '" x2="' + b.x + '" y2="' + b.y + '" stroke="' + color + '" stroke-opacity="' + op + '" stroke-width="' + sw + '"/>';
  }
  // Back chrome: pedestal, floor, far walls, back posts + back edges. Mirrors
  // Container.jsx ContainerBack — drawn BEHIND blocks.
  function bbRenderChromeBack(wallTopZ) {
    var C = bbChromeCorners(wallTopZ);
    if (!C) return '';
    return [
      '<polygon points="' + bbPolyStr(C.pRight, C.pFront, C.bFront, C.bRight) + '" fill="' + TEAL_DEEP + '"/>',
      '<polygon points="' + bbPolyStr(C.pLeft, C.pFront, C.bFront, C.bLeft) + '" fill="' + TEAL_DEEP + '"/>',
      '<polyline points="' + bbPolyStr(C.bRight, C.pRight, C.pFront, C.pLeft, C.bLeft) + '" fill="none" stroke="' + TEAL_DEEP + '" stroke-opacity="0.9" stroke-width="1.5" stroke-linejoin="round"/>',
      '<polygon points="' + bbPolyStr(C.bBack, C.bRight, C.bFront, C.bLeft) + '" fill="' + TEAL + '"/>',
      '<polygon points="' + bbPolyStr(C.bBack, C.bLeft, C.tLeft, C.tBack) + '" fill="url(#container-wall-left)" stroke="' + TEAL + '" stroke-opacity="0.55" stroke-width="1.5"/>',
      '<polygon points="' + bbPolyStr(C.bBack, C.bRight, C.tRight, C.tBack) + '" fill="url(#container-wall-right)" stroke="' + TEAL + '" stroke-opacity="0.55" stroke-width="1.5"/>',
      bbLine(C.bBack,  C.tBack,  '0.55', '1.5', TEAL),
      bbLine(C.bRight, C.tRight, '0.55', '1.5', TEAL),
      bbLine(C.bLeft,  C.tLeft,  '0.55', '1.5', TEAL),
      bbLine(C.bBack, C.bLeft,  '0.7', '1.5', TEAL),
      bbLine(C.bBack, C.bRight, '0.7', '1.5', TEAL),
      bbLine(C.tBack, C.tLeft,  '0.6', '1.5', TEAL),
      bbLine(C.tBack, C.tRight, '0.6', '1.5', TEAL)
    ].join('');
  }
  // Front chrome: front post + the 2 top edges meeting at the front corner.
  // Mirrors Container.jsx ContainerFront — drawn IN FRONT of blocks.
  function bbRenderChromeFront(wallTopZ) {
    var C = bbChromeCorners(wallTopZ);
    if (!C) return '';
    return [
      bbLine(C.bFront, C.tFront, '0.9',  '1.75', TEAL_LIGHT),
      bbLine(C.tFront, C.tRight, '0.85', '1.75', TEAL_LIGHT),
      bbLine(C.tFront, C.tLeft,  '0.85', '1.75', TEAL_LIGHT)
    ].join('');
  }

  function init() {
    var stage = document.querySelector('.bb-stage[data-bb-attr=' + cssEscape(ATTR) + ']');
    if (!stage) stage = document.querySelector('.bb-stage');
    if (!stage) return;
    var sceneEls = {};
    Array.prototype.forEach.call(stage.querySelectorAll('.bb-scene'), function (el) {
      sceneEls[el.getAttribute('data-scene')] = el;
    });
    // Per-scene connector layer (.bb-scene-conn). Lives behind the canvas
    // chrome so canvas walls occlude the inner portion of each line. Each
    // entry mirrors a sceneEls entry; absent if the scene had no connectors.
    var connEls = {};
    Array.prototype.forEach.call(stage.querySelectorAll('.bb-scene-conn'), function (el) {
      connEls[el.getAttribute('data-scene')] = el;
    });
    function fadeConn(id, toOpacity, durationMs, easing) {
      var co = connEls[id];
      if (!co) return;
      co.style.transition = 'opacity ' + durationMs + 'ms ' + (easing || 'ease-in-out');
      co.style.opacity = String(toOpacity);
    }
    function setConnInstant(id, opacity) {
      var co = connEls[id];
      if (!co) return;
      co.style.transition = 'none';
      co.style.opacity = String(opacity);
    }
    // Per-scene back-external layer (.bb-scene-back-ext). Mirrors connEls:
    // back-side externals were extracted out of the per-scene SVG so their
    // layer can sit BEHIND .bb-canvas (the hoisted canvas chrome) at z-index
    // -1. Fades sync with the active scene like the connector layer.
    var backExtEls = {};
    Array.prototype.forEach.call(stage.querySelectorAll('.bb-scene-back-ext'), function (el) {
      backExtEls[el.getAttribute('data-scene')] = el;
    });
    function fadeBackExt(id, toOpacity, durationMs, easing) {
      var be = backExtEls[id];
      if (!be) return;
      be.style.transition = 'opacity ' + durationMs + 'ms ' + (easing || 'ease-in-out');
      be.style.opacity = String(toOpacity);
    }
    function setBackExtInstant(id, opacity) {
      var be = backExtEls[id];
      if (!be) return;
      be.style.transition = 'none';
      be.style.opacity = String(opacity);
    }
    // Persistent canvas layer (optional — present when chrome was hoisted).
    // Animated via an inline transform on .bb-canvas-scale: shrink during
    // exit, grow during entry. Inline styles are how the block animations
    // also drive their movement, so we know this code path is reliable.
    var canvasScale = stage.querySelector('.bb-canvas-scale');
    // Dynamic canvas chrome target (the inner <g class="bb-canvas-inner"> the
    // bundler emits when CANVAS_DIMS is provided). When present, we drive the
    // wall-shrink animation by re-rendering chrome on every animation frame.
    var canvasInner = stage.querySelector('.bb-canvas-inner');
    var canvasInnerFront = stage.querySelector('.bb-canvas-inner-front');
    var canvasAnimRaf = null;
    var canvasAnimStart = 0;
    function paintCanvas(wallTopZ) {
      if (!HAS_DIMS) return;
      var z = wallTopZ < 0 ? 0 : wallTopZ;
      if (canvasInner) canvasInner.innerHTML = bbRenderChromeBack(z);
      if (canvasInnerFront) canvasInnerFront.innerHTML = bbRenderChromeFront(z);
    }
    function easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2; }
    function animateWalls(fromZ, toZ, durationMs, easing) {
      if (!canvasInner || !HAS_DIMS) return;
      if (canvasAnimRaf) cancelAnimationFrame(canvasAnimRaf);
      var start = performance.now();
      function step(now) {
        var t = Math.max(0, Math.min(1, (now - start) / durationMs));
        var k = easing ? easing(t) : t;
        paintCanvas(fromZ + (toZ - fromZ) * k);
        if (t < 1) canvasAnimRaf = requestAnimationFrame(step);
        else canvasAnimRaf = null;
      }
      canvasAnimRaf = requestAnimationFrame(step);
    }

    var current = null;
    var transitioning = false;
    var paused = false;
    var autoCancelled = false;
    var autoTimer = null;
    var pendingResolve = null;

    // ------------------------------------------------------------------
    // Webflow tab discovery
    // ------------------------------------------------------------------
    function findTabs() {
      return Array.prototype.slice.call(
        document.querySelectorAll('[' + ATTR + ']')
      );
    }
    function tabSceneId(t) { return t && t.getAttribute(ATTR); }
    function findActiveTab(tabs) {
      // Webflow marks the active tab with .w--current. Fall back to
      // [aria-selected="true"] for non-Webflow tab systems.
      for (var i = 0; i < tabs.length; i++) {
        if (tabs[i].classList.contains('w--current')) return tabs[i];
      }
      for (var j = 0; j < tabs.length; j++) {
        if (tabs[j].getAttribute('aria-selected') === 'true') return tabs[j];
      }
      return tabs[0] || null;
    }

    // ------------------------------------------------------------------
    // Per-block exit/entry animation. Reads [data-bb-block-id] from each
    // scene's snapshot SVG and staggers transforms by screen Y (top first
    // for exit, bottom first for entry). Container chrome (walls,
    // externals, accent) gets a single coarser fade.
    // ------------------------------------------------------------------
    function blocksOf(scene) {
      // Regular (in-canvas) blocks only — externals are wrapped in a
      // <g data-bb-ext> ancestor and get a different (scale-toward-face)
      // animation that mirrors the in-app ANIMATED preview.
      return Array.prototype.slice.call(
        scene.querySelectorAll('[data-bb-block-id]')
      ).filter(function (el) {
        return !el.closest('[data-bb-ext]');
      });
    }
    function extsOf(scene) {
      var inScene = Array.prototype.slice.call(scene.querySelectorAll('[data-bb-ext]'));
      var be = backExtEls[scene.getAttribute('data-scene')];
      var inBack = be ? Array.prototype.slice.call(be.querySelectorAll('[data-bb-ext]')) : [];
      return inScene.concat(inBack);
    }
    function chromeOf(scene) {
      // Everything inside the SVG that isn't a tagged block — we wrap
      // the SVG itself for a coarse fade so walls/externals/accent
      // disappear together.
      var svg = scene.querySelector('svg');
      return svg ? [svg] : [];
    }
    function sortByTopY(els) {
      var withRect = els.map(function (el) {
        var r = el.getBoundingClientRect();
        return { el: el, top: r.top };
      });
      withRect.sort(function (a, b) { return a.top - b.top; });
      return withRect.map(function (x) { return x.el; });
    }
    // CSS transform on an SVG element overrides the SVG transform attribute
    // (instead of composing). To animate a block's lift without losing its
    // resting position, parse the original SVG translate(x, y) once and
    // re-emit it as CSS-valid translate(xpx, ypx) — SVG attribute syntax is
    // unitless, but CSS transform requires units, so the unconverted form
    // would make the entire declaration invalid (and silently discarded).
    function origTf(el) {
      var cached = el.__bbOrig;
      if (cached !== undefined) return cached;
      var attr = el.getAttribute('transform') || '';
      var m = attr.match(/translate\\s*\\(\\s*(-?\\d+(?:\\.\\d+)?)\\s*[ ,]\\s*(-?\\d+(?:\\.\\d+)?)\\s*\\)/);
      cached = m ? 'translate(' + m[1] + 'px,' + m[2] + 'px)' : '';
      el.__bbOrig = cached;
      return cached;
    }
    function applyExit(scene) {
      var blocks = sortByTopY(blocksOf(scene));
      var n = Math.max(1, blocks.length - 1);
      blocks.forEach(function (el, i) {
        var delay = (i / n) * STAGGER_MS; // top blocks leave first
        el.style.transition =
          'transform ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53) ' + delay + 'ms,' +
          ' opacity ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53) ' + delay + 'ms';
        el.style.transform = origTf(el) + ' translate(0, -' + BLOCK_LIFT_PX + 'px)';
        el.style.opacity = '0';
      });
      chromeOf(scene).forEach(function (el) {
        el.style.transition = 'opacity ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53)';
        el.style.opacity = '0';
      });
    }
    function applyEntryStart(scene) {
      // Pre-state for entry: blocks lifted (relative to their resting position)
      // + transparent, chrome visible. No transition while we set the start
      // state, so the browser doesn't animate from "natural" to "lifted".
      blocksOf(scene).forEach(function (el) {
        el.style.transition = 'none';
        el.style.transform = origTf(el) + ' translate(0, -' + BLOCK_LIFT_PX + 'px)';
        el.style.opacity = '0';
      });
      chromeOf(scene).forEach(function (el) {
        el.style.transition = 'none';
        el.style.opacity = '1';
      });
    }
    function applyEntry(scene) {
      var blocks = sortByTopY(blocksOf(scene));
      var n = Math.max(1, blocks.length - 1);
      blocks.forEach(function (el, i) {
        // Bottom-up reassembly (mirror of exit). Animate to the resting
        // transform — same as origTf, so when style.transform is later
        // cleared the visual position is identical.
        var delay = ((n - i) / n) * STAGGER_MS;
        el.style.transition =
          'transform ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + delay + 'ms,' +
          ' opacity ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + delay + 'ms';
        el.style.transform = origTf(el);
        el.style.opacity = '1';
      });
    }
    function clearTransforms(scene) {
      blocksOf(scene).forEach(function (el) {
        el.style.transition = '';
        el.style.transform = '';
        el.style.opacity = '';
      });
      extsOf(scene).forEach(function (el) {
        el.style.transition = '';
        el.style.transform = '';
        el.style.opacity = '';
      });
      [accentOf(scene), logoOf(scene)].forEach(function (el) {
        if (!el) return;
        el.style.transition = '';
        el.style.transform = '';
        el.style.opacity = '';
      });
      chromeOf(scene).forEach(function (el) {
        el.style.transition = '';
        el.style.opacity = '';
      });
    }
    // -------- External-block scale-toward-face + connector retract --------
    // Mirrors the in-app ANIMATED preview's renderExtWrap math: each
    // external block scales toward its connector's near-face anchor (the
    // point the connector terminates at) while opacity fades. The connector
    // itself retracts from the face end toward the canvas pedestal via
    // stroke-dashoffset on the polyline.
    function _extScaleStr(fx, fy, s) {
      // translate(face) ∘ scale(s) ∘ translate(-face) — scale around face.
      return 'translate(' + fx + 'px,' + fy + 'px) scale(' + s + ') translate(' + (-fx) + 'px,' + (-fy) + 'px)';
    }
    function applyExitExt(scene) {
      var exts = extsOf(scene);
      var n = Math.max(1, exts.length - 1);
      exts.forEach(function (el, i) {
        var fx = parseFloat(el.getAttribute('data-bb-face-x'));
        var fy = parseFloat(el.getAttribute('data-bb-face-y'));
        if (isNaN(fx) || isNaN(fy)) return;
        var delay = (i / n) * STAGGER_MS;
        el.style.transformBox = 'view-box';
        el.style.transition =
          'transform ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53) ' + delay + 'ms,' +
          ' opacity ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53) ' + delay + 'ms';
        el.style.transform = _extScaleStr(fx, fy, 0);
        el.style.opacity = '0';
      });
    }
    function applyEntryExtStart(scene) {
      extsOf(scene).forEach(function (el) {
        var fx = parseFloat(el.getAttribute('data-bb-face-x'));
        var fy = parseFloat(el.getAttribute('data-bb-face-y'));
        if (isNaN(fx) || isNaN(fy)) return;
        el.style.transformBox = 'view-box';
        el.style.transition = 'none';
        el.style.transform = _extScaleStr(fx, fy, 0);
        el.style.opacity = '0';
      });
    }
    function applyEntryExt(scene) {
      var exts = extsOf(scene);
      var n = Math.max(1, exts.length - 1);
      exts.forEach(function (el, i) {
        var delay = ((n - i) / n) * STAGGER_MS;
        el.style.transition =
          'transform ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + delay + 'ms,' +
          ' opacity ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + delay + 'ms';
        el.style.transform = '';
        el.style.opacity = '1';
      });
    }
    // Polyline retraction: stroke-dasharray = L L (one solid dash, one gap),
    // then offset 0 → L makes the visible portion slide off the END first
    // (face → canvas) — same direction as the in-app truncatePolyline.
    var connPolysByScene = {};
    function setupConnPolylines() {
      Array.prototype.forEach.call(stage.querySelectorAll('.bb-scene-conn'), function (connEl) {
        var sceneId = connEl.getAttribute('data-scene');
        var polys = Array.prototype.slice.call(connEl.querySelectorAll('polyline'));
        polys.forEach(function (p) {
          var L = 0;
          try { L = p.getTotalLength(); } catch (_) { L = 0; }
          if (!L || !isFinite(L)) return;
          p.style.strokeDasharray = L + ' ' + L;
          p.__bbLen = L;
        });
        connPolysByScene[sceneId] = polys;
      });
    }
    function setPolyRetracted(sceneId, retracted) {
      var polys = connPolysByScene[sceneId] || [];
      polys.forEach(function (p) {
        var L = p.__bbLen || 0;
        p.style.transition = 'none';
        p.style.strokeDashoffset = retracted ? String(L) : '0';
      });
    }
    function animatePolyExit(sceneId, durationMs, easing) {
      var polys = connPolysByScene[sceneId] || [];
      polys.forEach(function (p) {
        var L = p.__bbLen || 0;
        p.style.transition = 'stroke-dashoffset ' + durationMs + 'ms ' + (easing || 'ease-in');
        p.style.strokeDashoffset = String(L);
      });
    }
    function animatePolyEntry(sceneId, durationMs, easing) {
      var polys = connPolysByScene[sceneId] || [];
      polys.forEach(function (p) {
        var L = p.__bbLen || 0;
        // Force layout flush so the dasharray/offset start state takes
        // effect before the transition kicks in next tick.
        // eslint-disable-next-line no-unused-expressions
        p.getBoundingClientRect && p.getBoundingClientRect();
        p.style.transition = 'stroke-dashoffset ' + durationMs + 'ms ' + (easing || 'ease-out');
        p.style.strokeDashoffset = '0';
      });
    }
    // -------- Accent block + logo lift --------
    // Mirror the in-app ANIMATED preview: both sit at the TOP of the stack,
    // so on exit they lift FIRST (no stagger delay — start at t=0) and on
    // entry they land LAST (full stagger delay — settle after every block).
    // Use the SAME duration as regular blocks so they read as smoothly as
    // everything else; the first/last ordering comes purely from delays.
    var ACCENT_LIFT_PX = 380;
    var LOGO_LIFT_PX = 340;
    function accentOf(scene) { return scene.querySelector('[data-bb-accent]'); }
    function logoOf(scene) { return scene.querySelector('[data-bb-logo]'); }
    function applyExitAccent(scene) {
      var el = accentOf(scene); if (!el) return;
      el.style.transition =
        'transform ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53),' +
        ' opacity ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53)';
      el.style.transform = 'translate(0px,' + (-ACCENT_LIFT_PX) + 'px)';
      el.style.opacity = '0';
    }
    function applyEntryAccentStart(scene) {
      var el = accentOf(scene); if (!el) return;
      el.style.transition = 'none';
      el.style.transform = 'translate(0px,' + (-ACCENT_LIFT_PX) + 'px)';
      el.style.opacity = '0';
    }
    function applyEntryAccent(scene) {
      var el = accentOf(scene); if (!el) return;
      // Land LAST: full stagger delay puts it after every regular block.
      el.style.transition =
        'transform ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + STAGGER_MS + 'ms,' +
        ' opacity ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + STAGGER_MS + 'ms';
      el.style.transform = '';
      el.style.opacity = '1';
    }
    function applyExitLogo(scene) {
      var el = logoOf(scene); if (!el) return;
      el.style.transition =
        'transform ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53),' +
        ' opacity ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.55,.085,.68,.53)';
      el.style.transform = 'translate(0px,' + (-LOGO_LIFT_PX) + 'px)';
      el.style.opacity = '0';
    }
    function applyEntryLogoStart(scene) {
      var el = logoOf(scene); if (!el) return;
      el.style.transition = 'none';
      el.style.transform = 'translate(0px,' + (-LOGO_LIFT_PX) + 'px)';
      el.style.opacity = '0';
    }
    function applyEntryLogo(scene) {
      var el = logoOf(scene); if (!el) return;
      el.style.transition =
        'transform ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + STAGGER_MS + 'ms,' +
        ' opacity ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.215,.61,.355,1) ' + STAGGER_MS + 'ms';
      el.style.transform = '';
      el.style.opacity = '1';
    }

    // ------------------------------------------------------------------
    // Scene transitions
    // ------------------------------------------------------------------
    function showScene(id, opts) {
      opts = opts || {};
      if (!sceneEls[id]) return Promise.resolve(false);
      if (current === id && !opts.force) {
        if (opts.onDone) opts.onDone();
        return Promise.resolve(false);
      }
      // If a transition is already in flight, queue this one.
      if (transitioning) {
        if (pendingResolve) pendingResolve();
        return new Promise(function (res) {
          pendingResolve = function () {
            pendingResolve = null;
            // showScene always returns a thenable now.
            Promise.resolve(showScene(id, opts)).then(res);
          };
        });
      }
      transitioning = true;

      var prev = current ? sceneEls[current] : null;
      var next = sceneEls[id];

      var disassembleMs = prev ? DURATION_DISASSEMBLE + STAGGER_MS : 0;
      if (prev) {
        // Top-of-stack first: accent + logo lift FIRST (before regular blocks).
        applyExitAccent(prev);
        applyExitLogo(prev);
        applyExit(prev);
        applyExitExt(prev);
        // Connector lines retract from face end toward canvas pedestal,
        // mirroring the in-app truncatePolyline direction.
        animatePolyExit(current, DURATION_DISASSEMBLE, 'cubic-bezier(.55,.085,.68,.53)');
        // Keep the conn-layer opacity logic for cleanup (small redundancy
        // with the dasharray retraction; lets inactive scene-conn divs stay
        // hidden between transitions even if a polyline can't compute its
        // length for some reason).
        fadeConn(current, 0, DURATION_DISASSEMBLE, 'cubic-bezier(.55,.085,.68,.53)');
        fadeBackExt(current, 0, DURATION_DISASSEMBLE, 'cubic-bezier(.55,.085,.68,.53)');
        // Collapse the canvas walls down to the floor while blocks fly out.
        // Dynamic mode: re-render chrome each frame with shrinking H so the
        // walls actually retract toward the floor (matches live preview).
        // Static fallback: scaleY on the canvas-scale div.
        if (canvasInner && HAS_DIMS) {
          animateWalls(targetHeightFor(current), WALL_LOW, DURATION_DISASSEMBLE, easeInOut);
        } else if (canvasScale) {
          canvasScale.style.transition = 'transform ' + DURATION_DISASSEMBLE + 'ms cubic-bezier(.45,0,.55,1)';
          canvasScale.style.transform = 'scaleY(0)';
        }
      }

      return new Promise(function (resolve) {
        setTimeout(function () {
          if (prev) {
            prev.removeAttribute('data-active');
            prev.setAttribute('aria-hidden', 'true');
            clearTransforms(prev);
            setConnInstant(current, 0);
            setBackExtInstant(current, 0);
            setPolyRetracted(current, true);
          }
          next.setAttribute('data-active', '');
          next.setAttribute('aria-hidden', 'false');
          applyEntryStart(next);
          applyEntryExtStart(next);
          applyEntryAccentStart(next);
          applyEntryLogoStart(next);
          // Pre-state for next conn layer: invisible, polylines fully
          // retracted (will extend during phase 2).
          setConnInstant(id, 0);
          setBackExtInstant(id, 0);
          setPolyRetracted(id, true);
          // No prev → walls aren't already at low; ensure baseline before
          // the grow animation in the next frame.
          if (!prev && canvasInner && HAS_DIMS) paintCanvas(WALL_LOW);
          // Force layout flush so the start state takes effect before we
          // animate.
          // eslint-disable-next-line no-unused-expressions
          next.getBoundingClientRect();
          // Next frame: transition to the natural state.
          requestAnimationFrame(function () {
            applyEntry(next);
            applyEntryExt(next);
            // Bottom-up: accent + logo land LAST (after all regular blocks).
            applyEntryAccent(next);
            applyEntryLogo(next);
            // Connector lines extend from canvas pedestal toward face,
            // mirroring the reverse of truncatePolyline.
            animatePolyEntry(id, DURATION_ASSEMBLE, 'cubic-bezier(.215,.61,.355,1)');
            fadeConn(id, 1, DURATION_ASSEMBLE, 'cubic-bezier(.215,.61,.355,1)');
            fadeBackExt(id, 1, DURATION_ASSEMBLE, 'cubic-bezier(.215,.61,.355,1)');
            // Walls rise back up from the floor as blocks reassemble.
            if (canvasInner && HAS_DIMS) {
              animateWalls(WALL_LOW, targetHeightFor(id), DURATION_ASSEMBLE, easeInOut);
            } else if (canvasScale) {
              canvasScale.style.transition = 'transform ' + DURATION_ASSEMBLE + 'ms cubic-bezier(.45,0,.55,1)';
              canvasScale.style.transform = 'scaleY(1)';
            }
            setTimeout(function () {
              clearTransforms(next);
              transitioning = false;
              current = id;
              stage.setAttribute('data-current', id);
              if (opts.onDone) opts.onDone();
              if (pendingResolve) {
                var r = pendingResolve;
                pendingResolve = null;
                r();
              }
              resolve();
            }, DURATION_ASSEMBLE + STAGGER_MS);
          });
        }, disassembleMs);
      });
    }

    // ------------------------------------------------------------------
    // Auto-advance
    // ------------------------------------------------------------------
    function nextInOrder(id) {
      var idx = ORDER.indexOf(id);
      if (idx < 0) return null;
      if (idx + 1 >= ORDER.length) return null; // end of cycle
      return ORDER[idx + 1];
    }
    function scheduleAuto() {
      if (autoCancelled || paused) return;
      if (autoTimer) clearTimeout(autoTimer);
      autoTimer = setTimeout(function () {
        if (autoCancelled || paused) return;
        var nextId = nextInOrder(current);
        if (!nextId) {
          // Completed a full cycle — stop.
          autoCancelled = true;
          return;
        }
        // Drive Webflow's tab UI by clicking the corresponding tab so
        // its highlight follows along. The MutationObserver on .w--current
        // would also re-fire showScene; the dedupe in showScene handles it.
        var tabs = findTabs();
        var tab = null;
        for (var i = 0; i < tabs.length; i++) {
          if (tabSceneId(tabs[i]) === nextId) { tab = tabs[i]; break; }
        }
        if (tab && typeof tab.click === 'function') {
          // Suppress our own click handler from cancelling auto-advance
          // — we mark this click as programmatic.
          tab.__bbProgrammatic = true;
          tab.click();
          tab.__bbProgrammatic = false;
        } else {
          showScene(nextId);
        }
        scheduleAuto();
      }, AUTO_INTERVAL_MS);
    }
    function cancelAuto() {
      autoCancelled = true;
      if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
    }

    // ------------------------------------------------------------------
    // Manual click + active-class change observation
    // ------------------------------------------------------------------
    function onTabClick(ev) {
      var t = ev.currentTarget;
      if (!t.__bbProgrammatic) cancelAuto();
      var id = tabSceneId(t);
      if (id) showScene(id);
    }
    var tabs = findTabs();
    tabs.forEach(function (t) { t.addEventListener('click', onTabClick); });

    var mo = null;
    if (typeof MutationObserver !== 'undefined') {
      mo = new MutationObserver(function () {
        var active = findActiveTab(findTabs());
        var id = tabSceneId(active);
        if (id && sceneEls[id] && id !== current) showScene(id);
      });
      tabs.forEach(function (t) {
        mo.observe(t, { attributes: true, attributeFilter: ['class', 'aria-selected'] });
      });
    }

    // ------------------------------------------------------------------
    // Initial render
    // ------------------------------------------------------------------
    // Compute polyline lengths once, then set initial dashoffset state:
    // active scene's polylines fully extended (offset 0), inactive scenes
    // fully retracted (offset L). Must run before the active scene is
    // assigned so getTotalLength sees the polylines in their resting state.
    setupConnPolylines();
    var firstActive = findActiveTab(tabs);
    var firstId = tabSceneId(firstActive);
    if (!firstId || !sceneEls[firstId]) firstId = ORDER[0];
    Object.keys(connPolysByScene).forEach(function (sid) {
      setPolyRetracted(sid, sid !== firstId);
    });
    var initial = sceneEls[firstId];
    if (initial) {
      initial.setAttribute('data-active', '');
      initial.setAttribute('aria-hidden', 'false');
      setConnInstant(firstId, 1);
      setBackExtInstant(firstId, 1);
      current = firstId;
      stage.setAttribute('data-current', firstId);
      // Initial chrome render at full canvas with the active scene's dims.
      if (canvasInner && HAS_DIMS) paintCanvas(targetHeightFor(firstId));
    }
    scheduleAuto();

    // ------------------------------------------------------------------
    // Public API. If multiple embeds are pasted on a page, the most
    // recent one wins; embeds are independent and don't need to coordinate.
    // ------------------------------------------------------------------
    var api = {
      assemble: function (id) { return showScene(id); },
      disassemble: function (id) {
        var target = id ? sceneEls[id] : (current ? sceneEls[current] : null);
        if (target) applyExit(target);
        return Promise.resolve();
      },
      transitionTo: function (id) { cancelAuto(); return showScene(id); },
      pause: function () {
        paused = true;
        if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
      },
      resume: function () {
        paused = false;
        scheduleAuto();
      },
      getCurrentScene: function () { return current; },
      destroy: function () {
        cancelAuto();
        if (mo) mo.disconnect();
        tabs.forEach(function (t) { t.removeEventListener('click', onTabClick); });
        Object.keys(sceneEls).forEach(function (k) { clearTransforms(sceneEls[k]); });
      },
    };
    window.BlockBuilder = api;
  }

  // Minimal CSS.escape polyfill for the attribute selector.
  function cssEscape(s) {
    if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(s);
    return String(s).replace(/[^a-zA-Z0-9_-]/g, function (c) {
      return '\\\\' + c.charCodeAt(0).toString(16) + ' ';
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
`.trim();

  return [
    '<!-- Block Builder · Webflow embed -->',
    '<!-- 1. Paste this whole block into a Webflow Custom Code embed. -->',
    '<!-- 2. On each tab link, set a Custom Attribute:           -->',
    `<!--      Name:  ${attrName}                                       -->`,
    '<!--      Value: <one of the scene names below>             -->',
    `<!--    Scenes: ${sceneSnapshots.map((s) => s.name).join(', ')}     -->`,
    '',
    fontHint,
    sharedDefs +
    `<div class="bb-stage" data-bb-attr="${escapeAttr(attrName)}">\n` +
    (connLayerHtml ? connLayerHtml + '\n' : '') +
    (backLayerHtml ? backLayerHtml + '\n' : '') +
    canvasLayerHtml +
    sceneHtml + '\n' +
    '</div>',
    '',
    fontStyle +
    '<style>\n' +
    // isolation:isolate creates a stacking context so .bb-scene-conn at
    // z-index:-1 stays trapped inside the stage (otherwise it could
    // disappear behind the host page's background).
    '  .bb-stage { position: relative; width: 100%; aspect-ratio: 16 / 10; isolation: isolate; }\n' +
    // Per-scene connector layer — sits BEHIND the canvas chrome so canvas
    // walls/floor/pedestal occlude the inner portion of each connector,
    // matching the live preview's paint order. z-index:-2 so it also sits
    // behind the back-external layer (which is at -1).
    (connLayerHasContent
      ? '  .bb-scene-conn { position: absolute; inset: 0; pointer-events: none; opacity: 0; z-index: -2;\n' +
        '                   transition: opacity 250ms ease-in-out; }\n' +
        '  .bb-scene-conn svg { width: 100%; height: 100%; display: block; }\n'
      : '') +
    // Per-scene back-external layer — back-side externals must sit BEHIND
    // .bb-canvas (the hoisted canvas chrome) so the canvas back walls
    // occlude them where they overlap, matching the live preview. z-index
    // -1 places this between the connector layer (-2) and canvas (0).
    (backLayerHasContent
      ? '  .bb-scene-back-ext { position: absolute; inset: 0; pointer-events: none; opacity: 0; z-index: -1;\n' +
        '                       transition: opacity 250ms ease-in-out; }\n' +
        '  .bb-scene-back-ext svg { width: 100%; height: 100%; display: block; }\n'
      : '') +
    // Persistent canvas layer — sits below scenes and stays visible across
    // tab switches. Walls shrink during exit and grow during entry via an
    // inline transform applied by the controller (see showScene below).
    (canvasLayerHtml
      ? '  .bb-canvas { position: absolute; inset: 0; pointer-events: none; z-index: 0; }\n' +
        // transform-origin is set inline (per-bundle) at the actual canvas
        // floor's Y position, so walls collapse toward the floor while the
        // floor itself stays put.
        '  .bb-canvas-scale { width: 100%; height: 100%; }\n' +
        '  .bb-canvas svg { width: 100%; height: 100%; display: block; }\n' +
        // Front-of-blocks layer: the front post + 2 top edges that should
        // sit ABOVE every block. Active scene is z-index:2, so this is :3.
        '  .bb-canvas-front { position: absolute; inset: 0; pointer-events: none; z-index: 3; }\n' +
        '  .bb-canvas-front svg { width: 100%; height: 100%; display: block; }\n'
      : '') +
    '  .bb-scene { position: absolute; inset: 0; width: 100%; height: 100%;\n' +
    '              opacity: 0; pointer-events: none; z-index: 1;\n' +
    '              transition: opacity 250ms ease-in-out; }\n' +
    '  .bb-scene[data-active] { opacity: 1; pointer-events: auto; z-index: 2; }\n' +
    '  .bb-scene svg { width: 100%; height: 100%; display: block; }\n' +
    '  .bb-scene [data-bb-block-id] { will-change: transform, opacity; }\n' +
    // Note: bb-s1/bb-edge/bb-shadow may live inside <symbol> defs (chrome or
    // block hoists), and CSS descendant selectors don't traverse into <use>'s
    // shadow tree. So these rules use class-only selectors. .block-label
    // stays scoped because labels live in scene divs, not symbols.
    (strokeClassUsed
      ? '  .bb-s1 { stroke:#8b8170; stroke-opacity:.9; stroke-width:3; stroke-linecap:round; stroke-linejoin:round; fill:none; }\n'
      : '') +
    (edgeClassUsed
      ? '  .bb-edge { fill:none; stroke:rgba(255,255,255,0.12); stroke-width:1; }\n'
      : '') +
    (shadowClassUsed
      ? '  .bb-shadow { fill:rgba(0,0,0,0.32); filter:blur(1.5px); }\n'
      : '') +
    (textClassUsed
      ? "  .bb-scene .block-label { font-family:'Ubuntu',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; font-weight:700; text-anchor:middle; fill:#fff; }\n"
      : '') +
    '</style>',
    '',
    '<script>',
    minifyJs(script),
    '</script>',
  ].filter(Boolean).join('\n');
}

function escapeAttr(s) {
  return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}

// ----- Beta export: post-process the standard bundle to shrink it -----
// Pure string→string transform. Applies 4 tiers of size reduction without
// changing any visual or behavioral output.
function shrinkWebflowBundle(html) {
  let s = html;

  // ── Tier 1: Pure deletions ──

  // 1a. Strip HTML comments (instructional header + font hint)
  s = s.replace(/<!--[\s\S]*?-->/g, '');

  // 1b. Remove redundant xmlns / xmlns:xlink from inner scene SVGs
  // (only needed on a standalone SVG document, not inline HTML5 SVG)
  s = s.replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, '');
  s = s.replace(/ xmlns:xlink="http:\/\/www\.w3\.org\/1999\/xlink"/g, '');

  // 1c. Remove empty <g></g> placeholder tags
  s = s.replace(/<g><\/g>/g, '');

  // 1d. Remove inline style="width:100%;height:100%;display:block" from
  // SVGs — CSS already covers every SVG via .bb-scene svg, .bb-canvas svg,
  // .bb-scene-conn svg, .bb-canvas-front svg rules.
  s = s.replace(/ style="width:100%;height:100%;display:block"/g, '');

  // 1e. Drop the "lift" point from external connectors. connectorsForSide
  // emits an under-pedestal anchor (z=-PED) plus a same-x/y lift (z=0) right
  // before the perpendicular run. In iso, anchor→lift is purely vertical and
  // produces a small visible elbow at the canvas wall. Removing only the
  // lift point preserves the rest of the path:
  //   • 1-block [anchor, lift, face]                   → [anchor, face]
  //   • 2-block [anchor, lift, elbow, slide, face]     → [anchor, elbow, slide, face]
  // So the branching H-shape for two externals on a side is kept intact;
  // only the wall-elbow disappears.
  s = s.replace(/<polyline\b([^>]*?)\/>/g, (m, attrs) => {
    if (!/class="bb-s1"/.test(attrs)) return m;
    const ptMatch = attrs.match(/points="([^"]+)"/);
    if (!ptMatch) return m;
    const pts = ptMatch[1].trim().split(/\s+/);
    if (pts.length !== 3 && pts.length !== 5) return m;
    const kept = [pts[0]].concat(pts.slice(2)); // drop pts[1] (the lift)
    return m.replace(/points="[^"]+"/, `points="${kept.join(' ')}"`);
  });

  // ── Tier 2: Mechanical renames ──

  // 2a. Shorten data-bb-block-id values (JS never reads the value, only
  // uses [data-bb-block-id] as a selector).
  let idCounter = 0;
  s = s.replace(/data-bb-block-id="[^"]+"/g, () => {
    return `data-bb-block-id="${(++idCounter).toString(36)}"`;
  });

  // 2b. Shorten attr name data-bb-block-id → data-b (HTML + CSS + JS)
  s = s.replace(/data-bb-block-id/g, 'data-b');

  // 2c. Rename class="block-label" → class="bl"
  s = s.replace(/class="block-label"/g, 'class="bl"');
  s = s.replace(/\.block-label/g, '.bl');

  // 2d. Rename class="bb-shadow" → class="bs"
  s = s.replace(/class="bb-shadow"/g, 'class="bs"');
  s = s.replace(/\.bb-shadow/g, '.bs');

  // 2e. Rename class="bb-edge" → class="be"
  s = s.replace(/class="bb-edge"/g, 'class="be"');
  s = s.replace(/\.bb-edge/g, '.be');

  // ── Tier 3: Logo dedup ──
  // Each scene has one <g data-bb-logo="1">…</g> block, but the geometry
  // (matrix transforms) DIFFERS when an accent is present (logo is mounted
  // on the right-front wall) vs absent (logo on the ceiling). Dedup is only
  // safe when every scene's logo HTML is byte-identical — otherwise a naive
  // first-match strip would leave variant logos in place AND inject the
  // first scene's logo into every scene, producing double logos on accent
  // scenes. So: only dedup when all matches are identical.
  const logoRe = /<g data-bb-logo="1">[\s\S]*?<\/g><\/g>/g;
  const logoMatches = s.match(logoRe) || [];
  if (logoMatches.length > 1 && new Set(logoMatches).size === 1) {
    const logoHtml = logoMatches[0]
      .replace(/&quot;/g, '"')
      .replace(/style="filter: url\("#logo-drop-shadow"\)"/g, 'style="filter:url(#logo-drop-shadow)"');
    s = s.replace(logoRe, '');

    const logoJs = `\nvar _LH='${logoHtml.replace(/'/g, "\\'")}';` +
      `\nObject.keys(sceneEls).forEach(function(k){var g=sceneEls[k].querySelector('svg>g');if(g){var d=document.createElementNS('http://www.w3.org/2000/svg','g');d.innerHTML=_LH;while(d.firstChild)g.appendChild(d.firstChild);}});`;

    // Inject logo JS right after the sceneEls/connEls setup block in init().
    // We locate the connEls forEach closing and insert after it.
    const connElsAnchor = "connEls[el.getAttribute('data-scene')] = el;\n});";
    const connElsIdx = s.indexOf(connElsAnchor);
    if (connElsIdx !== -1) {
      const insertAt = connElsIdx + connElsAnchor.length;
      s = s.slice(0, insertAt) + logoJs + s.slice(insertAt);
    }
  }

  // ── Tier 4: JS compression ──
  // Extract the <script> block, apply transforms, put it back.
  const scriptStart = s.indexOf('<script>');
  const scriptEnd = s.indexOf('</script>');
  if (scriptStart !== -1 && scriptEnd !== -1) {
    const before = s.slice(0, scriptStart + 8);
    let js = s.slice(scriptStart + 8, scriptEnd);
    const after = s.slice(scriptEnd);

    // 4a. Extract repeated cubic-bezier strings to variables.
    // Replace inline occurrences FIRST, then insert declarations (so the
    // declarations themselves don't get replaced).
    const cb1 = 'cubic-bezier(.55,.085,.68,.53)';
    const cb2 = 'cubic-bezier(.215,.61,.355,1)';
    const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    js = js.replace(new RegExp("'" + esc(cb1) + "'", 'g'), "'+E1+'");
    js = js.replace(new RegExp(esc(cb1), 'g'), "'+E1+'");
    js = js.replace(new RegExp("'" + esc(cb2) + "'", 'g'), "'+E2+'");
    js = js.replace(new RegExp(esc(cb2), 'g'), "'+E2+'");
    js = js.replace(/'' \+ /g, '');
    js = js.replace(/ \+ ''/g, '');
    // Now insert the var declarations at the top of the IIFE.
    const iifeOpen = js.indexOf('\n');
    if (iifeOpen !== -1) {
      js = js.slice(0, iifeOpen + 1) +
        `var E1='${cb1}',E2='${cb2}';\n` +
        js.slice(iifeOpen + 1);
    }

    // 4b. Shorten long internal variable names.
    // Order matters: rename DURATION_DISASSEMBLE before DURATION_ASSEMBLE
    // to avoid partial matches.
    const renames = [
      ['DURATION_DISASSEMBLE', 'DIS'],
      ['DURATION_ASSEMBLE', 'ASM'],
      ['STAGGER_MS', 'STG'],
      ['BLOCK_LIFT_PX', 'LIFT'],
      ['AUTO_INTERVAL_MS', 'AUTO_MS'],
      ['ACCENT_LIFT_PX', 'ALF'],
      ['LOGO_LIFT_PX', 'LLF'],
      ['connPolysByScene', 'cpoly'],
      ['canvasInnerFront', 'ciFront'],
      ['canvasAnimRaf', 'caRaf'],
      ['canvasAnimStart', 'caStart'],
      ['transitioning', 'tring'],
      ['autoCancelled', 'autoCx'],
      ['pendingResolve', 'pRes'],
    ];
    for (const [from, to] of renames) {
      js = js.split(from).join(to);
    }

    // 4c. Merge accent/logo exit/entryStart/entry functions into
    // parameterized versions. The 6 functions differ only in which
    // finder (accentOf vs logoOf) and which lift constant they use.
    const exitAccentFn = /function applyExitAccent\(scene\) \{[\s\S]*?\n\}/;
    const exitLogoFn = /function applyExitLogo\(scene\) \{[\s\S]*?\n\}/;
    const entryAccentStartFn = /function applyEntryAccentStart\(scene\) \{[\s\S]*?\n\}/;
    const entryLogoStartFn = /function applyEntryLogoStart\(scene\) \{[\s\S]*?\n\}/;
    const entryAccentFn = /function applyEntryAccent\(scene\) \{[\s\S]*?\n\}/;
    const entryLogoFn = /function applyEntryLogo\(scene\) \{[\s\S]*?\n\}/;

    // Only merge if all 6 functions are found
    if (exitAccentFn.test(js) && exitLogoFn.test(js) &&
        entryAccentStartFn.test(js) && entryLogoStartFn.test(js) &&
        entryAccentFn.test(js) && entryLogoFn.test(js)) {
      js = js.replace(exitAccentFn, `function _exitEl(scene,fn,lift) {
var el=fn(scene);if(!el)return;
el.style.transition='transform '+DIS+'ms '+E1+',opacity '+DIS+'ms '+E1;
el.style.transform='translate(0px,'+(-lift)+'px)';
el.style.opacity='0';
}`);
      js = js.replace(exitLogoFn, '');
      js = js.replace(entryAccentStartFn, `function _entryElStart(scene,fn,lift) {
var el=fn(scene);if(!el)return;
el.style.transition='none';
el.style.transform='translate(0px,'+(-lift)+'px)';
el.style.opacity='0';
}`);
      js = js.replace(entryLogoStartFn, '');
      js = js.replace(entryAccentFn, `function _entryEl(scene,fn,lift) {
var el=fn(scene);if(!el)return;
el.style.transition='transform '+ASM+'ms '+E2+' '+STG+'ms,opacity '+ASM+'ms '+E2+' '+STG+'ms';
el.style.transform='';
el.style.opacity='1';
}`);
      js = js.replace(entryLogoFn, '');

      // Update call sites
      js = js.replace(/applyExitAccent\((\w+)\)/g, '_exitEl($1,accentOf,ALF)');
      js = js.replace(/applyExitLogo\((\w+)\)/g, '_exitEl($1,logoOf,LLF)');
      js = js.replace(/applyEntryAccentStart\((\w+)\)/g, '_entryElStart($1,accentOf,ALF)');
      js = js.replace(/applyEntryLogoStart\((\w+)\)/g, '_entryElStart($1,logoOf,LLF)');
      js = js.replace(/applyEntryAccent\((\w+)\)/g, '_entryEl($1,accentOf,ALF)');
      js = js.replace(/applyEntryLogo\((\w+)\)/g, '_entryEl($1,logoOf,LLF)');
    }

    s = before + js + after;
  }

  // Clean up blank lines left by comment/tag removal
  s = s.replace(/\n{3,}/g, '\n\n');

  return s;
}

function buildWebflowExportBeta(args) {
  const bundle = buildWebflowExport(args);
  return shrinkWebflowBundle(bundle);
}

// ----- Hook for Webflow export prefs -----
// Persists { attrName, scenes: [{ id, include, name }] } in localStorage.
// Merges with the live scene list on read so newly-added scenes show up.
const DEFAULT_WEBFLOW_LOGO_URL = 'https://cdn.prod.website-files.com/68bb0738eb2e6c6ef63de53a/69f8e6afb022271a266526e2_Harper%20Logo%20White.svg';

function useWebflowExport(liveScenes) {
  const KEY = () => _key('webflow.export');
  const [state, setState] = _sUseState(() => {
    try {
      const raw = JSON.parse(localStorage.getItem(KEY()) || 'null');
      if (raw && typeof raw === 'object') {
        // Default new fields without overwriting user-saved values.
        if (raw.embedFonts === undefined) raw.embedFonts = true;
        if (raw.logoUrl === undefined) raw.logoUrl = DEFAULT_WEBFLOW_LOGO_URL;
        return raw;
      }
    } catch (_) {}
    return { attrName: 'block-scene', scenes: [], embedFonts: true, logoUrl: DEFAULT_WEBFLOW_LOGO_URL };
  });
  // Re-merge whenever liveScenes changes so newly-added scenes show up
  // pre-checked with a sensible default name (sluggified, deduped against
  // existing rows so two scenes that happen to share a display name don't
  // collide on export).
  _sUseEffect(() => {
    setState((cur) => {
      const byId = new Map((cur.scenes || []).map((s) => [s.id, s]));
      const usedNames = new Set((cur.scenes || []).filter((s) => byId.get(s.id)).map((s) => s.name));
      const merged = (liveScenes || []).map((s) => {
        const prev = byId.get(s.id);
        if (prev) return prev;
        const base = _slugify(s.name || s.id);
        let name = base;
        let n = 2;
        while (usedNames.has(name)) name = `${base}-${n++}`;
        usedNames.add(name);
        return { id: s.id, include: true, name };
      });
      // Drop entries for scenes that no longer exist.
      const liveIds = new Set((liveScenes || []).map((s) => s.id));
      const filtered = merged.filter((s) => liveIds.has(s.id));
      // Only update if something actually changed.
      if (filtered.length === (cur.scenes || []).length &&
          filtered.every((s, i) => cur.scenes[i] && cur.scenes[i].id === s.id)) {
        return cur;
      }
      return { ...cur, scenes: filtered };
    });
  }, [liveScenes]);
  _sUseEffect(() => {
    try { localStorage.setItem(KEY(), JSON.stringify(state)); } catch (_) {}
  }, [state]);
  return [state, setState];
}

function _slugify(s) {
  return String(s).toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 40) || 'scene';
}

// ----- Hook for transitions persistence (small, separate from scenes) -----
function useSceneTransitions() {
  const [t, setT] = _sUseState(() => {
    try { return JSON.parse(localStorage.getItem(TRANSITIONS_KEY()) || '{}'); } catch { return {}; }
  });
  _sUseEffect(() => {
    try { localStorage.setItem(TRANSITIONS_KEY(), JSON.stringify(t)); } catch {}
    // Sync transitions to Harper (only in authenticated/bypass mode)
    if (window.HarperSync && window.BB_HARPER_ENABLED) window.HarperSync.saveAppState(null, t);
  }, [t]);
  return [t, setT];
}

// Expose to window so App.jsx can pull them after the script loads.
Object.assign(window, {
  useScenes,
  useSceneTransitions,
  useWebflowExport,
  SceneTabs,
  SceneTransitions,
  buildWebflowExport,
  buildWebflowExportBeta,
});
