// App.jsx — main Block Builder application

const { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } = React;

// -------------- Icons (inline SVG) --------------
const Icon = {
  plus: <svg viewBox="0 0 24 24" 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>,
  check: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>,
  cube: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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>,
  frame: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" strokeDasharray="2.5 2"><path d="M12 3 L20 7.5 L20 16.5 L12 21 L4 16.5 L4 7.5 Z"/></svg>,
  up: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 14 12 8 18 14"/></svg>,
  down: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 10 12 16 18 10"/></svg>,
  trash: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>,
  drag: <svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>,
};

// -------------- Swatch preview (tiny iso cube SVG for color buttons) --------------
function SwatchCube({ color, type }) {
  return (
    <svg viewBox="-30 -34 60 60">
      <defs>
        <radialGradient id={`sw-glow-${color}`} cx="50%" cy="50%" r="50%">
          <stop offset="0%" stopColor="#b392ff" stopOpacity="0.6"/>
          <stop offset="100%" stopColor="#6a2bf0" stopOpacity="0"/>
        </radialGradient>
      </defs>
      <g transform="scale(0.45)">
        <BlockShape color={color} type={type} />
      </g>
    </svg>
  );
}

// Tiny iso preview of a (dx,dy,dz) box for the dimension picker.
// `unitPx` is the on-screen size of one world unit — every swatch shares the
// same value so a 1×1×1 reads as half the volume of a 2×1×1, etc.
function DimSwatch({ dx, dy, dz, color = 'magenta', unitPx = 5 }) {
  // Project all 8 corners and find the SVG bounding box.
  const pts = [
    iso(0,  0,  0), iso(dx, 0,  0), iso(dx, dy, 0), iso(0,  dy, 0),
    iso(0,  0,  dz), iso(dx, 0,  dz), iso(dx, dy, dz), iso(0,  dy, dz),
  ];
  const xs = pts.map((p) => p.x);
  const ys = pts.map((p) => p.y);
  const minX = Math.min(...xs), maxX = Math.max(...xs);
  const minY = Math.min(...ys), maxY = Math.max(...ys);
  const w = maxX - minX, h = maxY - minY;
  // Scale: world UNIT -> unitPx
  const scale = unitPx / UNIT;
  const pad = 2;
  return (
    <svg
      width={w * scale + pad * 2}
      height={h * scale + pad * 2}
      viewBox={`${minX - pad} ${minY - pad} ${w + pad * 2} ${h + pad * 2}`}
      style={{ display: 'block' }}
    >
      <BlockShape color={color} type="solid" dim={{ x: dx, y: dy, z: dz }} />
    </svg>
  );
}

// Tiny iso preview of the CANVAS volume (matches ContainerBack/ContainerFront
// styling: solid teal floor + pedestal, semi-transparent gradient back walls,
// teal-light front edges) so the picker swatches read as miniature canvases.
function ContainerSwatch({ n, unitPx = 12 }) {
  // Match the real container's lift / pedestal / front-buffer offsets so the
  // proportions read identically to the on-stage canvas.
  const CEIL_LIFT = 0.16;
  const PEDESTAL = 0.16;
  const FB = 0.12;
  const W = n, D = n, H = n;
  const Wf = W + FB, Df = D + FB;
  const Ht = H + CEIL_LIFT;

  // Named corners (same vocabulary as Container.jsx).
  const C = {
    bBack:  iso(0,  0,  0),
    bRight: iso(Wf, 0,  0),
    bLeft:  iso(0,  Df, 0),
    bFront: iso(Wf, Df, 0),
    tBack:  iso(0,  0,  Ht),
    tRight: iso(Wf, 0,  Ht),
    tLeft:  iso(0,  Df, Ht),
    tFront: iso(Wf, Df, Ht),
    pBack:  iso(0,  0,  -PEDESTAL),
    pRight: iso(Wf, 0,  -PEDESTAL),
    pLeft:  iso(0,  Df, -PEDESTAL),
    pFront: iso(Wf, Df, -PEDESTAL),
  };

  const all = Object.values(C);
  const xs = all.map((p) => p.x);
  const ys = all.map((p) => p.y);
  const minX = Math.min(...xs), maxX = Math.max(...xs);
  const minY = Math.min(...ys), maxY = Math.max(...ys);
  const w = maxX - minX, h = maxY - minY;
  const scale = unitPx / UNIT;
  const pad = 3;

  const teal      = '#2dd4a0';
  const tealLight = '#7df0c4';
  const tealDeep  = '#14b88a';
  const polyStr = (...pts) => pts.map((p) => `${p.x},${p.y}`).join(' ');

  // Stable, locally-scoped gradient ids (one per swatch instance) so multiple
  // swatches don't share/collide.
  const uid = `cs-${n}`;

  return (
    <svg
      width={w * scale + pad * 2}
      height={h * scale + pad * 2}
      viewBox={`${minX - pad} ${minY - pad} ${w + pad * 2} ${h + pad * 2}`}
      style={{ display: 'block' }}
    >
      <defs>
        <linearGradient id={`${uid}-wall-left`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   stopColor={tealLight} stopOpacity="0.18" />
          <stop offset="100%" stopColor={teal}      stopOpacity="0.55" />
        </linearGradient>
        <linearGradient id={`${uid}-wall-right`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%"   stopColor={tealLight} stopOpacity="0.14" />
          <stop offset="100%" stopColor={teal}      stopOpacity="0.50" />
        </linearGradient>
      </defs>

      {/* Pedestal — two camera-facing side faces + outer silhouette. */}
      <polygon points={polyStr(C.pRight, C.pFront, C.bFront, C.bRight)} fill={tealDeep} />
      <polygon points={polyStr(C.pLeft,  C.pFront, C.bFront, C.bLeft)}  fill={tealDeep} />
      <polyline
        points={polyStr(C.bRight, C.pRight, C.pFront, C.pLeft, C.bLeft)}
        fill="none"
        stroke={tealDeep}
        strokeOpacity="0.9"
        strokeWidth="0.6"
        strokeLinejoin="round"
      />

      {/* Floor (top of pedestal). */}
      <polygon points={polyStr(C.bBack, C.bRight, C.bFront, C.bLeft)} fill={teal} />

      {/* Back-left + back-right semi-transparent walls. */}
      <polygon
        points={polyStr(C.bBack, C.bLeft, C.tLeft, C.tBack)}
        fill={`url(#${uid}-wall-left)`}
        stroke={teal}
        strokeOpacity="0.55"
        strokeWidth="0.6"
      />
      <polygon
        points={polyStr(C.bBack, C.bRight, C.tRight, C.tBack)}
        fill={`url(#${uid}-wall-right)`}
        stroke={teal}
        strokeOpacity="0.55"
        strokeWidth="0.6"
      />

      {/* Vertical posts (BACK / RIGHT / LEFT). */}
      <line x1={C.bBack.x}  y1={C.bBack.y}  x2={C.tBack.x}  y2={C.tBack.y}  stroke={teal} strokeOpacity="0.55" strokeWidth="0.6" />
      <line x1={C.bRight.x} y1={C.bRight.y} x2={C.tRight.x} y2={C.tRight.y} stroke={teal} strokeOpacity="0.55" strokeWidth="0.6" />
      <line x1={C.bLeft.x}  y1={C.bLeft.y}  x2={C.tLeft.x}  y2={C.tLeft.y}  stroke={teal} strokeOpacity="0.55" strokeWidth="0.6" />

      {/* Floor edges meeting at BACK corner. */}
      <line x1={C.bBack.x} y1={C.bBack.y} x2={C.bLeft.x}  y2={C.bLeft.y}  stroke={teal} strokeOpacity="0.7" strokeWidth="0.6" />
      <line x1={C.bBack.x} y1={C.bBack.y} x2={C.bRight.x} y2={C.bRight.y} stroke={teal} strokeOpacity="0.7" strokeWidth="0.6" />

      {/* Back ceiling edges. */}
      <line x1={C.tBack.x} y1={C.tBack.y} x2={C.tLeft.x}  y2={C.tLeft.y}  stroke={teal} strokeOpacity="0.6" strokeWidth="0.6" />
      <line x1={C.tBack.x} y1={C.tBack.y} x2={C.tRight.x} y2={C.tRight.y} stroke={teal} strokeOpacity="0.6" strokeWidth="0.6" />

      {/* Front-facing edges (drawn last, brighter teal-light). */}
      <line x1={C.bFront.x} y1={C.bFront.y} x2={C.tFront.x} y2={C.tFront.y} stroke={tealLight} strokeOpacity="0.9"  strokeWidth="0.7" />
      <line x1={C.tFront.x} y1={C.tFront.y} x2={C.tRight.x} y2={C.tRight.y} stroke={tealLight} strokeOpacity="0.85" strokeWidth="0.7" />
      <line x1={C.tFront.x} y1={C.tFront.y} x2={C.tLeft.x}  y2={C.tLeft.y}  stroke={tealLight} strokeOpacity="0.85" strokeWidth="0.7" />
    </svg>
  );
}

// -------------- App --------------

// FloatingPopover — renders its children at a fixed screen position computed
// from a trigger element's bounding rect. Use to escape an overflow:hidden
// panel so popover content can extend beyond panel edges.
//
// `placement` controls anchoring:
//   'left-of-trigger' → popover's right edge sits 8px left of trigger
//   'below-trigger'   → popover's top sits 4px below trigger, aligned right
function FloatingPopover({ triggerRef, placement = 'left-of-trigger', boundarySelector, children, className, onClose }) {
  const [pos, setPos] = useState(null);
  const popRef = useRef(null);

  useLayoutEffect(() => {
    const measure = () => {
      const t = triggerRef.current;
      const p = popRef.current;
      if (!t || !p) return;
      const tr = t.getBoundingClientRect();
      const pr = p.getBoundingClientRect();
      // Skip until the popover has laid out — otherwise we anchor with the
      // wrong width and end up inside the panel we're trying to escape.
      if (pr.width < 1 || pr.height < 1) return;
      const popW = pr.width;
      const popH = pr.height;
      // For 'left-of-trigger' placement the popover should sit OUTSIDE the
      // trigger's containing panel — not just left of the trigger button —
      // otherwise wide popovers from rightmost triggers overlap the panel.
      const boundary = boundarySelector ? t.closest(boundarySelector) : null;
      const boundaryRect = boundary ? boundary.getBoundingClientRect() : tr;
      let top, left;
      if (placement === 'left-of-trigger') {
        top = tr.top + tr.height / 2 - popH / 2;
        left = boundaryRect.left - popW - 8;
      } else {
        top = tr.bottom + 4;
        left = tr.right - popW;
      }
      // Clamp to viewport with 8px gutter
      const vw = window.innerWidth, vh = window.innerHeight;
      top = Math.max(8, Math.min(vh - popH - 8, top));
      left = Math.max(8, Math.min(vw - popW - 8, left));
      setPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
    };
    measure();
    // ResizeObserver catches the moment the popover's children lay out (which
    // is AFTER the first measure call above when popRef is still empty).
    const ro = (typeof ResizeObserver !== 'undefined') ? new ResizeObserver(measure) : null;
    if (ro && popRef.current) ro.observe(popRef.current);
    window.addEventListener('resize', measure);
    window.addEventListener('scroll', measure, true);
    return () => {
      if (ro) ro.disconnect();
      window.removeEventListener('resize', measure);
      window.removeEventListener('scroll', measure, true);
    };
  }, [triggerRef, placement, boundarySelector]);

  // Close on outside click / Esc
  useEffect(() => {
    if (!onClose) return;
    const onDown = (e) => {
      const p = popRef.current;
      const t = triggerRef.current;
      if (!p) return;
      if (p.contains(e.target)) return;
      if (t && t.contains(e.target)) return;
      onClose();
    };
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('mousedown', onDown);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDown);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose, triggerRef]);

  return ReactDOM.createPortal(
    <div
      ref={popRef}
      className={className}
      style={{
        position: 'fixed',
        top: pos ? pos.top : -9999,
        left: pos ? pos.left : -9999,
        visibility: pos ? 'visible' : 'hidden',
      }}
      onClick={(e) => e.stopPropagation()}
      onMouseDown={(e) => e.stopPropagation()}
    >
      {children}
    </div>,
    document.body
  );
}

// LayerRow — one row in the Layers panel. Owns refs for its two popover triggers
// (color swatch, resize button) so the popovers can render via FloatingPopover
// at fixed screen positions outside the panel's clipping region.
function LayerRow({
  b, selected, isEditingName, colorOpen, sizeOpen, fontOpen, canvas, isAccent = false,
  onSelect, onToggleColor, onToggleSize, onToggleFont, onClosePopover,
  onSetColor, onSetSize, onSetFont, onStartRename, onCommitRename, onCancelRename, onDelete,
}) {
  const colorTriggerRef = useRef(null);
  const sizeTriggerRef = useRef(null);
  const fontTriggerRef = useRef(null);

  // Build the size options once per render — same logic as before.
  const sizeRows = useMemo(() => {
    const opts = [];
    for (let x = 1; x <= canvas.W; x++)
      for (let y = 1; y <= canvas.D; y++)
        for (let z = 1; z <= canvas.H; z++)
          if (Math.min(x, y, z) === 1) opts.push({ x, y, z });
    const groups = new Map();
    for (const o of opts) {
      const key = (o.x > 1 ? 1 : 0) + (o.y > 1 ? 1 : 0) + (o.z > 1 ? 1 : 0);
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(o);
    }
    const rowKeys = [...groups.keys()].sort((a, b) => a - b);
    return rowKeys.map((rk) =>
      groups.get(rk).sort((a, b) => (a.x*100+a.y*10+a.z) - (b.x*100+b.y*10+b.z))
    );
  }, [canvas.W, canvas.D, canvas.H]);

  return (
    <div
      className={`layer-row ${selected ? 'selected' : ''} ${isAccent ? 'accent' : ''}`}
      onClick={onSelect}
    >
      <span className="layer-drag">{Icon.drag}</span>

      {/* Color swatch — click toggles a popover with the 4 presets.
          For the accent block, the swatch wrapper is rotated 45° so the
          square reads as a diamond — visually distinguishing the accent
          row in the layer list. */}
      <button
        ref={colorTriggerRef}
        type="button"
        className={`layer-swatch ${colorOpen ? 'open' : ''} ${isAccent ? 'diamond' : ''}`}
        style={{
          background: PALETTE[b.color].solidSwatch,
          border: `1.5px solid ${PALETTE[b.color].edge}`,
        }}
        title="Change color"
        onClick={(e) => { e.stopPropagation(); onToggleColor(); }}
      />
      {colorOpen && (
        <FloatingPopover
          triggerRef={colorTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-color"
          onClose={onClosePopover}
        >
          {COLOR_KEYS.map((k) => (
            <button
              key={k}
              type="button"
              className={`color-swatch ${b.color === k ? 'active' : ''}`}
              title={PALETTE[k].name}
              onClick={() => onSetColor(k)}
            >
              <SwatchCube color={k} type="solid" />
            </button>
          ))}
        </FloatingPopover>
      )}

      {/* Label — click to edit */}
      {isEditingName ? (
        <input
          autoFocus
          className="layer-label-input"
          defaultValue={b.label}
          maxLength={16}
          onClick={(e) => e.stopPropagation()}
          onBlur={(e) => onCommitRename(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') { e.target.blur(); }
            if (e.key === 'Escape') { onCancelRename(); }
          }}
        />
      ) : (
        <span
          className="layer-label"
          onClick={(e) => { e.stopPropagation(); onStartRename(); }}
          title="Rename"
        >
          {b.label || <span style={{color:'var(--fg-3)'}}>untitled</span>}
        </span>
      )}

      <div className="layer-actions">
        {/* Text-size button — opens a slider popover for the label's font scale. */}
        <button
          ref={fontTriggerRef}
          className={`icon-btn font-btn ${fontOpen ? 'active' : ''}`}
          onClick={(e) => { e.stopPropagation(); onToggleFont(); }}
          title={`Text size (${Math.round((b.labelScale ?? 1) * 100)}%)`}
        >
          <span className="font-btn-glyph">Aa</span>
        </button>
        {/* Resize button — hidden for accent blocks (their dims are
            canvas-relative, not user-pickable). */}
        {!isAccent && (
        <button
          ref={sizeTriggerRef}
          className={`icon-btn dim-btn ${sizeOpen ? 'active' : ''}`}
          onClick={(e) => { e.stopPropagation(); onToggleSize(); }}
          title={`Resize (${b.dx}×${b.dy}×${b.dz})`}
        >
          <DimSwatch dx={b.dx} dy={b.dy} dz={b.dz} color={b.color} unitPx={4} />
        </button>
        )}
        <button
          className="icon-btn danger"
          onClick={(e) => { e.stopPropagation(); onDelete(); }}
          title="Delete"
        >{Icon.trash}</button>
      </div>

      {fontOpen && (
        <FloatingPopover
          triggerRef={fontTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-font"
          onClose={onClosePopover}
        >
          <div className="font-popover-row">
            <span className="font-popover-min">A</span>
            <input
              type="range"
              min="50"
              max="250"
              step="5"
              value={Math.round((b.labelScale ?? 1) * 100)}
              onChange={(e) => onSetFont(parseInt(e.target.value, 10) / 100)}
              className="font-popover-slider"
            />
            <span className="font-popover-max">A</span>
          </div>
          <div className="font-popover-row">
            <button
              type="button"
              className="font-popover-reset"
              onClick={() => onSetFont(1)}
            >
              {Math.round((b.labelScale ?? 1) * 100)}% · Reset
            </button>
          </div>
        </FloatingPopover>
      )}

      {!isAccent && sizeOpen && (
        <FloatingPopover
          triggerRef={sizeTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-size"
          onClose={onClosePopover}
        >
          {sizeRows.map((row, i) => (
            <div className="dim-row" key={i}>
              {row.map(({ x, y, z }) => {
                const active = b.dx === x && b.dy === y && b.dz === z;
                return (
                  <button
                    key={`${x}-${y}-${z}`}
                    className={`dim-option ${active ? 'active' : ''}`}
                    title={`${x}×${y}×${z}`}
                    onClick={() => onSetSize(x, y, z)}
                  >
                    <DimSwatch dx={x} dy={y} dz={z} color={b.color} />
                  </button>
                );
              })}
            </div>
          ))}
        </FloatingPopover>
      )}
    </div>
  );
}

// External-block row in the Layers panel. Same visual language as LayerRow
// but with the external-only Direction dropdown and the fixed 3-option size
// set (1×1×1, 1×2×1, 2×1×1). Color popover lives in the warm-gray slate
// palette since externals are visually distinct from primary blocks.
function ExternalLayerRow({
  b, selected, isEditingName, colorOpen, sizeOpen, sideOpen, fontOpen, externalsBySide,
  onSelect, onToggleColor, onToggleSize, onToggleSide, onToggleFont, onClosePopover,
  onSetColor, onSetSize, onSetSide, onSetFont, onStartRename, onCommitRename,
  onCancelRename, onDelete,
}) {
  const colorTriggerRef = useRef(null);
  const sizeTriggerRef = useRef(null);
  const sideTriggerRef = useRef(null);
  const fontTriggerRef = useRef(null);

  const sideLabels = {
    'back-left': 'Back-Left',
    'back-right': 'Back-Right',
    'front-right': 'Front-Right',
    'front-left': 'Front-Left',
  };

  return (
    <div
      className={`layer-row external ${selected ? 'selected' : ''}`}
      onClick={onSelect}
    >
      <span className="layer-drag">{Icon.drag}</span>

      <button
        ref={colorTriggerRef}
        type="button"
        className={`layer-swatch ${colorOpen ? 'open' : ''}`}
        style={{
          background: PALETTE[b.color].solidSwatch,
          border: `1.5px solid ${PALETTE[b.color].edge}`,
        }}
        title="Change color"
        onClick={(e) => { e.stopPropagation(); onToggleColor(); }}
      />
      {colorOpen && (
        <FloatingPopover
          triggerRef={colorTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-color"
          onClose={onClosePopover}
        >
          {EXT_COLOR_KEYS.map((k) => (
            <button
              key={k}
              type="button"
              className={`color-swatch ${b.color === k ? 'active' : ''}`}
              title={PALETTE[k].name}
              onClick={() => onSetColor(k)}
            >
              <SwatchCube color={k} type="solid" />
            </button>
          ))}
        </FloatingPopover>
      )}

      {isEditingName ? (
        <input
          autoFocus
          className="layer-label-input"
          defaultValue={b.label}
          maxLength={16}
          onClick={(e) => e.stopPropagation()}
          onBlur={(e) => onCommitRename(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') { e.target.blur(); }
            if (e.key === 'Escape') { onCancelRename(); }
          }}
        />
      ) : (
        <span
          className="layer-label"
          onClick={(e) => { e.stopPropagation(); onStartRename(); }}
          title="Rename"
        >
          {b.label || <span style={{color:'var(--fg-3)'}}>untitled</span>}
        </span>
      )}

      <div className="layer-actions">
        {/* Text-size button */}
        <button
          ref={fontTriggerRef}
          className={`icon-btn font-btn ${fontOpen ? 'active' : ''}`}
          onClick={(e) => { e.stopPropagation(); onToggleFont(); }}
          title={`Text size (${Math.round((b.labelScale ?? 1) * 100)}%)`}
        >
          <span className="font-btn-glyph">Aa</span>
        </button>
        {/* Direction button — shows the current side's mini swatch and
            opens a popover to switch sides (full sides disabled). */}
        <button
          ref={sideTriggerRef}
          className={`icon-btn dim-btn ${sideOpen ? 'active' : ''}`}
          onClick={(e) => { e.stopPropagation(); onToggleSide(); }}
          title={`Direction (${sideLabels[b.side]})`}
        >
          <ExtDirectionSwatch side={b.side} active size={26} />
        </button>
        <button
          ref={sizeTriggerRef}
          className={`icon-btn dim-btn ${sizeOpen ? 'active' : ''}`}
          onClick={(e) => { e.stopPropagation(); onToggleSize(); }}
          title={`Resize (${b.dx}×${b.dy}×${b.dz})`}
        >
          <DimSwatch dx={b.dx} dy={b.dy} dz={b.dz} color={b.color} unitPx={4} />
        </button>
        <button
          className="icon-btn danger"
          onClick={(e) => { e.stopPropagation(); onDelete(); }}
          title="Delete"
        >{Icon.trash}</button>
      </div>

      {fontOpen && (
        <FloatingPopover
          triggerRef={fontTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-font"
          onClose={onClosePopover}
        >
          <div className="font-popover-row">
            <span className="font-popover-min">A</span>
            <input
              type="range"
              min="50"
              max="250"
              step="5"
              value={Math.round((b.labelScale ?? 1) * 100)}
              onChange={(e) => onSetFont(parseInt(e.target.value, 10) / 100)}
              className="font-popover-slider"
            />
            <span className="font-popover-max">A</span>
          </div>
          <div className="font-popover-row">
            <button
              type="button"
              className="font-popover-reset"
              onClick={() => onSetFont(1)}
            >
              {Math.round((b.labelScale ?? 1) * 100)}% · Reset
            </button>
          </div>
        </FloatingPopover>
      )}

      {sideOpen && (
        <FloatingPopover
          triggerRef={sideTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-side"
          onClose={onClosePopover}
        >
          <div className="ext-dir-grid">
            {EXT_SIDES.map((side) => {
              // A side counts as full if it already holds 2 externals
              // — but the CURRENT block's own side doesn't count toward
              // its own capacity (you can always re-pick the same side).
              const occupants = externalsBySide[side].filter((e) => e.id !== b.id).length;
              const full = occupants >= 2;
              const active = b.side === side;
              return (
                <button
                  key={side}
                  type="button"
                  className={`ext-dir-option ${active ? 'active' : ''} ${full ? 'full' : ''}`}
                  onClick={() => { if (!full) onSetSide(side); }}
                  disabled={full}
                  title={full ? `${sideLabels[side]} (full)` : sideLabels[side]}
                >
                  <ExtDirectionSwatch side={side} active={active} size={48} />
                  <span className="ext-dir-label">{sideLabels[side]}</span>
                </button>
              );
            })}
          </div>
        </FloatingPopover>
      )}

      {sizeOpen && (
        <FloatingPopover
          triggerRef={sizeTriggerRef}
          placement="left-of-trigger"
          boundarySelector=".right-rail"
          className="layer-popover layer-popover-size"
          onClose={onClosePopover}
        >
          <div className="dim-row">
            {EXT_SIZES.map(({ dx, dy, dz }) => {
              const active = b.dx === dx && b.dy === dy && b.dz === dz;
              return (
                <button
                  key={`${dx}-${dy}-${dz}`}
                  className={`dim-option ${active ? 'active' : ''}`}
                  title={`${dx}×${dy}×${dz}`}
                  onClick={() => onSetSize(dx, dy, dz)}
                >
                  <DimSwatch dx={dx} dy={dy} dz={dz} color={b.color} />
                </button>
              );
            })}
          </div>
        </FloatingPopover>
      )}
    </div>
  );
}

function App() {
  // -------- Scenes (multi-canvas projects) --------
  // Each scene snapshots { canvas, blocks, accent, externals } and is
  // auto-saved to localStorage by useScenes. The active scene's payload
  // is mirrored into the local state hooks below on switch, and changes
  // are pushed back via a sync effect.
  const scenesApi = window.useScenes ? window.useScenes() : null;
  const [sceneTransitions, setSceneTransitions] = window.useSceneTransitions
    ? window.useSceneTransitions()
    : [{}, () => {}];
  const _activeSceneId = scenesApi ? scenesApi.activeId : null;
  const _scenesLoadedRef = useRef(false);
  // Stable handle so the keyboard effect (which only depends on selectedId)
  // can call scenesApi.undo/redo without re-binding every render.
  const _scenesApiRef = useRef(scenesApi);
  useEffect(() => { _scenesApiRef.current = scenesApi; });
  const [transitionsOpen, setTransitionsOpen] = useState(false);
  // Active scene-transition animation. Null = idle.
  // When set: { fromScene, toScene, t (0→1), exitIds, enterIds, persistIds, blockIndex }
  // Drives an interpolated canvas + a per-block role classification so each
  // block can animate independently (exit blocks fly up, canvas frame morphs,
  // enter blocks drop down with stagger).
  const [previewAnim, setPreviewAnim] = useState(null);
  const _previewRafRef = useRef(null);
  const _previewStartRef = useRef(0);
  const _previewTimerRef = useRef(null);

  // -------- Builder state --------
  const [builderOpen, setBuilderOpen] = useState(false);
  const [draftColor, setDraftColor] = useState('magenta');
  const [draftLabel, setDraftLabel] = useState('');
  // Block dimensions for the next placement. At least one axis must be 1.
  const [draftDim, setDraftDim] = useState({ x: 1, y: 1, z: 1 });
  // When true, the New Block panel is in "accent" mode: dimensions are
  // hidden (the accent's size is canvas-derived), and clicking Place drops
  // the accent directly into its dedicated slot above the canvas — no
  // placement-mode cursor / hover-cell needed.
  const [draftAccent, setDraftAccent] = useState(false);

  // -------- External nodes (systems connected to Harper) --------
  // Each: { id, color: 'slate', label, dx, dy, dz, side }
  // Stored by side so we can derive _slotIndex / _slotCount on render.
  // Up to 2 externals per side.
  const [externals, setExternals] = useState([]);
  // External draft state: dim and side for the next placement.
  const [draftExtDim, setDraftExtDim] = useState({ x: 1, y: 1, z: 1 });
  const [draftExtSide, setDraftExtSide] = useState('back-right');
  // Draft tab in the New Block panel: 'primary' | 'accent' | 'external'
  // Mirrors `draftAccent` (kept for backwards-compat in callsites that
  // still read it).
  const [draftTab, setDraftTab] = useState('primary');

  // -------- Canvas (single empty container) --------
  const [canvas, setCanvas] = useState({ W: 2, D: 2, H: 2 });
  // Right-rail section collapse state
  const [canvasOpen, setCanvasOpen] = useState(true);
  const [layersOpen, setLayersOpen] = useState(true);
  const [exportOpen, setExportOpen] = useState(false);
  // Export feedback: 'svg' | 'png' | null — drives a brief checkmark flash.
  const [exportFlash, setExportFlash] = useState(null);
  // Export crop mode: 'visible' tracks the rendered scene; 'build-area' uses
  // a stable bbox (canvas + worst-case externals on every side) so successive
  // exports of the same canvas size are pixel-aligned and stackable.
  const [exportMode, setExportMode] = useState('visible');

  // ---- Footprint helpers --------------------------------------------------
  // A block's "occupied cells" are every (gx+i, gy+j, gz+k) for i<dx, j<dy, k<dz.
  const cellsOf = (b) => {
    const out = [];
    for (let i = 0; i < (b.dx || 1); i++)
      for (let j = 0; j < (b.dy || 1); j++)
        for (let k = 0; k < (b.dz || 1); k++)
          out.push(`${b.gx + i},${b.gy + j},${b.gz + k}`);
    return out;
  };
  // Build a set of occupied cell-keys, optionally ignoring one block id.
  const buildOccupancy = (bs, ignoreId = null) => {
    const set = new Set();
    for (const b of bs) {
      if (b.id === ignoreId) continue;
      for (const c of cellsOf(b)) set.add(c);
    }
    return set;
  };
  // Does the block fit at (gx, gy, gz) with its dimensions, given an occupancy set?
  const fits = (gx, gy, gz, dx, dy, dz, occ, W, D, H) => {
    if (gx < 0 || gy < 0 || gz < 0) return false;
    if (gx + dx > W || gy + dy > D || gz + dz > H) return false;
    for (let i = 0; i < dx; i++)
      for (let j = 0; j < dy; j++)
        for (let k = 0; k < dz; k++)
          if (occ.has(`${gx + i},${gy + j},${gz + k}`)) return false;
    return true;
  };
  // Find the lowest gz at which a block of size (dx,dy,dz) fits at (gx,gy)
  // given an occupancy set. Returns -1 if no fit.
  const lowestFitGz = (gx, gy, dx, dy, dz, occ, W, D, H) => {
    for (let gz = 0; gz <= H - dz; gz++) {
      if (fits(gx, gy, gz, dx, dy, dz, occ, W, D, H)) return gz;
    }
    return -1;
  };

  // -------- Blocks list. Each block:
  //   { id, color, type, label, gx, gy, gz, dx, dy, dz, z } where z is layer order bias
  const [blocks, setBlocks] = useState(() => ([
    // Seed with a couple so the canvas isn't empty
    { 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 },
  ]));
  // The single accent block (or null). Lives separately from `blocks` so
  // none of the footprint / occupancy / painter / drag logic needs to learn
  // about its rotated, canvas-relative geometry.
  //   { id, color, label, accent: true }
  const [accent, setAccent] = useState(null);
  const [selectedId, setSelectedId] = useState(null);
  const [placing, setPlacing] = useState(false); // in placement mode (ghost cursor)
  const [hoverCell, setHoverCell] = useState(null); // {x, y} in grid coords

  // -------- Layer-row inline editors --------
  // editingNameId: which block's name input is open
  // openPopover: { blockId, kind: 'color'|'size' } or null
  const [editingNameId, setEditingNameId] = useState(null);
  const [openPopover, setOpenPopover] = useState(null);

  // -------- View (pan + zoom) --------
  const [view, setView] = useState({ tx: 0, ty: -40, scale: 1 });
  const panningRef = useRef(null); // { startX, startY, startTx, startTy }

  const stageRef = useRef(null);
  const dragRef = useRef(null); // { id, startX, startY, origGx, origGy, moved }

  // -------- Scene sync --------
  // On scene-switch: hydrate local state from the active scene.
  // On any state change while a scene is mounted: push back to scenes store.
  // While a preview animation is running, both directions are suppressed —
  // the RAF tick mutates local state directly with interpolated values, and
  // we don't want those temporary in-between states to either be overwritten
  // by the active scene's snapshot, or to be persisted back into the store.
  const _previewActiveRef = useRef(false);
  // Set when the input mirror has just repopulated local state from
  // scenesApi.active (on scene switch OR after undo/redo). Tells the output
  // effect below to skip one cycle so it doesn't immediately patchActive the
  // restored values back over what we just loaded.
  const _skipNextPatchRef = useRef(false);
  const _historyEpoch = scenesApi?.historyEpoch ?? 0;
  useEffect(() => {
    if (!scenesApi || !scenesApi.active) return;
    if (_previewActiveRef.current) return;
    const a = scenesApi.active;
    _skipNextPatchRef.current = true;
    setCanvas(a.canvas || { W: 2, D: 2, H: 2 });
    setBlocks(Array.isArray(a.blocks) ? a.blocks : []);
    setAccent(a.accent || null);
    setExternals(Array.isArray(a.externals) ? a.externals : []);
    setSelectedId(null);
    setPlacing(false);
    _scenesLoadedRef.current = true;
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [_activeSceneId, _historyEpoch]);

  useEffect(() => {
    if (!scenesApi || !_scenesLoadedRef.current) return;
    if (_previewActiveRef.current) return;
    if (_skipNextPatchRef.current) {
      _skipNextPatchRef.current = false;
      return;
    }
    scenesApi.patchActive({ canvas, blocks, accent, externals });
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [canvas, blocks, accent, externals]);

  // ---- Convert client coords → grid cell (accounting for transform) ----
  // Our stage-inner is at (50%, 50%) of the stage with transform translate(tx,ty) scale(s).
  // Inside stage-inner, world (0,0,0) is at origin.
  const clientToWorld = useCallback((cx, cy) => {
    if (!stageRef.current) return null;
    const rect = stageRef.current.getBoundingClientRect();
    const cxLocal = cx - rect.left;
    const cyLocal = cy - rect.top;
    const centerX = rect.width / 2;
    const centerY = rect.height / 2;
    // Inverse of: screen = center + (tx + worldIso) * scale
    // worldIso = (screenLocal - center) / scale - (tx,ty)
    const wx = (cxLocal - centerX) / view.scale - view.tx;
    const wy = (cyLocal - centerY) / view.scale - view.ty;

    // Inverse iso (z=0 plane):
    //   worldIso.x = (gx - gy) * UNIT * COS30
    //   worldIso.y = (gx + gy) * UNIT * SIN30
    // Solve:
    const gx = wx / (UNIT * COS30) + wy / (UNIT * SIN30);
    const gy = wy / (UNIT * SIN30) - wx / (UNIT * COS30);
    return { gx: gx / 2, gy: gy / 2 };
  }, [view]);

  // ---- Pointer handlers on stage ----
  const onStagePointerDown = (e) => {
    // Middle button OR space+drag OR right-click pans. Otherwise if placing, place.
    if (e.button === 1 || e.button === 2 || e.altKey) {
      panningRef.current = {
        startX: e.clientX, startY: e.clientY,
        startTx: view.tx, startTy: view.ty,
      };
      e.preventDefault();
      return;
    }
    // Left click: if placing, drop block
    if (placing && hoverCell) {
      placeBlockAt(hoverCell.x, hoverCell.y);
      return;
    }
    // Otherwise, pan as well (left drag pans). Track to detect drag vs click.
    panningRef.current = {
      startX: e.clientX, startY: e.clientY,
      startTx: view.tx, startTy: view.ty,
      maybePan: true,
    };
  };
  const onStagePointerMove = (e) => {
    // Dragging a block
    if (dragRef.current) {
      const d = dragRef.current;
      const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY);
      if (dist > 3) d.moved = true;
      const w = clientToWorld(e.clientX, e.clientY);
      if (w) {
        // Apply the grab-offset so the block doesn't jump under the cursor.
        const adjGx = w.gx + (d.offX || 0);
        const adjGy = w.gy + (d.offY || 0);
        // Find the dragged block to read its footprint
        const dragBlock = blocks.find((b) => b.id === d.id);
        const bdx = dragBlock?.dx || 1;
        const bdy = dragBlock?.dy || 1;
        const bdz = dragBlock?.dz || 1;
        const hx = Math.floor(adjGx);
        const hy = Math.floor(adjGy);
        // Clamp so the entire footprint fits in the canvas
        const cx = Math.max(0, Math.min(canvas.W - bdx, hx));
        const cy = Math.max(0, Math.min(canvas.D - bdy, hy));
        // Only update if we actually have a new cell
        if (cx !== d.lastGx || cy !== d.lastGy) {
          d.lastGx = cx;
          d.lastGy = cy;
          setBlocks((bs) => {
            // Settle to lowest free gz at the new (x,y) ignoring the dragged block.
            const occ = buildOccupancy(bs, d.id);
            const gz = lowestFitGz(cx, cy, bdx, bdy, bdz, occ, canvas.W, canvas.D, canvas.H);
            if (gz < 0) return bs; // no fit at this column
            return bs.map((b) => b.id === d.id ? { ...b, gx: cx, gy: cy, gz } : b);
          });
          setHoverCell({ x: cx, y: cy });
        }
      }
      return;
    }
    if (panningRef.current) {
      const p = panningRef.current;
      const dx = e.clientX - p.startX;
      const dy = e.clientY - p.startY;
      if (p.maybePan && Math.hypot(dx, dy) < 4) return; // small-move threshold
      setView((v) => ({ ...v, tx: p.startTx + dx / v.scale, ty: p.startTy + dy / v.scale }));
      return;
    }
    // Update hover cell for placement preview.
    //
    // "Aggressive" snap: rather than requiring the cursor to be over a valid
    // empty cell, we always snap to the nearest valid cell in the canvas
    // footprint. The ghost will follow the cursor wherever it goes inside the
    // window, gravity-settled into the lowest available gz at that column.
    //
    // We also bias the cursor's grid position UPWARD by half the draft block's
    // gravity-settled gz so that hovering visually OVER an existing block
    // targets the cell on top of it, not the floor cell behind it.
    const w = clientToWorld(e.clientX, e.clientY);
    if (!w) return;
    const dx = draftDim.x, dy = draftDim.y, dz = draftDim.z;
    const maxX = Math.max(0, canvas.W - dx);
    const maxY = Math.max(0, canvas.D - dy);
    // Center the footprint UNDER the cursor (user is pointing at the block's
    // center, not its back-bottom corner). For a 1×1 block this is a no-op.
    let hx = Math.round(w.gx - dx / 2);
    let hy = Math.round(w.gy - dy / 2);
    // Clamp into canvas footprint so the ghost stays visible everywhere.
    hx = Math.max(0, Math.min(maxX, hx));
    hy = Math.max(0, Math.min(maxY, hy));
    if (!hoverCell || hoverCell.x !== hx || hoverCell.y !== hy) {
      setHoverCell({ x: hx, y: hy });
    }
  };
  const onStagePointerUp = (e) => {
    const wasDragging = !!dragRef.current;
    // Treat a left-button release with no meaningful pan movement as a
    // "click on empty canvas" — deselect the current block. Block / accent
    // / external onMouseDown handlers stopPropagation, so this only fires
    // when the user actually clicked the empty stage.
    const p = panningRef.current;
    const wasClick = p && p.maybePan
      && Math.hypot(e.clientX - p.startX, e.clientY - p.startY) < 4;
    panningRef.current = null;
    dragRef.current = null;
    if (wasClick && !placing && !wasDragging && selectedId) {
      setSelectedId(null);
    }
    // After a drag ends, re-settle every column under gravity so any blocks
    // that were left "floating" above a now-empty cell fall to fill the gap.
    if (wasDragging) {
      setBlocks((bs) => settleGravity(bs));
    }
  };
  const onStageWheel = (e) => {
    // If the wheel event originated inside one of the floating UI panels
    // (builder, layers, popovers), let that panel scroll normally and don't
    // hijack the wheel for canvas zoom.
    if (e.target && e.target.closest && e.target.closest('.builder, .right-rail, .popover, .header, .zoom-controls, .hotkeys')) {
      return;
    }
    e.preventDefault();
    const delta = -e.deltaY * 0.0015;
    setView((v) => {
      const nextScale = Math.min(2.5, Math.max(0.4, v.scale * (1 + delta)));
      return { ...v, scale: nextScale };
    });
  };

  // ---- Place a block at the hover cell. Gravity: lowest free gz at the column. ----
  const placeBlockAt = (gx, gy) => {
    const dx = draftDim.x, dy = draftDim.y, dz = draftDim.z;
    if (gx < 0 || gy < 0 || gx + dx > canvas.W || gy + dy > canvas.D) return;
    const occ = buildOccupancy(blocks);
    const gz = lowestFitGz(gx, gy, dx, dy, dz, occ, canvas.W, canvas.D, canvas.H);
    if (gz < 0) return; // no room
    const id = 'b_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
    const label = draftLabel.trim();
    setBlocks((bs) => [...bs, { id, color: draftColor, type: 'solid', label, gx, gy, gz, dx, dy, dz }]);
    setSelectedId(id);
    setPlacing(false);
    setDraftLabel('');
    setBuilderOpen(false); // auto-close New Block panel after placement
  };

  // ---- Place / replace the accent block. Accents have no grid coords —
  // they always render in the dedicated slot above the canvas. There is
  // only ever one accent at a time; placing again replaces the previous.
  const placeAccent = () => {
    const id = accent?.id || 'accent_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
    const label = draftLabel.trim();
    const next = { id, color: draftColor, label, accent: true };
    setAccent(next);
    setSelectedId(id);
    setDraftLabel('');
    setDraftAccent(false);
    setBuilderOpen(false);
  };

  const removeAccent = () => {
    setAccent(null);
    if (accent && selectedId === accent.id) setSelectedId(null);
  };
  const updateAccent = (patch) => {
    setAccent((a) => (a ? { ...a, ...patch } : a));
  };

  // ---- Externals (warm-gray nodes outside the canvas, connected by line) ----
  // Up to 2 externals per side. Side has 0/1/2 already? -> reject silently
  // when full.
  const placeExternal = () => {
    const counts = { 'back-left': 0, 'back-right': 0, 'front-right': 0, 'front-left': 0 };
    for (const e of externals) counts[e.side] = (counts[e.side] || 0) + 1;
    if (counts[draftExtSide] >= 2) return; // side full
    const id = 'ext_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
    const label = draftLabel.trim();
    const next = {
      id,
      color: 'slate',
      label,
      dx: draftExtDim.x, dy: draftExtDim.y, dz: draftExtDim.z,
      side: draftExtSide,
    };
    setExternals((es) => [...es, next]);
    setSelectedId(id);
    setDraftLabel('');
    setBuilderOpen(false);
  };

  const removeExternal = (id) => {
    setExternals((es) => es.filter((e) => e.id !== id));
    if (selectedId === id) setSelectedId(null);
  };

  // Patch an external by id. If `side` changes, the slotIndex/slotCount
  // metadata is recomputed downstream by `externalsBySide`. Capacity
  // checks (max 2 per side) are enforced at the call site.
  const updateExternal = (id, patch) => {
    setExternals((es) => es.map((e) => (e.id === id ? { ...e, ...patch } : e)));
  };

  // Group externals by side, with per-block _slotIndex / _slotCount
  // metadata used by the renderer for branching.
  const externalsBySide = useMemo(() => {
    const out = { 'back-left': [], 'back-right': [], 'front-right': [], 'front-left': [] };
    for (const e of externals) {
      if (out[e.side]) out[e.side].push(e);
    }
    for (const side of Object.keys(out)) {
      const list = out[side];
      list.forEach((e, i) => {
        e._slotIndex = i;
        e._slotCount = list.length;
      });
    }
    return out;
  }, [externals]);

  // ---- Render order: iso painter's algorithm.
  //
  // Foolproof method for axis-aligned, non-overlapping boxes viewed from the
  // +x+y+z camera octant:
  //
  //   For each pair (A, B), test the SEPARATING-PLANE rule on each axis.
  //   On axis k, A is behind B iff   A.kmax <= B.kmin
  //   This is the canonical painter's relation for AABBs.
  //
  //   When boxes share a face (equality), the rule fires in BOTH directions
  //   along that axis → cycle. Two cures:
  //     1. Use STRICT inequality (<) so equality emits no edge.
  //     2. Require the boxes to OVERLAP on the other two axes — otherwise
  //        their 2D screen silhouettes don't actually cover each other and
  //        ordering them is meaningless.
  //
  //   Both cures together: A is behind B iff there exists an axis k where
  //   A.kmax < B.kmin AND on the other two axes the boxes have positive
  //   overlap. This emits at most one direction per pair (no cycles), and it
  //   only emits when the boxes can actually occlude each other on screen.
  //
  // Topological sort produces a partial order; ready-set tie-break by the
  // FRONT-corner depth ((gx+dx) + (gy+dy) + (gz+dz)) so independent blocks
  // come out in a stable, intuitive order.
  const sortedBlocks = useMemo(() => {
    const n = blocks.length;
    if (n === 0) return [];

    // Half-open box bounds (cells [min, max)).
    const bounds = blocks.map((b) => ({
      x0: b.gx, x1: b.gx + b.dx,
      y0: b.gy, y1: b.gy + b.dy,
      z0: b.gz, z1: b.gz + b.dz,
    }));

    // Positive overlap on axis k between A and B (using half-open bounds:
    // touching faces give zero overlap, so they don't satisfy this).
    const overlapX = (i, j) => Math.min(bounds[i].x1, bounds[j].x1) - Math.max(bounds[i].x0, bounds[j].x0) > 0;
    const overlapY = (i, j) => Math.min(bounds[i].y1, bounds[j].y1) - Math.max(bounds[i].y0, bounds[j].y0) > 0;
    const overlapZ = (i, j) => Math.min(bounds[i].z1, bounds[j].z1) - Math.max(bounds[i].z0, bounds[j].z0) > 0;

    // i is BEHIND j iff there's a strict separating plane along an axis where
    // i is on the camera-far side, AND the other two axes' silhouettes
    // overlap (so they could actually occlude each other on screen).
    const isBehind = (i, j) => {
      const a = bounds[i], b = bounds[j];
      // Behind on x: i is to the +x... wait, +x is FORWARD, so i behind j on x
      // means i is at LOWER x than j. The camera is at +x+y+z, so closer to
      // the camera = higher coordinates.
      if (a.x1 < b.x0 + 1e-9 && overlapY(i, j) && overlapZ(i, j)) return true; // i below j on x AND silhouettes overlap on y,z
      if (a.y1 < b.y0 + 1e-9 && overlapX(i, j) && overlapZ(i, j)) return true; // i below j on y AND silhouettes overlap on x,z
      if (a.z1 < b.z0 + 1e-9 && overlapX(i, j) && overlapY(i, j)) return true; // i below j on z AND silhouettes overlap on x,y
      return false;
    };

    // Build edges i → j ("i must draw before j").
    const indeg = new Array(n).fill(0);
    const adj = Array.from({ length: n }, () => []);
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
        if (i === j) continue;
        if (isBehind(i, j)) {
          adj[i].push(j);
          indeg[j]++;
        }
      }
    }

    // Kahn-style toposort with a stable tiebreaker among ready nodes:
    // front-corner depth (so blocks closer to the viewer come out later).
    const ready = [];
    for (let i = 0; i < n; i++) if (indeg[i] === 0) ready.push(i);
    const cmp = (i, j) => {
      const a = blocks[i], bk = blocks[j];
      const da = (a.gx + a.dx) + (a.gy + a.dy) + (a.gz + a.dz);
      const db = (bk.gx + bk.dx) + (bk.gy + bk.dy) + (bk.gz + bk.dz);
      if (da !== db) return da - db;
      return i - j; // stable
    };
    const out = [];
    while (ready.length) {
      ready.sort(cmp);
      const i = ready.shift();
      out.push(blocks[i]);
      for (const j of adj[i]) {
        if (--indeg[j] === 0) ready.push(j);
      }
    }

    // Cycle guard: should never trigger given strict + overlap-required rule,
    // but if it does (overlapping geometry), append leftovers in input order.
    if (out.length < n) {
      const seen = new Set(out.map((b) => b.id));
      for (const b of blocks) if (!seen.has(b.id)) out.push(b);
    }
    return out;
  }, [blocks]);

  // ---- Label face + strip picker.
  //
  // Strip choices depend on the block's height (dz):
  //   dz === 1: single full-face region. No stripping ever.
  //   dz === 2: default = full face. Fallbacks (when occluded):
  //              top-half (1u) and bottom-half (1u).
  //   dz === 3: default = middle 1u strip. Fallbacks (in order):
  //              upper 2u (covers middle+top — biggest readable region when
  //                bottom is occluded),
  //              top 1u, bottom 1u, lower 2u, full face.
  //
  // Each candidate is a {vLo, vHi} fraction of face vertical (0=bottom, 1=top
  // in face-local coordinates where v=0 is the BOTTOM of the face). We pick
  // the candidate with the FEWEST occluded sample points; tie-break favors:
  //   1) the wider visible face,
  //   2) the candidate listed earliest in the per-height priority order
  //      (i.e., the most "natural" / centered choice).
  //
  // Output: Map<id, {face, vLo, vHi}> where vLo/vHi are the strip's vertical
  // bounds in face-local coordinates (v=0 bottom, v=1 top).
  const labelFaceById = useMemo(() => {
    const result = new Map();
    if (!blocks.length) return result;
    const PAD = 0.08;
    const blockSilhouette = (b) => {
      const bdx = b.dx || 1, bdy = b.dy || 1, bdz = b.dz || 1;
      const x0 = b.gx + PAD, x1 = b.gx + bdx - PAD;
      const y0 = b.gy + PAD, y1 = b.gy + bdy - PAD;
      const z0 = b.gz + PAD, z1 = b.gz + bdz - PAD;
      const p1 = iso(x1, y0, z0);
      const p2 = iso(x1, y1, z0);
      const p3 = iso(x0, y1, z0);
      const p4 = iso(x0, y1, z1);
      const p5 = iso(x0, y0, z1);
      const p6 = iso(x1, y0, z1);
      return [p1, p2, p3, p4, p5, p6];
    };
    const pointInPoly = (px, py, poly) => {
      let inside = false;
      for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
        const xi = poly[i].x, yi = poly[i].y;
        const xj = poly[j].x, yj = poly[j].y;
        const intersect = ((yi > py) !== (yj > py)) &&
          (px < (xj - xi) * (py - yi) / ((yj - yi) || 1e-9) + xi);
        if (intersect) inside = !inside;
      }
      return inside;
    };
    const sil = new Map();
    for (const b of blocks) sil.set(b.id, blockSilhouette(b));
    const orderIdx = new Map();
    sortedBlocks.forEach((b, i) => orderIdx.set(b.id, i));
    const sk = (b) => orderIdx.get(b.id);

    // Per-height candidate list (in priority/preference order).
    // vLo/vHi in [0, 1] face-local: 0 = bottom of face, 1 = top of face.
    const candidatesForHeight = (dz) => {
      if (dz <= 1) {
        return [{ vLo: 0, vHi: 1 }]; // single full face — no stripping
      }
      if (dz === 2) {
        return [
          { vLo: 0,   vHi: 1   }, // full face (default)
          { vLo: 0.5, vHi: 1   }, // top half (1u)
          { vLo: 0,   vHi: 0.5 }, // bottom half (1u)
        ];
      }
      // dz >= 3 — treat as 3 unit strips
      return [
        { vLo: 1/3, vHi: 2/3 }, // middle 1u (default)
        { vLo: 1/3, vHi: 1   }, // upper 2u (middle+top)
        { vLo: 2/3, vHi: 1   }, // top 1u
        { vLo: 0,   vHi: 1/3 }, // bottom 1u
        { vLo: 0,   vHi: 2/3 }, // lower 2u
        { vLo: 0,   vHi: 1   }, // full face
      ];
    };

    for (const b of blocks) {
      const bdx = b.dx || 1, bdy = b.dy || 1, bdz = b.dz || 1;
      const x0 = b.gx + PAD, x1 = b.gx + bdx - PAD;
      const y0 = b.gy + PAD, y1 = b.gy + bdy - PAD;
      const z0 = b.gz + PAD, z1 = b.gz + bdz - PAD;
      const myK = sk(b);

      // Sample within a (face, vRange) band: 5 horizontal × 3 vertical.
      const sampleBand = (cornerFn, vRange) => {
        const samples = [];
        const [vLo, vHi] = vRange;
        for (let i = 1; i <= 5; i++) {
          for (let k = 1; k <= 3; k++) {
            const u = i / 6;
            const t = k / 4;
            const v = vLo + (vHi - vLo) * t;
            samples.push(cornerFn(u, v));
          }
        }
        return samples;
      };
      const countOccluded = (samples) => {
        let n = 0;
        outer: for (const s of samples) {
          for (const o of blocks) {
            if (o.id === b.id) continue;
            if (sk(o) <= myK) continue;
            const poly = sil.get(o.id);
            if (pointInPoly(s.x, s.y, poly)) { n++; continue outer; }
          }
        }
        return n;
      };

      // LEFT face: y = y1. v=0 → BOTTOM of face (z0), v=1 → TOP (z1).
      const leftCorner  = (u, v) => iso(x0 + (x1 - x0) * u, y1, z0 + (z1 - z0) * v);
      // RIGHT face: x = x1.
      const rightCorner = (u, v) => iso(x1, y0 + (y1 - y0) * u, z0 + (z1 - z0) * v);

      const cands = candidatesForHeight(bdz);
      const wideFace = bdx >= bdy ? 'left' : 'right';

      // Build all (face, candidate) options with their occlusion counts.
      const scored = [];
      for (const face of ['left', 'right']) {
        const corner = face === 'left' ? leftCorner : rightCorner;
        cands.forEach((c, idx) => {
          const samples = sampleBand(corner, [c.vLo, c.vHi]);
          const occ = countOccluded(samples);
          scored.push({
            face, idx, vLo: c.vLo, vHi: c.vHi,
            occ, total: samples.length,
          });
        });
      }

      // Preference rules:
      //   1) Lower occlusion wins.
      //   2) Wider face breaks ties.
      //   3) Earlier candidate (the more "natural" choice) breaks remaining ties.
      scored.sort((a, b) => {
        if (a.occ !== b.occ) return a.occ - b.occ;
        if ((a.face === wideFace) !== (b.face === wideFace)) {
          return a.face === wideFace ? -1 : 1;
        }
        return a.idx - b.idx;
      });

      const best = scored[0];
      if (best.occ >= best.total * 0.8) {
        result.set(b.id, { face: 'hidden', vLo: 0, vHi: 1 });
      } else {
        result.set(b.id, { face: best.face, vLo: best.vLo, vHi: best.vHi });
      }
    }
    return result;
  }, [blocks, sortedBlocks]);

  // ---- Shadows: a single offset drop-shadow per block, cast onto the
  // highest surface (floor or lower-block top) beneath its footprint. We
  // shift the shadow toward +x/+y (front of the iso scene) so it pokes out
  // past the casting block's near edges and is actually visible — otherwise
  // it would sit fully under the block.
  // Shadows are slightly smaller than the casting block (inset on all sides)
  // and offset toward +x/+y so the visible portion peeks out past the block's
  // front-right corner — a soft contact shadow rather than a hard footprint.
  const SHADOW_OFFSET = 0.1; // world units toward +x/+y
  const SHADOW_INSET  = 0.10; // world units inset on all sides
  const shadowTiles = useMemo(() => {
    if (!blocks.length) return [];
    // Build per-column tops so we can find what surface is under each cell.
    const cols = new Map(); // "cx,cy" -> [{ topZ, id }, ...]
    for (const b of blocks) {
      const bdx = b.dx || 1, bdy = b.dy || 1, bdz = b.dz || 1;
      for (let cx = b.gx; cx < b.gx + bdx; cx++) {
        for (let cy = b.gy; cy < b.gy + bdy; cy++) {
          const k = `${cx},${cy}`;
          if (!cols.has(k)) cols.set(k, []);
          cols.get(k).push({ id: b.id, topZ: b.gz + bdz });
        }
      }
    }
    const shadows = [];
    for (const b of blocks) {
      const bdx = b.dx || 1, bdy = b.dy || 1, bdz = b.dz || 1;
      // Highest surface across the footprint that is strictly below b.gz.
      let surfaceZ = 0;
      for (let cx = b.gx; cx < b.gx + bdx; cx++) {
        for (let cy = b.gy; cy < b.gy + bdy; cy++) {
          const col = cols.get(`${cx},${cy}`) || [];
          for (const e of col) {
            if (e.id === b.id) continue;
            if (e.topZ <= b.gz && e.topZ > surfaceZ) surfaceZ = e.topZ;
          }
        }
      }
      // No shadow if the block has nothing visible to cast onto (it's
      // somehow below or at the same z as the floor — shouldn't happen, but
      // belt & suspenders).
      if (b.gz < surfaceZ) continue;
      shadows.push({
        id: b.id,
        gx: b.gx, gy: b.gy,
        dx: bdx, dy: bdy,
        surfaceZ,
        // sortKey is a placeholder; it gets replaced in drawList below using
        // the casting block's topological position so we draw right before it.
        sortKey: 0,
      });
    }
    return shadows;
  }, [blocks]);

  // ---- Accent shadow tiles. Per-HOST-BLOCK tiles: for each block whose
  // top is the highest surface anywhere under the accent's XY footprint,
  // emit ONE tile clipped to the intersection of the accent footprint
  // and that host's PADDED top face (the same inset the block renders
  // with). Clipping to the padded silhouette is what prevents "bleed":
  // the shadow has hard edges at the block's visible boundary instead
  // of leaking into space beyond the block's top.
  //
  // Each tile carries the host block's id so it can be interleaved into
  // the painter's drawList right AFTER the host. Any block painted
  // later (i.e., in front of the host in topological order) cleanly
  // overdraws the tile, so the shadow respects layering.
  const accentShadowTiles = useMemo(() => {
    if (!accent) return [];
    const SHADOW_PAD = 0.08; // must match the per-block render inset
    const fp = accentFootprint(canvas.W, canvas.D, canvas.H);

    // Build per-column highest block-top + that block's id (footprint-cell granularity).
    const colTop = new Map(); // key "ix,iy" -> { top, id }
    for (const b of blocks) {
      const dx = b.dx || 1, dy = b.dy || 1, dz = b.dz || 1;
      const top = b.gz + dz;
      for (let ix = b.gx; ix < b.gx + dx; ix++) {
        for (let iy = b.gy; iy < b.gy + dy; iy++) {
          const k = `${ix},${iy}`;
          const cur = colTop.get(k);
          if (cur === undefined || top > cur.top) colTop.set(k, { top, id: b.id });
        }
      }
    }

    // Collect the set of host block ids that are the topmost surface in
    // any cell touched by the accent's footprint.
    const hostIds = new Set();
    const cellMinX = Math.floor(fp.x0);
    const cellMaxX = Math.ceil(fp.x1);
    const cellMinY = Math.floor(fp.y0);
    const cellMaxY = Math.ceil(fp.y1);
    for (let ix = cellMinX; ix < cellMaxX; ix++) {
      for (let iy = cellMinY; iy < cellMaxY; iy++) {
        const ent = colTop.get(`${ix},${iy}`);
        if (!ent) continue;
        // The cell rectangle [ix..ix+1, iy..iy+1] must actually overlap fp
        // with positive area before we count this host.
        const ox0 = Math.max(fp.x0, ix), ox1 = Math.min(fp.x1, ix + 1);
        const oy0 = Math.max(fp.y0, iy), oy1 = Math.min(fp.y1, iy + 1);
        if (ox1 - ox0 <= 1e-6 || oy1 - oy0 <= 1e-6) continue;
        hostIds.add(ent.id);
      }
    }

    const tiles = [];
    const blockById = new Map(blocks.map((b) => [b.id, b]));
    for (const id of hostIds) {
      const host = blockById.get(id);
      if (!host) continue;
      const hdx = host.dx || 1, hdy = host.dy || 1, hdz = host.dz || 1;
      // Padded top-face rectangle of this host (matches its rendered silhouette).
      const hx0 = host.gx + SHADOW_PAD;
      const hx1 = host.gx + hdx - SHADOW_PAD;
      const hy0 = host.gy + SHADOW_PAD;
      const hy1 = host.gy + hdy - SHADOW_PAD;
      const hz  = host.gz + hdz - SHADOW_PAD;
      // Intersect with the accent's XY footprint.
      const x0 = Math.max(fp.x0, hx0);
      const x1 = Math.min(fp.x1, hx1);
      const y0 = Math.max(fp.y0, hy0);
      const y1 = Math.min(fp.y1, hy1);
      if (x1 - x0 <= 1e-6 || y1 - y0 <= 1e-6) continue;
      tiles.push({
        key: `acc-${id}`,
        hostId: id,
        z: hz,
        x0, x1, y0, y1,
      });
    }
    return tiles;
  }, [accent, blocks, canvas.W, canvas.D, canvas.H]);

  // Combined depth-sorted draw list: shadows + blocks. Use topological
  // draw order (sortedBlocks) and place each shadow just before its
  // casting block so the block covers any shadow under its own footprint.
  // Accent-shadow tiles are inserted just AFTER their host block, so
  // any block painted LATER than the host (i.e., in front of it per the
  // topological order) will overdraw the tile and the accent-shadow
  // will visibly respect layering — a tile cast on a back block won't
  // bleed over a block that stands in front of it.
  const drawList = useMemo(() => {
    const items = [];
    const shadowById = new Map();
    for (const s of shadowTiles) shadowById.set(s.id, s);
    // Group accent-shadow tiles by their host block id.
    const accentTilesByHost = new Map();
    for (const t of accentShadowTiles) {
      if (!accentTilesByHost.has(t.hostId)) accentTilesByHost.set(t.hostId, []);
      accentTilesByHost.get(t.hostId).push(t);
    }
    sortedBlocks.forEach((b, i) => {
      const s = shadowById.get(b.id);
      if (s) items.push({ kind: 'shadow', data: { ...s, sortKey: i - 0.5 }, sortKey: i - 0.5 });
      items.push({ kind: 'block', data: b, sortKey: i });
      const accTiles = accentTilesByHost.get(b.id);
      if (accTiles && accTiles.length) {
        const SHADOW_PAD = 0.08;
        const hdx = b.dx || 1, hdy = b.dy || 1, hdz = b.dz || 1;
        const host = {
          x0: b.gx + SHADOW_PAD,
          x1: b.gx + hdx - SHADOW_PAD,
          y0: b.gy + SHADOW_PAD,
          y1: b.gy + hdy - SHADOW_PAD,
          z:  b.gz + hdz - SHADOW_PAD,
        };
        items.push({
          kind: 'accent-shadow',
          data: { hostId: b.id, tiles: accTiles, host },
          sortKey: i + 0.5,
        });
      }
    });
    return items;
  }, [sortedBlocks, shadowTiles, accentShadowTiles]);

  // ---- Stack analysis: which blocks share the EXACT same cell (gx,gy,gz)? ----
  // Stacking on the vertical axis (different gz) doesn't count — those are visibly distinct.
  const stackInfo = useMemo(() => {
    const map = new Map(); // key "gx,gy,gz" -> [blocks at the exact same cell]
    for (const b of blocks) {
      const k = `${b.gx},${b.gy},${b.gz}`;
      if (!map.has(k)) map.set(k, []);
      map.get(k).push(b);
    }
    const info = {};
    for (const b of blocks) {
      const k = `${b.gx},${b.gy},${b.gz}`;
      const arr = map.get(k) || [];
      info[b.id] = {
        size: arr.length,
        isTop: arr[0]?.id === b.id, // arbitrary: first in source order at this exact cell
        cellKey: k,
      };
    }
    return info;
  }, [blocks]);

  // Cycle selection through blocks at an exact cell.
  const cycleStack = (cellKey) => {
    const arr = blocks.filter(b => `${b.gx},${b.gy},${b.gz}` === cellKey);
    if (arr.length < 2) return;
    const curIdx = arr.findIndex(b => b.id === selectedId);
    const next = arr[(curIdx + 1) % arr.length] || arr[0];
    setSelectedId(next.id);
  };

  // ---- Clamp draftDim if canvas shrinks below the current draft size ----
  useEffect(() => {
    setDraftDim((d) => {
      const x = Math.min(d.x, canvas.W);
      const y = Math.min(d.y, canvas.D);
      const z = Math.min(d.z, canvas.H);
      // Ensure at least one axis is 1; if not (shouldn't normally happen), clamp x to 1
      const needsAxis1 = Math.min(x, y, z) > 1;
      return { x: needsAxis1 ? 1 : x, y, z };
    });
  }, [canvas.W, canvas.D, canvas.H]);

  // ---- Keyboard: Esc exits placement mode, Delete removes selected, Up/Down raises/lowers ----
  useEffect(() => {
    const onKey = (e) => {
      const isInput = document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA';

      // Undo / redo. Cmd on macOS, Ctrl elsewhere. Shift+Z = redo. We let the
      // browser handle undo when the focus is on a text input so character-
      // level undo in the rename field still works.
      const accel = e.metaKey || e.ctrlKey;
      if (accel && (e.key === 'z' || e.key === 'Z') && !isInput) {
        e.preventDefault();
        if (e.shiftKey) {
          _scenesApiRef.current?.redo?.();
        } else {
          _scenesApiRef.current?.undo?.();
        }
        return;
      }
      // Common alt-redo binding (Cmd+Y / Ctrl+Y).
      if (accel && (e.key === 'y' || e.key === 'Y') && !isInput) {
        e.preventDefault();
        _scenesApiRef.current?.redo?.();
        return;
      }

      if (e.key === 'Escape') {
        setPlacing(false);
        setSelectedId(null);
      }
      if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && !isInput) {
        setBlocks((bs) => settleGravity(bs.filter((b) => b.id !== selectedId)));
        setSelectedId(null);
      }
      // Raise / lower the selected block in z (vertical stack)
      if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && selectedId && !isInput) {
        e.preventDefault();
        const dir = e.key === 'ArrowUp' ? 1 : -1;
        setBlocks((bs) => {
          const sel = bs.find((b) => b.id === selectedId);
          if (!sel) return bs;
          const occ = buildOccupancy(bs, sel.id);
          const next = sel.gz + dir;
          const sdx = sel.dx || 1, sdy = sel.dy || 1, sdz = sel.dz || 1;
          if (!fits(sel.gx, sel.gy, next, sdx, sdy, sdz, occ, canvas.W, canvas.D, canvas.H)) return bs;
          return bs.map((b) => b.id === sel.id ? { ...b, gz: next } : b);
        });
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedId]);

  // ---- Re-settle every block under gravity. After a removal/move, any block
  // that's left floating above empty space falls until it lands on something.
  // Sort by current gz ascending; greedily "drop" each one to the lowest gz
  // that fits given the already-settled blocks.
  const settleGravity = (bs) => {
    const sorted = [...bs].sort((a, b) => a.gz - b.gz);
    const settled = [];
    for (const b of sorted) {
      const occ = buildOccupancy(settled);
      const sdx = b.dx || 1, sdy = b.dy || 1, sdz = b.dz || 1;
      const gz = lowestFitGz(b.gx, b.gy, sdx, sdy, sdz, occ, canvas.W, canvas.D, canvas.H);
      settled.push({ ...b, gz: gz < 0 ? b.gz : gz });
    }
    // Preserve the input order so React keys stay stable
    const byId = new Map(settled.map((b) => [b.id, b]));
    return bs.map((b) => byId.get(b.id) || b);
  };

  const removeBlock = (id) => {
    setBlocks((bs) => settleGravity(bs.filter((b) => b.id !== id)));
    if (selectedId === id) setSelectedId(null);
  };

  // Update a block's mutable props (label, color). Geometry edits should go
  // through tryResize so we can validate fit / cascade gravity.
  const updateBlock = (id, patch) => {
    setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, ...patch } : b)));
  };

  // Try to resize a block to (dx,dy,dz). Resize anchors on (gx,gy) so the
  // block grows toward +x/+y. If the new footprint doesn't fit at the
  // current (gx,gy,gz) we slide it to the lowest gz that fits at (gx,gy);
  // if even that fails, we abort. After a successful resize we run gravity
  // so anything stacked on top settles.
  const tryResize = (id, dx, dy, dz) => {
    setBlocks((bs) => {
      const target = bs.find((b) => b.id === id);
      if (!target) return bs;
      // Clamp footprint into canvas. We anchor on (gx,gy), so if the new size
      // overflows, slide the anchor inward.
      let gx = target.gx, gy = target.gy;
      if (gx + dx > canvas.W) gx = Math.max(0, canvas.W - dx);
      if (gy + dy > canvas.D) gy = Math.max(0, canvas.D - dy);
      const occ = buildOccupancy(bs, id);
      // Prefer the existing gz; otherwise drop to the lowest fitting gz.
      let gz = target.gz;
      if (!fits(gx, gy, gz, dx, dy, dz, occ, canvas.W, canvas.D, canvas.H)) {
        gz = lowestFitGz(gx, gy, dx, dy, dz, occ, canvas.W, canvas.D, canvas.H);
        if (gz < 0) return bs; // no room: refuse the resize
      }
      const next = bs.map((b) => (b.id === id ? { ...b, gx, gy, gz, dx, dy, dz } : b));
      return settleGravity(next);
    });
  };

  // ---- Compute total scene extent so SVG has a consistent viewBox ----
  const vbSize = 1400;
  const halfGW = canvas.W / 2;
  const halfGD = canvas.D / 2;
  // Accent slot extends the visible cube wireframe upward when an accent
  // is placed. When there's no accent, slot height is 0 and the canvas
  // looks identical to before.
  //
  // During a preview transition we lerp this value from "from-side slot"
  // to "to-side slot" smoothly, even though the actual `accent` mount
  // (which determines whether AccentBlock renders) may swap discretely.
  // Without this the wireframe rim would jump at the moment we mount the
  // new accent; with it, the rim glides upward/downward in lockstep with
  // the accent block falling in or lifting off.
  let accentSlotH;
  if (previewAnim) {
    const { fromAccent, toAccent, fromCanvas, toCanvas, t } = previewAnim;
    const fromSlot = fromAccent ? accentSlotHeight(fromCanvas.W, fromCanvas.D, fromCanvas.H) : 0;
    const toSlot = toAccent ? accentSlotHeight(toCanvas.W, toCanvas.D, toCanvas.H) : 0;
    // Use the same H window as the rim itself so accent-slot growth and
    // base-H growth share one timeline.
    const k = Math.max(0, Math.min(1, (t - 0.10) / (0.85 - 0.10)));
    const e = k < 0.5 ? 2*k*k : 1 - Math.pow(-2*k + 2, 2) / 2;
    accentSlotH = fromSlot + (toSlot - fromSlot) * e;
  } else {
    accentSlotH = accent ? accentSlotHeight(canvas.W, canvas.D, canvas.H) : 0;
  }
  // During a preview animation we want the BASE of the canvas to stay
  // anchored on screen — the user reads the floor as "real ground" and
  // any vertical drift breaks the illusion of the walls extending upward.
  // We achieve this by computing gridCenter against a FIXED reference
  // height (the largest H + accentSlot across from/to), so the base sits
  // at the same screen Y throughout the morph while the top edges climb.
  let halfGH;
  if (previewAnim) {
    const fromAccentSlot = previewAnim.fromAccent ? accentSlotHeight(previewAnim.fromCanvas.W, previewAnim.fromCanvas.D, previewAnim.fromCanvas.H) : 0;
    const toAccentSlot = previewAnim.toAccent ? accentSlotHeight(previewAnim.toCanvas.W, previewAnim.toCanvas.D, previewAnim.toCanvas.H) : 0;
    const fromTotal = previewAnim.fromCanvas.H + fromAccentSlot;
    const toTotal = previewAnim.toCanvas.H + toAccentSlot;
    halfGH = Math.max(fromTotal, toTotal) / 2;
  } else {
    halfGH = (canvas.H + accentSlotH) / 2;
  }
  // Center iso coord of the volume center (so the canvas sits centered on screen)
  const gridCenter = iso(halfGW, halfGD, halfGH * 0.5);

  // Publish current canvas state for the Export module so it can compute
  // a stable "build area" viewBox without re-importing canvas state. This
  // is read-only from Export's side and is rewritten every render here.
  // (See Export.jsx → _buildAreaSvgBBox.)
  if (typeof window !== 'undefined') {
    window.__bbExportCtx = {
      W: canvas.W, D: canvas.D, H: canvas.H,
      accentSlotH,
      gridCenter,
    };
  }

  // ---- Draft/ghost block at hover ----
  const showGhost = placing && hoverCell &&
    hoverCell.x >= 0 && hoverCell.y >= 0 &&
    hoverCell.x + draftDim.x <= canvas.W && hoverCell.y + draftDim.y <= canvas.D;

  // ---- The big iso SVG — world origin (0,0,0) mapped to (0,0) in SVG space ----
  return (
    <div
      ref={stageRef}
      className={`stage ${panningRef.current ? 'panning' : ''} ${placing ? 'placing' : ''}`}
      onMouseDown={onStagePointerDown}
      onMouseMove={onStagePointerMove}
      onMouseUp={onStagePointerUp}
      onMouseLeave={onStagePointerUp}
      onContextMenu={(e) => e.preventDefault()}
      onWheel={onStageWheel}
    >
      <div
        className="stage-inner"
        style={{ transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})` }}
      >
        <div
          className={`stage-anim ${previewAnim ? 'bb-previewing' : ''}`}
        >
        <svg
          width={vbSize * 1.4}
          height={vbSize}
          viewBox={`${-vbSize*0.7} ${-vbSize*0.55} ${vbSize*1.4} ${vbSize}`}
          style={{
            display: 'block',
            overflow: 'visible',
            position: 'absolute',
            left: -vbSize * 0.7,
            top: -vbSize * 0.55,
          }}
        >
          <defs>
            <radialGradient id="agent-glow" cx="50%" cy="50%" r="50%">
              <stop offset="0%"   stopColor="#b392ff" stopOpacity="0.7"/>
              <stop offset="60%"  stopColor="#6a2bf0" stopOpacity="0.35"/>
              <stop offset="100%" stopColor="#6a2bf0" stopOpacity="0"/>
            </radialGradient>
            <filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
              <feGaussianBlur stdDeviation="3" />
            </filter>
          </defs>

          {/* Center the volume at origin */}
          <g transform={`translate(${-gridCenter.x}, ${-gridCenter.y})`}>
            {/* External connectors — drawn FIRST so the canvas pedestal,
                floor, and translucent walls all sit ON TOP of any portion
                of the connector that runs inside the canvas. The portion
                outside the canvas is unobscured (nothing is drawn over it
                at those world coords). */}
            {EXT_SIDES.map((side) => (
              <ExternalConnector
                key={`conn-${side}`}
                side={side}
                externalsOnSide={externalsBySide[side]}
                W={canvas.W}
                D={canvas.D}
              />
            ))}

            {/* Back-side externals — drawn BEFORE the container back so
                the canvas's back walls / pedestal / floor occlude any
                portion of these blocks that sits behind the canvas
                volume. Front-side externals are drawn after ContainerBack
                (below) so they appear in front of the back walls. */}
            {externals.filter((e) => e.side === 'back-left' || e.side === 'back-right').map((e) => (
              <ExternalBlock
                key={e.id}
                block={e}
                W={canvas.W}
                D={canvas.D}
                selected={selectedId === e.id}
                onSelect={() => setSelectedId(e.id)}
              />
            ))}

            {/* Container back/floor — drawn after connectors so the floor
                hides the inside-canvas portion of the connector lines. */}
            <ContainerBack W={canvas.W} D={canvas.D} H={canvas.H} accentSlotH={accentSlotH} />

            {/* Front-side externals — drawn AFTER the canvas back so they
                sit in front of the back walls but get occluded by
                ContainerFront's front-post / front-edge strokes. */}
            {externals.filter((e) => e.side === 'front-left' || e.side === 'front-right').map((e) => (
              <ExternalBlock
                key={e.id}
                block={e}
                W={canvas.W}
                D={canvas.D}
                selected={selectedId === e.id}
                onSelect={() => setSelectedId(e.id)}
              />
            ))}

            {/* Hover cell highlight */}
            {/* Hover cell highlight (sized to the draft block's footprint) */}
            {hoverCell && hoverCell.x >= 0 && hoverCell.y >= 0 &&
              hoverCell.x + draftDim.x <= canvas.W && hoverCell.y + draftDim.y <= canvas.D && (() => {
              const { x, y } = hoverCell;
              const dx = draftDim.x, dy = draftDim.y;
              const p0 = iso(x,      y,      0);
              const p1 = iso(x + dx, y,      0);
              const p2 = iso(x + dx, y + dy, 0);
              const p3 = iso(x,      y + dy, 0);
              return (
                <polygon
                  points={`${p0.x},${p0.y} ${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`}
                  fill="rgba(45, 212, 160, 0.22)"
                  stroke="rgba(45, 212, 160, 0.7)"
                  strokeWidth="1.5"
                  style={{ pointerEvents: 'none' }}
                />
              );
            })()}

            {/* Blocks + cast shadows — interleaved by iso depth so shadows
                land on the correct surface (floor or lower-block top) */}
            {drawList.map((it) => {
              if (it.kind === 'shadow') {
                const s = it.data;
                // Full bottom-face quad shifted toward +x/+y. Drawn at
                // surfaceZ on the surface beneath. Sortkey places this
                // BEFORE the casting block so the block itself covers the
                // portion under its own footprint — what's left visible is
                // the offset fringe poking out toward the viewer.
                const ox = SHADOW_OFFSET, oy = SHADOW_OFFSET;
                const i = SHADOW_INSET;
                const z = s.surfaceZ;
                const x0 = s.gx + i + ox,        x1 = s.gx + s.dx - i + ox;
                const y0 = s.gy + i + oy,        y1 = s.gy + s.dy - i + oy;
                const p0 = iso(x0, y0, z);
                const p1 = iso(x1, y0, z);
                const p2 = iso(x1, y1, z);
                const p3 = iso(x0, y1, z);
                // ---- Distance-based shadow scale ----
                // During scene transitions blocks fly UP on exit and drop
                // DOWN on enter (340px translateY). The cast shadow stays
                // pinned to the surface beneath, so without compensation
                // it would just appear/disappear in step with the block's
                // opacity — making the shadow seem to "appear first" before
                // the block has actually arrived.
                //
                // Instead, scale the shadow around its centroid based on
                // the casting block's vertical distance from its rest
                // surface: full size when touching (distance ≤ 0), zero
                // when ≥ 2 world units away. The block has to descend to
                // within 2 units before its shadow even starts to appear.
                const castingBlock = sortedBlocks.find((bb) => bb.id === s.id);
                let shadowScale = 1;
                if (castingBlock) {
                  const role = castingBlock._previewRole;
                  const pk = typeof castingBlock._previewK === 'number' ? castingBlock._previewK : 0;
                  // Lift in world units. The render step translates the
                  // block by ±340px which equals 340/UNIT ≈ 7.4 world units.
                  // Mirror the same easing the block uses so the shadow
                  // tracks the block's perceived height exactly.
                  const PX_PER_UNIT = 46; // matches Block.jsx UNIT
                  const MAX_LIFT_PX = 340;
                  const MAX_LIFT_UNITS = MAX_LIFT_PX / PX_PER_UNIT;
                  let liftUnits = 0;
                  if (role === 'exit') {
                    // ease-in (k*k) — block accelerates upward
                    liftUnits = pk * pk * MAX_LIFT_UNITS;
                  } else if (role === 'enter') {
                    // ease-out cubic — block decelerates into place.
                    // translateY is `-340 * (1 - e)` where e = 1 - (1-k)^3,
                    // so lift = 340 * (1-k)^3
                    const inv = 1 - pk;
                    liftUnits = inv * inv * inv * MAX_LIFT_UNITS;
                  }
                  // Linear ramp: scale=1 at 0u, scale=0 at ≥2u.
                  shadowScale = Math.max(0, Math.min(1, 1 - liftUnits / 2));
                }
                // Centroid of the shadow quad — scale around this point
                // so the shadow grows/shrinks from its own center, not
                // from a corner. p0..p3 form an iso parallelogram; the
                // centroid is the average of the four projected corners.
                const cx = (p0.x + p1.x + p2.x + p3.x) / 4;
                const cy = (p0.y + p1.y + p2.y + p3.y) / 4;
                if (shadowScale <= 0) return null;
                return (
                  <polygon
                    key={`shadow-${s.id}`}
                    points={`${p0.x},${p0.y} ${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`}
                    fill="rgba(0, 0, 0, 0.32)"
                    transform={shadowScale < 1 ? `translate(${cx} ${cy}) scale(${shadowScale}) translate(${-cx} ${-cy})` : undefined}
                    style={{ pointerEvents: 'none', filter: 'blur(1.5px)' }}
                  />
                );
              }

              if (it.kind === 'accent-shadow') {
                // One <g> per host block holding all the tiles cast onto
                // its top. Drawn AFTER the host so it's visible on the
                // host's top face — but BEFORE any block in front of the
                // host, so layering is respected.
                //
                // Edge treatment: the shadow is BLURRED, then hard-clipped
                // to the host's padded top-face silhouette. Edges of the
                // tile that touch the host boundary (the "outside" edges
                // of the shadow) get cut off cleanly by the clip — hard
                // cutoff at the block edge. Edges of the tile that sit
                // INSIDE the host top (where the accent footprint ends
                // before the block edge) stay soft, because the blurred
                // pixels there are well within the clip region.
                const { hostId, tiles, host } = it.data;
                const hx0 = host.x0, hx1 = host.x1, hy0 = host.y0, hy1 = host.y1, hz = host.z;
                const cp0 = iso(hx0, hy0, hz);
                const cp1 = iso(hx1, hy0, hz);
                const cp2 = iso(hx1, hy1, hz);
                const cp3 = iso(hx0, hy1, hz);
                const clipId = `accshadow-clip-${hostId}`;
                const filterId = `accshadow-blur-${hostId}`;
                // ---- Distance-based accent shadow scale ----
                // Match the same rule as block shadows: the accent flies
                // up on exit / drops in on enter (translateY ±380px) and
                // its shadow tiles should grow from zero only once the
                // accent is within 2 world units of its host top.
                let accShadowScale = 1;
                if (previewAnim) {
                  const { fromAccent, toAccent, t } = previewAnim;
                  const fromHas = !!fromAccent;
                  const toHas = !!toAccent;
                  const PX_PER_UNIT = 46;
                  const MAX_LIFT_PX = 380; // accent uses 380px, not 340
                  const MAX_LIFT_UNITS = MAX_LIFT_PX / PX_PER_UNIT;
                  let liftUnits = 0;
                  if (fromHas && !toHas) {
                    // exit window 0..0.20, ease-in (k*k)
                    const k = Math.max(0, Math.min(1, t / 0.20));
                    liftUnits = k * k * MAX_LIFT_UNITS;
                  } else if (!fromHas && toHas) {
                    // enter window 0.78..1.0, ease-out cubic
                    const k = Math.max(0, Math.min(1, (t - 0.78) / 0.22));
                    const inv = 1 - k;
                    liftUnits = inv * inv * inv * MAX_LIFT_UNITS;
                  }
                  accShadowScale = Math.max(0, Math.min(1, 1 - liftUnits / 2));
                }
                if (accShadowScale <= 0) return null;
                // Centroid of the host top to scale around.
                const accCx = (cp0.x + cp1.x + cp2.x + cp3.x) / 4;
                const accCy = (cp0.y + cp1.y + cp2.y + cp3.y) / 4;
                return (
                  <g
                    key={`accshadow-${hostId}`}
                    style={{ pointerEvents: 'none' }}
                    transform={accShadowScale < 1 ? `translate(${accCx} ${accCy}) scale(${accShadowScale}) translate(${-accCx} ${-accCy})` : undefined}
                  >
                    <defs>
                      <clipPath id={clipId}>
                        <polygon points={`${cp0.x},${cp0.y} ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${cp3.x},${cp3.y}`} />
                      </clipPath>
                      <filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
                        <feGaussianBlur stdDeviation="3" />
                      </filter>
                    </defs>
                    <g clipPath={`url(#${clipId})`} filter={`url(#${filterId})`}>
                      {tiles.map((t) => {
                        const p0 = iso(t.x0, t.y0, t.z);
                        const p1 = iso(t.x1, t.y0, t.z);
                        const p2 = iso(t.x1, t.y1, t.z);
                        const p3 = iso(t.x0, t.y1, t.z);
                        return (
                          <polygon
                            key={t.key}
                            points={`${p0.x},${p0.y} ${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`}
                            fill="rgba(0, 0, 0, 0.275)"
                          />
                        );
                      })}
                    </g>
                  </g>
                );
              }

              const b = it.data;
              const PAD = 0.08;
              const bdx = b.dx || 1, bdy = b.dy || 1, bdz = b.dz || 1;
              // Inset the box by PAD on every axis (so blocks visibly float)
              const dim = { x: bdx - PAD * 2, y: bdy - PAD * 2, z: bdz - PAD * 2 };
              const origin = iso(b.gx + PAD, b.gy + PAD, b.gz + PAD);
              const isSel = selectedId === b.id;
              const role = b._previewRole;
              const k = typeof b._previewK === 'number' ? b._previewK : 0;
              // Per-role visual: exit blocks fly UP & fade as k→1; enter
              // blocks drop DOWN into place as k→1. Persist blocks have no
              // extra transform — their gx/gy/gz are already lerped above.
              let wrapStyle = null;
              if (role === 'exit') {
                // ease-in: slow start, fast end (block accelerates upward)
                const e = k * k;
                wrapStyle = {
                  transform: `translate(0px, ${-340 * e}px)`,
                  opacity: Math.max(0, 1 - k * 1.05),
                };
              } else if (role === 'enter') {
                // ease-out cubic: decelerates into place. NO overshoot —
                // we want the block to feel like it's falling under gravity
                // and resting, not bouncing off the floor.
                const e = 1 - Math.pow(1 - k, 3);
                wrapStyle = {
                  transform: `translate(0px, ${-340 * (1 - e)}px)`,
                  opacity: Math.min(1, k * 1.4),
                };
              }
              const inner = (
                <g
                  data-bb-block-id={b.id}
                  transform={`translate(${origin.x}, ${origin.y})`}
                  onMouseDown={(e) => {
                    if (e.button !== 0 || placing) return;
                    e.stopPropagation();
                    setSelectedId(b.id);
                    // Cursor-to-footprint offset so block doesn't jump under cursor
                    const w = clientToWorld(e.clientX, e.clientY);
                    const offX = w ? (b.gx + bdx / 2) - w.gx : 0;
                    const offY = w ? (b.gy + bdy / 2) - w.gy : 0;
                    dragRef.current = {
                      id: b.id,
                      startX: e.clientX,
                      startY: e.clientY,
                      origGx: b.gx,
                      origGy: b.gy,
                      offX, offY,
                      lastGx: b.gx,
                      lastGy: b.gy,
                      moved: false,
                    };
                  }}
                  style={{ cursor: 'grab' }}
                >
                  {isSel && (
                    <g opacity="0.9">
                      {/* selection outline: dashed teal ring around the base footprint */}
                      <polygon
                        points={(() => {
                          const p0 = iso(0,     0,     0);
                          const p1 = iso(dim.x, 0,     0);
                          const p2 = iso(dim.x, dim.y, 0);
                          const p3 = iso(0,     dim.y, 0);
                          return `${p0.x},${p0.y} ${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`;
                        })()}
                        fill="none"
                        stroke="var(--teal-400)"
                        strokeWidth="1.5"
                        strokeDasharray="4 3"
                      />
                    </g>
                  )}
                  <BlockShape
                    color={b.color}
                    type="solid"
                    dim={dim}
                    label={b.label}
                    labelFace={labelFaceById.get(b.id)}
                    labelScale={b.labelScale ?? 1}
                  />
                </g>
              );
              if (role) {
                return (
                  <g key={b.id} className={`bb-block-anim bb-block-${role}`} style={wrapStyle}>
                    {inner}
                  </g>
                );
              }
              return <g key={b.id}>{inner}</g>;
            })}

            {/* Accent block — rendered BEFORE the container's front edges so
                the front post and the two front-corner ceiling edges paint
                OVER the accent (it sits behind that front frame). Its
                shadow tiles are NOT drawn here — they're interleaved into
                the painter's drawList above so each tile sits right after
                the block it lands on, and any block painted later (in
                front of that host) cleanly overdraws the tile. This makes
                the accent shadow respect block layering. */}
            {accent && (() => {
              // Accent participates in the orchestration too. It always
              // sits on TOP of the stack, so:
              //   exit  → it lifts off FIRST (before any block)
              //   enter → it drops in LAST (after all enter blocks)
              let accentStyle = null;
              if (previewAnim) {
                const { fromAccent, toAccent, t } = previewAnim;
                const fromHas = !!fromAccent;
                const toHas = !!toAccent;
                if (fromHas && !toHas) {
                  // Exit window: 0..0.20 (early — top of stack lifts first).
                  const k = Math.max(0, Math.min(1, t / 0.20));
                  const e = k * k;
                  accentStyle = {
                    transform: `translate(0px, ${-380 * e}px)`,
                    opacity: Math.max(0, 1 - k * 1.05),
                  };
                  if (k >= 1) accentStyle.display = 'none';
                } else if (!fromHas && toHas) {
                  // Enter window: 0.78..1.0 (last to settle, after blocks).
                  const k = Math.max(0, Math.min(1, (t - 0.78) / 0.22));
                  if (k <= 0) accentStyle = { display: 'none' };
                  else {
                    const e = 1 - Math.pow(1 - k, 3);
                    accentStyle = {
                      transform: `translate(0px, ${-380 * (1 - e)}px)`,
                      opacity: Math.min(1, k * 1.4),
                    };
                  }
                }
                // If both have an accent we let the existing render show
                // whichever is currently mounted (the RAF tick swaps it
                // at midpoint).
              }
              return (
                <g style={accentStyle} className={previewAnim ? 'bb-block-anim' : ''}>
                  <g
                    onMouseDown={(e) => {
                      if (e.button !== 0 || placing) return;
                      e.stopPropagation();
                      setSelectedId(accent.id);
                    }}
                  >
                    <AccentBlock
                      block={accent}
                      W={canvas.W}
                      D={canvas.D}
                      H={canvas.H}
                      selected={selectedId === accent.id}
                    />
                  </g>
                </g>
              );
            })()}

            {/* Container FRONT edges — rendered AFTER the accent so the
                front post and front ceiling edges occlude it. */}
            <ContainerFront W={canvas.W} D={canvas.D} H={canvas.H} accentSlotH={accentSlotH} />

            {/* Stack badges — rendered AFTER all blocks so they're never occluded.
                One per (gx,gy,gz) cell that has 2+ blocks at the exact same coords. */}
            {(() => {
              const seen = new Set();
              const badges = [];
              for (const b of blocks) {
                const k = `${b.gx},${b.gy},${b.gz}`;
                if (seen.has(k)) continue;
                seen.add(k);
                const info = stackInfo[b.id];
                if (!info || info.size < 2) continue;
                // Position: top-back corner of the cell at this gz
                const PAD = 0.08;
                const size = 1 - PAD * 2;
                const local = iso(0, 0, size); // top-back corner of the cube
                const origin = iso(b.gx + PAD, b.gy + PAD, b.gz + PAD);
                badges.push(
                  <g
                    key={`badge-${k}`}
                    transform={`translate(${origin.x + local.x}, ${origin.y + local.y})`}
                    onMouseDown={(e) => {
                      e.stopPropagation();
                      cycleStack(k);
                    }}
                    style={{ cursor: 'pointer' }}
                  >
                    <circle r="11" fill="var(--bg-1)" stroke="var(--teal-400)" strokeWidth="1.5" />
                    <text
                      x="0" y="0" textAnchor="middle" dominantBaseline="central"
                      fontSize="11" fontFamily="var(--font-mono), monospace" fontWeight="700"
                      fill="var(--teal-400)"
                      style={{ pointerEvents: 'none' }}
                    >
                      {info.size}
                    </text>
                  </g>
                );
              }
              return badges;
            })()}

            {/* Ghost preview */}
            {showGhost && (() => {
              const PAD = 0.08;
              const dim = { x: draftDim.x - PAD * 2, y: draftDim.y - PAD * 2, z: draftDim.z - PAD * 2 };
              // Settle ghost gz under gravity, just like a real placement would
              const occ = buildOccupancy(blocks);
              let gz = lowestFitGz(hoverCell.x, hoverCell.y, draftDim.x, draftDim.y, draftDim.z, occ, canvas.W, canvas.D, canvas.H);
              if (gz < 0) return null;
              const origin = iso(hoverCell.x + PAD, hoverCell.y + PAD, gz + PAD);
              return (
                <g transform={`translate(${origin.x}, ${origin.y})`} className="ghost">
                  <BlockShape color={draftColor} type="solid" dim={dim} label={draftLabel} />
                </g>
              );
            })()}
          </g>
        </svg>
        </div>
      </div>

      {/* Header */}
      <div className="app-header">
        <div className="app-title">
          <span className="wm">block<em>.</em>builder</span>
          <span className="eyebrow">Harper</span>
        </div>
        {scenesApi && (
          <window.SceneTabs
            scenes={scenesApi.scenes}
            activeId={scenesApi.activeId}
            onActivate={(id) => scenesApi.setActiveId(id)}
            onCreate={({ kind, name, url }) => {
              if (kind === 'auto' && url) scenesApi.createFromGithub(name, url);
              else scenesApi.createManual(name);
            }}
            onRename={(id, name) => scenesApi.renameScene(id, name)}
            onDelete={(id) => scenesApi.deleteScene(id)}
            onReorder={(from, to) => scenesApi.reorderScenes(from, to)}
          />
        )}
        <div className="header-spacer" />
      </div>

      {/* New Block panel — slides in from right when builderOpen */}
      <div className={`builder ${builderOpen ? '' : 'collapsed'}`}>
        <div className="builder-body">
          <div className="builder-title">
            <span>New Block</span>
            <button
              type="button"
              className="builder-close"
              onClick={() => {
                setBuilderOpen(false);
                setPlacing(false);
              }}
              aria-label="Close New Block panel"
            >
              <svg viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
                <path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" fill="none" />
              </svg>
            </button>
          </div>

          {/* Tabs — pick the kind of block to add. Replaces the old
              accent toggle. Three tabs: Primary (regular grid block),
              Accent (the floating block), External (warm-gray nodes
              connected by a line outside the canvas). */}
          <div className="builder-tabs" role="tablist" aria-label="Block kind">
            {[
              { key: 'primary', label: 'Primary' },
              { key: 'accent', label: 'Accent' },
              { key: 'external', label: 'External' },
            ].map((t) => (
              <button
                key={t.key}
                type="button"
                role="tab"
                aria-selected={draftTab === t.key}
                className={`builder-tab ${draftTab === t.key ? 'active' : ''}`}
                onClick={() => {
                  setDraftTab(t.key);
                  setDraftAccent(t.key === 'accent');
                  // Switching tabs cancels any pending grid placement.
                  if (t.key !== 'primary') setPlacing(false);
                  // Default color per tab.
                  if (t.key === 'external') {
                    if (!EXT_COLOR_KEYS.includes(draftColor)) setDraftColor('slate');
                  } else if (EXT_COLOR_KEYS.includes(draftColor)) {
                    setDraftColor('magenta');
                  }
                }}
              >
                {t.label}
              </button>
            ))}
          </div>

          {draftTab === 'primary' && (
          <div className="builder-section">
            <div className="builder-label">Dimensions</div>
            {(() => {
              // Generate all valid (x,y,z) options where each fits the canvas
              // and at least one axis is 1.
              const opts = [];
              for (let x = 1; x <= canvas.W; x++)
                for (let y = 1; y <= canvas.D; y++)
                  for (let z = 1; z <= canvas.H; z++)
                    if (Math.min(x, y, z) === 1) opts.push({ x, y, z });

              // Row layout:
              //   Row 0: the 1×1×1 cube (singleton).
              //   Row 1: "1-bar" shapes — exactly one axis > 1.
              //   Row 2: "2-slab" shapes — two axes > 1, but neither is the
              //          MAX canvas size (so a 3×3-face block goes elsewhere).
              //   Row 3: "max-face slabs" — at least two axes equal the LARGEST
              //          canvas axis (canvas.W if cube). A 3×3-face block lands
              //          here regardless of its third-axis depth, lined up.
              //
              // This keeps the smallest options at the top and the BIGGEST
              // (3×3-face) family on its own bottom row.
              const maxAxis = Math.max(canvas.W, canvas.D, canvas.H);
              const rowFor = (o) => {
                const big = (o.x === maxAxis ? 1 : 0) + (o.y === maxAxis ? 1 : 0) + (o.z === maxAxis ? 1 : 0);
                const gt1 = (o.x > 1 ? 1 : 0) + (o.y > 1 ? 1 : 0) + (o.z > 1 ? 1 : 0);
                if (gt1 === 0) return 0;        // 1×1×1
                if (big >= 2) return 3;         // has a max-face (e.g. 3×3 on 3-canvas)
                if (gt1 === 1) return 1;        // bar
                return 2;                       // slab
              };
              const groups = new Map();
              for (const o of opts) {
                const k = rowFor(o);
                if (!groups.has(k)) groups.set(k, []);
                groups.get(k).push(o);
              }
              const rowKeys = [...groups.keys()].sort((a, b) => a - b);

              return rowKeys.map((rk) => {
                // Custom in-row ordering. The default x*100+y*10+z key works
                // for rows 0/1/3, but row 2 (the 9 free slabs) has a specific
                // user-requested layout that pairs up rotations and groups
                // smallest→largest. Hardcode the priority for that row:
                //   7..9   = sum-5 family (1×2×2 rotated): (1,2,2)(2,2,1)(2,1,2)
                //   10..12 = sum-6 batch A:                (1,2,3)(2,3,1)(3,1,2)
                //   13..15 = sum-6 batch B:                (1,3,2)(2,1,3)(3,2,1)
                const slabOrder = new Map([
                  ['1,2,2', 1], ['2,2,1', 2], ['2,1,2', 3],
                  ['1,2,3', 4], ['2,3,1', 5], ['3,1,2', 6],
                  ['1,3,2', 7], ['2,1,3', 8], ['3,2,1', 9],
                ]);
                const items = groups.get(rk).sort((a, b) => {
                  if (rk === 2) {
                    const ka = slabOrder.get(`${a.x},${a.y},${a.z}`) ?? 99;
                    const kb = slabOrder.get(`${b.x},${b.y},${b.z}`) ?? 99;
                    if (ka !== kb) return ka - kb;
                  }
                  // Default: x first, then y, then z
                  const av = a.x * 100 + a.y * 10 + a.z;
                  const bv = b.x * 100 + b.y * 10 + b.z;
                  return av - bv;
                });
                return (
                  <div className="dim-row" key={rk}>
                    {items.map(({ x, y, z }) => {
                      const active = draftDim.x === x && draftDim.y === y && draftDim.z === z;
                      return (
                        <button
                          key={`${x}-${y}-${z}`}
                          className={`dim-option ${active ? 'active' : ''}`}
                          onClick={() => setDraftDim({ x, y, z })}
                          title={`${x}×${y}×${z}`}
                        >
                          <DimSwatch dx={x} dy={y} dz={z} color={draftColor} />
                        </button>
                      );
                    })}
                  </div>
                );
              });
            })()}
          </div>
          )}

          {draftTab === 'external' && (
            <>
              <div className="builder-section">
                <div className="builder-label">Color</div>
                <div className="color-grid">
                  {EXT_COLOR_KEYS.map((key) => (
                    <button
                      key={key}
                      type="button"
                      className={`color-swatch ${draftColor === key ? 'active' : ''}`}
                      title={PALETTE[key].name}
                      onClick={() => setDraftColor(key)}
                    >
                      <SwatchCube color={key} type="solid" />
                    </button>
                  ))}
                </div>
              </div>

              <div className="builder-section">
                <div className="builder-label">Size</div>
                <div className="dim-row">
                  {EXT_SIZES.map(({ dx, dy, dz }) => {
                    const active = draftExtDim.x === dx && draftExtDim.y === dy && draftExtDim.z === dz;
                    return (
                      <button
                        key={`${dx}-${dy}-${dz}`}
                        className={`dim-option ${active ? 'active' : ''}`}
                        onClick={() => setDraftExtDim({ x: dx, y: dy, z: dz })}
                        title={`${dx}×${dy}×${dz}`}
                      >
                        <DimSwatch dx={dx} dy={dy} dz={dz} color={draftColor} />
                      </button>
                    );
                  })}
                </div>
              </div>

              <div className="builder-section">
                <div className="builder-label">Direction</div>
                <div className="ext-dir-grid">
                  {EXT_SIDES.map((side) => {
                    const count = externalsBySide[side].length;
                    const full = count >= 2;
                    const active = draftExtSide === side;
                    const labelMap = {
                      'back-left': 'Back-Left',
                      'back-right': 'Back-Right',
                      'front-right': 'Front-Right',
                      'front-left': 'Front-Left',
                    };
                    return (
                      <button
                        key={side}
                        type="button"
                        className={`ext-dir-option ${active ? 'active' : ''} ${full ? 'full' : ''}`}
                        onClick={() => { if (!full) setDraftExtSide(side); }}
                        disabled={full}
                        title={full ? `${labelMap[side]} (full)` : labelMap[side]}
                      >
                        <ExtDirectionSwatch side={side} active={active} size={56} />
                        <span className="ext-dir-label">
                          {labelMap[side]}
                          <span className="ext-dir-count">{count}/2</span>
                        </span>
                      </button>
                    );
                  })}
                </div>
              </div>
            </>
          )}

          {draftTab !== 'external' && (
          <div className="builder-section">
            <div className="builder-label">Color</div>
            <div className={`color-grid ${draftTab === 'accent' ? 'diamond' : ''}`}>
              {COLOR_KEYS.map((key) => (
                <button
                  key={key}
                  className={`color-swatch ${draftColor === key ? 'active' : ''} ${draftTab === 'accent' ? 'diamond' : ''}`}
                  title={PALETTE[key].name}
                  onClick={() => setDraftColor(key)}
                >
                  <SwatchCube color={key} type="solid" />
                </button>
              ))}
            </div>
          </div>
          )}

          <div className="builder-section">
            <div className="builder-label">Label</div>
            <input
              className="label-input"
              placeholder="e.g. GraphQL, Cache, Vector"
              value={draftLabel}
              onChange={(e) => setDraftLabel(e.target.value)}
              maxLength={16}
            />
          </div>

          <div className="builder-section">
            <button
              className="place-btn"
              disabled={draftTab === 'external' && externalsBySide[draftExtSide].length >= 2}
              onClick={() => {
                if (draftTab === 'accent') {
                  placeAccent();
                } else if (draftTab === 'external') {
                  placeExternal();
                } else {
                  // Primary: enter placement mode AND collapse the New
                  // Block panel so it gets out of the way.
                  setPlacing(true);
                  setBuilderOpen(false);
                }
              }}
            >
              {draftTab === 'accent'
                ? (<>{Icon.plus} {accent ? 'Replace Accent' : 'Place Accent'}</>)
                : draftTab === 'external'
                  ? (<>{Icon.plus} Place External</>)
                  : (<>{Icon.plus} Place on Grid</>)}
            </button>
          </div>
        </div>
      </div>

      {/* Right rail — full-height combined Canvas + Layers panel */}
      <div className={`right-rail ${scenesApi && scenesApi.scenes.length > 1 ? 'with-transitions' : ''} ${transitionsOpen ? 'with-transitions-open' : ''}`}>
        {/* Panel title — mirrors the "Animation" title style on the
            scene-transitions panel so the two read as a matched pair. */}
        <div className="rail-panel-header">
          <span className="rail-panel-title">Scene Properties</span>
        </div>

        {/* Canvas section — at top of rail */}
        <div className={`rail-section ${canvasOpen ? '' : 'closed'}`}>
          <button
            type="button"
            className="rail-section-header"
            onClick={() => setCanvasOpen((v) => !v)}
            aria-expanded={canvasOpen}
          >
            <span className="rail-section-title">Canvas</span>
            <span className="rail-section-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>
          </button>
          {canvasOpen && (
            <div className="rail-section-body">
              <div className="canvas-presets">
                {[2, 3].map((n) => {
                  const active = canvas.W === n && canvas.D === n && canvas.H === n;
                  return (
                    <button
                      key={n}
                      type="button"
                      className={`canvas-preset ${active ? 'active' : ''}`}
                      onClick={() => setCanvas({ W: n, D: n, H: n })}
                      title={`${n}×${n}×${n}`}
                    >
                      <span className="canvas-preset-multiplier">{n}x</span>
                      <span className="canvas-preset-preview">
                        <ContainerSwatch n={n} unitPx={11} />
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>
          )}
        </div>

        {/* Layers section — fills remaining height */}
        <div className={`rail-section ${layersOpen ? 'rail-section-layers' : 'closed'}`}>
          <button
            type="button"
            className="rail-section-header"
            onClick={() => setLayersOpen((v) => !v)}
            aria-expanded={layersOpen}
          >
            <span className="rail-section-title">Layers</span>
            <span className="rail-section-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>
          </button>
          {layersOpen && (
          <div className="layers-list">
            {(blocks.length === 0 && !accent && externals.length === 0) && (
              <div className="layers-empty">
                No blocks yet.<br/>Click <em>+ Add Block</em> below.
              </div>
            )}

            {/* When externals exist, split the list into two labeled
                subsections — "Primary" (accent + regular blocks) and
                "External" (warm-gray nodes). When no externals exist,
                we render a single flat list with no headers. */}
            {externals.length > 0 && (blocks.length > 0 || accent) && (
              <div className="layers-subhead">Primary</div>
            )}

            {/* Accent block — always rendered FIRST in the layer list
                (i.e. on top), regardless of when it was placed. */}
            {accent && (
              <LayerRow
                key={accent.id}
                b={accent}
                isAccent
                selected={selectedId === accent.id}
                isEditingName={editingNameId === accent.id}
                colorOpen={!!(openPopover && openPopover.blockId === accent.id && openPopover.kind === 'color')}
                sizeOpen={false}
                fontOpen={!!(openPopover && openPopover.blockId === accent.id && openPopover.kind === 'font')}
                canvas={canvas}
                onSelect={() => setSelectedId(accent.id)}
                onToggleColor={() =>
                  setOpenPopover(openPopover && openPopover.blockId === accent.id && openPopover.kind === 'color'
                    ? null : { blockId: accent.id, kind: 'color' })}
                onToggleSize={() => {}}
                onToggleFont={() =>
                  setOpenPopover(openPopover && openPopover.blockId === accent.id && openPopover.kind === 'font'
                    ? null : { blockId: accent.id, kind: 'font' })}
                onClosePopover={() => setOpenPopover(null)}
                onSetColor={(k) => { updateAccent({ color: k }); setOpenPopover(null); }}
                onSetSize={() => {}}
                onSetFont={(s) => updateAccent({ labelScale: s })}
                onStartRename={() => setEditingNameId(accent.id)}
                onCommitRename={(v) => { updateAccent({ label: v.trim() }); setEditingNameId(null); }}
                onCancelRename={() => setEditingNameId(null)}
                onDelete={removeAccent}
              />
            )}
            {/* AI-suggested accent alternatives — rendered just under the
                accent row when the scene was auto-generated and the agent
                returned suggestions. Click a chip to swap; the previously
                active accent gets pushed back into the suggestions list so
                the swap is reversible (and Cmd+Z just works because the
                whole change goes through patchActive). */}
            {accent && Array.isArray(scenesApi?.active?.accentSuggestions) && scenesApi.active.accentSuggestions.length > 0 && (
              <div className="accent-suggestions">
                <span className="accent-suggestions-label">Alt</span>
                {scenesApi.active.accentSuggestions.map((s, i) => {
                  const swatch = (window.PALETTE?.[s.color]?.solidSwatch) || '#888';
                  return (
                    <button
                      key={`${s.color}-${s.label}-${i}`}
                      type="button"
                      className="accent-suggestion-chip"
                      title={`Use ${s.label} as the accent`}
                      onClick={() => {
                        const prev = { color: accent.color, label: accent.label };
                        const nextSuggestions = scenesApi.active.accentSuggestions
                          .filter((_, j) => j !== i)
                          .concat([prev]);
                        // Two patches: accent (color+label) and the suggestions
                        // list. Both go through patchActive so undo captures
                        // the whole swap as a single history step.
                        updateAccent({ color: s.color, label: s.label });
                        scenesApi.patchActive({ accentSuggestions: nextSuggestions });
                      }}
                    >
                      <span className="accent-suggestion-swatch" style={{ background: swatch }} />
                      {s.label}
                    </button>
                  );
                })}
              </div>
            )}
            {[...blocks].reverse().map((b) => (
              <LayerRow
                key={b.id}
                b={b}
                selected={selectedId === b.id}
                isEditingName={editingNameId === b.id}
                colorOpen={!!(openPopover && openPopover.blockId === b.id && openPopover.kind === 'color')}
                sizeOpen={!!(openPopover && openPopover.blockId === b.id && openPopover.kind === 'size')}
                fontOpen={!!(openPopover && openPopover.blockId === b.id && openPopover.kind === 'font')}
                canvas={canvas}
                onSelect={() => setSelectedId(b.id)}
                onToggleColor={() =>
                  setOpenPopover(openPopover && openPopover.blockId === b.id && openPopover.kind === 'color'
                    ? null : { blockId: b.id, kind: 'color' })}
                onToggleSize={() =>
                  setOpenPopover(openPopover && openPopover.blockId === b.id && openPopover.kind === 'size'
                    ? null : { blockId: b.id, kind: 'size' })}
                onToggleFont={() =>
                  setOpenPopover(openPopover && openPopover.blockId === b.id && openPopover.kind === 'font'
                    ? null : { blockId: b.id, kind: 'font' })}
                onClosePopover={() => setOpenPopover(null)}
                onSetColor={(k) => { updateBlock(b.id, { color: k }); setOpenPopover(null); }}
                onSetSize={(x, y, z) => { tryResize(b.id, x, y, z); setOpenPopover(null); }}
                onSetFont={(s) => updateBlock(b.id, { labelScale: s })}
                onStartRename={() => setEditingNameId(b.id)}
                onCommitRename={(v) => { updateBlock(b.id, { label: v.trim() }); setEditingNameId(null); }}
                onCancelRename={() => setEditingNameId(null)}
                onDelete={() => removeBlock(b.id)}
              />
            ))}

            {/* External subsection — only rendered when there are
                externals. Header is hidden if it would be the only
                section (no primaries) so the list reads cleanly as
                "just externals" without a redundant label. */}
            {externals.length > 0 && (blocks.length > 0 || accent) && (
              <div className="layers-subhead">External</div>
            )}
            {externals.map((e) => (
              <ExternalLayerRow
                key={e.id}
                b={e}
                externalsBySide={externalsBySide}
                selected={selectedId === e.id}
                isEditingName={editingNameId === e.id}
                colorOpen={!!(openPopover && openPopover.blockId === e.id && openPopover.kind === 'color')}
                sizeOpen={!!(openPopover && openPopover.blockId === e.id && openPopover.kind === 'size')}
                sideOpen={!!(openPopover && openPopover.blockId === e.id && openPopover.kind === 'side')}
                fontOpen={!!(openPopover && openPopover.blockId === e.id && openPopover.kind === 'font')}
                onSelect={() => setSelectedId(e.id)}
                onToggleColor={() =>
                  setOpenPopover(openPopover && openPopover.blockId === e.id && openPopover.kind === 'color'
                    ? null : { blockId: e.id, kind: 'color' })}
                onToggleSize={() =>
                  setOpenPopover(openPopover && openPopover.blockId === e.id && openPopover.kind === 'size'
                    ? null : { blockId: e.id, kind: 'size' })}
                onToggleSide={() =>
                  setOpenPopover(openPopover && openPopover.blockId === e.id && openPopover.kind === 'side'
                    ? null : { blockId: e.id, kind: 'side' })}
                onToggleFont={() =>
                  setOpenPopover(openPopover && openPopover.blockId === e.id && openPopover.kind === 'font'
                    ? null : { blockId: e.id, kind: 'font' })}
                onClosePopover={() => setOpenPopover(null)}
                onSetColor={(k) => { updateExternal(e.id, { color: k }); setOpenPopover(null); }}
                onSetSize={(dx, dy, dz) => { updateExternal(e.id, { dx, dy, dz }); setOpenPopover(null); }}
                onSetSide={(side) => { updateExternal(e.id, { side }); setOpenPopover(null); }}
                onSetFont={(s) => updateExternal(e.id, { labelScale: s })}
                onStartRename={() => setEditingNameId(e.id)}
                onCommitRename={(v) => { updateExternal(e.id, { label: v.trim() }); setEditingNameId(null); }}
                onCancelRename={() => setEditingNameId(null)}
                onDelete={() => removeExternal(e.id)}
              />
            ))}

            {/* Add Block action row — sits at the bottom of the layers
                list and acts as the entry point for the New Block panel.
                Visually this is a "ghost" row: dashed outline, no swatch,
                a "+" affordance up front. Tristate logic mirrors the old
                rail-top button:
                  idle  → click opens the New Block panel
                  open  → click closes the panel
                  armed → click cancels the pending grid placement
                The label reflects the current state. */}
            <button
              type="button"
              className="layers-add-row"
              onClick={() => {
                if (placing) { setPlacing(false); return; }
                setBuilderOpen((v) => !v);
              }}
              aria-label="Add Block"
            >
              <span className="layers-add-icon" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="12" height="12">
                  <path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" fill="none" />
                </svg>
              </span>
              <span className="layers-add-label">Add Block</span>
            </button>
          </div>
          )}
        </div>

        {/* Export section — sits below Layers. Two formats: transparent
            PNG (raster, hi-DPI) and SVG (vector). Both are derived from
            the live scene SVG via a tight bbox crop — see Export.jsx. */}
        <div className={`rail-section rail-section-export ${exportOpen ? '' : 'closed'}`}>
          <button
            type="button"
            className="rail-section-header"
            onClick={() => setExportOpen((v) => !v)}
            aria-expanded={exportOpen}
          >
            <span className="rail-section-title">Scene Export</span>
            <span className="rail-section-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>
          </button>
          {exportOpen && (
            <div className="rail-section-body">
              {/* Crop-mode toggle. 'visible' = tight bbox of what's
                  rendered; 'build-area' = bbox of the maximum scene
                  for the current canvas size (canvas + worst-case
                  externals on all 4 sides). Build-area produces a
                  stable crop across exports so they can be stacked
                  / compared pixel-aligned. */}
              <div className="builder-label export-section-label">Crop</div>
              <div className="export-mode-toggle" role="radiogroup" aria-label="Export crop mode">
                <button
                  type="button"
                  role="radio"
                  aria-checked={exportMode === 'visible'}
                  className={`export-mode-btn ${exportMode === 'visible' ? 'on' : ''}`}
                  onClick={() => setExportMode('visible')}
                >
                  <span className="export-mode-icon" aria-hidden="true">
                    {/* Tight crop icon: box hugging the silhouette */}
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                      <path d="M12 5 L18 8.5 L18 15.5 L12 19 L6 15.5 L6 8.5 Z"/>
                      <path d="M3 4 L3 7 M3 17 L3 20 M21 4 L21 7 M21 17 L21 20 M3 4 L6 4 M18 4 L21 4 M3 20 L6 20 M18 20 L21 20"/>
                    </svg>
                  </span>
                  <span>Visible</span>
                </button>
                <button
                  type="button"
                  role="radio"
                  aria-checked={exportMode === 'build-area'}
                  className={`export-mode-btn ${exportMode === 'build-area' ? 'on' : ''}`}
                  onClick={() => setExportMode('build-area')}
                >
                  <span className="export-mode-icon" aria-hidden="true">
                    {/* Build-area icon: dashed wide frame containing a small box */}
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                      <rect x="2" y="3" width="20" height="18" rx="1.5" strokeDasharray="2.5 2"/>
                      <path d="M12 8 L16 10.5 L16 14.5 L12 17 L8 14.5 L8 10.5 Z"/>
                    </svg>
                  </span>
                  <span>Build area</span>
                </button>
              </div>
              <div className="export-hint">
                {exportMode === 'visible'
                  ? 'Crops to the visible scene.'
                  : 'Stable crop area, so successive exports stack pixel-aligned.'}
              </div>
              <div className="builder-label export-section-label">Type</div>
              <div className="export-grid">
                <button
                  type="button"
                  className={`export-btn ${exportFlash === 'png' ? 'flash' : ''}`}
                  onClick={async () => {
                    try {
                      await window.BlockBuilderExport.exportPNG({ scale: 3, mode: exportMode });
                      setExportFlash('png');
                      setTimeout(() => setExportFlash(null), 900);
                    } catch (err) {
                      console.error('PNG export failed', err);
                    }
                  }}
                >
                  <span className="export-btn-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="3.5" y="3.5" width="17" height="17" rx="2"/>
                      <path d="M3.5 16 L9 11 L13 14 L16.5 11 L20.5 14.5"/>
                      <circle cx="8.5" cy="8.5" r="1.4"/>
                    </svg>
                  </span>
                  <span className="export-btn-body">
                    <span className="export-btn-label">
                      {exportFlash === 'png' ? 'Saved' : 'PNG'}
                    </span>
                  </span>
                </button>
                <button
                  type="button"
                  className={`export-btn ${exportFlash === 'svg' ? 'flash' : ''}`}
                  onClick={async () => {
                    try {
                      await window.BlockBuilderExport.exportSVG({ mode: exportMode });
                      setExportFlash('svg');
                      setTimeout(() => setExportFlash(null), 900);
                    } catch (err) {
                      console.error('SVG export failed', err);
                    }
                  }}
                >
                  <span className="export-btn-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="M5 4 L5 20 L19 20"/>
                      <path d="M9 16 L13 10 L17 14"/>
                      <circle cx="9" cy="16" r="1.4" fill="currentColor" stroke="none"/>
                      <circle cx="13" cy="10" r="1.4" fill="currentColor" stroke="none"/>
                      <circle cx="17" cy="14" r="1.4" fill="currentColor" stroke="none"/>
                    </svg>
                  </span>
                  <span className="export-btn-body">
                    <span className="export-btn-label">
                      {exportFlash === 'svg' ? 'Saved' : 'SVG'}
                    </span>
                  </span>
                </button>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* Zoom dock */}
      <div className="zoom-dock">
        <button className="zoom-btn" onClick={() => setView((v) => ({ ...v, scale: Math.max(0.4, v.scale - 0.1) }))}>−</button>
        <div className="zoom-readout">{Math.round(view.scale * 100)}%</div>
        <button className="zoom-btn" onClick={() => setView((v) => ({ ...v, scale: Math.min(2.5, v.scale + 0.1) }))}>+</button>
        <button className="zoom-btn" onClick={() => setView({ tx: 0, ty: -40, scale: 1 })} title="Reset view">⊙</button>
      </div>

      {/* Scene transitions — sits below the right rail when 2+ scenes */}
      {scenesApi && scenesApi.scenes.length > 1 && (
        <window.SceneTransitions
          scenes={scenesApi.scenes}
          activeId={scenesApi.activeId}
          transitions={sceneTransitions}
          setTransitions={setSceneTransitions}
          onOpenChange={setTransitionsOpen}
          onPreview={({ toId }) => {
            // Cancel any in-flight preview.
            if (_previewRafRef.current) cancelAnimationFrame(_previewRafRef.current);
            if (_previewTimerRef.current) clearTimeout(_previewTimerRef.current);
            const fromScene = scenesApi.scenes.find((s) => s.id === scenesApi.activeId);
            const toScene = scenesApi.scenes.find((s) => s.id === toId);
            if (!fromScene || !toScene) return;

            const fromBlocks = Array.isArray(fromScene.blocks) ? fromScene.blocks : [];
            const toBlocks = Array.isArray(toScene.blocks) ? toScene.blocks : [];
            const fromIds = new Set(fromBlocks.map((b) => b.id));
            const toIds = new Set(toBlocks.map((b) => b.id));
            // Roles
            const exitIds = new Set([...fromIds].filter((id) => !toIds.has(id)));
            const enterIds = new Set([...toIds].filter((id) => !fromIds.has(id)));
            const persistIds = new Set([...fromIds].filter((id) => toIds.has(id)));
            const fromCanvas = fromScene.canvas || { W: 2, D: 2, H: 2 };
            const toCanvas = toScene.canvas || { W: 2, D: 2, H: 2 };
            const fromAccent = fromScene.accent || null;
            const toAccent = toScene.accent || null;
            const fromExternals = Array.isArray(fromScene.externals) ? fromScene.externals : [];
            const toExternals = Array.isArray(toScene.externals) ? toScene.externals : [];

            // Stagger by Z-position so the orchestration feels physical:
            //   Exit  — TOP blocks lift off first (high gz first), bottom last.
            //   Enter — BOTTOM blocks settle first (low gz first), top last.
            // We compute each block's delay based on its Z-rank, not its
            // index in the array.
            const exitArr = [...exitIds].sort((a, b) => {
              const ba = fromBlocks.find((x) => x.id === a);
              const bb = fromBlocks.find((x) => x.id === b);
              const za = ba ? (ba.gz + (ba.dz || 1)) : 0;
              const zb = bb ? (bb.gz + (bb.dz || 1)) : 0;
              return zb - za; // highest top first
            });
            const enterArr = [...enterIds].sort((a, b) => {
              const ba = toBlocks.find((x) => x.id === a);
              const bb = toBlocks.find((x) => x.id === b);
              const za = ba ? ba.gz : 0;
              const zb = bb ? bb.gz : 0;
              return za - zb; // lowest base first
            });
            const exitDelays = new Map();
            exitArr.forEach((id, i) => {
              const span = exitArr.length > 1 ? i / (exitArr.length - 1) : 0;
              exitDelays.set(id, span * 0.18); // 0..0.18 within the 0..0.30 window
            });
            const enterDelays = new Map();
            enterArr.forEach((id, i) => {
              const span = enterArr.length > 1 ? i / (enterArr.length - 1) : 0;
              enterDelays.set(id, span * 0.30); // 0..0.30 within the 0.55..1.00 window
            });

            const DURATION = 2400;
            _previewStartRef.current = performance.now();
            _previewActiveRef.current = true;

            // Easing helpers
            const easeInCubic = (x) => x * x * x;
            const easeOutCubic = (x) => 1 - Math.pow(1 - x, 3);
            const easeOutBack = (x) => {
              const c1 = 1.70158, c3 = c1 + 1;
              return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
            };
            const easeInOut = (x) => x < 0.5 ? 2*x*x : 1 - Math.pow(-2*x + 2, 2) / 2;
            const lerp = (a, b, t) => a + (b - a) * t;
            const subT = (t, start, end) => Math.max(0, Math.min(1, (t - start) / (end - start)));

            // Initial commit: set blocks to the union, canvas to from, accent/externals to from.
            // Mark roles + delays on each block so the renderer can style them.
            const tagBlocks = (t) => {
              const out = [];
              // Persist blocks: lerp position from→to
              for (const id of persistIds) {
                const fb = fromBlocks.find((x) => x.id === id);
                const tb = toBlocks.find((x) => x.id === id);
                if (!fb || !tb) continue;
                const k = subT(t, 0.30, 0.80);
                const e = easeInOut(k);
                out.push({
                  ...tb,
                  gx: lerp(fb.gx, tb.gx, e),
                  gy: lerp(fb.gy, tb.gy, e),
                  gz: lerp(fb.gz, tb.gz, e),
                  dx: tb.dx, dy: tb.dy, dz: tb.dz,
                  _previewRole: 'persist',
                });
              }
              // Exit blocks: present until they've lifted off, then drop them
              for (const id of exitIds) {
                const fb = fromBlocks.find((x) => x.id === id);
                if (!fb) continue;
                const delay = exitDelays.get(id) || 0;
                const k = subT(t, delay, delay + 0.30);
                if (k >= 1) continue; // fully gone
                out.push({
                  ...fb,
                  _previewRole: 'exit',
                  _previewK: k, // 0..1 within its own exit window
                });
              }
              // Enter blocks: appear after canvas has started morphing
              for (const id of enterIds) {
                const tb = toBlocks.find((x) => x.id === id);
                if (!tb) continue;
                const delay = enterDelays.get(id) || 0;
                const start = 0.55 + delay * (0.40 / 0.30); // map into 0.55..0.95
                const end = Math.min(1, start + 0.35);
                const k = subT(t, start, end);
                if (k <= 0) continue; // not yet visible
                out.push({
                  ...tb,
                  _previewRole: 'enter',
                  _previewK: k,
                });
              }
              return out;
            };

            const tick = () => {
              const elapsed = performance.now() - _previewStartRef.current;
              const t = Math.min(1, elapsed / DURATION);
              // Canvas frame morph timing.
              //   W / D / H all share the same long, smooth window
              //   (0.10 → 0.85, ~1800ms with ease-in-out). The user reads
              //   the canvas as a single rigid object that changes size,
              //   not three independent dimensions; if W/D move on a
              //   different beat than H the cube reads as deforming. One
              //   gentle glide for all three feels like the wireframe is
              //   physically growing/shrinking. The exit phase (top-down)
              //   already finished by the time the rim+sides really start
              //   moving, and the enter phase (bottom-up) overlaps the
              //   final 60% so blocks drop into the new shape as it
              //   settles around them.
              const cK = subT(t, 0.10, 0.85);
              const cE = easeInOut(cK);
              setCanvas({
                W: lerp(fromCanvas.W, toCanvas.W, cE),
                D: lerp(fromCanvas.D, toCanvas.D, cE),
                H: lerp(fromCanvas.H, toCanvas.H, cE),
              });
              setBlocks(tagBlocks(t));
              // Accent: when only one side has an accent, mount the side
              // that's currently being animated so the wrapper above can
              // drive its translate/opacity. When both sides have one,
              // swap at midpoint (effectively a hard cut — they'd both
              // sit in the same slot anyway).
              const fromHasAccent = !!fromAccent;
              const toHasAccent = !!toAccent;
              if (fromHasAccent && toHasAccent) {
                setAccent(t < 0.5 ? fromAccent : toAccent);
              } else if (fromHasAccent && !toHasAccent) {
                // Keep mounting from-accent so it can fly up; the wrapper
                // sets display:none once its k reaches 1.
                setAccent(fromAccent);
              } else if (!fromHasAccent && toHasAccent) {
                // Mount to-accent throughout so its wrapper can drive
                // the drop-in. Wrapper hides it (display:none) before t<0.78.
                setAccent(toAccent);
              } else {
                setAccent(null);
              }
              // Externals: also crossfade at midpoint
              if (t < 0.5) setExternals(fromExternals);
              else setExternals(toExternals);
              setPreviewAnim({ t, exitIds, enterIds, persistIds, fromCanvas, toCanvas, fromAccent, toAccent });
              if (t < 1) {
                _previewRafRef.current = requestAnimationFrame(tick);
              } else {
                _previewRafRef.current = null;
                _previewActiveRef.current = false;
                // Commit the scene switch — load effect will hydrate clean state.
                if (scenesApi) scenesApi.setActiveId(toId);
                _previewTimerRef.current = setTimeout(() => {
                  setPreviewAnim(null);
                  _previewTimerRef.current = null;
                }, 30);
              }
            };
            _previewRafRef.current = requestAnimationFrame(tick);
          }}
        />
      )}

      {/* Bottom-left footer: logout (OAuth only) + key hints */}
      <div className="bottom-bar">
        {window.BB_AUTH_MODE === 'oauth' && (
          <button
            type="button"
            className="logout-btn"
            title={window.BB_USER?.email ? `Signed in as ${window.BB_USER.email} — click to sign out` : 'Sign out'}
            onClick={async () => {
              // Call our custom /SignOut resource — it actually deletes the
              // session record from hdb_session, unlike the OAuth plugin's
              // /oauth/logout which only mutates an in-memory copy.
              try {
                await fetch('/SignOut', { method: 'POST', credentials: 'include' });
              } catch {}
              // Wipe every bb.scenes.* key out of localStorage so a guest who
              // visits next on this browser never sees this user's cached
              // scenes. Per-user scenes are still safe in Harper and reload
              // on the next sign-in.
              try {
                for (const k of Object.keys(localStorage)) {
                  if (k.startsWith('bb.scenes.') || k.startsWith('bb.legacy.')) {
                    localStorage.removeItem(k);
                  }
                }
                sessionStorage.removeItem('bb.legacy.migrated.v1');
              } catch {}
              window.BB_HARPER_ENABLED = false;
              window.BB_AUTH_MODE = null;
              window.BB_USER = null;
              window.location.href = '/';
            }}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
              <polyline points="16 17 21 12 16 7" />
              <line x1="21" y1="12" x2="9" y2="12" />
            </svg>
            Sign out
          </button>
        )}
        <div className="key-hints">
          <span><kbd>drag</kbd>move</span>
          <span><kbd>scroll</kbd>zoom</span>
          <span><kbd>esc</kbd>cancel</span>
          <span><kbd>del</kbd>remove</span>
        </div>
      </div>
    </div>
  );
}

// ---- Auth Gate ----
// States:
//   'loading'  — fetching /Me
//   'landing'  — show the two-option landing screen (guest vs sign-in)
//   'app'      — render the app (guest or authenticated)
//
// window.BB_HARPER_ENABLED:
//   true  — the app will load/save scenes through Harper (authenticated user OR
//           local AUTH_BYPASS_LOCAL — same persistence behaviour as before)
//   false — guest mode; Scenes.jsx stays localStorage-only and never calls Harper
function AuthGate() {
  const [authState, setAuthState] = useState('loading');
  const [user, setUser] = useState(null);

  useEffect(() => {
    // If HarperSync isn't loaded for some reason, fall back to guest mode so the
    // app is still usable.
    if (!window.HarperSync) {
      window.BB_HARPER_ENABLED = false;
      setAuthState('landing');
      return;
    }
    (async () => {
      try {
        const me = await window.HarperSync.getMe();
        if (me.authenticated) {
          // Real OAuth user OR local bypass — both get full Harper persistence.
          window.BB_HARPER_ENABLED = true;
          window.BB_AUTH_MODE = me.isLocalBypass ? 'bypass' : 'oauth';
          window.BB_USER = me;
          setUser(me);
          setAuthState('app');
        } else {
          window.BB_HARPER_ENABLED = false;
          window.BB_AUTH_MODE = null;
          setAuthState('landing');
        }
      } catch {
        // If /Me errors, present the landing screen rather than silently
        // dropping into authed mode.
        window.BB_HARPER_ENABLED = false;
        window.BB_AUTH_MODE = null;
        setAuthState('landing');
      }
    })();
  }, []);

  if (authState === 'loading') {
    return (
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        height: '100vh', background: 'var(--bg-900, #0d1117)',
        color: 'var(--fg-400, #8b949e)', fontFamily: 'Ubuntu, sans-serif',
      }}>
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 24, fontWeight: 600, color: 'var(--fg-100, #f0f6fc)', marginBottom: 8 }}>
            block.builder
          </div>
          <div>Loading...</div>
        </div>
      </div>
    );
  }

  if (authState === 'landing') {
    return (
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        minHeight: '100vh', background: 'var(--bg-900, #0d1117)',
        fontFamily: 'Ubuntu, sans-serif', padding: 24,
      }}>
        <div style={{ width: '100%', maxWidth: 880 }}>
          <div style={{ textAlign: 'center', marginBottom: 40 }}>
            <div style={{ fontSize: 36, fontWeight: 700, color: 'var(--fg-100, #f0f6fc)', marginBottom: 6, letterSpacing: '-0.02em' }}>
              block.builder
            </div>
            <div style={{ fontSize: 14, color: 'var(--fg-400, #8b949e)', letterSpacing: '0.08em' }}>
              // HARPER
            </div>
            <div style={{ fontSize: 16, color: 'var(--fg-300, #c9d1d9)', marginTop: 24, maxWidth: 520, marginLeft: 'auto', marginRight: 'auto' }}>
              Build isometric architecture diagrams. Choose how you'd like to start.
            </div>
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 20 }}>
            {/* Option 1: Guest — localStorage only */}
            <div style={{
              padding: 28, borderRadius: 16,
              background: 'var(--bg-800, #161b22)',
              border: '1px solid var(--border-subtle, #30363d)',
              display: 'flex', flexDirection: 'column',
            }}>
              <div style={{ fontSize: 13, color: 'var(--fg-500, #6e7681)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 8 }}>
                Option 1
              </div>
              <div style={{ fontSize: 22, fontWeight: 700, color: 'var(--fg-100, #f0f6fc)', marginBottom: 10 }}>
                Try without signing in
              </div>
              <div style={{ fontSize: 14, color: 'var(--fg-400, #8b949e)', lineHeight: 1.55, marginBottom: 22, flexGrow: 1 }}>
                Use the full app right now. Your work stays in this browser only — it
                may persist if you come back, but is not guaranteed and will not sync
                anywhere.
              </div>
              <button
                type="button"
                onClick={() => {
                  window.BB_HARPER_ENABLED = false;
                  window.BB_AUTH_MODE = 'guest';
                  setAuthState('app');
                }}
                style={{
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 10,
                  padding: '12px 20px', borderRadius: 8, fontSize: 15, fontWeight: 600,
                  background: 'transparent', color: 'var(--fg-100, #f0f6fc)',
                  border: '1px solid var(--border-default, #484f58)',
                  cursor: 'pointer', transition: 'background 0.15s, border-color 0.15s',
                }}
                onMouseOver={(e) => { e.currentTarget.style.background = 'var(--bg-700, #21262d)'; e.currentTarget.style.borderColor = 'var(--fg-400, #8b949e)'; }}
                onMouseOut={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'var(--border-default, #484f58)'; }}
              >
                Continue as guest
              </button>
            </div>

            {/* Option 2: Sign in with Google — @harperdb.io only */}
            <div style={{
              padding: 28, borderRadius: 16,
              background: 'var(--bg-800, #161b22)',
              border: '1px solid var(--primary, #00d4aa)',
              display: 'flex', flexDirection: 'column',
              boxShadow: '0 0 0 1px rgba(0,212,170,0.15), 0 8px 24px rgba(0,212,170,0.06)',
            }}>
              <div style={{ fontSize: 13, color: 'var(--primary, #00d4aa)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 8 }}>
                Option 2
              </div>
              <div style={{ fontSize: 22, fontWeight: 700, color: 'var(--fg-100, #f0f6fc)', marginBottom: 10 }}>
                Sign in with Google
              </div>
              <div style={{ fontSize: 14, color: 'var(--fg-400, #8b949e)', lineHeight: 1.55, marginBottom: 22, flexGrow: 1 }}>
                Save your scenes to your account. Your workspace follows you across
                browsers and devices, and updates auto-save in real time.
              </div>
              <a
                href="/oauth/google/login"
                style={{
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 10,
                  padding: '12px 20px', borderRadius: 8, fontSize: 15, fontWeight: 600,
                  background: 'var(--primary, #00d4aa)', color: '#0d1117',
                  textDecoration: 'none', transition: 'opacity 0.15s',
                }}
                onMouseOver={(e) => e.currentTarget.style.opacity = '0.85'}
                onMouseOut={(e) => e.currentTarget.style.opacity = '1'}
              >
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
                  <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
                  <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
                  <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
                  <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
                </svg>
                Sign in with Google
              </a>
              <div style={{ fontSize: 12, color: 'var(--fg-500, #6e7681)', marginTop: 12, textAlign: 'center' }}>
                Restricted to @harperdb.io accounts
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }

  return <App />;
}

window.App = App;
window.AuthGate = AuthGate;
