/* Custex editor — topbar, sidebar, grid, validation, wire tools, minimap. */
const { useState: uS, useRef: uR, useEffect: uE, useMemo: uM } = React;
const { NODE_W, PORT_FIRST, PORT_STRIDE } = window.CxGeom;

const { nodeNumFor, nodeTitleFor, CX_STUDIO_LOGO_SRC, CX_APPEARANCE_OPTIONS } = window.CxEditorShared;

function CxTopbar({ onTechpack, onReset, route='studio', setRoute, appearanceValue, onAppearanceChange }) {
  const NAV = [
    ['home','Home'],
    ['studio','Studio'],
    ['library','Library'],
    ['manufacture','Manufacture'],
    ['community','Community'],
  ];
  const inStudio = route === 'studio';
  return (
    <div className="cx-topbar">
      <div className="cx-topbar-l">
        <div className="cx-wordmark">
          <img className="cx-mark" src={CX_STUDIO_LOGO_SRC} alt="Custex" width="52" height="52" decoding="async" />
        </div>
      </div>
      <div className="cx-topbar-mid">
        <nav className="cx-topbar-nav cx-topnav" aria-label="Primary">
          {NAV.map(([id,label]) => (
            <button key={id}
              type="button"
              className={`cx-navlink ${route===id?'active':''}`}
              onClick={() => setRoute && setRoute(id)}>{label}</button>
          ))}
        </nav>
      </div>
      <div className="cx-topbar-r">
        {inStudio ? (
          <>
            <button type="button" className="cx-tbtn ghost" onClick={onReset}>Reset View</button>
            <button type="button" className="cx-tbtn ghost">Auto-Layout</button>
          </>
        ) : null}
        <label className="cx-theme-picker-wrap" title="Theme and canvas background">
          <span className="cx-sr-only">Theme and canvas background</span>
          <select
            className="cx-theme-picker cx-appearance-picker"
            value={appearanceValue}
            onChange={(e) => onAppearanceChange && onAppearanceChange(e.target.value)}
            aria-label="Theme and canvas background"
          >
            {CX_APPEARANCE_OPTIONS.map(([id, label]) => (
              <option key={id} value={id}>{label}</option>
            ))}
          </select>
        </label>
        <button type="button" className="cx-tbtn ghost">Share</button>
        <div className="cx-avatars">
          <span className="cx-av" style={{ background: 'var(--accent)' }}>WK</span>
          <span className="cx-av" style={{ background: 'var(--accent-2)' }}>JL</span>
        </div>
        <button type="button" className="cx-tbtn primary cx-tbtn-techpack" onClick={onTechpack}>
          Tech-Pack&nbsp;→
        </button>
      </div>
    </div>
  );
}

// ─────────── left sidebar (project list + node library + validation list) ──
function CxSidebar({
  projectName = 'Untitled project',
  studioProjects = [],
  activeStudioProjectId = '',
  onSelectStudioProject,
  onAddStudioProject,
  onDeleteStudioProject,
  selected,
  graph,
  setSelected,
  validation,
  workflowNodeIds = new Set(),
}) {
  const paletteDragMime = window.CxGeom?.PALETTE_DRAG_MIME || 'application/x-custex-node';
  const canDeleteProject = studioProjects.length > 1;
  return (
    <aside className="cx-sidebar">
      <div className="cx-sidebar-proj" aria-label="Current project">
        <div className="cx-proj-name">{projectName}</div>
        <div className="cx-saved">saved 2m ago</div>
      </div>
      <div className="cx-side-section">
        <div className="cx-side-h cx-side-h-proj">
          <span>Projects</span>
          <button
            type="button"
            className="cx-proj-add"
            aria-label="Add project"
            title="Add project"
            onClick={() => onAddStudioProject && onAddStudioProject()}
          >
            +
          </button>
        </div>
        <ul className="cx-proj-list">
          {studioProjects.map((p) => (
            <li key={p.id} className={p.id === activeStudioProjectId ? 'active' : ''}>
              <a
                href={`#project=${encodeURIComponent(p.id)}`}
                className="cx-proj-hit"
                onClick={(e) => {
                  e.preventDefault();
                  if (onSelectStudioProject) onSelectStudioProject(p.id);
                }}
              >
                <span className="cx-proj-dot" style={{ background: p.dot }} aria-hidden="true" />
                <span className="cx-proj-label">{p.name}</span>
              </a>
              <button
                type="button"
                className="cx-proj-del"
                aria-label={`Remove project ${p.name}`}
                title="Remove project"
                disabled={!canDeleteProject}
                onClick={(e) => {
                  e.preventDefault();
                  e.stopPropagation();
                  if (canDeleteProject && onDeleteStudioProject) onDeleteStudioProject(p.id);
                }}
              >
                <svg className="cx-proj-del-svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" aria-hidden="true">
                  <path d="M4 8h8" />
                </svg>
              </button>
            </li>
          ))}
        </ul>
      </div>
      <div className="cx-side-section">
        <div className="cx-side-h">Active graph</div>
        <ul className="cx-graph-list">
          {Object.values(graph.nodes).map(n => {
            const issueErr = validation.some(v => v.node === n.id && v.level === 'error');
            const showMeta = workflowNodeIds.has(n.id);
            return (
              <li key={n.id}
                draggable
                title="Drag onto canvas to add a new node of this type"
                className={`${selected===n.id?'sel':''} ${issueErr?'err':''}`}
                onDragStart={(e) => {
                  e.dataTransfer.setData(paletteDragMime, n.id);
                  e.dataTransfer.setData('text/plain', n.id);
                  e.dataTransfer.effectAllowed = 'copy';
                }}
                onClick={() => setSelected(n.id)}>
                <span className="cx-gl-num">{nodeNumFor(n.role)}</span>
                <span className="cx-gl-label">{nodeTitleFor(n.role)}</span>
                <span className="cx-gl-meta">{showMeta ? summaryFor(n, graph) : ''}</span>
                {issueErr && <span className="cx-gl-dot" />}
              </li>
            );
          })}
        </ul>
      </div>
      <div className="cx-side-section">
        <div className="cx-side-h">Satellite Workshops <span className="cx-pill">3 nearby</span></div>
        <ul className="cx-workshop-list">
          <li><span className="cx-ws-dot" /> Camden Loom Co. · 4.2km <span className="cx-ws-tag">weave</span></li>
          <li><span className="cx-ws-dot ok" /> Bankside Tuft Studio · 6.8km <span className="cx-ws-tag">tuft</span></li>
          <li><span className="cx-ws-dot busy" /> Hackney Stitch House · 9.1km <span className="cx-ws-tag">embr</span></li>
        </ul>
      </div>
    </aside>
  );
}
function summaryFor(n, g) {
  const S = window.CxSchema;
  if (n.role === 'product') return S.PRODUCTS.find(p=>p.id===n.data.selected)?.name || '—';
  if (n.role === 'craft')   return S.CRAFTS.find(c=>c.id===n.data.type)?.short || '—';
  if (n.role === 'fabric')  return S.MATERIALS.find(m=>m.id===n.data.materialId)?.name || '—';
  if (n.role === 'yarn')    return `${(n.data.selected||[]).length} yarn(s)`;
  if (n.role === 'motif')   return S.MOTIFS.find(m=>m.id===n.data.motifId)?.name || '—';
  if (n.role === 'matte')   return (n.data?.strippedImageDataUrl || n.data?.strippedImageUrl) ? 'Stripped' : '—';
  if (n.role === 'colorSplit') {
    const nLayers = (n.data?.layers || []).filter((l) => l.visible !== false).length;
    return nLayers ? `${nLayers} layer(s)` : '—';
  }
  if (n.role === 'layout') {
    return window.CxLayoutTiles?.label ? window.CxLayoutTiles.label(n.data.tile) : n.data.tile;
  }
  if (n.role === 'airender') {
    const p = (n.data.renderPrompt || '').trim();
    if (p) return p.length > 28 ? p.slice(0, 28) + '…' : p;
    if (n.data.refImageName) return n.data.refImageName;
    return 'mockup';
  }
  if (n.role === 'output')  return `×${n.data.qty}`;
  return '';
}
function CxCanvasGrid({ theme, pan, scale, canvasPattern = 'weave' }) {
  const isAtelier = theme === 'atelier';
  const size = 24 * scale;
  const px = pan.x;
  const py = pan.y;
  let backgroundSize;
  let backgroundPosition;
  if (isAtelier && canvasPattern === 'dots') {
    const d = 11 * scale;
    backgroundSize = `${d}px ${d}px, ${d}px ${d}px`;
    backgroundPosition = `${px}px ${py}px, ${px + d / 2}px ${py + d / 2}px`;
  } else if (isAtelier && canvasPattern === 'stripes') {
    const a = 8 * scale;
    const b = 22 * scale;
    const c = 30 * scale;
    backgroundSize = `${a}px 100%, ${b}px 100%, ${c}px 100%`;
    backgroundPosition = `${px}px 0, ${px}px 0, ${px}px 0`;
  } else if (isAtelier && canvasPattern === 'solid') {
    backgroundSize = 'auto';
    backgroundPosition = '0 0';
  } else {
    backgroundSize = `${size}px ${size}px`;
    backgroundPosition = `${px}px ${py}px`;
  }
  return (
    <div
      className="cx-grid"
      data-cx-canvas-pattern={isAtelier ? canvasPattern : undefined}
      style={{ backgroundSize, backgroundPosition }}
    />
  );
}

// ─────────── decorative woven swatch tag ───────────
function CxFabricTag() {
  const tag = (label, bg) => (
    <div style={{
      fontFamily: "'JetBrains Mono', monospace", fontSize: 9, letterSpacing: '0.18em',
      textTransform: 'uppercase', color: '#8a7d68',
      display: 'flex', alignItems: 'center', gap: 6
    }}>
      <span style={{ width: 28, height: 14, borderRadius: 2, background: bg, border: '0.5px solid #3a2f26' }} />
      {label}
    </div>
  );
  return (
    <div className="cx-fabric-tag">
      {tag('weave · 32×32 epi',
        'repeating-linear-gradient(90deg, #944a20 0 2px, transparent 2px 4px), repeating-linear-gradient(0deg, #7d946d 0 1px, transparent 1px 3px), #faf6ed')}
      {tag('twill · 2/2 Z',
        'repeating-linear-gradient(45deg, #4a2d3e 0 1px, transparent 1px 4px), #9a8a70')}
      {tag('herringbone · 2/2',
        'repeating-linear-gradient(60deg, #3a2f26 0 1px, transparent 1px 3px), repeating-linear-gradient(-60deg, #3a2f26 0 1px, transparent 1px 3px), #dccfa8')}
    </div>
  );
}

// ─────────── validation banner ───────────
function CxValidation({ validation }) {
  const errs = (validation || []).filter((v) => v.level === 'error');
  const warns = (validation || []).filter((v) => v.level === 'warn');
  const [warnDismissed, setWarnDismissed] = uS(false);
  const warnKey = warns.map((w) => w.node + '\0' + w.msg).join('|');

  uE(() => {
    if (!warns.length) {
      setWarnDismissed(false);
      return;
    }
    setWarnDismissed(false);
    const id = setTimeout(() => setWarnDismissed(true), 3000);
    return () => clearTimeout(id);
  }, [warnKey]);

  const showWarn = warns.length > 0 && !warnDismissed;
  if (!errs.length && !showWarn) {
    return null;
  }
  if (errs.length) {
    return (
      <div className="cx-validation err">
        <span className="cx-val-dot err" />
        <strong>{errs.length} issue{errs.length > 1 ? 's' : ''}</strong>
        {errs.slice(0, 2).map((v, i) => (
          <span key={i} className="cx-val-msg">· {v.msg}</span>
        ))}
      </div>
    );
  }
  return (
    <div className="cx-validation warn">
      <span className="cx-val-dot warn" />
      <strong>{warns.length} note{warns.length > 1 ? 's' : ''}</strong>
      {warns.slice(0, 2).map((v, i) => (
        <span key={i} className="cx-val-msg">· {v.msg}</span>
      ))}
    </div>
  );
}

// ─────────── wire tools (needle = connect · scissors = cut) + hint, minimap, zoom ───────────
function CxWireTools({ tool, onChange }) {
  return (
    <div className="cx-wire-tools" role="toolbar" aria-label="Wire tools">
      <button
        type="button"
        className={`cx-wire-tool ${tool === 'scissors' ? 'sel' : ''}`}
        onClick={() => onChange && onChange('scissors')}
        title="Drag across wires to remove them"
      >
        <span className="cx-wire-tool-ico" aria-hidden="true">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
            <circle cx="7" cy="7" r="3.5" />
            <circle cx="17" cy="17" r="3.5" />
            <path d="M10.2 10.2l8.1 8.1M14.5 9.5l-5 5" />
          </svg>
        </span>
        <span className="cx-wire-tool-label">Delete</span>
      </button>
      <button
        type="button"
        className={`cx-wire-tool ${tool === 'needle' ? 'sel' : ''}`}
        onClick={() => onChange && onChange('needle')}
        title="Drag from an output port to an input port"
      >
        <span className="cx-wire-tool-ico" aria-hidden="true">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
            <path d="M13.5 3.5l-10 10c-1.2 1.2-1.2 3.1 0 4.3s3.1 1.2 4.3 0l10-10" />
            <path d="M16 6l2 2M8.5 13.5L11 16" />
            <ellipse cx="17" cy="5" rx="1.8" ry="2.2" transform="rotate(45 17 5)" />
          </svg>
        </span>
        <span className="cx-wire-tool-label">Connect</span>
      </button>
    </div>
  );
}

// ─────────── moodboard (free-form image canvas inside the panel) ───────────
function CxMoodboard({
  moodboard,
  scale = 1,
  onMoodboardMoveStart,
  onMoodboardResizeStart,
  updateMoodboard,
}) {
  const C = window.CxEditorConstants || {};
  const {
    normalizeMoodboardImages,
    normalizeMoodboardImage,
    MOODBOARD_ITEM_MIN,
    MOODBOARD_ITEM_MAX,
    DEFAULT_MOODBOARD_ITEM_SIZE,
  } = C;
  const mb = moodboard || { x: 40, y: -468, w: 500, h: 500, images: [] };
  const images = uM(
    () => (normalizeMoodboardImages ? normalizeMoodboardImages(mb.images) : mb.images || []),
    [mb.images, normalizeMoodboardImages]
  );

  const bodyRef = uR(null);
  const [selectedId, setSelectedId] = uS(null);
  const [itemDrag, setItemDrag] = uS(null);
  const [itemResize, setItemResize] = uS(null);
  const itemDragRef = uR(null);
  const itemResizeRef = uR(null);
  const updateMoodboardRef = uR(updateMoodboard);
  const scaleRef = uR(scale);
  itemDragRef.current = itemDrag;
  itemResizeRef.current = itemResize;
  updateMoodboardRef.current = updateMoodboard;
  scaleRef.current = scale;

  const bodyLocal = (e) => {
    const el = bodyRef.current;
    if (!el) return { x: 0, y: 0 };
    const r = el.getBoundingClientRect();
    const s = scaleRef.current || 1;
    return { x: (e.clientX - r.left) / s, y: (e.clientY - r.top) / s };
  };

  const patchImage = (id, patch) => {
    const fn = updateMoodboardRef.current;
    if (!fn) return;
    fn((base) => ({
      images: (base.images || []).map((img) => (img.id === id ? { ...img, ...patch } : img)),
    }));
  };

  const deleteImage = (id) => {
    const fn = updateMoodboardRef.current;
    if (!fn) return;
    fn((base) => ({
      images: (base.images || []).filter((img) => img.id !== id),
    }));
    setSelectedId((cur) => (cur === id ? null : cur));
  };

  const onItemMoveStart = (e, id) => {
    if (e.button !== 0) return;
    if (e.target.closest('.cx-moodboard-item-resize, .cx-moodboard-item-delete')) return;
    e.stopPropagation();
    e.preventDefault();
    const it = images.find((img) => img.id === id);
    if (!it) return;
    const p = bodyLocal(e);
    setSelectedId(id);
    const fn = updateMoodboardRef.current;
    if (fn) {
      fn((base) => {
        const list = base.images || [];
        const maxZ = list.reduce((m, img) => Math.max(m, Number(img.z) || 0), 0);
        return {
          images: list.map((img) => (img.id === id ? { ...img, z: maxZ + 1 } : img)),
        };
      });
    }
    setItemDrag({ id, ox: p.x - it.x, oy: p.y - it.y });
  };

  const onItemResizeStart = (e, id) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    e.preventDefault();
    const it = images.find((img) => img.id === id);
    if (!it) return;
    const p = bodyLocal(e);
    setSelectedId(id);
    setItemResize({
      id,
      startW: it.w,
      startH: it.h,
      startMx: p.x,
      startMy: p.y,
    });
  };

  uE(() => {
    const needNorm = (mb.images || []).some(
      (img) => !Number.isFinite(img.x) || !Number.isFinite(img.w)
    );
    if (needNorm && normalizeMoodboardImages && updateMoodboardRef.current) {
      updateMoodboardRef.current({ images: normalizeMoodboardImages(mb.images) });
    }
  }, []);

  uE(() => {
    const move = (e) => {
      const drag = itemDragRef.current;
      const resize = itemResizeRef.current;
      if (drag) {
        e.preventDefault();
        const p = bodyLocal(e);
        patchImage(drag.id, {
          x: Math.round(p.x - drag.ox),
          y: Math.round(p.y - drag.oy),
        });
      }
      if (resize) {
        e.preventDefault();
        const p = bodyLocal(e);
        const dw = p.x - resize.startMx;
        const dh = p.y - resize.startMy;
        patchImage(resize.id, {
          w: Math.round(Math.min(MOODBOARD_ITEM_MAX, Math.max(MOODBOARD_ITEM_MIN, resize.startW + dw))),
          h: Math.round(Math.min(MOODBOARD_ITEM_MAX, Math.max(MOODBOARD_ITEM_MIN, resize.startH + dh))),
        });
      }
    };
    const up = () => {
      setItemDrag(null);
      setItemResize(null);
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
  }, [MOODBOARD_ITEM_MIN, MOODBOARD_ITEM_MAX]);

  uE(() => {
    const onKey = (e) => {
      if (!selectedId) return;
      if (e.key !== 'Delete' && e.key !== 'Backspace') return;
      const tag = e.target && e.target.tagName;
      if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
      e.preventDefault();
      deleteImage(selectedId);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedId]);

  const onDragOver = (e) => {
    e.preventDefault();
    e.stopPropagation();
    e.dataTransfer.dropEffect = 'copy';
  };

  const onDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();
    const files = Array.from(e.dataTransfer.files || []).filter((f) => f.type.startsWith('image/'));
    if (!files.length || !updateMoodboard) return;
    const p = bodyLocal(e);
    const size = DEFAULT_MOODBOARD_ITEM_SIZE || 120;
    files.slice(0, 8).forEach((file, fi) => {
      const reader = new FileReader();
      reader.onload = () => {
        if (typeof reader.result !== 'string') return;
        const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
        updateMoodboard((base) => {
          const list = base.images || [];
          const maxZ = list.reduce((m, img) => Math.max(m, Number(img.z) || 0), 0);
          const next = normalizeMoodboardImage
            ? normalizeMoodboardImage({
                id,
                name: file.name,
                url: reader.result,
                x: Math.round(p.x - size / 2 + fi * 12),
                y: Math.round(p.y - size / 2 + fi * 12),
                w: size,
                h: size,
                z: maxZ + 1 + fi,
              }, list.length + fi)
            : { id, name: file.name, url: reader.result };
          return { images: [...list, next].slice(-24) };
        });
      };
      reader.readAsDataURL(file);
    });
  };

  const onCanvasMouseDown = (e) => {
    e.stopPropagation();
    if (e.target === e.currentTarget) setSelectedId(null);
  };

  return (
    <div
      className="cx-moodboard"
      style={{ left: mb.x, top: mb.y, width: mb.w, height: mb.h }}
      onDragOver={onDragOver}
      onDrop={onDrop}
      role="region"
      aria-label="Moodboard — drop your inspiration here"
    >
      <div
        className="cx-moodboard-head"
        onMouseDown={onMoodboardMoveStart}
        title="Drag to move moodboard"
      >
        <strong>Moodboard</strong>
      </div>
      <div className="cx-moodboard-body" ref={bodyRef} onMouseDown={onCanvasMouseDown}>
        <div className="cx-moodboard-canvas">
          {images.length === 0 ? (
            <p className="cx-moodboard-label">Drop your inspiration here</p>
          ) : null}
          {images.map((it) => (
            <div
              key={it.id}
              className={`cx-moodboard-item${selectedId === it.id ? ' cx-moodboard-item-sel' : ''}`}
              style={{ left: it.x, top: it.y, width: it.w, height: it.h, zIndex: it.z || 1 }}
              onMouseDown={(e) => onItemMoveStart(e, it.id)}
              title={it.name}
            >
              <img src={it.url} alt={it.name} draggable={false} />
              {selectedId === it.id ? (
                <button
                  type="button"
                  className="cx-moodboard-item-delete"
                  title="Remove image"
                  aria-label="Remove image"
                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                  onClick={(e) => { e.stopPropagation(); deleteImage(it.id); }}
                >
                  ×
                </button>
              ) : null}
              <div
                className="cx-moodboard-item-resize"
                onMouseDown={(e) => onItemResizeStart(e, it.id)}
                aria-hidden="true"
              />
            </div>
          ))}
        </div>
      </div>
      <div
        className="cx-moodboard-resize"
        onMouseDown={onMoodboardResizeStart}
        title="Drag to resize moodboard"
        aria-hidden="true"
      />
    </div>
  );
}

// ─────────── hint, minimap, zoom ───────────
function CxHint() {
  return (
    <div className="cx-hint">
      <span>↻</span> Drag empty canvas to pan
      <span>·</span> drag node header to move
      <span>·</span> <em>○</em> → <em>●</em> to connect
      <span>·</span> right-click wire to remove
      <span>·</span> scroll to zoom
    </div>
  );
}

function CxMinimap({ graph, pan, scale, setPan, wrapRef }) {
  // compute graph bounding box (coerce coords; bad data must not break render)
  const nodes = Object.values(graph?.nodes || {}).filter((n) => {
    if (!n || n.hidden) return false;
    const x = Number(n.x);
    const y = Number(n.y);
    return Number.isFinite(x) && Number.isFinite(y);
  });
  const pad = 40;
  let minX = 0;
  let minY = 0;
  let maxX = 800;
  let maxY = 600;
  if (nodes.length > 0) {
    minX = Math.min(...nodes.map((n) => n.x)) - pad;
    minY = Math.min(...nodes.map((n) => n.y)) - pad;
    maxX = Math.max(...nodes.map((n) => n.x + NODE_W)) + pad;
    maxY = Math.max(...nodes.map((n) => n.y + (window.CxGeom?.estimateNodeHeight?.(n) ?? 200))) + pad;
  }
  let w = maxX - minX;
  let h = maxY - minY;
  if (!Number.isFinite(w) || w < 120) w = 800;
  if (!Number.isFinite(h) || h < 120) h = 600;
  const onClick = (e) => {
    if (!wrapRef.current) return;
    const r = e.currentTarget.getBoundingClientRect();
    const fx = (e.clientX - r.left) / r.width;
    const fy = (e.clientY - r.top)  / r.height;
    const wx = minX + fx * w;
    const wy = minY + fy * h;
    const cr = wrapRef.current.getBoundingClientRect();
    setPan({ x: cr.width/2 - wx*scale, y: cr.height/2 - wy*scale });
  };
  return (
    <div className="cx-minimap">
      <div className="cx-mm-h">map</div>
      <svg viewBox={`${minX} ${minY} ${w} ${h}`} className="cx-mm-svg" preserveAspectRatio="xMidYMid meet" onClick={onClick}>
        <rect className="cx-mm-bg" x={minX} y={minY} width={w} height={h} />
        {(Array.isArray(graph?.connections) ? graph.connections : []).map((c, i) => {
          const fromN = graph.nodes?.[c.from];
          const toN = graph.nodes?.[c.to];
          if (!fromN || !toN || !c.fromPort || !c.toPort) return null;
          let a; let b;
          try {
            a = window.CxGeom.portPos(fromN, c.fromPort, 'out');
            b = window.CxGeom.portPos(toN, c.toPort, 'in');
          } catch {
            return null;
          }
          if (!a || !b || ![a.x, a.y, b.x, b.y].every(Number.isFinite)) return null;
          return <path key={i} d={window.CxGeom.bezier(a, b)} className="cx-mm-wire" />;
        })}
        {nodes.map(n => (
          <rect key={n.id} x={n.x} y={n.y} width={NODE_W} height={window.CxGeom?.estimateNodeHeight?.(n) ?? 180}
                className={`cx-mm-node cx-mm-${n.role}`} />
        ))}
      </svg>
    </div>
  );
}

function CxZoomCtrl({ scale, setScale, resetView }) {
  const zMax = window.CxGeom?.FIT_SCALE_MAX ?? 2.5;
  const zMin = window.CxGeom?.FIT_SCALE_MIN ?? 0.35;
  return (
    <div className="cx-zoom">
      <button onClick={() => setScale(Math.min(zMax, scale * 1.2))}>+</button>
      <span>{Math.round(scale * 100)}%</span>
      <button onClick={() => setScale(Math.max(zMin, scale / 1.2))}>−</button>
      <button onClick={resetView}>⊡</button>
    </div>
  );
}
window.CxChrome = {
  CxTopbar, CxSidebar, summaryFor,
  CxCanvasGrid, CxFabricTag, CxMoodboard,
  CxValidation, CxWireTools, CxHint, CxMinimap, CxZoomCtrl,
};
