// 1Radar Hybrid — Workflow (node-based AI pipeline builder)
// Inspired by Vibe-Workflow / ComfyUI / Blender Nodes — visual, modular pipelines
// for generative image and video creation.

const WORKFLOW_NODE_LIBRARY = [
  { group: 'Triggers', items: [
    { id: 'lib-trigger-schedule', type: 'trigger-schedule', label: 'Schedule',        icon: 'clock',    color: '#F97316' },
    { id: 'lib-trigger-ad',       type: 'trigger-ad-saved', label: 'Ad saved to…',   icon: 'bookmark', color: '#F97316' },
  ]},
  { group: 'Inputs', items: [
    { id: 'lib-prompt',   type: 'prompt',   label: 'Text prompt',   icon: 'sparkles', color: '#335CFF' },
    { id: 'lib-image',    type: 'image-in', label: 'Image file',    icon: 'image',    color: '#335CFF' },
    { id: 'lib-video-in', type: 'video-in', label: 'Video / Audio', icon: 'video',    color: '#335CFF' },
    { id: 'lib-ad',       type: 'ad-in',    label: 'Saved ad',      icon: 'bookmark', color: '#335CFF' },
    { id: 'lib-brand',    type: 'brand-in', label: 'Brand voice',   icon: 'building', color: '#335CFF' },
  ]},
  { group: 'Generate', items: [
    { id: 'lib-img-gen',    type: 'img-gen',    label: 'AI Image',       icon: 'image-ai', color: '#7B5BFF' },
    { id: 'lib-vid-gen',    type: 'vid-gen',    label: 'AI Video',       icon: 'video',    color: '#7B5BFF' },
    { id: 'lib-copy-gen',   type: 'copy-gen',   label: 'Chat AI',        icon: 'chat',     color: '#7B5BFF' },
    { id: 'lib-transcribe', type: 'transcribe', label: 'Transcription',  icon: 'mic-line', color: '#7B5BFF' },
  ]},
  { group: 'Transform', items: [
    { id: 'lib-bg', type: 'bg-remove', label: 'Background remove', icon: 'bg-remove', color: '#10B981' },
  ]},
  { group: 'Outputs', items: [
    { id: 'lib-save',   type: 'save',   label: 'Save to folder', icon: 'bookmark', color: '#F59E0B' },
    { id: 'lib-export', type: 'export', label: 'Export',         icon: 'download', color: '#F59E0B' },
  ]},
];

const WORKFLOW_TEMPLATES = [
  { id: 'tpl-1', name: 'Product photo pipeline',  desc: 'Image → BG remove → Export',              nodes: 3 },
  { id: 'tpl-2', name: 'AI image from brief',     desc: 'Prompt + Image → AI Image → BG remove',   nodes: 4 },
  { id: 'tpl-3', name: 'Video from script',       desc: 'Brief → Chat AI script → AI Video',        nodes: 3 },
  { id: 'tpl-4', name: 'Ad transcription & copy', desc: 'Video → Transcription → Chat AI → Save',  nodes: 4 },
];

// Per-type defaults used when adding a new node from the library.
// Field shape: { k, label, type, v, ...typeOpts }
//   types: 'text' | 'textarea' | 'select' | 'chips' | 'aspect' | 'stepper'
//          | 'slider' | 'file' | 'select-ad' | 'select-brand' | 'select-folder' | 'color'
// ── Port type system ──────────────────────────────────────────────────────────
const PORT_COLOR = {
  trigger:   '#F97316',
  image:     '#06B6D4',
  video:     '#A855F7',
  media:     '#A855F7',
  text:      '#22C55E',
  ad:        '#F59E0B',
  brand:     '#EC4899',
  brief:     '#22C55E',
  prompt:    '#22C55E',
  reference: '#06B6D4',
  context:   '#22C55E',
  content:   '#94A3B8',
  data:      '#94A3B8',
};

const PORT_TYPE = {
  trigger: 'trigger', image: 'image',  video: 'video',   media: 'media',
  text:    'text',    ad:    'ad',      brand: 'brand',
  brief:   'text',    prompt:'text',    reference:'image', context:'text',
  content: 'any',     data:  'any',
};

const TYPE_ACCEPTS = {
  trigger: ['trigger'],
  image:   ['image', 'reference'],
  video:   ['video', 'media'],
  media:   ['media', 'video'],
  text:    ['text', 'brief', 'prompt', 'context'],
  ad:      ['ad'],
  brand:   ['brand'],
  // 'any' handled below
};

const portsCompatible = (outPort, inPort) => {
  const outT = PORT_TYPE[outPort] ?? outPort;
  const inT  = PORT_TYPE[inPort]  ?? inPort;
  if (outT === 'any' || inT === 'any') return true;
  const accepts = TYPE_ACCEPTS[outT];
  if (!accepts) return false;
  return accepts.includes(inT);
};

const edgePortColor = (fromPortName, fromNode) => {
  const name = (fromPortName && fromPortName !== 'out') ? fromPortName : fromNode?.out?.[0];
  return PORT_COLOR[name] || '#94A3B8';
};

const ASPECT_OPTIONS = [
  { v: 'auto', w: 18, h: 14, dashed: true },
  { v: '16:9', w: 18, h: 10 },
  { v: '4:3',  w: 16, h: 12 },
  { v: '1:1',  w: 14, h: 14 },
  { v: '3:4',  w: 12, h: 16 },
  { v: '9:16', w: 10, h: 18 },
];

const NODE_DEFAULTS = {
  // ── Inputs ──────────────────────────────────────────────────────────────────
  'prompt': {
    w: 240, in: [], out: ['text'],
    fields: [
      { k: 'value', label: 'Prompt', type: 'textarea',
        v: 'A cinematic product shot, soft window light, beige linen.',
        placeholder: 'Describe what you want…' },
    ],
  },
  'image-in': {
    w: 260, in: [], out: ['image'],
    fields: [
      { k: 'value', label: 'Image', type: 'file', accept: 'image/*', v: null },
    ],
  },
  'video-in': {
    w: 260, in: [], out: ['media'],
    fields: [
      { k: 'value', label: 'Video / Audio', type: 'file', accept: 'video/*,audio/*', v: null },
    ],
  },
  'ad-in': {
    w: 240, in: [], out: ['ad'],
    fields: [
      { k: 'value', label: 'Saved ad', type: 'select-ad', v: 'ad1' },
    ],
  },
  'brand-in': {
    w: 240, in: [], out: ['brand'],
    fields: [
      { k: 'value', label: 'Brand', type: 'select-brand', v: 'b2' },
      { k: 'tone',  label: 'Tone',  type: 'chips', v: 'On-brand',
        options: ['On-brand','Bold','Soft','Witty','Premium'] },
    ],
  },
  // ── Generate ────────────────────────────────────────────────────────────────
  'img-gen': {
    w: 280, in: ['trigger','prompt','reference'], out: ['image'],
    fields: [
      { k: 'model', label: 'Model', type: 'select', v: 'nano-banana-pro',
        options: [
          { v: 'nano-banana-pro', label: 'Nano Banana Pro', sub: 'Highest quality · slower' },
          { v: 'nano-banana-2',   label: 'Nano Banana 2',   sub: 'Faster, balanced' },
          { v: 'flux-pro',        label: 'Flux Pro 1.1',    sub: 'Photoreal' },
          { v: 'imagen-3',        label: 'Imagen 3',        sub: 'Google · sharp' },
        ]},
      { k: 'aspect',  label: 'Aspect ratio', type: 'aspect',  v: '1:1' },
      { k: 'quality', label: 'Quality',      type: 'chips',   v: '2K', options: ['1K','2K','4K'] },
      { k: 'count',   label: 'Images',       type: 'stepper', v: 1, min: 1, max: 8 },
    ],
    portFields: {
      prompt:    { k: '_p_prompt', label: 'Prompt',    type: 'textarea', v: '', placeholder: 'Describe the image…' },
      reference: { k: '_p_ref',   label: 'Reference images', type: 'multi-file', v: [], accept: 'image/*', max: 14 },
    },
  },
  'vid-gen': {
    w: 280, in: ['trigger','prompt','image'], out: ['video'],
    fields: [
      { k: 'model', label: 'Model', type: 'select', v: 'vadoo',
        options: [
          { v: 'vadoo',  label: 'Vadoo MuAPI',  sub: 'Image-to-video · fast' },
          { v: 'kling',  label: 'Kling 1.6',    sub: 'Cinematic motion' },
          { v: 'runway', label: 'Runway Gen-4',  sub: 'High fidelity' },
        ]},
      { k: 'aspect',   label: 'Aspect ratio', type: 'aspect',  v: '16:9' },
      { k: 'duration', label: 'Duration (s)', type: 'stepper', v: 5, min: 2, max: 30 },
    ],
    portFields: {
      prompt: { k: '_p_prompt', label: 'Prompt',      type: 'textarea', v: '', placeholder: 'Describe the video…' },
      image:  { k: '_p_image',  label: 'Image / frame', type: 'file',   v: null, accept: 'image/*' },
    },
  },
  'copy-gen': {
    w: 280, in: ['trigger','brief','brand','context'], out: ['text'],
    fields: [
      { k: 'model', label: 'Model', type: 'select', v: 'gpt-4o',
        options: [
          { v: 'gpt-4o',     label: 'GPT-4o',          sub: 'OpenAI' },
          { v: 'claude-4-7', label: 'Claude Opus 4.7',  sub: 'Anthropic' },
          { v: 'gemini-2',   label: 'Gemini 2.5 Pro',   sub: 'Google' },
        ]},
      { k: 'task', label: 'Task', type: 'chips', v: 'Ad copy',
        options: ['Ad copy','Hook','Script','Caption','Analysis','Summary'] },
      { k: 'lang', label: 'Language', type: 'select', v: 'en',
        options: [
          { v: 'en', label: 'English' }, { v: 'fr', label: 'Français' },
          { v: 'es', label: 'Español' }, { v: 'de', label: 'Deutsch' },
        ]},
    ],
    portFields: {
      brief:   { k: '_p_brief',   label: 'Brief',   type: 'textarea',    v: '', placeholder: 'Describe the task…' },
      brand:   { k: '_p_brand',   label: 'Brand',   type: 'select-brand', v: null },
      context: { k: '_p_context', label: 'Context', type: 'textarea',    v: '', placeholder: 'Paste context here…' },
    },
  },
  'transcribe': {
    w: 260, in: ['trigger','media'], out: ['text'],
    fields: [
      { k: 'lang', label: 'Language', type: 'select', v: 'auto',
        options: [
          { v: 'auto', label: 'Auto-detect' },
          { v: 'en',   label: 'English' },
          { v: 'fr',   label: 'Français' },
          { v: 'es',   label: 'Español' },
          { v: 'de',   label: 'Deutsch' },
          { v: 'pt',   label: 'Português' },
        ]},
      { k: 'timestamps', label: 'Timestamps', type: 'chips', v: 'None',
        options: ['None','Word','Segment'] },
    ],
    portFields: {
      media: { k: '_p_media', label: 'Video / Audio', type: 'file', v: null, accept: 'video/*,audio/*' },
    },
  },
  // ── Transform ───────────────────────────────────────────────────────────────
  'bg-remove': {
    w: 260, in: ['trigger','image'], out: ['image'],
    fields: [
      { k: 'mode', label: 'Mode', type: 'chips', v: 'Auto', options: ['Auto','Subject','Product'] },
      { k: 'edge', label: 'Edge feather', type: 'slider', v: 0.3, min: 0, max: 1, step: 0.05 },
    ],
    portFields: {
      image: { k: '_p_image', label: 'Image', type: 'file', v: null, accept: 'image/*' },
    },
  },
  // ── Outputs ─────────────────────────────────────────────────────────────────
  'save': {
    w: 240, in: ['trigger','content'], out: [],
    fields: [
      { k: 'folder', label: 'Folder', type: 'select-folder', v: 'inspo' },
    ],
  },
  'export': {
    w: 240, in: ['trigger','content'], out: [],
    fields: [
      { k: 'format', label: 'Format', type: 'chips', v: 'PNG', options: ['PNG','JPG','WebP','MP4','TXT'] },
      { k: 'quality', label: 'Quality', type: 'slider', v: 0.92, min: 0.5, max: 1, step: 0.01 },
    ],
  },
  // ── Triggers ─────────────────────────────────────────────────────────────────
  'trigger-schedule': {
    w: 240, in: [], out: ['trigger'],
    fields: [
      { k: 'freq',  label: 'Frequency', type: 'chips', v: 'Daily',
        options: ['Hourly','Daily','Weekly','Monthly'] },
      { k: 'time',  label: 'Time',      type: 'text',  v: '09:00', placeholder: 'HH:MM' },
      { k: 'days',  label: 'Days',      type: 'chips', v: 'Mon–Fri',
        options: ['Mon–Fri','Every day','Mon','Tue','Wed','Thu','Fri','Sat','Sun'] },
    ],
  },
  'trigger-ad-saved': {
    w: 250, in: [], out: ['trigger', 'ad'],
    fields: [
      { k: 'folder',   label: 'Folder',    type: 'select-folder', v: null },
      { k: 'filetype', label: 'File type', type: 'chips', v: 'Any',
        options: ['Any','Image','Video','Audio'] },
      { k: 'platform', label: 'Platform',  type: 'chips', v: 'All',
        options: ['All','Meta','TikTok','Google','Instagram','YouTube'] },
    ],
  },
};

let __nodeSeq = 100;
const newNodeId = () => `n${++__nodeSeq}`;
const newEdgeId = () => `e_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,5)}`;
const newAnnId  = () => `a_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,5)}`;

// Annex tool palette — annotations live alongside (but separate from) the workflow graph.
// Pen colour palette also applies to text/sticky/shape via the picker in the bottom bar.
const ANN_COLORS = ['#FFCB5C', '#FF8A80', '#A5D8FF', '#B7F0AD', '#D7B4FF', '#FFB59A', '#0E121B', '#FFFFFF'];

// Format a field value for display on the node card (compact preview)
const fieldPreviewText = (f) => {
  if (f.v == null || f.v === '') return '—';
  switch (f.type) {
    case 'select': {
      const opt = (f.options || []).find(o => o.v === f.v);
      return opt ? opt.label : String(f.v);
    }
    case 'select-brand': {
      const b = (window.BRANDS_V3 || []).find(x => x.id === f.v);
      return b ? b.name : String(f.v);
    }
    case 'select-ad': {
      const a = (window.ADS_V3 || []).find(x => x.id === f.v);
      return a ? (a.headline || a.title) : String(f.v);
    }
    case 'select-folder': {
      const fo = (window.SAVE_FOLDERS_V3 || []).find(x => x.id === f.v);
      return fo ? fo.name : String(f.v);
    }
    case 'slider': {
      const step = f.step ?? 0.05;
      const n = Number(f.v);
      return `${n.toFixed(step < 0.1 ? 2 : (step < 1 ? 2 : 0))}${f.unit || ''}`;
    }
    case 'stepper': return `${f.v}${f.unit || ''}`;
    case 'aspect':  return f.v === 'auto' ? 'Auto' : f.v;
    case 'chips':   return String(f.v);
    case 'textarea':
    case 'text': {
      const s = String(f.v);
      return s.length > 38 ? s.slice(0, 38) + '…' : s;
    }
    default: return String(f.v);
  }
};

// ===========================================================================
// Field controls — used inside the Inspector. All accept { value, onChange }
// and return a single styled control. Designed to look editable, not greyed.
// ===========================================================================

const fldInputStyle = (focused) => ({
  width: '100%', height: 34,
  padding: '0 10px', borderRadius: 8,
  border: `1px solid ${focused ? 'var(--accent)' : 'var(--border)'}`,
  background: 'var(--bg-surface)',
  color: 'var(--text)', fontSize: 13, fontFamily: 'inherit',
  outline: 'none',
  boxShadow: focused ? '0 0 0 3px var(--accent-glow)' : 'none',
  transition: 'border-color 120ms, box-shadow 120ms',
});

const TextField = ({ value, onChange, placeholder }) => {
  const [foc, setFoc] = React.useState(false);
  return (
    <input value={value ?? ''} placeholder={placeholder}
      onFocus={() => setFoc(true)} onBlur={() => setFoc(false)}
      onChange={(e) => onChange(e.target.value)}
      style={fldInputStyle(foc)}/>
  );
};

const TextareaField = ({ value, onChange, placeholder }) => {
  const [foc, setFoc] = React.useState(false);
  return (
    <textarea value={value ?? ''} placeholder={placeholder} rows={3}
      onFocus={() => setFoc(true)} onBlur={() => setFoc(false)}
      onChange={(e) => onChange(e.target.value)}
      style={{
        ...fldInputStyle(foc),
        height: 'auto', minHeight: 72, padding: '8px 10px',
        resize: 'vertical', lineHeight: 1.5,
      }}/>
  );
};

// Generic styled select with rich options support via renderOption.
// Popover is rendered fixed-position so it can escape clipping containers.
const SelectField = ({ value, options, onChange, renderOption, placeholder = 'Select…' }) => {
  const [open, setOpen] = React.useState(false);
  const [pos, setPos]   = React.useState(null);
  const btnRef          = React.useRef(null);
  const cur             = options.find(o => o.v === value);

  const openMenu = () => {
    const r = btnRef.current?.getBoundingClientRect();
    if (r) {
      const menuH = Math.min(280, options.length * 40 + 16);
      const below = window.innerHeight - r.bottom > menuH + 16;
      setPos({
        left: r.left,
        top: below ? r.bottom + 4 : Math.max(8, r.top - menuH - 4),
        width: r.width,
      });
    }
    setOpen(true);
  };

  React.useEffect(() => {
    if (!open) return;
    const close = () => setOpen(false);
    window.addEventListener('resize', close);
    return () => window.removeEventListener('resize', close);
  }, [open]);

  return (
    <>
      <button ref={btnRef} type="button"
        onClick={() => open ? setOpen(false) : openMenu()}
        style={{
          width: '100%', minHeight: 34,
          padding: '6px 10px', borderRadius: 8,
          border: `1px solid ${open ? 'var(--accent)' : 'var(--border)'}`,
          background: 'var(--bg-surface)',
          color: 'var(--text)', fontSize: 13, fontFamily: 'inherit',
          cursor: 'pointer', textAlign: 'left',
          display: 'flex', alignItems: 'center', gap: 8,
          boxShadow: open ? '0 0 0 3px var(--accent-glow)' : 'none',
        }}>
        <span style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
          {cur
            ? (renderOption ? renderOption(cur, true) : <span>{cur.label}</span>)
            : <span style={{ color: 'var(--text-faint)' }}>{placeholder}</span>}
        </span>
        <Icon name="chevron-down" size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }}/>
      </button>
      {open && pos && (
        <>
          <div onClick={() => setOpen(false)}
               onContextMenu={(e) => { e.preventDefault(); setOpen(false); }}
               style={{ position: 'fixed', inset: 0, zIndex: 220 }}/>
          <div className="scroll" style={{
            position: 'fixed', left: pos.left, top: pos.top, width: pos.width,
            background: 'var(--bg-surface)',
            border: '1px solid var(--border)',
            borderRadius: 10,
            boxShadow: '0 14px 36px rgba(14,18,27,0.18), 0 2px 6px rgba(14,18,27,0.06)',
            padding: 4, zIndex: 221, maxHeight: 280, overflowY: 'auto',
          }}>
            {options.map(o => {
              const sel = o.v === value;
              return (
                <button key={o.v} type="button"
                  onClick={() => { onChange(o.v); setOpen(false); }}
                  onMouseEnter={(e) => { if (!sel) e.currentTarget.style.background = 'var(--bg-soft)'; }}
                  onMouseLeave={(e) => { if (!sel) e.currentTarget.style.background = 'transparent'; }}
                  style={{
                    display: 'flex', alignItems: 'center', gap: 8,
                    width: '100%', padding: '7px 8px', borderRadius: 6,
                    background: sel ? 'var(--bg-soft)' : 'transparent',
                    border: 0, cursor: 'pointer', textAlign: 'left',
                    color: 'var(--text)', fontSize: 13, fontFamily: 'inherit',
                  }}>
                  {renderOption ? renderOption(o, false) : <span style={{ flex: 1 }}>{o.label}</span>}
                  {sel && <Icon name="check" size={12} style={{ marginLeft: 'auto', color: 'var(--accent)' }}/>}
                </button>
              );
            })}
          </div>
        </>
      )}
    </>
  );
};

const ChipsField = ({ value, options, onChange }) => (
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
    {options.map(o => {
      const sel = (typeof o === 'string' ? o : o.v) === value;
      const label = typeof o === 'string' ? o : o.label;
      const v = typeof o === 'string' ? o : o.v;
      return (
        <button key={v} type="button" onClick={() => onChange(v)}
          style={{
            height: 28, padding: '0 12px',
            border: `1px solid ${sel ? 'var(--accent)' : 'var(--border)'}`,
            background: sel ? 'var(--accent-soft)' : 'var(--bg-surface)',
            color: sel ? 'var(--accent)' : 'var(--text-2)',
            fontSize: 12, fontWeight: sel ? 600 : 500,
            borderRadius: 999, cursor: 'pointer', fontFamily: 'inherit',
            transition: 'all 120ms',
          }}>{label}</button>
      );
    })}
  </div>
);

const AspectGlyph = ({ w, h, dashed, color = 'var(--text-2)' }) => (
  <span style={{ width: 22, height: 22, display: 'inline-grid', placeItems: 'center', flexShrink: 0 }}>
    <span style={{ width: w, height: h, border: `1.5px ${dashed ? 'dashed' : 'solid'} ${color}`, borderRadius: 2 }}/>
  </span>
);

const AspectField = ({ value, onChange }) => (
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
    {ASPECT_OPTIONS.map(a => {
      const sel = a.v === value;
      return (
        <button key={a.v} type="button" onClick={() => onChange(a.v)}
          title={a.v === 'auto' ? 'Auto' : a.v}
          style={{
            display: 'inline-flex', alignItems: 'center', gap: 5,
            height: 32, padding: '0 8px',
            border: `1px solid ${sel ? 'var(--accent)' : 'var(--border)'}`,
            background: sel ? 'var(--accent-soft)' : 'var(--bg-surface)',
            color: sel ? 'var(--accent)' : 'var(--text-2)',
            fontSize: 11, fontWeight: 500,
            borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit',
          }}>
          <AspectGlyph {...a} color={sel ? 'var(--accent)' : 'var(--text-2)'}/>
          {a.v === 'auto' ? 'Auto' : a.v}
        </button>
      );
    })}
  </div>
);

const StepperField = ({ value, min = 1, max = 99, onChange, unit }) => {
  const set = (v) => onChange(Math.max(min, Math.min(max, v)));
  return (
    <div style={{
      display: 'inline-flex', alignItems: 'center',
      height: 34, borderRadius: 8,
      background: 'var(--bg-surface)',
      border: '1px solid var(--border)',
      overflow: 'hidden',
    }}>
      <button type="button" onClick={() => set(Number(value) - 1)} disabled={value <= min}
        style={{ width: 32, height: 32, border: 0, background: 'transparent',
                 color: value <= min ? 'var(--text-faint)' : 'var(--text-2)',
                 cursor: value <= min ? 'not-allowed' : 'pointer',
                 display: 'grid', placeItems: 'center' }}>
        <Icon name="minus" size={12}/>
      </button>
      <input type="number" min={min} max={max} value={value}
        onChange={(e) => { const n = parseFloat(e.target.value); if (!Number.isNaN(n)) set(n); }}
        onWheel={(e) => e.currentTarget.blur()}
        style={{ width: 48, height: 32, padding: 0, border: 0, background: 'transparent',
                 textAlign: 'center', fontSize: 13, fontWeight: 600, color: 'var(--text)',
                 fontFamily: 'inherit', outline: 'none', MozAppearance: 'textfield' }}/>
      <button type="button" onClick={() => set(Number(value) + 1)} disabled={value >= max}
        style={{ width: 32, height: 32, border: 0, background: 'transparent',
                 color: value >= max ? 'var(--text-faint)' : 'var(--text-2)',
                 cursor: value >= max ? 'not-allowed' : 'pointer',
                 display: 'grid', placeItems: 'center' }}>
        <Icon name="plus" size={12}/>
      </button>
      {unit && <span style={{ padding: '0 10px 0 4px', fontSize: 12, color: 'var(--text-faint)' }}>{unit}</span>}
    </div>
  );
};

const SliderField = ({ value, min = 0, max = 1, step = 0.05, onChange, unit = '' }) => {
  const pct = ((value - min) / (max - min)) * 100;
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <input type="range" min={min} max={max} step={step} value={value}
          onChange={(e) => onChange(parseFloat(e.target.value))}
          style={{
            flex: 1, height: 4, WebkitAppearance: 'none', appearance: 'none',
            background: `linear-gradient(to right, var(--accent) 0%, var(--accent) ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
            borderRadius: 999, outline: 'none', cursor: 'pointer',
          }}/>
        <span style={{
          minWidth: 48, textAlign: 'right',
          fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
          color: 'var(--text)',
        }}>{Number(value).toFixed(step < 0.1 ? 2 : (step < 1 ? 2 : 0))}{unit}</span>
      </div>
    </div>
  );
};

// Inline-editing textarea for text/sticky annotations.
// Uses useLayoutEffect to auto-grow the wrapper as content (lines, font-size,
// width) changes — runs AFTER paint, so scrollHeight is always accurate.
const InlineAnnotationEditor = ({ ann, isSticky, padding, onChangeText, onCommitHeight, onBlur, onKeyDown, onMouseDown }) => {
  const taRef = React.useRef(null);
  React.useLayoutEffect(() => {
    const ta = taRef.current;
    if (!ta) return;
    // Reset to measure true content height, then restore flex sizing
    ta.style.height = 'auto';
    const contentH = ta.scrollHeight;
    ta.style.height = '';
    const desiredH = contentH + padding;
    if (desiredH > ann.h) onCommitHeight(desiredH);
  }, [ann.text, ann.size, ann.w]);
  return (
    <textarea
      ref={taRef}
      autoFocus
      value={ann.text || ''}
      placeholder={isSticky ? 'Type your note…' : 'Add label…'}
      onChange={(e) => onChangeText(e.target.value)}
      onBlur={onBlur}
      onKeyDown={onKeyDown}
      onMouseDown={onMouseDown}
      className="wf-no-scrollbar"
      style={{
        flex: 1, width: '100%',
        background: 'transparent', border: 0, outline: 0,
        padding: 0, margin: 0, resize: 'none',
        overflow: 'hidden',
        scrollbarWidth: 'none',     // Firefox
        msOverflowStyle: 'none',    // IE/old Edge
        fontFamily: 'inherit',
        fontSize: ann.size ?? (isSticky ? 14 : 16),
        fontWeight: isSticky ? 500 : 600,
        lineHeight: 1.4,
        color: 'inherit',
        textAlign: ann.align || 'left',
      }}/>
  );
};

const FileField = ({ value, accept, onChange }) => {
  const inputRef = React.useRef(null);
  const [drag, setDrag] = React.useState(false);
  const handle = (files) => {
    const f = files?.[0]; if (!f) return;
    const url = URL.createObjectURL(f);
    onChange({ url, name: f.name, size: f.size, mime: f.type });
  };
  if (value && value.url) {
    return (
      <div style={{
        position: 'relative', borderRadius: 10,
        border: '1px solid var(--border)',
        background: 'var(--bg-surface)', overflow: 'hidden',
      }}>
        <div style={{
          width: '100%', aspectRatio: '4/3',
          background: `center/cover no-repeat url(${value.url}), var(--bg-soft)`,
        }}/>
        <div style={{
          padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 8,
          borderTop: '1px solid var(--border-soft)',
        }}>
          <Icon name="image" size={13} style={{ color: 'var(--text-faint)' }}/>
          <span style={{ flex: 1, fontSize: 12, color: 'var(--text-2)',
                         overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
            {value.name || 'image'}
          </span>
          {value.size && <span style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>
            {(value.size / 1024).toFixed(0)} KB
          </span>}
          <button type="button" onClick={() => inputRef.current?.click()} title="Replace"
            style={{ width: 24, height: 24, border: 0, background: 'transparent',
                     color: 'var(--text-faint)', cursor: 'pointer', borderRadius: 5,
                     display: 'grid', placeItems: 'center' }}>
            <Icon name="upload" size={12}/>
          </button>
          <button type="button" onClick={() => onChange(null)} title="Remove"
            style={{ width: 24, height: 24, border: 0, background: 'transparent',
                     color: 'var(--text-faint)', cursor: 'pointer', borderRadius: 5,
                     display: 'grid', placeItems: 'center' }}>
            <Icon name="trash" size={12}/>
          </button>
        </div>
        <input ref={inputRef} type="file" accept={accept} hidden
               onChange={(e) => { handle(e.target.files); e.target.value = ''; }}/>
      </div>
    );
  }
  return (
    <label
      onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
      onDragLeave={() => setDrag(false)}
      onDrop={(e) => { e.preventDefault(); setDrag(false); handle(e.dataTransfer.files); }}
      style={{
        display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
        padding: '24px 16px',
        borderRadius: 10,
        border: `1.5px dashed ${drag ? 'var(--accent)' : 'var(--border)'}`,
        background: drag ? 'var(--accent-soft)' : 'var(--bg-soft)',
        cursor: 'pointer',
        transition: 'all 120ms',
      }}>
      <input ref={inputRef} type="file" accept={accept} hidden
             onChange={(e) => { handle(e.target.files); e.target.value = ''; }}/>
      <div style={{ width: 36, height: 36, borderRadius: 9,
                    background: 'var(--bg-surface)', border: '1px solid var(--border)',
                    display: 'grid', placeItems: 'center', color: 'var(--text-muted)' }}>
        <Icon name="upload" size={16}/>
      </div>
      <div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-2)' }}>
        Drop an image here
      </div>
      <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
        or click to browse
      </div>
    </label>
  );
};

// Compact file picker — fits inside a node card port row
const CompactFileField = ({ value, accept, label, onChange }) => {
  const inputRef = React.useRef(null);
  const [isDrop, setIsDrop] = React.useState(false);
  const handle = (files) => {
    const f = files?.[0]; if (!f) return;
    onChange({ url: URL.createObjectURL(f), name: f.name, size: f.size, mime: f.type });
  };
  if (value?.url) {
    const isMedia = value.mime?.startsWith('video') || value.mime?.startsWith('audio');
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '2px 0' }}>
        <span style={{
          width: 30, height: 30, borderRadius: 6, flexShrink: 0,
          background: isMedia ? 'var(--bg-soft)' : `center/cover no-repeat url(${value.url})`,
          border: '1px solid var(--border-soft)',
          display: 'grid', placeItems: 'center', color: 'var(--text-faint)',
        }}>{isMedia && <Icon name="video" size={12}/>}</span>
        <span style={{ flex: 1, fontSize: 11, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {value.name}
        </span>
        <button onMouseDown={e => e.stopPropagation()} onClick={() => inputRef.current?.click()}
          style={{ border:0, background:'transparent', color:'var(--text-faint)', cursor:'pointer', padding:2, borderRadius:4, display:'grid', placeItems:'center' }}>
          <Icon name="upload" size={11}/>
        </button>
        <button onMouseDown={e => e.stopPropagation()} onClick={() => onChange(null)}
          style={{ border:0, background:'transparent', color:'var(--text-faint)', cursor:'pointer', padding:2, borderRadius:4, display:'grid', placeItems:'center' }}>
          <Icon name="close" size={11}/>
        </button>
        <input ref={inputRef} type="file" accept={accept} hidden
               onChange={e => { handle(e.target.files); e.target.value = ''; }}/>
      </div>
    );
  }
  return (
    <label
      onDragOver={e => { e.preventDefault(); setIsDrop(true); }}
      onDragLeave={() => setIsDrop(false)}
      onDrop={e => { e.preventDefault(); setIsDrop(false); handle(e.dataTransfer.files); }}
      style={{
        display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
        borderRadius: 8, cursor: 'pointer', transition: 'all 120ms',
        border: `1.5px dashed ${isDrop ? 'var(--accent)' : 'var(--border)'}`,
        background: isDrop ? 'var(--accent-soft)' : 'var(--bg-soft)',
      }}>
      <input ref={inputRef} type="file" accept={accept} hidden
             onChange={e => { handle(e.target.files); e.target.value = ''; }}/>
      <Icon name="upload" size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }}/>
      <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>Drop or click to browse</span>
    </label>
  );
};

// Compact multi-image picker (up to `max` images) for node card inline inputs
const CompactMultiFileField = ({ value, accept, max, onChange }) => {
  const inputRef = React.useRef(null);
  const [isDrop, setIsDrop] = React.useState(false);
  const files = value || [];
  const limit = max || 14;
  const canAdd = files.length < limit;

  const handleFiles = (incoming) => {
    const remaining = limit - files.length;
    const toAdd = Array.from(incoming).slice(0, remaining).map(f => ({
      url: URL.createObjectURL(f), name: f.name, size: f.size, mime: f.type,
    }));
    if (toAdd.length) onChange([...files, ...toAdd]);
  };

  const remove = (i) => onChange(files.filter((_, idx) => idx !== i));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
      {files.length > 0 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
          {files.map((f, i) => (
            <div key={i} style={{ position: 'relative', width: 40, height: 40, flexShrink: 0 }}>
              <img src={f.url} alt={f.name} style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 6, border: '1px solid var(--border-soft)', display: 'block' }}/>
              <button
                onMouseDown={e => e.stopPropagation()}
                onClick={() => remove(i)}
                style={{ position: 'absolute', top: -4, right: -4, width: 15, height: 15, borderRadius: 999, border: 0, background: 'var(--bg-surface)', boxShadow: '0 1px 3px rgba(0,0,0,.3)', cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0, color: 'var(--text-faint)' }}>
                <Icon name="close" size={8}/>
              </button>
            </div>
          ))}
        </div>
      )}
      {canAdd ? (
        <label
          onDragOver={e => { e.preventDefault(); setIsDrop(true); }}
          onDragLeave={() => setIsDrop(false)}
          onDrop={e => { e.preventDefault(); setIsDrop(false); handleFiles(e.dataTransfer.files); }}
          style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderRadius: 8, cursor: 'pointer', transition: 'all 120ms', border: `1.5px dashed ${isDrop ? 'var(--accent)' : 'var(--border)'}`, background: isDrop ? 'var(--accent-soft)' : 'var(--bg-soft)' }}>
          <input ref={inputRef} type="file" accept={accept || 'image/*'} multiple hidden
                 onChange={e => { handleFiles(e.target.files); e.target.value = ''; }}/>
          <Icon name="upload" size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }}/>
          <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
            {files.length === 0 ? 'Drop or click to browse' : `Add more · ${files.length}/${limit}`}
          </span>
        </label>
      ) : (
        <div style={{ fontSize: 10, color: 'var(--text-faint)', textAlign: 'center', padding: '4px 0' }}>
          {limit}/{limit} images · remove one to add more
        </div>
      )}
    </div>
  );
};

// Compact 2-row textarea for node card inline inputs
const CompactTextareaField = ({ value, onChange, placeholder }) => {
  const [foc, setFoc] = React.useState(false);
  return (
    <textarea value={value ?? ''} placeholder={placeholder ?? 'Type here…'} rows={2}
      onFocus={() => setFoc(true)} onBlur={() => setFoc(false)}
      onChange={e => onChange(e.target.value)}
      onKeyDown={e => e.stopPropagation()}
      style={{
        width: '100%', padding: '6px 8px', borderRadius: 7, boxSizing: 'border-box',
        border: `1px solid ${foc ? 'var(--accent)' : 'var(--border)'}`,
        background: 'var(--bg-soft)', color: 'var(--text)', fontSize: 11,
        fontFamily: 'inherit', outline: 'none', resize: 'vertical', lineHeight: 1.45,
        boxShadow: foc ? '0 0 0 3px var(--accent-glow)' : 'none',
        transition: 'border-color 120ms, box-shadow 120ms',
      }}/>
  );
};

// Helper: build a node from the library defaults — used for the initial graph
// AND when adding a new node via the library.
// portFields are appended to the fields array so they survive a custom `fields` override.
const buildNode = (id, type, libMeta, x, y, overrides = {}) => {
  const def = NODE_DEFAULTS[type];
  const portFieldArr = def.portFields ? Object.values(def.portFields).map(pf => ({ ...pf })) : [];
  const portFieldMap = def.portFields
    ? Object.fromEntries(Object.entries(def.portFields).map(([port, pf]) => [port, pf.k]))
    : {};
  const { fields: overrideFields, ...restOverrides } = overrides;
  const baseFields = overrideFields ?? def.fields.map(f => ({ ...f }));
  return {
    id, type, label: libMeta.label, icon: libMeta.icon, color: libMeta.color,
    x, y, w: def.w,
    in:  def.in.slice(),
    out: def.out.slice(),
    fields: [...baseFields, ...portFieldArr],
    portFieldMap,
    ...restOverrides,
  };
};
const META = {
  'trigger-schedule': { label: 'Schedule',      icon: 'clock',    color: '#F97316' },
  'trigger-ad-saved': { label: 'Ad saved to…', icon: 'bookmark', color: '#F97316' },
  prompt:      { label: 'Text prompt',       icon: 'sparkles',   color: '#335CFF' },
  'image-in':  { label: 'Image file',        icon: 'image',      color: '#335CFF' },
  'video-in':  { label: 'Video / Audio',     icon: 'video',      color: '#335CFF' },
  'ad-in':     { label: 'Saved ad',          icon: 'bookmark',   color: '#335CFF' },
  'brand-in':  { label: 'Brand voice',       icon: 'building',   color: '#335CFF' },
  'img-gen':   { label: 'AI Image',          icon: 'image-ai',   color: '#7B5BFF' },
  'vid-gen':   { label: 'AI Video',          icon: 'video',      color: '#7B5BFF' },
  'copy-gen':  { label: 'Chat AI',           icon: 'chat',       color: '#7B5BFF' },
  'transcribe':{ label: 'Transcription',     icon: 'mic-line',   color: '#7B5BFF' },
  'bg-remove': { label: 'Background remove', icon: 'bg-remove', color: '#10B981' },
  'save':      { label: 'Save to folder',    icon: 'bookmark',   color: '#F59E0B' },
  'export':    { label: 'Export',            icon: 'download',   color: '#F59E0B' },
};

// Initial demo graph — two real pipelines laid out left → right
//
// Pipeline A (image): Prompt + Image → AI Image → BG Remove → Export
// Pipeline B (text):  Video → Transcription → Chat AI → Save
const INITIAL_NODES = [
  // ── Pipeline A ──────────────────────────────────────────────────────────────
  buildNode('n1', 'prompt',    META.prompt,   32, 40, {
    fields: [{ k:'value', label:'Prompt', type:'textarea',
      v:'Soft-lit ceramic skincare bottle on a beige linen surface, morning light, editorial.',
      placeholder:'Describe what you want…' }],
  }),
  buildNode('n2', 'image-in',  META['image-in'],  32, 250),
  buildNode('n3', 'img-gen',   META['img-gen'],  340, 110, {
    fields: NODE_DEFAULTS['img-gen'].fields.map(f =>
      f.k === 'aspect' ? { ...f, v: '4:3' } :
      f.k === 'count'  ? { ...f, v: 2 }     : { ...f }),
  }),
  buildNode('n4', 'bg-remove', META['bg-remove'], 660, 90),
  buildNode('n5', 'export',    META['export'],    930, 160),
  // ── Pipeline B ──────────────────────────────────────────────────────────────
  buildNode('n6', 'video-in',   META['video-in'],   32, 470),
  buildNode('n7', 'transcribe', META['transcribe'], 340, 430),
  buildNode('n8', 'copy-gen',   META['copy-gen'],   640, 370, {
    fields: NODE_DEFAULTS['copy-gen'].fields.map(f =>
      f.k === 'task' ? { ...f, v: 'Analysis' } : { ...f }),
  }),
  buildNode('n9', 'save',       META['save'],       930, 430),
];

const INITIAL_EDGES = [
  // Pipeline A
  { id: 'e1', from: 'n1', to: 'n3', fromPort: 'out', toPort: 'prompt' },
  { id: 'e2', from: 'n2', to: 'n3', fromPort: 'out', toPort: 'reference' },
  { id: 'e3', from: 'n3', to: 'n4', fromPort: 'out', toPort: 'image' },
  { id: 'e4', from: 'n4', to: 'n5', fromPort: 'out', toPort: 'content' },
  // Pipeline B
  { id: 'e5', from: 'n6', to: 'n7', fromPort: 'out', toPort: 'media' },
  { id: 'e6', from: 'n7', to: 'n8', fromPort: 'out', toPort: 'context' },
  { id: 'e7', from: 'n8', to: 'n9', fromPort: 'out', toPort: 'content' },
];

const NODE_HEADER_H = 36;
const NODE_PORT_GAP = 26;

const WorkflowV3 = ({ onBack, workflowId }) => {
  const [nodes, setNodes]       = React.useState(workflowId ? INITIAL_NODES : []);
  const [edges, setEdges]       = React.useState(workflowId ? INITIAL_EDGES : []);
  const [selectedIds, setSelectedIds] = React.useState(new Set());
  const [running, setRunning]   = React.useState(false);
  const [zoom, setZoom]         = React.useState(1);
  const [openLib, setOpenLib]   = React.useState(true);
  const [libSearch, setLibSearch] = React.useState('');
  const [showTemplates, setShowTemplates] = React.useState(false);
  const [wfId, setWfId]         = React.useState(workflowId || null);
  const [wfName, setWfName]     = React.useState('Untitled workflow');
  const [saveStatus, setSaveStatus] = React.useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
  const [editingName, setEditingName] = React.useState(false);
  const [runResults, setRunResults] = React.useState(new Map()); // nodeId → { status, output, preview }
  const [drag, setDrag]         = React.useState(null); // { ids }
  const [panning, setPanning]   = React.useState(false);
  const [pan, setPan]           = React.useState({ x: 0, y: 0 });
  const [connecting, setConnecting] = React.useState(null); // { from, mouseX, mouseY }
  const [hoverEdge, setHoverEdge]   = React.useState(null);
  const [nodeMenu, setNodeMenu]     = React.useState(null); // { id, x, y }
  const [boxSel, setBoxSel]         = React.useState(null); // { x1,y1,x2,y2 } in canvas coords
  const [spacePan, setSpacePan]     = React.useState(false);
  const [tool, setTool]             = React.useState('grab'); // 'grab' | 'select' | 'text' | 'sticky' | 'shape' | 'pen'
  const [annColor, setAnnColor]     = React.useState('#FFCB5C');
  const [annotations, setAnnotations] = React.useState([]);
  const [selectedAnnIds, setSelectedAnnIds] = React.useState(new Set());
  const [editingAnnId, setEditingAnnId]     = React.useState(null);
  const [shapePreview, setShapePreview]     = React.useState(null); // { x,y,w,h }
  const [colorOpen, setColorOpen]           = React.useState(false);
  const [guides, setGuides]                 = React.useState({ x: null, y: null });
  const viewportRef             = React.useRef(null);
  const spaceHeldRef            = React.useRef(false);
  // Measured DOM heights for nodes (canvas-coord px). Updated via callback ref
  // on each render — guarantees snap guides match the actually-rendered bottom.
  const nodeHeightsRef          = React.useRef(new Map());

  // ── Undo / Redo ──────────────────────────────────────────────────────────────
  // History stores { nodes, edges, annotations } snapshots (after-states).
  const historyRef      = React.useRef([{ nodes: workflowId ? INITIAL_NODES : [], edges: workflowId ? INITIAL_EDGES : [], annotations: [] }]);
  const historyIndexRef = React.useRef(0);

  const pushHistory = (newNodes, newEdges, newAnnotations) => {
    const h = historyRef.current.slice(0, historyIndexRef.current + 1);
    h.push({ nodes: newNodes, edges: newEdges, annotations: newAnnotations });
    if (h.length > 100) h.shift();
    historyRef.current = h;
    historyIndexRef.current = h.length - 1;
  };

  const undo = () => {
    if (historyIndexRef.current <= 0) return;
    historyIndexRef.current--;
    const snap = historyRef.current[historyIndexRef.current];
    setNodes(snap.nodes);
    setEdges(snap.edges);
    setAnnotations(snap.annotations);
    setSelectedIds(new Set());
    setSelectedAnnIds(new Set());
  };

  const redo = () => {
    if (historyIndexRef.current >= historyRef.current.length - 1) return;
    historyIndexRef.current++;
    const snap = historyRef.current[historyIndexRef.current];
    setNodes(snap.nodes);
    setEdges(snap.edges);
    setAnnotations(snap.annotations);
    setSelectedIds(new Set());
    setSelectedAnnIds(new Set());
  };

  // Stable refs so the keyboard listener (registered once) always calls fresh functions
  const undoRef = React.useRef(null);
  const redoRef = React.useRef(null);
  undoRef.current = undo;
  redoRef.current = redo;

  // ── Load workflow from DB ────────────────────────────────────────────────────
  const suppressSaveRef = React.useRef(false);

  React.useEffect(() => {
    if (!workflowId) return;
    suppressSaveRef.current = true;
    apiFetch(`/workflows/${workflowId}`).then(wf => {
      if (!wf) return;
      setWfId(wf._id);
      setWfName(wf.name || 'Untitled workflow');
      const ns = wf.nodes || [];
      const es = wf.edges || [];
      const as = wf.annotations || [];
      setNodes(ns);
      setEdges(es);
      setAnnotations(as);
      historyRef.current = [{ nodes: ns, edges: es, annotations: as }];
      historyIndexRef.current = 0;
      setSelectedIds(new Set());
    }).catch(() => {}).finally(() => {
      setTimeout(() => { suppressSaveRef.current = false; }, 100);
    });
  }, [workflowId]);

  // ── Compute mini preview from current nodes/edges ───────────────────────────
  const computePreview = (ns, es) => {
    if (!ns || ns.length === 0) return { nodes: [], edges: [] };
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const n of ns) {
      minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
      maxX = Math.max(maxX, n.x + (n.w || 240)); maxY = Math.max(maxY, n.y + 80);
    }
    const W = 284, H = 132, PAD = 16;
    const scale = Math.min((W - PAD*2) / Math.max(maxX - minX, 1), (H - PAD*2) / Math.max(maxY - minY, 1));
    const tx = (v) => PAD + (v - minX) * scale;
    const ty = (v) => PAD + (v - minY) * scale;
    const typeColor = {
      'prompt':'#335CFF','image-in':'#335CFF','video-in':'#335CFF','ad-in':'#335CFF','brand-in':'#335CFF',
      'img-gen':'#7B5BFF','vid-gen':'#7B5BFF','copy-gen':'#7B5BFF','transcribe':'#7B5BFF',
      'bg-remove':'#10B981','save':'#F59E0B','export':'#F59E0B',
      'trigger-schedule':'#F97316','trigger-ad-saved':'#F97316',
    };
    const pNodes = ns.map(n => ({ x: tx(n.x), y: ty(n.y), w: Math.max(24, (n.w||240)*scale), h: Math.max(14, 28*scale), color: typeColor[n.type] || '#94A3B8' }));
    const nodeMap = {};
    for (const n of ns) nodeMap[n.id] = n;
    const pEdges = (es||[]).map(e => {
      const f = nodeMap[e.from], t = nodeMap[e.to];
      if (!f || !t) return null;
      return { x1: tx(f.x + (f.w||240)), y1: ty(f.y+40), x2: tx(t.x), y2: ty(t.y+40) };
    }).filter(Boolean);
    return { nodes: pNodes, edges: pEdges };
  };

  // ── Save workflow to DB ──────────────────────────────────────────────────────

  // ── Export workflow as JSON ──────────────────────────────────────────────────
  const exportJson = () => {
    const data = JSON.stringify({ name: wfName, nodes, edges, annotations }, null, 2);
    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${wfName.replace(/[^a-z0-9]/gi, '_')}.json`;
    a.click();
    URL.revokeObjectURL(url);
  };

  // Derived: when exactly one node is selected, expose it as `selected` for the inspector
  const selectedId = selectedIds.size === 1 ? [...selectedIds][0] : null;
  const selected   = selectedId ? nodes.find(n => n.id === selectedId) : null;

  const setSingleSelection = (id) => setSelectedIds(id ? new Set([id]) : new Set());

  // Inject a global rule once to hide WebKit scrollbars on .wf-no-scrollbar elements.
  // (Inline style cannot target the ::-webkit-scrollbar pseudo-element.)
  React.useEffect(() => {
    if (document.getElementById('wf-no-scrollbar-style')) return;
    const style = document.createElement('style');
    style.id = 'wf-no-scrollbar-style';
    style.textContent = `.wf-no-scrollbar::-webkit-scrollbar { width: 0 !important; height: 0 !important; display: none; }`;
    document.head.appendChild(style);
  }, []);

  // Track Space-key for pan mode (Figma-style)
  React.useEffect(() => {
    const onDown = (e) => {
      if (e.code !== 'Space') return;
      const tag = (e.target?.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
      if (!spaceHeldRef.current) {
        spaceHeldRef.current = true;
        setSpacePan(true);
      }
      e.preventDefault();
    };
    const onUp = (e) => {
      if (e.code !== 'Space') return;
      spaceHeldRef.current = false;
      setSpacePan(false);
    };
    window.addEventListener('keydown', onDown);
    window.addEventListener('keyup',   onUp);
    return () => {
      window.removeEventListener('keydown', onDown);
      window.removeEventListener('keyup',   onUp);
    };
  }, []);

  // Real DOM-measured height (set via callback ref). Falls back to a rough
  // estimate before the node has been measured for the first time.
  const nodeHeight = (n) => {
    const measured = nodeHeightsRef.current.get(n.id);
    if (measured) return measured;
    const portsIn = (n.in?.length || 0);
    const fields  = (n.fields?.length || 0);
    return NODE_HEADER_H + 22 + portsIn * NODE_PORT_GAP + fields * 22;
  };

  // Port positions for SVG edges
  const portXY = (nodeId, port, side) => {
    const n = nodes.find(nn => nn.id === nodeId);
    if (!n) return { x: 0, y: 0 };
    // side='out' or side='in' avoids ambiguity when a port name (e.g. 'image')
    // appears in both n.in and n.out (like bg-remove node).
    const isOut = side
      ? side === 'out'
      : (port === 'out' || (n.out || []).includes(port));
    if (isOut) {
      return { x: n.x + n.w, y: n.y + NODE_HEADER_H + 18 };
    }
    const inPorts = n.in || ['in'];
    const idx = Math.max(0, inPorts.indexOf(port));
    return { x: n.x, y: n.y + NODE_HEADER_H + 18 + idx * NODE_PORT_GAP };
  };

  // Right-click on empty canvas → switch back to "grab" tool
  const onCanvasContextMenu = (e) => {
    e.preventDefault();
    setTool('grab');
  };

  const onCanvasMouseDown = (e) => {
    const isMiddle = e.button === 1;
    const isLeft   = e.button === 0;
    if (!isMiddle && !isLeft) return;

    // Always cancel inline text edit on canvas click
    if (editingAnnId) setEditingAnnId(null);

    // Annotation creation tools — preempt pan/select
    if (isLeft && (tool === 'text' || tool === 'sticky')) {
      e.preventDefault();
      const p = screenToCanvas(e.clientX, e.clientY);
      const id = newAnnId();
      const ann = tool === 'text'
        ? { id, type: 'text',   x: p.x, y: p.y, w: 220, h: 40,  text: '', color: annColor, size: 16, align: 'left' }
        : { id, type: 'sticky', x: p.x - 80, y: p.y - 60, w: 180, h: 140, text: '', color: annColor, size: 14, align: 'left' };
      const newAnnotations = [...annotations, ann];
      setAnnotations(newAnnotations);
      pushHistory(nodes, edges, newAnnotations);
      setSelectedAnnIds(new Set([id]));
      setSelectedIds(new Set());
      setEditingAnnId(id);
      setTool('select');
      return;
    }

    if (isLeft && tool === 'shape') {
      e.preventDefault();
      const start = screenToCanvas(e.clientX, e.clientY);
      setShapePreview({ x: start.x, y: start.y, w: 0, h: 0 });
      const onMove = (ev) => {
        const p = screenToCanvas(ev.clientX, ev.clientY);
        setShapePreview({
          x: Math.min(start.x, p.x),
          y: Math.min(start.y, p.y),
          w: Math.abs(p.x - start.x),
          h: Math.abs(p.y - start.y),
        });
      };
      const onUp = (ev) => {
        const p = screenToCanvas(ev.clientX, ev.clientY);
        const w = Math.abs(p.x - start.x);
        const h = Math.abs(p.y - start.y);
        if (w > 8 && h > 8) {
          const id = newAnnId();
          const newAnn = { id, type: 'shape', x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), w, h, color: annColor };
          const newAnnotations = [...annotations, newAnn];
          setAnnotations(newAnnotations);
          pushHistory(nodes, edges, newAnnotations);
          setSelectedAnnIds(new Set([id]));
          setSelectedIds(new Set());
        }
        setShapePreview(null);
        setTool('select');
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
      return;
    }

    if (isLeft && tool === 'pen') {
      e.preventDefault();
      const start = screenToCanvas(e.clientX, e.clientY);
      const id = newAnnId();
      const startAnn = { id, type: 'pen', points: [start], color: annColor, width: 2.5 };
      setAnnotations(a => [...a, startAnn]);
      let penPoints = [start];
      const onMove = (ev) => {
        const p = screenToCanvas(ev.clientX, ev.clientY);
        penPoints = [...penPoints, p];
        setAnnotations(a => a.map(x => x.id === id ? { ...x, points: penPoints } : x));
      };
      const onUp = () => {
        // Push state after stroke is complete (annotations pre-stroke + this completed stroke)
        const newAnnotations = [...annotations, { ...startAnn, points: penPoints }];
        pushHistory(nodes, edges, newAnnotations);
        // Pen stays active for multiple strokes — does NOT auto-switch
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
      return;
    }

    // Pan: middle mouse, OR left + Space/Ctrl/Meta, OR left when tool === 'grab'
    const wantPan = isMiddle
      || (isLeft && (spaceHeldRef.current || e.ctrlKey || e.metaKey))
      || (isLeft && tool === 'grab');

    e.preventDefault();
    const startX = e.clientX, startY = e.clientY;

    if (wantPan) {
      setPanning(true);
      const origPan = pan;
      const onMove = (ev) => {
        setPan({
          x: origPan.x + (ev.clientX - startX),
          y: origPan.y + (ev.clientY - startY),
        });
      };
      const onUp = () => {
        setPanning(false);
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
      return;
    }

    // Otherwise: box-selection (left-drag on empty canvas)
    const additive = e.shiftKey;
    const baseSelection    = additive ? new Set(selectedIds)    : new Set();
    const baseAnnSelection = additive ? new Set(selectedAnnIds) : new Set();
    if (!additive) { setSelectedIds(new Set()); setSelectedAnnIds(new Set()); }
    const start = screenToCanvas(e.clientX, e.clientY);
    setBoxSel({ x1: start.x, y1: start.y, x2: start.x, y2: start.y });

    const onMove = (ev) => {
      const p = screenToCanvas(ev.clientX, ev.clientY);
      setBoxSel(b => b ? { ...b, x2: p.x, y2: p.y } : null);
    };
    const onUp = (ev) => {
      const end = screenToCanvas(ev.clientX, ev.clientY);
      const dxScreen = ev.clientX - startX;
      const dyScreen = ev.clientY - startY;
      const moved = Math.abs(dxScreen) > 4 || Math.abs(dyScreen) > 4;
      if (moved) {
        const xMin = Math.min(start.x, end.x);
        const xMax = Math.max(start.x, end.x);
        const yMin = Math.min(start.y, end.y);
        const yMax = Math.max(start.y, end.y);
        const within = nodes.filter(n => {
          const nx2 = n.x + n.w;
          const ny2 = n.y + nodeHeight(n);
          return n.x < xMax && nx2 > xMin && n.y < yMax && ny2 > yMin;
        }).map(n => n.id);
        const annWithin = annotations.filter(a => {
          const b = annBounds(a);
          return b.x < xMax && (b.x + b.w) > xMin && b.y < yMax && (b.y + b.h) > yMin;
        }).map(a => a.id);
        const next    = new Set(baseSelection);    within.forEach(id => next.add(id));
        const nextAnn = new Set(baseAnnSelection); annWithin.forEach(id => nextAnn.add(id));
        setSelectedIds(next);
        setSelectedAnnIds(nextAnn);
      }
      setBoxSel(null);
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // ----- Snap-to-other-elements during single-element drag -----
  // Returns { dx, dy, gx, gy } — adjusted delta and the guide axis to draw (canvas coords)
  const SNAP_PX = 6; // canvas-space tolerance
  const computeSnap = (proposedX, proposedY, w, h, others) => {
    const me = {
      l: proposedX, r: proposedX + w, cx: proposedX + w / 2,
      t: proposedY, b: proposedY + h, cy: proposedY + h / 2,
    };
    let bestDX = SNAP_PX + 1, bestDY = SNAP_PX + 1;
    let snapX = proposedX, snapY = proposedY, gx = null, gy = null;
    for (const o of others) {
      const oo = {
        l: o.x, r: o.x + o.w, cx: o.x + o.w / 2,
        t: o.y, b: o.y + o.h, cy: o.y + o.h / 2,
      };
      // Horizontal alignments (X axis): match left↔left, right↔right, cx↔cx, l↔r, etc.
      for (const myK of ['l', 'r', 'cx']) {
        for (const oK of ['l', 'r', 'cx']) {
          const diff = oo[oK] - me[myK];
          if (Math.abs(diff) < bestDX) {
            bestDX = Math.abs(diff);
            snapX = proposedX + diff;
            gx = oo[oK];
          }
        }
      }
      for (const myK of ['t', 'b', 'cy']) {
        for (const oK of ['t', 'b', 'cy']) {
          const diff = oo[oK] - me[myK];
          if (Math.abs(diff) < bestDY) {
            bestDY = Math.abs(diff);
            snapY = proposedY + diff;
            gy = oo[oK];
          }
        }
      }
    }
    return { x: snapX, y: snapY, gx: bestDX <= SNAP_PX ? gx : null, gy: bestDY <= SNAP_PX ? gy : null };
  };

  // Build the list of static "others" rectangles to snap against
  const buildSnapOthers = (excludeNodeIds, excludeAnnIds) => {
    const ex1 = new Set(excludeNodeIds || []);
    const ex2 = new Set(excludeAnnIds  || []);
    const out = [];
    nodes.forEach(n => { if (!ex1.has(n.id)) out.push({ x: n.x, y: n.y, w: n.w, h: nodeHeight(n) }); });
    annotations.forEach(a => {
      if (ex2.has(a.id)) return;
      if (a.type === 'pen') return;
      out.push({ x: a.x, y: a.y, w: a.w, h: a.h });
    });
    return out;
  };

  // Bounding rect for an annotation (used by box-select hit-testing & rendering)
  const annBounds = (a) => {
    if (a.type === 'pen') {
      if (!a.points.length) return { x: a.x ?? 0, y: a.y ?? 0, w: 0, h: 0 };
      let xMin = Infinity, yMin = Infinity, xMax = -Infinity, yMax = -Infinity;
      a.points.forEach(p => {
        if (p.x < xMin) xMin = p.x; if (p.x > xMax) xMax = p.x;
        if (p.y < yMin) yMin = p.y; if (p.y > yMax) yMax = p.y;
      });
      return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
    }
    return { x: a.x, y: a.y, w: a.w, h: a.h };
  };

  // Annotation drag — moves selected annotation(s)
  const onAnnMouseDown = (e, ann) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    if (tool !== 'select') setTool('select');
    setEditingAnnId(null);

    if (e.shiftKey) {
      setSelectedAnnIds(s => {
        const next = new Set(s);
        if (next.has(ann.id)) next.delete(ann.id); else next.add(ann.id);
        return next;
      });
      return;
    }

    let activeIds;
    if (selectedAnnIds.has(ann.id) && selectedAnnIds.size > 1) {
      activeIds = [...selectedAnnIds];
    } else {
      setSelectedAnnIds(new Set([ann.id]));
      setSelectedIds(new Set());
      activeIds = [ann.id];
    }

    const startX = e.clientX, startY = e.clientY;
    const orig = new Map();
    activeIds.forEach(id => {
      const a = annotations.find(x => x.id === id);
      if (!a) return;
      if (a.type === 'pen') {
        orig.set(id, { points: a.points.map(p => ({ ...p })) });
      } else {
        orig.set(id, { x: a.x, y: a.y });
      }
    });

    // Snap when dragging a single non-pen annotation
    const moverAnn = (activeIds.length === 1) ? annotations.find(x => x.id === activeIds[0]) : null;
    const snapEnabled = moverAnn && moverAnn.type !== 'pen';
    const snapOthers = snapEnabled ? buildSnapOthers([], activeIds) : [];

    let annFinalDx = 0, annFinalDy = 0;
    const onMove = (ev) => {
      let dx = (ev.clientX - startX) / zoom;
      let dy = (ev.clientY - startY) / zoom;
      if (snapEnabled) {
        const o = orig.get(moverAnn.id);
        const snap = computeSnap(o.x + dx, o.y + dy, moverAnn.w, moverAnn.h, snapOthers);
        dx = snap.x - o.x;
        dy = snap.y - o.y;
        setGuides({ x: snap.gx, y: snap.gy });
      }
      annFinalDx = dx; annFinalDy = dy;
      setAnnotations(as => as.map(a => {
        const o = orig.get(a.id);
        if (!o) return a;
        if (a.type === 'pen') {
          return { ...a, points: o.points.map(p => ({ x: p.x + dx, y: p.y + dy })) };
        }
        return { ...a, x: o.x + dx, y: o.y + dy };
      }));
    };
    const onUp = () => {
      if (annFinalDx !== 0 || annFinalDy !== 0) {
        const newAnnotations = annotations.map(a => {
          const o = orig.get(a.id);
          if (!o) return a;
          if (a.type === 'pen') {
            return { ...a, points: o.points.map(p => ({ x: p.x + annFinalDx, y: p.y + annFinalDy })) };
          }
          return { ...a, x: o.x + annFinalDx, y: o.y + annFinalDy };
        });
        pushHistory(nodes, edges, newAnnotations);
      }
      setGuides({ x: null, y: null });
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  const updateAnn = (id, patch) =>
    setAnnotations(as => as.map(a => a.id === id ? { ...a, ...patch } : a));

  const deleteAnnotations = (ids) => {
    const newAnnotations = annotations.filter(a => !ids.includes(a.id));
    setAnnotations(newAnnotations);
    setSelectedAnnIds(s => {
      const n = new Set(s); ids.forEach(id => n.delete(id)); return n;
    });
    pushHistory(nodes, edges, newAnnotations);
  };

  // Resize handle drag — handle is one of: nw n ne e se s sw w
  // Corner handles on text/sticky also scale the font size proportionally.
  const onResizeHandle = (e, ann, handle) => {
    e.stopPropagation();
    e.preventDefault();
    const startX = e.clientX, startY = e.clientY;
    const orig = {
      x: ann.x, y: ann.y, w: ann.w, h: ann.h,
      size: ann.size ?? (ann.type === 'sticky' ? 14 : 16),
    };
    const MIN = 24;
    const isCorner = handle.length === 2; // nw ne se sw
    const isText = ann.type === 'text' || ann.type === 'sticky';

    let resizeFinalPatch = null;
    const onMove = (ev) => {
      const dx = (ev.clientX - startX) / zoom;
      const dy = (ev.clientY - startY) / zoom;
      let { x, y, w, h } = orig;
      if (handle.includes('e')) w = Math.max(MIN, orig.w + dx);
      if (handle.includes('s')) h = Math.max(MIN, orig.h + dy);
      if (handle.includes('w')) {
        const nw = Math.max(MIN, orig.w - dx);
        x = orig.x + (orig.w - nw);
        w = nw;
      }
      if (handle.includes('n')) {
        const nh = Math.max(MIN, orig.h - dy);
        y = orig.y + (orig.h - nh);
        h = nh;
      }
      const patch = { x, y, w, h };
      if (isCorner && isText) {
        const scale = (w / orig.w + h / orig.h) / 2;
        patch.size = Math.max(8, Math.min(200, Math.round(orig.size * scale)));
      }
      resizeFinalPatch = patch;
      updateAnn(ann.id, patch);
    };
    const onUp = () => {
      if (resizeFinalPatch) {
        const newAnnotations = annotations.map(a => a.id === ann.id ? { ...a, ...resizeFinalPatch } : a);
        pushHistory(nodes, edges, newAnnotations);
      }
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // Render the 8 resize handles around a selected resizable annotation
  const ResizeHandles = ({ ann }) => {
    const cs = {
      nw: 'nwse-resize', n:  'ns-resize',  ne: 'nesw-resize',
      e:  'ew-resize',                    w:  'ew-resize',
      sw: 'nesw-resize', s:  'ns-resize',  se: 'nwse-resize',
    };
    const dot = (cx, cy, h) => ({
      position: 'absolute', left: cx - 5, top: cy - 5,
      width: 10, height: 10, borderRadius: 3,
      background: 'var(--bg-surface)',
      border: '1.5px solid var(--accent)',
      cursor: cs[h], zIndex: 10,
    });
    return (
      <>
        <span style={dot(ann.x,             ann.y,            'nw')} onMouseDown={(e) => onResizeHandle(e, ann, 'nw')}/>
        <span style={dot(ann.x + ann.w / 2, ann.y,            'n' )} onMouseDown={(e) => onResizeHandle(e, ann, 'n' )}/>
        <span style={dot(ann.x + ann.w,     ann.y,            'ne')} onMouseDown={(e) => onResizeHandle(e, ann, 'ne')}/>
        <span style={dot(ann.x + ann.w,     ann.y + ann.h / 2,'e' )} onMouseDown={(e) => onResizeHandle(e, ann, 'e' )}/>
        <span style={dot(ann.x + ann.w,     ann.y + ann.h,    'se')} onMouseDown={(e) => onResizeHandle(e, ann, 'se')}/>
        <span style={dot(ann.x + ann.w / 2, ann.y + ann.h,    's' )} onMouseDown={(e) => onResizeHandle(e, ann, 's' )}/>
        <span style={dot(ann.x,             ann.y + ann.h,    'sw')} onMouseDown={(e) => onResizeHandle(e, ann, 'sw')}/>
        <span style={dot(ann.x,             ann.y + ann.h / 2,'w' )} onMouseDown={(e) => onResizeHandle(e, ann, 'w' )}/>
      </>
    );
  };

  const onNodeMouseDown = (e, n) => {
    if (e.button !== 0) return;
    e.stopPropagation();

    // Touching a node auto-switches the canvas to "select" mode
    if (tool !== 'select') setTool('select');

    // Shift+click toggles in selection (no drag)
    if (e.shiftKey) {
      setSelectedIds(s => {
        const next = new Set(s);
        if (next.has(n.id)) next.delete(n.id); else next.add(n.id);
        return next;
      });
      return;
    }

    // Determine the active drag set: if clicked node is part of multi-selection,
    // drag all selected nodes together; otherwise select-only-this and drag.
    let activeIds;
    if (selectedIds.has(n.id) && selectedIds.size > 1) {
      activeIds = [...selectedIds];
    } else {
      setSelectedIds(new Set([n.id]));
      setSelectedAnnIds(new Set());
      activeIds = [n.id];
    }

    const startX = e.clientX, startY = e.clientY;
    const origPositions = new Map();
    activeIds.forEach(id => {
      const node = nodes.find(nn => nn.id === id);
      if (node) origPositions.set(id, { x: node.x, y: node.y });
    });
    setDrag({ ids: activeIds });

    // Snap is enabled only when dragging a single node (not a group)
    const snapEnabled = activeIds.length === 1;
    const moverNode = snapEnabled ? nodes.find(x => x.id === activeIds[0]) : null;
    const snapOthers = snapEnabled ? buildSnapOthers(activeIds, []) : [];

    let finalDx = 0, finalDy = 0;
    const onMove = (ev) => {
      let dx = (ev.clientX - startX) / zoom;
      let dy = (ev.clientY - startY) / zoom;
      if (snapEnabled && moverNode) {
        const orig = origPositions.get(moverNode.id);
        const snap = computeSnap(orig.x + dx, orig.y + dy, moverNode.w, nodeHeight(moverNode), snapOthers);
        dx = snap.x - orig.x;
        dy = snap.y - orig.y;
        setGuides({ x: snap.gx, y: snap.gy });
      }
      finalDx = dx; finalDy = dy;
      setNodes(ns => ns.map(nn => {
        const orig = origPositions.get(nn.id);
        return orig ? { ...nn, x: orig.x + dx, y: orig.y + dy } : nn;
      }));
    };
    const onUp = () => {
      if (finalDx !== 0 || finalDy !== 0) {
        const newNodes = nodes.map(nn => {
          const orig = origPositions.get(nn.id);
          return orig ? { ...nn, x: orig.x + finalDx, y: orig.y + finalDy } : nn;
        });
        pushHistory(newNodes, edges, annotations);
      }
      setDrag(null);
      setGuides({ x: null, y: null });
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // Convert a screen-space point to canvas (workflow) coordinates
  const screenToCanvas = (clientX, clientY) => {
    const vp = viewportRef.current?.getBoundingClientRect();
    if (!vp) return { x: 0, y: 0 };
    return {
      x: (clientX - vp.left - pan.x) / zoom,
      y: (clientY - vp.top  - pan.y) / zoom,
    };
  };

  // Add a new node at the visible viewport center
  const addNodeFromLibrary = (lib) => {
    const def = NODE_DEFAULTS[lib.type] || { w: 220, in: [], out: [], fields: [] };
    const vp = viewportRef.current?.getBoundingClientRect();
    const cx = vp ? (vp.width / 2 - pan.x) / zoom : 200;
    const cy = vp ? (vp.height / 2 - pan.y) / zoom : 200;
    const off = (nodes.length % 6) * 18;
    const id = newNodeId();
    const portFieldArr = def.portFields ? Object.values(def.portFields).map(pf => ({ ...pf })) : [];
    const portFieldMap = def.portFields
      ? Object.fromEntries(Object.entries(def.portFields).map(([port, pf]) => [port, pf.k]))
      : {};
    const newNodes = [...nodes, {
      id, type: lib.type, label: lib.label, icon: lib.icon, color: lib.color,
      x: cx - def.w / 2 + off, y: cy - 50 + off, w: def.w,
      in: def.in.slice(), out: def.out.slice(),
      fields: [...def.fields.map(f => ({ ...f })), ...portFieldArr],
      portFieldMap,
    }];
    setNodes(newNodes);
    pushHistory(newNodes, edges, annotations);
    setSingleSelection(id);
  };

  const deleteNode = (id) => {
    const newNodes = nodes.filter(n => n.id !== id);
    const newEdges = edges.filter(e => e.from !== id && e.to !== id);
    setNodes(newNodes);
    setEdges(newEdges);
    setSelectedIds(s => { const n = new Set(s); n.delete(id); return n; });
    pushHistory(newNodes, newEdges, annotations);
  };

  const deleteSelected = () => {
    if (selectedIds.size === 0) return;
    const ids = selectedIds;
    const newNodes = nodes.filter(n => !ids.has(n.id));
    const newEdges = edges.filter(e => !ids.has(e.from) && !ids.has(e.to));
    setNodes(newNodes);
    setEdges(newEdges);
    setSelectedIds(new Set());
    pushHistory(newNodes, newEdges, annotations);
  };

  const deleteEdge = (id) => {
    const newEdges = edges.filter(e => e.id !== id);
    setEdges(newEdges);
    pushHistory(nodes, newEdges, annotations);
  };

  const duplicateNode = (id) => {
    const n = nodes.find(x => x.id === id);
    if (!n) return;
    const newId = newNodeId();
    const newNodes = [...nodes, {
      ...n,
      id: newId,
      x: n.x + 32, y: n.y + 32,
      in:  (n.in  || []).slice(),
      out: (n.out || []).slice(),
      fields: (n.fields || []).map(f => ({ ...f })),
    }];
    setNodes(newNodes);
    pushHistory(newNodes, edges, annotations);
    setSingleSelection(newId);
  };

  const disconnectNode = (id) => {
    const newEdges = edges.filter(e => e.from !== id && e.to !== id);
    setEdges(newEdges);
    pushHistory(nodes, newEdges, annotations);
  };

  const bringToFront = (id) => {
    setNodes(ns => {
      const n = ns.find(x => x.id === id);
      if (!n) return ns;
      return [...ns.filter(x => x.id !== id), n];
    });
  };

  const copyNodeJson = (id) => {
    const n = nodes.find(x => x.id === id);
    if (!n) return;
    try { navigator.clipboard?.writeText?.(JSON.stringify(n, null, 2)); } catch {}
  };

  const renameNode = (id) => {
    const n = nodes.find(x => x.id === id);
    if (!n) return;
    const next = window.prompt('Rename node', n.label);
    if (next != null && next.trim()) {
      const newNodes = nodes.map(x => x.id === id ? { ...x, label: next.trim() } : x);
      setNodes(newNodes);
      pushHistory(newNodes, edges, annotations);
    }
  };

  const openNodeMenu = (e, n) => {
    e.stopPropagation();
    e.preventDefault();
    const r = e.currentTarget.getBoundingClientRect();
    if (!selectedIds.has(n.id)) setSingleSelection(n.id);
    setNodeMenu({ id: n.id, x: r.right - 4, y: r.bottom + 6 });
  };

  // Drag from an output port to an input port to create an edge
  const onPortDown = (e, fromNodeId) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    e.preventDefault();
    const start = screenToCanvas(e.clientX, e.clientY);
    const fromNode = nodes.find(nn => nn.id === fromNodeId);
    const fromOutPort = fromNode?.out?.[0] || 'out';
    setConnecting({ from: fromNodeId, fromOutPort, mouseX: start.x, mouseY: start.y });

    const onMove = (ev) => {
      const p = screenToCanvas(ev.clientX, ev.clientY);
      setConnecting(c => c ? { ...c, mouseX: p.x, mouseY: p.y } : null);
    };
    const onUp = (ev) => {
      const target = document.elementFromPoint(ev.clientX, ev.clientY);
      const portEl = target?.closest?.('[data-port-target]');
      if (portEl) {
        const toNodeId = portEl.getAttribute('data-port-node');
        const toPort   = portEl.getAttribute('data-port-name');
        if (toNodeId && toNodeId !== fromNodeId && portsCompatible(fromOutPort, toPort)) {
          const exists = edges.some(x => x.from === fromNodeId && x.to === toNodeId && x.toPort === toPort);
          if (!exists) {
            const newEdges = [...edges, { id: newEdgeId(), from: fromNodeId, to: toNodeId, fromPort: fromOutPort, toPort }];
            setEdges(newEdges);
            pushHistory(nodes, newEdges, annotations);
          }
        }
      }
      setConnecting(null);
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // ── Workflow execution engine ────────────────────────────────────────────────

  // Topological sort (Kahn's algorithm)
  const topoSort = (ns, es) => {
    const inDeg = {};
    const adj = {};
    for (const n of ns) { inDeg[n.id] = 0; adj[n.id] = []; }
    for (const e of es) {
      adj[e.from] = adj[e.from] || [];
      adj[e.from].push(e.to);
      inDeg[e.to] = (inDeg[e.to] || 0) + 1;
    }
    const queue = ns.filter(n => inDeg[n.id] === 0);
    const result = [];
    while (queue.length) {
      const n = queue.shift();
      result.push(n);
      for (const next of (adj[n.id] || [])) {
        inDeg[next]--;
        if (inDeg[next] === 0) queue.push(ns.find(x => x.id === next));
      }
    }
    return result.filter(Boolean);
  };

  // Get the upstream output value for a given node+port
  const getInput = (nodeId, portName, ctx, es) => {
    const edge = es.find(e => e.to === nodeId && e.toPort === portName);
    if (!edge) return null;
    const r = ctx.get(edge.from);
    return r ? r.output : null;
  };

  // Read a node field value by key
  const fv = (node, key) => (node.fields || []).find(f => f.k === key)?.v;

  // Parse SSE stream from image generation, returns first image URL
  const streamImageGen = (response) => new Promise((resolve, reject) => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    const read = async () => {
      while (true) {
        const { done, value } = await reader.read();
        if (done) { reject(new Error('Stream ended without result')); return; }
        buffer += decoder.decode(value, { stream: true });
        const parts = buffer.split('\n\n');
        buffer = parts.pop();
        for (const block of parts) {
          let eventName = 'message', dataStr = '';
          for (const line of block.split('\n')) {
            if (line.startsWith('event: ')) eventName = line.slice(7).trim();
            else if (line.startsWith('data: ')) dataStr = line.slice(6).trim();
          }
          if (!dataStr) continue;
          try {
            const data = JSON.parse(dataStr);
            if (eventName === 'result' && data.url) { resolve(data.url); return; }
            if (eventName === 'error') { reject(new Error(data.message || 'Generation error')); return; }
            if (eventName === 'result_error') { reject(new Error(data.message || 'Slot error')); return; }
          } catch {}
        }
      }
    };
    read().catch(reject);
  });

  // Execute a single node given the context
  const executeNode = async (node, es, ctx) => {
    const token = localStorage.getItem('1radar_token');
    const authHdr = { 'Authorization': `Bearer ${token}` };

    switch (node.type) {
      // ── Inputs: pass through their values ────────────────────────────────────
      case 'prompt':
        return { text: fv(node, 'value') || '' };

      case 'image-in': {
        const v = fv(node, 'value');
        if (!v) throw new Error('No image selected');
        return { url: v };
      }
      case 'video-in': {
        const v = fv(node, 'value');
        if (!v) throw new Error('No video selected');
        return { url: v };
      }
      case 'ad-in':
        return { adId: fv(node, 'value') };
      case 'brand-in':
        return { brandId: fv(node, 'value'), tone: fv(node, 'tone') };

      // ── Triggers: just pass through ───────────────────────────────────────────
      case 'trigger-schedule':
      case 'trigger-ad-saved':
        return { triggered: true };

      // ── AI Image generation ───────────────────────────────────────────────────
      case 'img-gen': {
        const promptIn = getInput(node.id, 'prompt', ctx, es);
        const refIn    = getInput(node.id, 'reference', ctx, es);
        const prompt   = (promptIn?.text) || fv(node, '_p_prompt') || '';
        if (!prompt) throw new Error('No prompt connected or set');
        const aspect  = fv(node, 'aspect') || '1:1';
        const quality = fv(node, 'quality') || '2K';
        const count   = fv(node, 'count') || 1;
        const form = new FormData();
        form.append('prompt', prompt);
        form.append('aspect_ratio', aspect);
        form.append('quality', quality);
        form.append('count', String(count));
        if (refIn?.url) {
          const blob = await fetch(refIn.url).then(r => r.blob()).catch(() => null);
          if (blob) form.append('images', blob, 'reference.png');
        }
        const res = await fetch(`${(window.APP_CONFIG && window.APP_CONFIG.API_BASE) || 'http://localhost:3001/api'}/image/generate`, {
          method: 'POST', headers: authHdr, body: form,
        });
        if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Image generation failed'); }
        const url = await streamImageGen(res);
        return { url };
      }

      // ── Chat AI (copy generation) ─────────────────────────────────────────────
      case 'copy-gen': {
        const briefIn   = getInput(node.id, 'brief',   ctx, es);
        const contextIn = getInput(node.id, 'context', ctx, es);
        const task      = fv(node, 'task') || 'Ad copy';
        const lang      = fv(node, 'lang') || 'en';
        const brief     = (briefIn?.text) || fv(node, '_p_brief') || '';
        const context   = (contextIn?.text) || fv(node, '_p_context') || '';
        const userMsg   = [
          task && `Task: ${task}`,
          lang !== 'en' && `Reply in language code: ${lang}`,
          brief && `Brief: ${brief}`,
          context && `Context:\n${context}`,
        ].filter(Boolean).join('\n');
        if (!userMsg) throw new Error('No brief or context connected');
        return await new Promise((resolve, reject) => {
          let accumulated = '';
          streamChat({
            messages: [{ role: 'user', content: userMsg }],
            onChunk: (chunk) => { accumulated += chunk; },
            onDone: () => resolve({ text: accumulated }),
            onError: (msg) => reject(new Error(msg)),
          });
        });
      }

      // ── Background removal ────────────────────────────────────────────────────
      case 'bg-remove': {
        const imageIn = getInput(node.id, 'image', ctx, es);
        const src     = imageIn?.url || fv(node, '_p_image');
        if (!src) throw new Error('No image connected');
        while (!window.__imglyRemoveBg) await new Promise(r => setTimeout(r, 200));
        const blob = await window.__imglyRemoveBg(src);
        const url  = URL.createObjectURL(blob);
        return { url };
      }

      // ── Save to folder ────────────────────────────────────────────────────────
      case 'save': {
        const contentIn = getInput(node.id, 'content', ctx, es);
        if (!contentIn) throw new Error('Nothing connected to save');
        const folderId = fv(node, 'folder');
        const folderName = (window.SAVE_FOLDERS_V3 || []).find(f => f.id === folderId)?.name || folderId || 'Default';
        if (contentIn.url) {
          await apiFetch('/images', 'POST', {
            url: contentIn.url,
            prompt: contentIn.text || '',
            folderId: folderId || null,
          });
        } else if (contentIn.text) {
          // Text results: nothing to persist in images, but we report success
        } else {
          throw new Error('No image or text to save');
        }
        return { saved: true, folderName };
      }

      // ── Export (force download via blob — works cross-origin) ───────────────
      case 'export': {
        const contentIn = getInput(node.id, 'content', ctx, es);
        if (!contentIn) return { exported: false };
        const fmt = (fv(node, 'format') || 'PNG').toLowerCase();
        const triggerDownload = (blobUrl, filename) => {
          const a = document.createElement('a');
          a.href = blobUrl; a.download = filename;
          document.body.appendChild(a); a.click();
          document.body.removeChild(a);
          setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
        };
        if (contentIn.url) {
          // Fetch as blob to force download instead of navigation
          const res  = await fetch(contentIn.url);
          const blob = await res.blob();
          const ext  = blob.type.includes('png') ? 'png' : blob.type.includes('webp') ? 'webp' : 'jpg';
          triggerDownload(URL.createObjectURL(blob), `export_${Date.now()}.${ext}`);
        } else if (contentIn.text) {
          const blob = new Blob([contentIn.text], { type: 'text/plain' });
          triggerDownload(URL.createObjectURL(blob), `export_${Date.now()}.txt`);
        }
        return { exported: true };
      }

      default:
        return { skipped: true };
    }
  };

  const runWorkflow = async () => {
    setRunning(true);
    const ctx = new Map();
    const initial = new Map();
    for (const n of nodes) initial.set(n.id, { status: 'pending' });
    setRunResults(initial);

    const sorted = topoSort(nodes, edges);
    for (const node of sorted) {
      setRunResults(prev => new Map(prev).set(node.id, { status: 'running' }));
      try {
        const output = await executeNode(node, edges, ctx);
        ctx.set(node.id, { output });
        setRunResults(prev => new Map(prev).set(node.id, { status: 'done', output }));
      } catch (err) {
        ctx.set(node.id, { output: null });
        setRunResults(prev => new Map(prev).set(node.id, { status: 'error', error: err.message }));
      }
    }
    setRunning(false);
  };

  // ── Load folders on mount so select-folder fields have fresh data ───────────
  React.useEffect(() => {
    if (window.loadFolders) window.loadFolders();
    const sync = () => setNodes(ns => [...ns]); // force re-render when folders update
    window.addEventListener('folders-updated', sync);
    return () => window.removeEventListener('folders-updated', sync);
  }, []);

  // ── Save immediately (bypass debounce) then navigate back ────────────────────
  const handleBack = async () => {
    clearTimeout(autoSaveTimer.current);
    try {
      const preview = computePreview(nodes, edges);
      const payload = { name: wfName, nodes, edges, annotations, preview };
      if (wfId) {
        await apiFetch(`/workflows/${wfId}`, 'PATCH', payload);
      } else if (nodes.length > 0) {
        await apiFetch('/workflows', 'POST', payload);
      }
    } catch {}
    onBack && onBack();
  };

  // ── Auto-save (debounced 1.5 s after any canvas change) ────────────────────
  const autoSaveTimer = React.useRef(null);
  const isFirstRender = React.useRef(true);

  React.useEffect(() => {
    if (isFirstRender.current) { isFirstRender.current = false; return; }
    if (suppressSaveRef.current) return;
    clearTimeout(autoSaveTimer.current);
    autoSaveTimer.current = setTimeout(async () => {
      setSaveStatus('saving');
      try {
        const preview = computePreview(nodes, edges);
        const payload = { name: wfName, nodes, edges, annotations, preview };
        let result;
        if (wfId) {
          result = await apiFetch(`/workflows/${wfId}`, 'PATCH', payload);
        } else {
          result = await apiFetch('/workflows', 'POST', payload);
          setWfId(result._id);
        }
        setWfName(result.name);
        setSaveStatus('saved');
        setTimeout(() => setSaveStatus('idle'), 1500);
      } catch {
        setSaveStatus('error');
        setTimeout(() => setSaveStatus('idle'), 3000);
      }
    }, 1500);
    return () => clearTimeout(autoSaveTimer.current);
  }, [nodes, edges, annotations, wfName]);

  // Delete / Backspace removes all selected nodes AND annotations
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key !== 'Delete' && e.key !== 'Backspace') return;
      const tag = (e.target?.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
      if (editingAnnId) return;
      if (selectedIds.size > 0)    deleteSelected();
      if (selectedAnnIds.size > 0) deleteAnnotations([...selectedAnnIds]);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedIds, selectedAnnIds, editingAnnId]);

  // Cmd/Ctrl + A → select all (nodes + annotations). Esc clears all selection / editing.
  React.useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
        const tag = (e.target?.tagName || '').toLowerCase();
        if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
        e.preventDefault();
        setSelectedIds(new Set(nodes.map(n => n.id)));
        setSelectedAnnIds(new Set(annotations.map(a => a.id)));
      }
      if (e.key === 'Escape' && !nodeMenu) {
        if (editingAnnId) { setEditingAnnId(null); return; }
        setSelectedIds(new Set());
        setSelectedAnnIds(new Set());
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [nodes, annotations, nodeMenu, editingAnnId]);

  // Escape closes the node-actions popover
  React.useEffect(() => {
    if (!nodeMenu) return;
    const onKey = (e) => { if (e.key === 'Escape') setNodeMenu(null); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [nodeMenu]);

  // Ctrl/Cmd + Z → undo   Ctrl/Cmd + Y  or  Ctrl/Cmd + Shift + Z → redo
  React.useEffect(() => {
    const onKey = (e) => {
      if (!e.metaKey && !e.ctrlKey) return;
      const tag = (e.target?.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
      if (e.key.toLowerCase() === 'z' && !e.shiftKey) {
        e.preventDefault();
        undoRef.current();
      } else if (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey)) {
        e.preventDefault();
        redoRef.current();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  // Wheel-to-zoom on the canvas, anchored to the cursor position.
  // Attached via addEventListener so we can use { passive: false } and preventDefault.
  React.useEffect(() => {
    const el = viewportRef.current;
    if (!el) return;
    const onWheel = (e) => {
      e.preventDefault();
      const vp = el.getBoundingClientRect();
      const mx = e.clientX - vp.left;
      const my = e.clientY - vp.top;
      const factor = Math.exp(-e.deltaY * 0.0015); // smooth, direction-correct
      setZoom(z => {
        const newZ = Math.max(0.2, Math.min(3, z * factor));
        const ratio = newZ / z;
        // Keep the canvas point under the cursor stationary
        setPan(p => ({
          x: mx - (mx - p.x) * ratio,
          y: my - (my - p.y) * ratio,
        }));
        return +newZ.toFixed(3);
      });
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, []);

  const updateNodeField = (nodeId, k, v) => {
    setNodes(ns => ns.map(n => n.id === nodeId
      ? { ...n, fields: n.fields.map(f => f.k === k ? { ...f, v } : f) }
      : n
    ));
  };
  const updateField = (k, v) => updateNodeField(selectedId, k, v);

  // Curved bezier between two ports
  const edgePath = (a, b) => {
    const dx = Math.max(40, Math.abs(b.x - a.x) * 0.45);
    return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
  };

  return (
    <>
      {/* ===== Workflow header ===== */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '0 16px', height: 52, flexShrink: 0,
        borderBottom: '1px solid var(--border)',
        background: 'var(--bg-surface)',
      }}>
        {/* Left: back + name */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, minWidth: 0 }}>
          {onBack && (
            <button className="btn btn-ghost btn-icon" onClick={handleBack} title="Retour aux workflows" style={{ flexShrink: 0 }}>
              <Icon name="arrow-left-s" size={18}/>
            </button>
          )}
          {editingName ? (
            <input
              autoFocus
              value={wfName}
              onChange={(e) => setWfName(e.target.value)}
              onBlur={() => setEditingName(false)}
              onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') setEditingName(false); }}
              style={{
                background: 'transparent', border: 'none', outline: 'none',
                fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15,
                color: 'var(--text)', padding: '2px 4px',
                borderBottom: '2px solid var(--accent)',
                minWidth: 60, width: Math.max(120, wfName.length * 9),
              }}
            />
          ) : (
            <span
              onClick={() => setEditingName(true)}
              style={{
                fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15,
                color: 'var(--text)', cursor: 'text',
                padding: '2px 4px', borderRadius: 4,
                overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
              }}
              onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-soft)'}
              onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
              title="Cliquer pour renommer"
            >
              {wfName}
            </span>
          )}
          {/* Save status indicator */}
          {saveStatus === 'saving' && (
            <span style={{ fontSize: 11, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
              <span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--text-faint)', display: 'inline-block', animation: 'pulse 1s infinite' }}/>
              Saving…
            </span>
          )}
          {saveStatus === 'saved' && (
            <span style={{ fontSize: 11, color: '#10B981', display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
              <Icon name="check" size={11}/> Saved
            </span>
          )}
          {saveStatus === 'error' && (
            <span style={{ fontSize: 11, color: 'var(--danger)', flexShrink: 0 }}>Save failed</span>
          )}
        </div>

        {/* Right: action buttons */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
          <button className="btn btn-ghost btn-sm" onClick={() => setShowTemplates(true)}>
            <Icon name="package" size={14}/> Templates
          </button>
          <button className="btn btn-ghost btn-sm" onClick={exportJson}>
            <Icon name="download" size={14}/> Export
          </button>
          {runResults.size > 0 && !running && (
            <button className="btn btn-ghost btn-sm" onClick={() => setRunResults(new Map())}>
              <Icon name="close" size={12}/> Clear
            </button>
          )}
          <button className="btn btn-primary btn-sm" onClick={runWorkflow} disabled={running}>
            {running
              ? <><span style={{ width:6,height:6,borderRadius:999,background:'#fff',display:'inline-block',animation:'pulse 1s infinite'}}/> Running…</>
              : <><Icon name="play-fill" size={12}/> Run</>}
          </button>
        </div>
      </div>

      <div style={{ flex: 1, display: 'flex', overflow: 'hidden', position: 'relative' }}>
        {/* ===== LEFT — Node library ===== */}
        <div style={{
          width: openLib ? 248 : 0,
          borderRight: openLib ? '1px solid var(--border)' : 'none',
          display: 'flex', flexDirection: 'column',
          overflow: 'hidden',
          transition: 'width 180ms ease',
          background: 'var(--bg-surface)',
        }}>
          <div style={{
            padding: '14px 16px 8px',
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          }}>
            <div style={{
              fontSize: 11, fontWeight: 500, color: 'var(--text-faint)',
              textTransform: 'uppercase', letterSpacing: '0.06em',
            }}>Node library</div>
          </div>
          <div className="fld" style={{ margin: '0 12px 8px', height: 30, padding: '0 10px', borderRadius: 8 }}>
            <Icon name="search" size={12} style={{ color: 'var(--text-faint)' }}/>
            <input
              placeholder="Search nodes…"
              style={{ fontSize: 12 }}
              value={libSearch}
              onChange={(e) => setLibSearch(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Escape') { setLibSearch(''); e.target.blur(); }
                e.stopPropagation();
              }}
            />
            {libSearch && (
              <button onClick={() => setLibSearch('')}
                style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer',
                         display: 'flex', color: 'var(--text-faint)', lineHeight: 1 }}>
                <Icon name="close" size={11}/>
              </button>
            )}
          </div>
          <div className="scroll" style={{ flex: 1, overflowY: 'auto', padding: '4px 10px 16px' }}>
            {(() => {
              const q = libSearch.trim().toLowerCase();
              const filtered = WORKFLOW_NODE_LIBRARY
                .map(g => ({ ...g, items: q ? g.items.filter(it => it.label.toLowerCase().includes(q)) : g.items }))
                .filter(g => g.items.length > 0);

              if (filtered.length === 0) return (
                <div style={{ padding: '24px 8px', textAlign: 'center', color: 'var(--text-faint)', fontSize: 12 }}>
                  No nodes match "<b>{libSearch}</b>"
                </div>
              );

              return filtered.map(g => (
                <div key={g.group} style={{ marginBottom: 14 }}>
                  {!q && <div style={{
                    fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
                    textTransform: 'uppercase', letterSpacing: '0.06em',
                    padding: '6px 6px 4px',
                  }}>{g.group}</div>}
                  {g.items.map(it => (
                    <div key={it.id}
                      onClick={() => addNodeFromLibrary(it)}
                      title={`Add ${it.label} to the canvas`}
                      style={{
                        display: 'flex', alignItems: 'center', gap: 10,
                        padding: '7px 8px', borderRadius: 8,
                        cursor: 'pointer', userSelect: 'none',
                        fontSize: 12, color: 'var(--text-2)',
                        transition: 'background 100ms',
                      }}
                      onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-soft)'}
                      onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
                      <span style={{
                        width: 22, height: 22, borderRadius: 6,
                        background: `${it.color}1F`, color: it.color,
                        display: 'grid', placeItems: 'center',
                      }}>
                        <Icon name={it.icon} size={12}/>
                      </span>
                      <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</span>
                      <Icon name="plus" size={11} style={{ color: 'var(--text-faint)' }}/>
                    </div>
                  ))}
                </div>
              ));
            })()}
          </div>
        </div>

        {/* ===== CENTER — Canvas ===== */}
        <div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: 'var(--bg-soft)' }}>
          {/* Toolbar — left collapse only (tool selector moved to floating bottom bar) */}
          <div style={{
            position: 'absolute', top: 14, left: 14, zIndex: 5,
            display: 'flex', gap: 6,
          }}>
            <button onClick={() => setOpenLib(o => !o)}
              className="chip" style={{
              height: 30, padding: '0 10px', gap: 6, fontSize: 12,
              borderRadius: 8,
              background: 'var(--bg-surface)',
              border: '1px solid var(--border)',
            }}>
              <Icon name="sidebar" size={13}/>
              {openLib ? 'Hide library' : 'Show library'}
            </button>
            {tool !== 'grab' && tool !== 'select' && (
              <div style={{
                display: 'inline-flex', alignItems: 'center', gap: 6,
                height: 30, padding: '0 10px', borderRadius: 8,
                background: 'var(--accent-soft)',
                border: '1px solid var(--accent-soft)',
                fontSize: 11, color: 'var(--accent)', fontWeight: 500,
              }}>
                <Icon name={
                  tool === 'text'   ? 'text-tool' :
                  tool === 'sticky' ? 'sticky'    :
                  tool === 'shape'  ? 'shape-rect':
                  tool === 'pen'    ? 'pen'       : 'cursor'
                } size={12}/>
                {tool === 'text'   && 'Click anywhere to add text'}
                {tool === 'sticky' && 'Click anywhere to add a sticky'}
                {tool === 'shape'  && 'Click & drag to draw a frame'}
                {tool === 'pen'    && 'Click & drag to draw — Esc to finish'}
              </div>
            )}
          </div>

          <div style={{
            position: 'absolute', top: 14, right: 14, zIndex: 5,
            display: 'flex', gap: 4, alignItems: 'center',
            padding: 4, borderRadius: 8,
            background: 'var(--bg-surface)',
            border: '1px solid var(--border)',
          }}>
            <button onClick={undo} title="Undo (Ctrl+Z)"
              className="btn btn-ghost btn-icon" style={{ width: 26, height: 26 }}>
              <Icon name="undo" size={13}/>
            </button>
            <button onClick={redo} title="Redo (Ctrl+Y)"
              className="btn btn-ghost btn-icon" style={{ width: 26, height: 26 }}>
              <Icon name="redo" size={13}/>
            </button>
            <span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 2px' }}/>
            <button onClick={() => setZoom(z => Math.max(0.4, +(z - 0.1).toFixed(2)))}
              className="btn btn-ghost btn-icon" style={{ width: 26, height: 26 }}>
              <Icon name="minus" size={12}/>
            </button>
            <div style={{
              fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500,
              color: 'var(--text-2)', minWidth: 42, textAlign: 'center',
            }}>{Math.round(zoom * 100)}%</div>
            <button onClick={() => setZoom(z => Math.min(2, +(z + 0.1).toFixed(2)))}
              className="btn btn-ghost btn-icon" style={{ width: 26, height: 26 }}>
              <Icon name="plus" size={12}/>
            </button>
            <span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 2px' }}/>
            <button onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
              className="btn btn-ghost btn-icon" style={{ width: 26, height: 26 }} title="Reset view">
              <Icon name="expand" size={12}/>
            </button>
          </div>

          {/* Footer status */}
          <div style={{
            position: 'absolute', bottom: 14, left: 14, zIndex: 5,
            display: 'flex', gap: 12, alignItems: 'center',
            padding: '6px 12px', borderRadius: 999,
            background: 'var(--bg-surface)',
            border: '1px solid var(--border)',
            fontSize: 11, color: 'var(--text-muted)',
          }}>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
              <span style={{
                width: 6, height: 6, borderRadius: 999,
                background: running ? 'var(--accent)' : 'var(--good)',
                animation: running ? 'pulse 1s infinite' : 'none',
              }}/>
              {running ? 'Executing pipeline…' : 'Ready'}
            </span>
            <span>·</span>
            <span><b style={{ fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{nodes.length}</b> nodes</span>
            <span>·</span>
            <span><b style={{ fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{edges.length}</b> edges</span>
            <span>·</span>
            <span>Last run: 2m ago · 3.2s · €0.04</span>
          </div>

          {/* Infinite whiteboard — clipping viewport */}
          <div ref={viewportRef}
               onMouseDown={onCanvasMouseDown}
               onContextMenu={onCanvasContextMenu}
               style={{
                 position: 'absolute', inset: 0,
                 overflow: 'hidden',
                 cursor: connecting              ? 'crosshair'
                       : panning                 ? 'grabbing'
                       : boxSel                  ? 'crosshair'
                       : spacePan                ? 'grab'
                       : tool === 'grab'         ? 'grab'
                       : tool === 'text'         ? 'text'
                       : tool === 'sticky'       ? 'cell'
                       : tool === 'shape'        ? 'crosshair'
                       : tool === 'pen'          ? 'crosshair'
                                                 : 'default',
                 backgroundImage: 'radial-gradient(circle, rgba(15,20,30,0.08) 1px, transparent 1px)',
                 backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
                 backgroundPosition: `${pan.x}px ${pan.y}px`,
               }}>
            {/* Pan/zoom layer — virtually unbounded */}
            <div style={{
              position: 'absolute', left: 0, top: 0,
              width: 0, height: 0, // children are absolute, no size needed
              transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
              transformOrigin: '0 0',
            }}>
              {/* Shape annotations — rendered behind everything else */}
              {annotations.filter(a => a.type === 'shape').map(a => {
                const sel = selectedAnnIds.has(a.id);
                return (
                  <div key={a.id}
                    onMouseDown={(e) => onAnnMouseDown(e, a)}
                    onContextMenu={(e) => e.stopPropagation()}
                    style={{
                      position: 'absolute', left: a.x, top: a.y,
                      width: a.w, height: a.h,
                      background: `${a.color}28`,
                      border: `1.5px ${sel ? 'solid' : 'dashed'} ${sel ? 'var(--accent)' : a.color}`,
                      borderRadius: 8,
                      cursor: drag ? 'grabbing' : 'default',
                      boxShadow: sel ? `0 0 0 2px var(--accent-glow)` : 'none',
                      transition: 'box-shadow 140ms, border-color 140ms',
                    }}/>
                );
              })}

              {/* SVG edges — overflow visible so paths render anywhere */}
              <svg width="1" height="1" style={{
                position: 'absolute', left: 0, top: 0,
                overflow: 'visible', pointerEvents: 'none',
              }}>
                <defs>
                  <linearGradient id="edge-grad" x1="0" y1="0" x2="1" y2="0">
                    <stop offset="0%"  stopColor="var(--accent)" stopOpacity="0.5"/>
                    <stop offset="100%" stopColor="var(--accent)" stopOpacity="0.9"/>
                  </linearGradient>
                </defs>
                {edges.map(e => {
                  const a = portXY(e.from, e.fromPort, 'out');
                  const b = portXY(e.to,   e.toPort,   'in');
                  const isHover = hoverEdge === e.id;
                  return (
                    <g key={e.id}>
                      {/* Wide invisible hit-target */}
                      <path d={edgePath(a, b)} fill="none" stroke="transparent"
                        strokeWidth="14" strokeLinecap="round"
                        style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
                        onMouseEnter={() => setHoverEdge(e.id)}
                        onMouseLeave={() => setHoverEdge(h => h === e.id ? null : h)}
                        onMouseDown={(ev) => ev.stopPropagation()}
                        onClick={(ev) => { ev.stopPropagation(); deleteEdge(e.id); }}/>
                      {/* Visible stroke */}
                      <path d={edgePath(a, b)} fill="none"
                        stroke={isHover ? 'var(--danger)' : 'url(#edge-grad)'}
                        strokeWidth={isHover ? 2.5 : 2} strokeLinecap="round"
                        style={{ pointerEvents: 'none', transition: 'stroke 120ms' }}/>
                      <circle cx={b.x} cy={b.y} r="3" fill={isHover ? 'var(--danger)' : 'var(--accent)'}
                        style={{ pointerEvents: 'none' }}/>
                    </g>
                  );
                })}
                {/* Temp edge while connecting */}
                {connecting && (() => {
                  const a = portXY(connecting.from, connecting.fromOutPort || 'out', 'out');
                  const b = { x: connecting.mouseX, y: connecting.mouseY };
                  return (
                    <g>
                      <path d={edgePath(a, b)} fill="none" stroke="var(--accent)"
                        strokeWidth="2" strokeDasharray="5 4" strokeLinecap="round" opacity="0.8"/>
                      <circle cx={b.x} cy={b.y} r="4" fill="var(--accent)" opacity="0.8"/>
                    </g>
                  );
                })()}

                {/* Pen drawings (annotations) */}
                {annotations.filter(a => a.type === 'pen').map(a => {
                  if (!a.points.length) return null;
                  const d = a.points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ');
                  const sel = selectedAnnIds.has(a.id);
                  return (
                    <g key={a.id}>
                      {/* Wide invisible hit-target for selecting */}
                      <path d={d} fill="none" stroke="transparent"
                        strokeWidth={Math.max(14, a.width + 10)} strokeLinecap="round" strokeLinejoin="round"
                        style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
                        onMouseDown={(e) => onAnnMouseDown(e, a)}
                        onContextMenu={(e) => e.stopPropagation()}/>
                      <path d={d} fill="none" stroke={a.color} strokeWidth={a.width}
                        strokeLinecap="round" strokeLinejoin="round"
                        style={{ pointerEvents: 'none',
                                 filter: sel ? 'drop-shadow(0 0 0 var(--accent))' : 'none' }}/>
                      {sel && (
                        <path d={d} fill="none" stroke="var(--accent)"
                          strokeWidth={a.width + 4} strokeLinecap="round" strokeLinejoin="round"
                          opacity="0.25" style={{ pointerEvents: 'none' }}/>
                      )}
                    </g>
                  );
                })}

                {/* Shape preview while drawing */}
                {shapePreview && shapePreview.w > 0 && shapePreview.h > 0 && (
                  <rect x={shapePreview.x} y={shapePreview.y}
                    width={shapePreview.w} height={shapePreview.h}
                    rx="8"
                    fill={`${annColor}28`} stroke={annColor}
                    strokeWidth="1.5" strokeDasharray="5 4"/>
                )}
              </svg>

              {/* Box selection rectangle (in canvas coords, scales with zoom) */}
              {boxSel && (
                <div style={{
                  position: 'absolute',
                  left: Math.min(boxSel.x1, boxSel.x2),
                  top:  Math.min(boxSel.y1, boxSel.y2),
                  width:  Math.abs(boxSel.x2 - boxSel.x1),
                  height: Math.abs(boxSel.y2 - boxSel.y1),
                  background: 'var(--accent-soft)',
                  border: '1.5px solid var(--accent)',
                  borderRadius: 4,
                  pointerEvents: 'none',
                  zIndex: 1,
                }}/>
              )}

              {/* Nodes */}
              {nodes.map(n => {
                const isSelected = selectedIds.has(n.id);
                const inPorts    = n.in || [];
                const h          = nodeHeight(n);
                const isDragging = drag?.ids?.includes(n.id);
                return (
                  <div key={n.id}
                    ref={(el) => {
                      if (!el) { nodeHeightsRef.current.delete(n.id); return; }
                      // offsetHeight returns the un-transformed (canvas-coord) height,
                      // ignoring the parent zoom transform. Perfect for snap maths.
                      const h = el.offsetHeight;
                      if (h && nodeHeightsRef.current.get(n.id) !== h) {
                        nodeHeightsRef.current.set(n.id, h);
                      }
                    }}
                    onMouseDown={(e) => onNodeMouseDown(e, n)}
                    onContextMenu={(e) => e.stopPropagation()}
                    style={{
                      position: 'absolute', left: n.x, top: n.y,
                      width: n.w,
                      borderRadius: 12,
                      background: 'var(--bg-surface)',
                      border: `1px solid ${isSelected ? 'var(--accent)' : 'var(--border)'}`,
                      boxShadow: isSelected
                        ? `0 0 0 2px var(--accent-glow), 0 12px 28px -10px ${n.color}38`
                        : '0 4px 14px -4px rgba(15,20,30,0.10)',
                      cursor: isDragging ? 'grabbing' : 'default',
                      userSelect: 'none',
                      transition: drag ? 'none' : 'box-shadow 140ms ease, border-color 140ms ease',
                    }}>
                    {/* Header */}
                    <div style={{
                      height: NODE_HEADER_H, padding: '0 12px',
                      display: 'flex', alignItems: 'center', gap: 8,
                      borderBottom: '1px solid var(--border-soft)',
                      background: `linear-gradient(180deg, ${n.color}10, transparent)`,
                      borderRadius: '12px 12px 0 0',
                    }}>
                      <span style={{
                        width: 20, height: 20, borderRadius: 5,
                        background: `${n.color}22`, color: n.color,
                        display: 'grid', placeItems: 'center', flexShrink: 0,
                      }}>
                        <Icon name={n.icon} size={12}/>
                      </span>
                      <span style={{
                        fontSize: 12, fontWeight: 600, color: 'var(--text)',
                        flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                      }}>{n.label}</span>
                      {/* Run status badge */}
                      {(() => {
                        const r = runResults.get(n.id);
                        if (!r || r.status === 'pending') return null;
                        const cfg = {
                          running: { bg: '#335CFF22', color: '#335CFF', icon: null, pulse: true },
                          done:    { bg: '#10B98122', color: '#10B981', icon: 'check', pulse: false },
                          error:   { bg: '#EF444422', color: '#EF4444', icon: 'close', pulse: false },
                        }[r.status];
                        if (!cfg) return null;
                        return (
                          <span title={r.error || r.status} style={{
                            width: 18, height: 18, borderRadius: 999,
                            background: cfg.bg, color: cfg.color,
                            display: 'grid', placeItems: 'center', flexShrink: 0,
                          }}>
                            {cfg.pulse
                              ? <span style={{ width: 6, height: 6, borderRadius: 999, background: cfg.color, animation: 'pulse 1s infinite' }}/>
                              : <Icon name={cfg.icon} size={10}/>
                            }
                          </span>
                        );
                      })()}
                      <button
                        onMouseDown={(e) => e.stopPropagation()}
                        onClick={(e) => openNodeMenu(e, n)}
                        title="Node actions"
                        style={{
                          width: 22, height: 22, padding: 0,
                          display: 'grid', placeItems: 'center',
                          background: nodeMenu?.id === n.id ? 'var(--bg-soft)' : 'transparent',
                          border: 0, borderRadius: 6,
                          color: 'var(--text-faint)', cursor: 'pointer',
                        }}
                        onMouseEnter={(e) => { if (nodeMenu?.id !== n.id) e.currentTarget.style.background = 'var(--bg-soft)'; }}
                        onMouseLeave={(e) => { if (nodeMenu?.id !== n.id) e.currentTarget.style.background = 'transparent'; }}>
                        <Icon name="more-h" size={14}/>
                      </button>
                    </div>

                    {/* Body */}
                    <div style={{ padding: '10px 12px 12px', position: 'relative' }}>
                      {/* In-ports labels */}
                      {inPorts.map((p, i) => {
                        const isConnecting = connecting && connecting.from !== n.id;
                        const canDrop  = isConnecting && portsCompatible(connecting.fromOutPort, p);
                        const blocked  = isConnecting && !canDrop;
                        return (
                          <div key={p} style={{
                            display: 'flex', alignItems: 'center', gap: 6,
                            height: NODE_PORT_GAP, fontSize: 11,
                            color: 'var(--text-muted)',
                            paddingLeft: 4,
                            opacity: blocked ? 0.4 : 1,
                            transition: 'opacity 120ms',
                          }}>
                            {/* Larger invisible hit zone for connecting */}
                            <span
                              data-port-target=""
                              data-port-node={n.id}
                              data-port-name={p}
                              style={{
                                position: 'absolute', left: -14,
                                top: 13 + i * NODE_PORT_GAP - 9,
                                width: 28, height: 28, borderRadius: 999,
                                background: 'transparent',
                                cursor: canDrop ? 'crosshair' : 'default',
                                pointerEvents: canDrop ? 'auto' : 'none',
                                zIndex: 2,
                              }}/>
                            {/* Visible port dot */}
                            <span style={{
                              position: 'absolute', left: -5,
                              top: 13 + i * NODE_PORT_GAP,
                              width: 10, height: 10, borderRadius: 999,
                              background: canDrop ? n.color : 'var(--bg-surface)',
                              border: `2px solid ${n.color}`,
                              transform: canDrop ? 'scale(1.25)' : 'scale(1)',
                              boxShadow: canDrop ? `0 0 0 4px ${n.color}33` : 'none',
                              transition: 'transform 120ms, box-shadow 120ms, background 120ms',
                              pointerEvents: 'none',
                            }}/>
                            {p}
                          </div>
                        );
                      })}

                      {/* Inline inputs for unconnected ports */}
                      {n.portFieldMap && (() => {
                        const unconnected = (n.in || []).filter(portName =>
                          n.portFieldMap[portName] &&
                          !edges.some(e => e.to === n.id && e.toPort === portName)
                        );
                        if (!unconnected.length) return null;
                        return (
                          <div
                            onMouseDown={e => e.stopPropagation()}
                            style={{ borderTop: '1px solid var(--border-soft)', marginTop: 4, paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
                            {unconnected.map(portName => {
                              const fieldKey = n.portFieldMap[portName];
                              const f = n.fields.find(x => x.k === fieldKey);
                              if (!f) return null;
                              const set = v => updateNodeField(n.id, fieldKey, v);
                              let ctrl;
                              if (f.type === 'textarea') {
                                ctrl = <CompactTextareaField value={f.v} placeholder={f.placeholder} onChange={set}/>;
                              } else if (f.type === 'file') {
                                ctrl = <CompactFileField value={f.v} accept={f.accept} label={f.label} onChange={set}/>;
                              } else if (f.type === 'multi-file') {
                                ctrl = <CompactMultiFileField value={f.v} accept={f.accept} max={f.max} onChange={set}/>;
                              } else if (f.type === 'select-brand') {
                                const brands = (window.BRANDS_V3 || []).map(b => ({ v: b.id, label: b.name }));
                                ctrl = <SelectField value={f.v} options={brands} onChange={set} placeholder="Pick a brand…"/>;
                              } else {
                                return null;
                              }
                              return (
                                <div key={portName}>
                                  <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 4 }}>
                                    {f.label}
                                  </div>
                                  {ctrl}
                                </div>
                              );
                            })}
                          </div>
                        );
                      })()}

                      {/* Fields preview */}
                      {(() => {
                        const regularFields = n.fields?.filter(f => !f.k.startsWith('_p_')) || [];
                        const interactiveFields = regularFields.filter(f => f.type === 'textarea' || f.type === 'file' || f.type === 'multi-file');
                        const staticFields = regularFields.filter(f => f.type !== 'textarea' && f.type !== 'file' && f.type !== 'multi-file');
                        return (
                          <>
                            {interactiveFields.length > 0 && (
                              <div onMouseDown={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: staticFields.length ? 4 : 0 }}>
                                {interactiveFields.map(f => {
                                  const set = v => updateNodeField(n.id, f.k, v);
                                  if (f.type === 'textarea') return (
                                    <CompactTextareaField key={f.k} value={f.v} placeholder={f.placeholder} onChange={set}/>
                                  );
                                  if (f.type === 'file') return (
                                    <CompactFileField key={f.k} value={f.v} accept={f.accept} label={f.label} onChange={set}/>
                                  );
                                  if (f.type === 'multi-file') return (
                                    <CompactMultiFileField key={f.k} value={f.v} accept={f.accept} max={f.max} onChange={set}/>
                                  );
                                  return null;
                                })}
                              </div>
                            )}
                            {staticFields.map(f => (
                              <div key={f.k} style={{
                                fontSize: 11, color: 'var(--text-faint)',
                                display: 'flex', gap: 6, padding: '3px 0',
                                minWidth: 0,
                              }}>
                                <span style={{ color: 'var(--text-faint)', flexShrink: 0 }}>{f.label}:</span>
                                <span style={{
                                  color: 'var(--text-2)', fontWeight: 500,
                                  overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                                  minWidth: 0,
                                }}>{fieldPreviewText(f)}</span>
                              </div>
                            ))}
                          </>
                        );
                      })()}

                      {/* Run result preview */}
                      {(() => {
                        const r = runResults.get(n.id);
                        if (!r || r.status === 'pending') return null;
                        if (r.status === 'running') return (
                          <div style={{ marginTop: 8, padding: '6px 8px', borderRadius: 8, background: '#335CFF11', border: '1px solid #335CFF33', fontSize: 11, color: '#335CFF', display: 'flex', alignItems: 'center', gap: 6 }}>
                            <span style={{ width: 6, height: 6, borderRadius: 999, background: '#335CFF', display: 'inline-block', animation: 'pulse 1s infinite' }}/>
                            Running…
                          </div>
                        );
                        if (r.status === 'error') return (
                          <div style={{ marginTop: 8, padding: '6px 8px', borderRadius: 8, background: '#EF444411', border: '1px solid #EF444433', fontSize: 11, color: '#EF4444', wordBreak: 'break-word' }}>
                            {r.error}
                          </div>
                        );
                        if (r.status === 'done' && r.output) {
                          const out = r.output;
                          if (out.url && (n.type === 'img-gen' || n.type === 'bg-remove' || n.type === 'image-in')) return (
                            <div onMouseDown={e => e.stopPropagation()} style={{ marginTop: 8 }}>
                              <img src={out.url} alt="result"
                                style={{ width: '100%', borderRadius: 8, border: '1px solid var(--border)', display: 'block', cursor: 'pointer' }}
                                onClick={() => window.open(out.url, '_blank')}
                              />
                            </div>
                          );
                          if (out.text) return (
                            <div onMouseDown={e => e.stopPropagation()} style={{ marginTop: 8, padding: '6px 8px', borderRadius: 8, background: '#10B98111', border: '1px solid #10B98133', fontSize: 11, color: 'var(--text-2)', lineHeight: 1.5, maxHeight: 100, overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
                              {out.text.length > 300 ? out.text.slice(0, 300) + '…' : out.text}
                            </div>
                          );
                          if (out.saved) return (
                            <div style={{ marginTop: 8, padding: '6px 8px', borderRadius: 8, background: '#10B98111', border: '1px solid #10B98133', fontSize: 11, color: '#10B981', display: 'flex', alignItems: 'center', gap: 5 }}>
                              <Icon name="check" size={11}/>
                              {out.folderName ? `Saved → ${out.folderName}` : 'Saved'}
                            </div>
                          );
                          if (out.exported) return (
                            <div style={{ marginTop: 8, padding: '6px 8px', borderRadius: 8, background: '#10B98111', border: '1px solid #10B98133', fontSize: 11, color: '#10B981' }}>
                              Downloaded
                            </div>
                          );
                          if (out.triggered || out.skipped) return (
                            <div style={{ marginTop: 8, padding: '4px 8px', borderRadius: 8, background: '#10B98111', fontSize: 11, color: '#10B981' }}>
                              ✓ Ready
                            </div>
                          );
                        }
                        return null;
                      })()}

                      {/* Out-port — draggable to start a connection */}
                      {n.out && n.out.length > 0 && (
                        <>
                          <span
                            onMouseDown={(e) => onPortDown(e, n.id)}
                            title="Drag to connect"
                            style={{
                              position: 'absolute', right: -14,
                              top: 13 - 9,
                              width: 28, height: 28, borderRadius: 999,
                              background: 'transparent', cursor: 'crosshair',
                              zIndex: 2,
                            }}/>
                          <span style={{
                            position: 'absolute', right: -5,
                            top: 13,
                            width: 10, height: 10, borderRadius: 999,
                            background: n.color,
                            border: '2px solid var(--bg-surface)',
                            boxShadow: `0 0 0 1px ${n.color}`,
                            pointerEvents: 'none',
                          }}/>
                        </>
                      )}
                    </div>
                  </div>
                );
              })}

              {/* Alignment guides during drag */}
              {guides.x != null && (
                <div style={{
                  position: 'absolute', left: guides.x - 0.5, top: -100000,
                  width: 1, height: 200000,
                  background: 'var(--accent)', opacity: 0.7,
                  pointerEvents: 'none', zIndex: 9,
                }}/>
              )}
              {guides.y != null && (
                <div style={{
                  position: 'absolute', top: guides.y - 0.5, left: -100000,
                  height: 1, width: 200000,
                  background: 'var(--accent)', opacity: 0.7,
                  pointerEvents: 'none', zIndex: 9,
                }}/>
              )}

              {/* Text & sticky annotations — rendered ABOVE nodes so they can label them */}
              {annotations.filter(a => a.type === 'text' || a.type === 'sticky').map(a => {
                const sel = selectedAnnIds.has(a.id);
                const editing = editingAnnId === a.id;
                const isSticky = a.type === 'sticky';
                return (
                  <div key={a.id}
                    onMouseDown={(e) => {
                      if (editing) { e.stopPropagation(); return; }
                      onAnnMouseDown(e, a);
                    }}
                    onContextMenu={(e) => e.stopPropagation()}
                    onDoubleClick={(e) => { e.stopPropagation(); setEditingAnnId(a.id); }}
                    style={{
                      position: 'absolute', left: a.x, top: a.y,
                      width: a.w, height: a.h,
                      borderRadius: isSticky ? 6 : 4,
                      background: isSticky ? a.color : 'transparent',
                      color: isSticky ? '#1A1A2E' : a.color,
                      border: sel ? '1.5px solid var(--accent)' : (isSticky ? '1px solid rgba(0,0,0,0.06)' : '1px dashed transparent'),
                      boxShadow: isSticky
                        ? (sel ? '0 0 0 2px var(--accent-glow), 0 6px 16px rgba(15,20,30,0.16)'
                               : '0 4px 12px rgba(15,20,30,0.10), 0 1px 2px rgba(15,20,30,0.06)')
                        : (sel ? '0 0 0 2px var(--accent-glow)' : 'none'),
                      padding: isSticky ? 14 : 4,
                      cursor: editing ? 'text' : (drag ? 'grabbing' : 'default'),
                      userSelect: editing ? 'text' : 'none',
                      transition: 'box-shadow 140ms, border-color 140ms',
                      transform: isSticky ? 'rotate(-0.4deg)' : 'none',
                      overflow: 'hidden',
                      boxSizing: 'border-box',
                      display: 'flex', flexDirection: 'column',
                    }}>
                    {editing ? (
                      <InlineAnnotationEditor
                        ann={a}
                        isSticky={isSticky}
                        padding={isSticky ? 28 : 8}
                        onChangeText={(v) => updateAnn(a.id, { text: v })}
                        onCommitHeight={(h) => updateAnn(a.id, { h })}
                        onBlur={() => setEditingAnnId(null)}
                        onKeyDown={(e) => {
                          if (e.key === 'Escape') { e.target.blur(); return; }
                          // IMPORTANT: do NOT stopPropagation on Enter — let the textarea
                          // insert a newline naturally. We only stop propagation for keys
                          // that the canvas global handlers might react to.
                          if (e.key === 'Delete' || e.key === 'Backspace' || e.key === ' ' ||
                              ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a')) {
                            e.stopPropagation();
                          }
                        }}
                        onMouseDown={(e) => e.stopPropagation()}/>
                    ) : (
                      <div className="wf-no-scrollbar" style={{
                        flex: 1, overflow: 'hidden',
                        whiteSpace: 'pre-wrap', wordBreak: 'break-word',
                        fontSize: a.size ?? (isSticky ? 14 : 16),
                        fontWeight: isSticky ? 500 : 600,
                        lineHeight: 1.4,
                        color: 'inherit',
                        opacity: a.text ? 1 : 0.45,
                        textAlign: a.align || 'left',
                      }}>{a.text || (isSticky ? 'Empty note — double-click to edit' : 'Add label')}</div>
                    )}
                  </div>
                );
              })}

              {/* Resize handles — only when ONE resizable annotation is selected */}
              {selectedAnnIds.size === 1 && (() => {
                const ann = annotations.find(a => selectedAnnIds.has(a.id));
                if (!ann) return null;
                if (ann.type !== 'text' && ann.type !== 'sticky' && ann.type !== 'shape') return null;
                if (editingAnnId === ann.id) return null;
                return <ResizeHandles ann={ann}/>;
              })()}
            </div>
          </div>

          {/* ===== Floating bottom bar (liquid glass) — tools + colour ===== */}
          <div style={{
            position: 'absolute', left: '50%', bottom: 22,
            transform: 'translateX(-50%)',
            zIndex: 6,
            display: 'flex', alignItems: 'center', gap: 8,
            padding: 6,
            borderRadius: 14,
            background: 'rgba(255,255,255,0.86)',
            color: 'var(--text)',
            backdropFilter: 'blur(24px) saturate(180%)',
            WebkitBackdropFilter: 'blur(24px) saturate(180%)',
            boxShadow: '0 24px 48px -16px rgba(15,20,30,0.18), 0 4px 14px -4px rgba(15,20,30,0.08), inset 0 1px 0 rgba(255,255,255,0.9)',
            border: '1px solid rgba(255,255,255,0.8)',
          }}>
            {/* Cursor modes — Grab / Select */}
            <div style={{ display: 'flex', gap: 2 }}>
              {[
                { id: 'grab',   icon: 'hand',   title: 'Grab — drag to pan' },
                { id: 'select', icon: 'cursor', title: 'Select — drag to box-select' },
              ].map(t => {
                const active = tool === t.id;
                return (
                  <button key={t.id} onClick={() => setTool(t.id)} title={t.title}
                    style={{
                      width: 34, height: 34, padding: 0, border: 0,
                      borderRadius: 9, cursor: 'pointer',
                      background: active ? 'var(--bg-night)' : 'rgba(255,255,255,0.55)',
                      color: active ? '#fff' : 'var(--text)',
                      display: 'grid', placeItems: 'center',
                      transition: 'all 120ms',
                      backdropFilter: 'blur(8px)',
                    }}
                    onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.85)'; }}
                    onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.55)'; }}>
                    <Icon name={t.icon} size={15}/>
                  </button>
                );
              })}
            </div>

            <span style={{ width: 1, height: 24, background: 'rgba(15,20,30,0.10)' }}/>

            {/* Annotation tools */}
            <div style={{ display: 'flex', gap: 2 }}>
              {[
                { id: 'text',   icon: 'text-tool', title: 'Text — click to add a label' },
                { id: 'sticky', icon: 'sticky',    title: 'Sticky note — click to add a note' },
                { id: 'shape',  icon: 'shape-rect',title: 'Frame — drag to draw a grouping shape' },
                { id: 'pen',    icon: 'pen',       title: 'Pen — drag to draw freehand' },
              ].map(t => {
                const active = tool === t.id;
                return (
                  <button key={t.id} onClick={() => setTool(t.id)} title={t.title}
                    style={{
                      width: 34, height: 34, padding: 0, border: 0,
                      borderRadius: 9, cursor: 'pointer',
                      background: active ? 'var(--bg-night)' : 'rgba(255,255,255,0.55)',
                      color: active ? '#fff' : 'var(--text)',
                      display: 'grid', placeItems: 'center',
                      transition: 'all 120ms',
                    }}
                    onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.85)'; }}
                    onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.55)'; }}>
                    <Icon name={t.icon} size={15}/>
                  </button>
                );
              })}
            </div>

            <span style={{ width: 1, height: 24, background: 'rgba(15,20,30,0.10)' }}/>

            {/* Colour picker — dropdown */}
            <div style={{ position: 'relative' }}>
              <button onClick={() => setColorOpen(o => !o)} title="Annotation colour"
                style={{
                  height: 30, padding: '0 8px 0 8px', borderRadius: 9, border: 0,
                  background: colorOpen ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.55)',
                  cursor: 'pointer',
                  display: 'inline-flex', alignItems: 'center', gap: 6,
                  fontFamily: 'inherit', fontSize: 12, color: 'var(--text)',
                }}
                onMouseEnter={(e) => { if (!colorOpen) e.currentTarget.style.background = 'rgba(255,255,255,0.85)'; }}
                onMouseLeave={(e) => { if (!colorOpen) e.currentTarget.style.background = 'rgba(255,255,255,0.55)'; }}>
                <span style={{
                  width: 18, height: 18, borderRadius: 999,
                  background: annColor,
                  border: annColor === '#FFFFFF' ? '1px solid rgba(15,20,30,0.18)' : '1px solid rgba(15,20,30,0.10)',
                  boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.4)',
                }}/>
                <Icon name="chevron-down" size={11} style={{ color: 'var(--text-faint)' }}/>
              </button>
              {colorOpen && (
                <>
                  <div onClick={() => setColorOpen(false)}
                       onContextMenu={(e) => { e.preventDefault(); setColorOpen(false); }}
                       style={{ position: 'fixed', inset: 0, zIndex: 220 }}/>
                  <div style={{
                    position: 'absolute', bottom: 'calc(100% + 8px)', left: 0,
                    zIndex: 221,
                    background: 'var(--bg-surface)',
                    border: '1px solid var(--border)',
                    borderRadius: 12, padding: 8,
                    boxShadow: '0 14px 36px rgba(14,18,27,0.18), 0 2px 6px rgba(14,18,27,0.06)',
                    display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6,
                  }}>
                    {ANN_COLORS.map(c => {
                      const active = annColor === c;
                      return (
                        <button key={c}
                          onClick={() => { setAnnColor(c); setColorOpen(false); }}
                          title={c}
                          style={{
                            width: 28, height: 28, padding: 0,
                            borderRadius: 999, cursor: 'pointer',
                            background: c,
                            border: c === '#FFFFFF' ? '1px solid rgba(15,20,30,0.18)' : '1px solid rgba(15,20,30,0.06)',
                            outline: active ? '2px solid var(--accent)' : 'none',
                            outlineOffset: 2,
                            position: 'relative',
                            display: 'grid', placeItems: 'center',
                          }}>
                          {active && (
                            <Icon name="check" size={12}
                              stroke={3}
                              style={{ color: c === '#FFFFFF' || c === '#FFCB5C' || c === '#B7F0AD' || c === '#A5D8FF' ? '#1A1A2E' : '#fff' }}/>
                          )}
                        </button>
                      );
                    })}
                  </div>
                </>
              )}
            </div>

            <span style={{ width: 1, height: 24, background: 'rgba(15,20,30,0.10)' }}/>

            {/* Counts / clear annotations */}
            {annotations.length > 0 && (
              <button onClick={() => {
                if (confirm(`Remove all ${annotations.length} annotations?`)) {
                  setAnnotations([]); setSelectedAnnIds(new Set());
                }
              }}
                title="Clear all annotations"
                style={{
                  height: 30, padding: '0 10px', borderRadius: 9, border: 0,
                  background: 'rgba(255,255,255,0.55)',
                  color: 'var(--text-2)', fontSize: 11, fontFamily: 'inherit',
                  cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6,
                }}>
                <Icon name="eraser" size={12}/> {annotations.length}
              </button>
            )}
          </div>
        </div>

        {/* ===== RIGHT — Inspector ===== */}
        <div style={{
          width: 304,
          borderLeft: '1px solid var(--border)',
          background: 'var(--bg-surface)',
          display: 'flex', flexDirection: 'column',
          overflow: 'hidden',
        }}>
          {selectedAnnIds.size === 1 && selectedIds.size === 0 ? (() => {
            const ann = annotations.find(a => selectedAnnIds.has(a.id));
            if (!ann) return null;
            const isText   = ann.type === 'text';
            const isSticky = ann.type === 'sticky';
            const isShape  = ann.type === 'shape';
            const isPen    = ann.type === 'pen';
            const TYPE_META = {
              text:   { label: 'Text label', icon: 'text-tool' },
              sticky: { label: 'Sticky note', icon: 'sticky' },
              shape:  { label: 'Frame',       icon: 'shape-rect' },
              pen:    { label: 'Pen stroke',  icon: 'pen' },
            };
            const meta = TYPE_META[ann.type];
            return (
              <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
                <div style={{
                  padding: '14px 16px', borderBottom: '1px solid var(--border)',
                  display: 'flex', alignItems: 'center', gap: 10,
                }}>
                  <span style={{
                    width: 28, height: 28, borderRadius: 7,
                    background: `${ann.color}1F`, color: ann.color === '#FFFFFF' ? 'var(--text-2)' : ann.color,
                    border: ann.color === '#FFFFFF' ? '1px solid var(--border)' : 'none',
                    display: 'grid', placeItems: 'center', flexShrink: 0,
                  }}>
                    <Icon name={meta.icon} size={14}/>
                  </span>
                  <div style={{ minWidth: 0, flex: 1 }}>
                    <div style={{
                      fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
                      textTransform: 'uppercase', letterSpacing: '0.06em',
                    }}>Annotation</div>
                    <div className="font-display" style={{ fontSize: 14, fontWeight: 600 }}>{meta.label}</div>
                  </div>
                  <button onClick={() => deleteAnnotations([ann.id])}
                          className="btn btn-ghost btn-icon"
                          style={{ width: 26, height: 26 }} title="Delete">
                    <Icon name="trash" size={13}/>
                  </button>
                </div>

                <div className="scroll" style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 24px' }}>
                  {/* Text content for text/sticky */}
                  {(isText || isSticky) && (
                    <div style={{ marginBottom: 14 }}>
                      <div style={{ fontSize: 11, color: 'var(--text-2)', fontWeight: 600, marginBottom: 6 }}>Content</div>
                      <TextareaField value={ann.text || ''}
                        onChange={(v) => updateAnn(ann.id, { text: v })}
                        placeholder={isSticky ? 'Type your note…' : 'Add label…'}/>
                    </div>
                  )}

                  {/* Font size for text/sticky — free px input */}
                  {(isText || isSticky) && (
                    <div style={{ marginBottom: 14 }}>
                      <div style={{ fontSize: 11, color: 'var(--text-2)', fontWeight: 600, marginBottom: 6 }}>Font size</div>
                      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                        <StepperField value={ann.size ?? (isSticky ? 14 : 16)}
                          min={8} max={200}
                          onChange={(v) => updateAnn(ann.id, { size: Math.round(v) })}
                          unit="px"/>
                        <input type="range" min={8} max={120} step={1}
                          value={ann.size ?? (isSticky ? 14 : 16)}
                          onChange={(e) => updateAnn(ann.id, { size: parseInt(e.target.value, 10) })}
                          style={{
                            flex: 1, height: 4, WebkitAppearance: 'none', appearance: 'none',
                            background: (() => {
                              const v = ann.size ?? (isSticky ? 14 : 16);
                              const pct = ((v - 8) / (120 - 8)) * 100;
                              return `linear-gradient(to right, var(--accent) 0%, var(--accent) ${pct}%, var(--border) ${pct}%, var(--border) 100%)`;
                            })(),
                            borderRadius: 999, outline: 'none', cursor: 'pointer',
                          }}/>
                      </div>
                    </div>
                  )}

                  {/* Text alignment for text/sticky */}
                  {(isText || isSticky) && (
                    <div style={{ marginBottom: 14 }}>
                      <div style={{ fontSize: 11, color: 'var(--text-2)', fontWeight: 600, marginBottom: 6 }}>Alignment</div>
                      <div style={{ display: 'flex', gap: 4 }}>
                        {[
                          { v: 'left',    icon: 'sort-desc', flip: false, title: 'Align left' },
                          { v: 'center',  icon: 'sort-desc', flip: 'center', title: 'Align center' },
                          { v: 'right',   icon: 'sort-desc', flip: 'right', title: 'Align right' },
                          { v: 'justify', icon: 'menu',      title: 'Justify' },
                        ].map(o => {
                          const cur = ann.align || 'left';
                          const sel = cur === o.v;
                          // Inline align glyph (3 horizontal lines, varying widths)
                          const glyph = (
                            <span style={{ display: 'inline-flex', flexDirection: 'column', gap: 3,
                                           alignItems: o.v === 'center' ? 'center' : (o.v === 'right' ? 'flex-end' : 'stretch') }}>
                              {[12, 16, 10].map((w, i) => (
                                <span key={i} style={{
                                  width: o.v === 'justify' ? 16 : w,
                                  height: 1.5,
                                  background: 'currentColor',
                                  borderRadius: 1,
                                }}/>
                              ))}
                            </span>
                          );
                          return (
                            <button key={o.v} type="button"
                              onClick={() => updateAnn(ann.id, { align: o.v })}
                              title={o.title}
                              style={{
                                flex: 1, height: 32, padding: 0, borderRadius: 8,
                                border: `1px solid ${sel ? 'var(--accent)' : 'var(--border)'}`,
                                background: sel ? 'var(--accent-soft)' : 'var(--bg-surface)',
                                color: sel ? 'var(--accent)' : 'var(--text-2)',
                                cursor: 'pointer', fontFamily: 'inherit',
                                display: 'grid', placeItems: 'center',
                              }}>
                              {glyph}
                            </button>
                          );
                        })}
                      </div>
                    </div>
                  )}

                  {/* Stroke width for pen */}
                  {isPen && (
                    <div style={{ marginBottom: 14 }}>
                      <div style={{ fontSize: 11, color: 'var(--text-2)', fontWeight: 600, marginBottom: 6 }}>Stroke width</div>
                      <SliderField value={ann.width ?? 2.5} min={1} max={12} step={0.5}
                        onChange={(v) => updateAnn(ann.id, { width: v })} unit=" px"/>
                    </div>
                  )}

                  {/* Color */}
                  <div style={{ marginBottom: 14 }}>
                    <div style={{ fontSize: 11, color: 'var(--text-2)', fontWeight: 600, marginBottom: 6 }}>Colour</div>
                    <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                      {ANN_COLORS.map(c => {
                        const active = ann.color === c;
                        return (
                          <button key={c}
                            onClick={() => updateAnn(ann.id, { color: c })}
                            title={c}
                            style={{
                              width: 28, height: 28, padding: 0,
                              borderRadius: 999, cursor: 'pointer',
                              background: c,
                              border: c === '#FFFFFF' ? '1px solid rgba(15,20,30,0.18)' : '1px solid rgba(15,20,30,0.06)',
                              outline: active ? '2px solid var(--accent)' : 'none',
                              outlineOffset: 2,
                              display: 'grid', placeItems: 'center',
                            }}>
                            {active && <Icon name="check" size={12} stroke={3}
                              style={{ color: c === '#FFFFFF' || c === '#FFCB5C' || c === '#B7F0AD' || c === '#A5D8FF' ? '#1A1A2E' : '#fff' }}/>}
                          </button>
                        );
                      })}
                    </div>
                  </div>

                  {/* Tip */}
                  <div style={{
                    marginTop: 8, padding: 12, borderRadius: 10,
                    background: 'var(--bg-soft)', border: '1px solid var(--border)',
                    fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.6,
                  }}>
                    {(isText || isSticky) && <>· Double-click on canvas to edit text<br/></>}
                    {(isText || isSticky || isShape) && <>· Drag the handles to resize<br/></>}
                    · Drag to move · <kbd style={{ fontFamily: 'var(--font-mono)' }}>Del</kbd> to delete
                  </div>
                </div>
              </div>
            );
          })()
          : (selectedIds.size + selectedAnnIds.size) > 1 ? (
            <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
              <div style={{
                padding: '14px 16px', borderBottom: '1px solid var(--border)',
                display: 'flex', alignItems: 'center', gap: 10,
              }}>
                <span style={{
                  width: 28, height: 28, borderRadius: 7,
                  background: 'var(--accent-soft)', color: 'var(--accent)',
                  display: 'grid', placeItems: 'center', flexShrink: 0,
                  fontWeight: 700, fontSize: 13, fontFamily: 'var(--font-mono)',
                }}>{selectedIds.size + selectedAnnIds.size}</span>
                <div style={{ minWidth: 0, flex: 1 }}>
                  <div style={{
                    fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
                    textTransform: 'uppercase', letterSpacing: '0.06em',
                  }}>Multi-selection</div>
                  <div className="font-display" style={{ fontSize: 14, fontWeight: 600 }}>
                    {selectedIds.size > 0 && `${selectedIds.size} node${selectedIds.size > 1 ? 's' : ''}`}
                    {selectedIds.size > 0 && selectedAnnIds.size > 0 && ' · '}
                    {selectedAnnIds.size > 0 && `${selectedAnnIds.size} annotation${selectedAnnIds.size > 1 ? 's' : ''}`}
                  </div>
                </div>
                <button onClick={() => { setSelectedIds(new Set()); setSelectedAnnIds(new Set()); }}
                        className="btn btn-ghost btn-icon"
                        style={{ width: 26, height: 26 }} title="Clear selection">
                  <Icon name="close" size={13}/>
                </button>
              </div>
              <div className="scroll" style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }}>
                <div style={{
                  fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
                  textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8,
                }}>Bulk actions</div>
                <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                  {selectedIds.size > 0 && (
                    <>
                      <button onClick={() => {
                        [...selectedIds].forEach(id => duplicateNode(id));
                      }} className="btn btn-stroke btn-sm" style={{ justifyContent: 'flex-start' }}>
                        <Icon name="copy" size={13}/> Duplicate {selectedIds.size} node{selectedIds.size > 1 ? 's' : ''}
                      </button>
                      <button onClick={() => {
                        setEdges(es => es.filter(e => !selectedIds.has(e.from) && !selectedIds.has(e.to)));
                      }} className="btn btn-stroke btn-sm" style={{ justifyContent: 'flex-start' }}>
                        <Icon name="close" size={13}/> Disconnect node edges
                      </button>
                    </>
                  )}
                  <button onClick={() => {
                    if (selectedIds.size > 0) deleteSelected();
                    if (selectedAnnIds.size > 0) deleteAnnotations([...selectedAnnIds]);
                  }} className="btn btn-stroke btn-sm" style={{
                    justifyContent: 'flex-start',
                    color: 'var(--danger)', borderColor: 'rgba(220,53,69,0.3)',
                  }}>
                    <Icon name="trash" size={13}/> Delete {selectedIds.size + selectedAnnIds.size} item{(selectedIds.size + selectedAnnIds.size) > 1 ? 's' : ''}
                  </button>
                </div>
                <div style={{
                  marginTop: 16, padding: 12, borderRadius: 10,
                  background: 'var(--bg-soft)', border: '1px solid var(--border)',
                  fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.6,
                }}>
                  <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
                                textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
                    Tips
                  </div>
                  <div>· Drag any selected item to move the whole group</div>
                  <div>· <kbd style={{ fontFamily: 'var(--font-mono)' }}>Shift</kbd>+click to add/remove an item</div>
                  <div>· <kbd style={{ fontFamily: 'var(--font-mono)' }}>Esc</kbd> clears selection</div>
                </div>
              </div>
            </div>
          ) : selected ? (
            <>
              <div style={{
                padding: '14px 16px',
                borderBottom: '1px solid var(--border)',
                display: 'flex', alignItems: 'center', gap: 10,
              }}>
                <span style={{
                  width: 28, height: 28, borderRadius: 7,
                  background: `${selected.color}1F`, color: selected.color,
                  display: 'grid', placeItems: 'center', flexShrink: 0,
                }}>
                  <Icon name={selected.icon} size={14}/>
                </span>
                <div style={{ minWidth: 0, flex: 1 }}>
                  <div style={{
                    fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
                    textTransform: 'uppercase', letterSpacing: '0.06em',
                  }}>Node · {selected.type}</div>
                  <div className="font-display" style={{
                    fontSize: 14, fontWeight: 600,
                    overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                  }}>{selected.label}</div>
                </div>
                <button onClick={() => deleteNode(selected.id)}
                        className="btn btn-ghost btn-icon"
                        style={{ width: 26, height: 26 }} title="Delete node">
                  <Icon name="trash" size={13}/>
                </button>
              </div>

              <div className="scroll" style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 24px' }}>
                <div style={{
                  fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
                  textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8,
                }}>Parameters</div>

                <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
                  {selected.fields?.filter(f => {
                    if (!f.k.startsWith('_p_')) return true;
                    const portName = Object.entries(selected.portFieldMap || {}).find(([, key]) => key === f.k)?.[0];
                    return portName && !edges.some(e => e.to === selected.id && e.toPort === portName);
                  }).map((f) => {
                    const set = (v) => updateField(f.k, v);
                    let control;
                    switch (f.type) {
                      case 'textarea':
                        control = <TextareaField value={f.v} onChange={set} placeholder={f.placeholder}/>;
                        break;
                      case 'select':
                        control = <SelectField value={f.v} options={f.options} onChange={set}
                          renderOption={(o) => (
                            <span style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
                              <span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{o.label}</span>
                              {o.sub && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{o.sub}</span>}
                            </span>
                          )}/>;
                        break;
                      case 'select-brand': {
                        const brands = (window.BRANDS_V3 || []).map(b => ({
                          v: b.id, label: b.name, handle: b.handle, country: b.country,
                        }));
                        control = <SelectField value={f.v} options={brands} onChange={set}
                          renderOption={(o, isTrigger) => (
                            <>
                              <BrandChip name={o.label} size={isTrigger ? 22 : 24} square/>
                              <span style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
                                <span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{o.label}</span>
                                <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{o.handle}</span>
                              </span>
                            </>
                          )}/>;
                        break;
                      }
                      case 'select-ad': {
                        const ads = (window.ADS_V3 || []).slice(0, 24).map(a => ({
                          v: a.id, label: a.headline || a.title, brand: a.brand, thumb: a.thumb,
                        }));
                        control = <SelectField value={f.v} options={ads} onChange={set}
                          renderOption={(o, isTrigger) => (
                            <>
                              <span style={{
                                width: isTrigger ? 22 : 28, height: isTrigger ? 22 : 28,
                                borderRadius: 5, background: o.thumb, flexShrink: 0,
                                border: '1px solid var(--border-soft)',
                              }}/>
                              <span style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
                                <span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{o.label}</span>
                                <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{o.brand}</span>
                              </span>
                            </>
                          )}/>;
                        break;
                      }
                      case 'select-folder': {
                        const folders = (window.SAVE_FOLDERS_V3 || []).filter(f => !f.system || f.id === 'all').map(fo => ({
                          v: fo.id, label: fo.name, icon: fo.icon, color: fo.color, count: fo.count,
                        }));
                        control = <SelectField value={f.v} options={folders} onChange={set}
                          renderOption={(o) => (
                            <>
                              <span style={{
                                width: 22, height: 22, borderRadius: 5,
                                background: o.color ? `${o.color}22` : 'var(--bg-soft)',
                                color: o.color || 'var(--text-2)',
                                display: 'grid', placeItems: 'center', flexShrink: 0,
                              }}>
                                <Icon name={o.icon} size={12}/>
                              </span>
                              <span style={{ flex: 1, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{o.label}</span>
                              <span style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>{o.count}</span>
                            </>
                          )}/>;
                        break;
                      }
                      case 'chips':
                        control = <ChipsField value={f.v} options={f.options} onChange={set}/>;
                        break;
                      case 'aspect':
                        control = <AspectField value={f.v} onChange={set}/>;
                        break;
                      case 'stepper':
                        control = <StepperField value={f.v} min={f.min} max={f.max} onChange={set} unit={f.unit}/>;
                        break;
                      case 'slider':
                        control = <SliderField value={f.v} min={f.min} max={f.max} step={f.step} onChange={set} unit={f.unit}/>;
                        break;
                      case 'file':
                        control = <FileField value={f.v} accept={f.accept} onChange={set}/>;
                        break;
                      case 'text':
                      default:
                        control = <TextField value={f.v} onChange={set} placeholder={f.placeholder}/>;
                    }
                    return (
                      <div key={f.k}>
                        <div style={{
                          fontSize: 11, color: 'var(--text-2)', fontWeight: 600,
                          marginBottom: 6, letterSpacing: 0.01,
                        }}>{f.label}</div>
                        {control}
                      </div>
                    );
                  })}
                </div>

                {/* I/O recap */}
                <div style={{
                  marginTop: 18, padding: 12, borderRadius: 10,
                  background: 'var(--bg-soft)', border: '1px solid var(--border)',
                  fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.6,
                }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--text-2)', fontWeight: 500, marginBottom: 4 }}>
                    <span>Inputs</span><span>{selected.in?.length || 0}</span>
                  </div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--text-2)', fontWeight: 500 }}>
                    <span>Outputs</span><span>{selected.out?.length || 0}</span>
                  </div>
                </div>

                {/* AI hint */}
                <div style={{
                  marginTop: 14, padding: 12, borderRadius: 10,
                  background: 'var(--accent-soft)', border: '1px solid var(--accent-soft)',
                  fontSize: 12, lineHeight: 1.5, color: 'var(--text-2)',
                }}>
                  <div style={{
                    display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4,
                    color: 'var(--accent)', fontSize: 10, fontWeight: 600,
                    textTransform: 'uppercase', letterSpacing: 0.04,
                  }}>
                    <Icon name="sparkles" size={11}/> Tip
                  </div>
                  Connect a Brand voice node upstream of this generator to keep tone, palette and CTA consistent across runs.
                </div>
              </div>
            </>
          ) : (
            <div style={{
              flex: 1, display: 'flex', flexDirection: 'column',
              alignItems: 'center', justifyContent: 'center', gap: 14,
              padding: 24, color: 'var(--text-faint)', fontSize: 12, textAlign: 'center',
            }}>
              <Icon name="cursor" size={22} style={{ color: 'var(--text-faint)' }}/>
              <div style={{ color: 'var(--text-2)', fontWeight: 500 }}>Nothing selected</div>
              <div style={{ lineHeight: 1.7, maxWidth: 240 }}>
                <Icon name="hand" size={11}/> <b>Grab</b> tool — drag to pan<br/>
                <Icon name="cursor" size={11}/> <b>Select</b> tool — drag to box-select<br/>
                Click a node to auto-switch to Select.<br/>
                Right-click empty canvas to switch back to Grab.
              </div>
            </div>
          )}
        </div>
      </div>

      {/* ===== Node actions popover ===== */}
      {nodeMenu && (() => {
        const items = [
          { label: 'Rename',         icon: 'edit',     onClick: () => renameNode(nodeMenu.id) },
          { label: 'Duplicate',      icon: 'copy',     onClick: () => duplicateNode(nodeMenu.id) },
          { label: 'Bring to front', icon: 'arrow-up', onClick: () => bringToFront(nodeMenu.id) },
          { label: 'Copy as JSON',   icon: 'export',   onClick: () => copyNodeJson(nodeMenu.id) },
          { label: 'Disconnect all', icon: 'close',    onClick: () => disconnectNode(nodeMenu.id) },
          { divider: true },
          { label: 'Delete',         icon: 'trash',    onClick: () => deleteNode(nodeMenu.id), danger: true },
        ];
        return (
          <>
            <div onMouseDown={() => setNodeMenu(null)}
                 onContextMenu={(e) => { e.preventDefault(); setNodeMenu(null); }}
                 style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'transparent' }}/>
            <div style={{
              position: 'fixed', left: nodeMenu.x, top: nodeMenu.y,
              zIndex: 201, minWidth: 184,
              background: 'var(--bg-surface)',
              border: '1px solid var(--border)',
              borderRadius: 10,
              boxShadow: '0 14px 36px rgba(14,18,27,0.18), 0 2px 6px rgba(14,18,27,0.06)',
              padding: 4,
              transform: 'translateX(-100%)', // anchor right edge to icon's right
              animation: 'rise 120ms ease-out',
            }}
            onMouseDown={(e) => e.stopPropagation()}>
              {items.map((it, i) => it.divider ? (
                <div key={`d${i}`} style={{ height: 1, background: 'var(--border-soft)', margin: '4px 6px' }}/>
              ) : (
                <button key={it.label}
                  onClick={(e) => { e.stopPropagation(); it.onClick(); setNodeMenu(null); }}
                  onMouseEnter={(e) => e.currentTarget.style.background = it.danger ? 'rgba(220,53,69,0.10)' : 'var(--bg-soft)'}
                  onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
                  style={{
                    display: 'flex', alignItems: 'center', gap: 9,
                    width: '100%', padding: '7px 10px',
                    background: 'transparent', border: 0, borderRadius: 6,
                    color: it.danger ? 'var(--danger)' : 'var(--text)',
                    fontSize: 13, fontFamily: 'inherit', textAlign: 'left',
                    cursor: 'pointer',
                  }}>
                  <Icon name={it.icon} size={13} style={{ color: it.danger ? 'var(--danger)' : 'var(--text-faint)' }}/>
                  <span style={{ flex: 1 }}>{it.label}</span>
                </button>
              ))}
            </div>
          </>
        );
      })()}

      {/* ===== Templates modal ===== */}
      {showTemplates && (
        <div onClick={() => setShowTemplates(false)} style={{
          position: 'fixed', inset: 0, zIndex: 150,
          background: 'rgba(14,18,27,0.50)', backdropFilter: 'blur(6px)',
          display: 'grid', placeItems: 'center',
        }}>
          <div onClick={(e) => e.stopPropagation()} className="card" style={{
            width: 640, maxWidth: '92vw',
            display: 'flex', flexDirection: 'column', overflow: 'hidden',
          }}>
            <div style={{
              padding: '18px 22px', borderBottom: '1px solid var(--border)',
              display: 'flex', alignItems: 'center', gap: 12,
            }}>
              <div style={{
                width: 36, height: 36, borderRadius: 10,
                background: 'linear-gradient(135deg,#7B5BFF,#3B2BAE)',
                color: '#fff', display: 'grid', placeItems: 'center',
              }}>
                <Icon name="package" size={18}/>
              </div>
              <div style={{ flex: 1 }}>
                <div className="font-display" style={{ fontWeight: 600, fontSize: 16 }}>Workflow templates</div>
                <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>Start from a preset or build from scratch</div>
              </div>
              <button onClick={() => setShowTemplates(false)} className="btn btn-ghost btn-icon">
                <Icon name="close" size={16}/>
              </button>
            </div>
            <div style={{ padding: 18, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
              {WORKFLOW_TEMPLATES.map(t => (
                <div key={t.id} onClick={() => setShowTemplates(false)}
                  className="card" style={{
                    padding: 14, cursor: 'pointer', border: '1px solid var(--border)', boxShadow: 'none',
                  }}
                  onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-soft)'}
                  onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg-surface)'}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
                    <Icon name="workflow" size={14} style={{ color: 'var(--accent)' }}/>
                    <span className="font-display" style={{ fontWeight: 600, fontSize: 14 }}>{t.name}</span>
                  </div>
                  <div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: 10 }}>{t.desc}</div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                    <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t.nodes} nodes</span>
                    <span style={{ fontSize: 11, color: 'var(--accent)', fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
                      Use template <Icon name="arrow-right-s" size={12}/>
                    </span>
                  </div>
                </div>
              ))}
            </div>
          </div>
        </div>
      )}
    </>
  );
};

Object.assign(window, { WorkflowV3 });
