> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deepidv.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflow Builder

> Build a deepidv verification workflow visually — drag services in, connect them, configure each step, preview the applicant experience, and export the workflow payload

export const WorkflowBuilderApp = props => {
  const d = props && props.deps || ({});
  const App = useMemo(() => {
    if (typeof d.makeApp !== 'function') return () => <div className="wb-boot-error">Workflow Builder failed to load.</div>;
    const ui = d.makeUi();
    const utils = d.makeUtils({
      WB: d.WB,
      STEP_PROPERTY_GROUPS: d.STEP_PROPERTY_GROUPS
    });
    const geometry = d.makeGeometry({
      WB: d.WB
    });
    const store = d.makeStore({
      WB: d.WB,
      utils,
      geometry
    });
    const beats = d.makeBeats();
    const previewSteps = d.makePreviewSteps({
      WB: d.WB,
      utils,
      ui,
      beats
    });
    const previewMod = d.makePreview({
      WB: d.WB,
      utils,
      store,
      ui,
      previewSteps,
      beats
    });
    const fields = d.makeFields({
      WB: d.WB,
      utils,
      ui
    });
    const configPanelMod = d.makeConfigPanel({
      WB: d.WB,
      utils,
      store,
      ui,
      fields
    });
    const paletteMod = d.makePalette({
      WB: d.WB,
      utils,
      store,
      ui
    });
    const listModeMod = d.makeListMode({
      WB: d.WB,
      utils,
      store,
      ui
    });
    const canvasMod = d.makeCanvas({
      WB: d.WB,
      utils,
      geometry,
      store,
      ui
    });
    return d.makeApp({
      WB: d.WB,
      WB_CSS: d.WB_CSS,
      ui,
      utils,
      geometry,
      store,
      palette: paletteMod,
      listMode: listModeMod,
      canvas: canvasMod,
      configPanel: configPanelMod,
      preview: previewMod
    }).WorkflowBuilder;
  }, []);
  return <App />;
};

export const makeApp = ({WB, WB_CSS, ui, utils, geometry, store, palette, listMode, canvas, configPanel, preview}) => {
  const {WorkflowProvider, useWorkflow} = store;
  const {Palette} = palette;
  const {ListCanvas} = listMode;
  const {WorkflowCanvas} = canvas;
  const {RightConfigPanel} = configPanel;
  const {ClientPreview} = preview;
  const {Button, IconButton, TextField, Select, Dialog, ToastProvider, useToast, FeatureLockedDialog, LucideIcon, Alert} = ui;
  const cx = (...p) => p.filter(Boolean).join(' ');
  const arr = v => Array.isArray(v) ? v : [];
  const Styles = () => {
    const done = useRef(false);
    useEffect(() => {
      if (done.current) return;
      done.current = true;
      try {
        if (!document.getElementById('wb-css')) {
          const el = document.createElement('style');
          el.id = 'wb-css';
          el.textContent = WB_CSS;
          document.head.appendChild(el);
        }
      } catch (e) {}
    }, []);
    return null;
  };
  const Shell = () => {
    const wf = useWorkflow() || ({});
    const state = wf.state || ({});
    const actions = wf.actions || ({});
    const selectors = wf.selectors || ({});
    const toast = useToast();
    const workflow = state.workflow || ({});
    const steps = arr(workflow.steps);
    const viewMode = state.viewMode || 'builder';
    const [gated, setGated] = useState(null);
    const [saveOpen, setSaveOpen] = useState(false);
    const [payload, setPayload] = useState(null);
    const [apiKey, setApiKey] = useState('');
    const [sending, setSending] = useState(false);
    const [sendResult, setSendResult] = useState(null);
    const [zoom, setZoom] = useState(1);
    const [device, setDevice] = useState('mobile');
    const [headerH, setHeaderH] = useState(112);
    useEffect(() => {
      const measure = () => {
        try {
          const el = document.querySelector('header');
          const b = el ? Math.round(el.getBoundingClientRect().bottom) : 112;
          setHeaderH(b > 0 ? b : 112);
        } catch (e) {
          setHeaderH(112);
        }
      };
      measure();
      window.addEventListener('resize', measure);
      return () => window.removeEventListener('resize', measure);
    }, []);
    const onGated = useCallback(service => setGated(service || null), []);
    const cost = selectors.cost || 0;
    const costLabel = (utils.fCurrency ? utils.fCurrency(cost) : '$' + Number(cost).toFixed(2)) + ' / session';
    const handleSave = () => {
      const name = String(workflow.name || '').trim();
      if (!steps.length) {
        toast('Add at least one service before saving.', {
          type: 'error'
        });
        return;
      }
      if (steps.some(s => s && s.id === 'add-step')) {
        toast('Remove placeholder steps before saving.', {
          type: 'error'
        });
        return;
      }
      if (!name) {
        toast('Give your workflow a name.', {
          type: 'error'
        });
        return;
      }
      let errors = [];
      try {
        errors = arr(utils.stepValidation({
          ...workflow,
          name
        }));
      } catch (e) {
        errors = [];
      }
      if (errors.length) {
        errors.slice(0, 4).forEach(m => toast(String(m), {
          type: 'error'
        }));
        return;
      }
      let ordered = steps;
      try {
        const res = utils.resolveStepOrderFromConnections(steps, arr(workflow.canvasData && workflow.canvasData.connections));
        if (res && arr(res.warnings).some(w => (/cycle/i).test(String(w)))) {
          toast('Cycle detected in the workflow — fix the connections first.', {
            type: 'error'
          });
          return;
        }
        if (res && arr(res.orderedSteps).length) ordered = res.orderedSteps; else if (res && arr(res.ordered).length) ordered = res.ordered;
      } catch (e) {}
      let body = null;
      try {
        body = utils.buildSavePayload({
          workflow: {
            ...workflow,
            name
          },
          orderedSteps: ordered,
          organizationId: 'org_demo',
          createdBy: 'you@deepidv.com'
        });
      } catch (e) {
        body = {
          organizationId: 'org_demo',
          createdBy: 'you@deepidv.com',
          name,
          steps: utils.serializeWorkflowForSave ? utils.serializeWorkflowForSave(ordered) : [],
          canvasData: utils.serializeCanvasData ? utils.serializeCanvasData(workflow.canvasData) : workflow.canvasData,
          status: 'active'
        };
      }
      if (body && !body.status) body.status = 'active';
      setPayload(body);
      setSendResult(null);
      setSaveOpen(true);
      toast('Workflow payload ready.', {
        type: 'success'
      });
    };
    const copyPayload = () => {
      try {
        const text = JSON.stringify(payload, null, 2);
        if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text);
        toast('Payload copied.', {
          type: 'success'
        });
      } catch (e) {
        toast('Could not copy.', {
          type: 'error'
        });
      }
    };
    const downloadPayload = () => {
      try {
        const blob = new Blob([JSON.stringify(payload, null, 2)], {
          type: 'application/json'
        });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = (String(workflow.name || 'workflow').toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'workflow') + '.json';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
      } catch (e) {
        toast('Download failed.', {
          type: 'error'
        });
      }
    };
    const sendToApi = () => {
      if (!apiKey.trim()) {
        toast('Enter an API key first.', {
          type: 'error'
        });
        return;
      }
      setSending(true);
      setSendResult(null);
      try {
        fetch('https://api.deepidv.com/v1/workflows', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'x-api-key': apiKey.trim()
          },
          body: JSON.stringify(payload)
        }).then(r => r.text().then(t => {
          setSending(false);
          setSendResult({
            ok: r.ok,
            status: r.status,
            body: t.slice(0, 600)
          });
        })).catch(err => {
          setSending(false);
          setSendResult({
            ok: false,
            status: 0,
            body: String(err && err.message)
          });
        });
      } catch (e) {
        setSending(false);
        setSendResult({
          ok: false,
          status: 0,
          body: String(e && e.message)
        });
      }
    };
    const templates = arr(WB.TEMPLATES);
    return <div className="wb-root" data-testid="wb-root" style={{
      height: 'calc(100vh - ' + headerH + 'px)'
    }}>
        <Styles />

        <header className="wb-header">
          <a className="wb-back" href="/workflows/workflows"><LucideIcon name="arrow-left" size={16} /><span>{'Workflows'}</span></a>
          <span className="wb-cost" data-testid="wb-cost">{costLabel}</span>
          <div className="wb-header-mid">
            <TextField value={workflow.name || ''} placeholder="Untitled workflow" testId="wb-name-input" onChange={v => {
      if (typeof actions.setName === 'function') actions.setName({
        name: v
      });
    }} />
            <Select value="" placeholder="Start from a template" testId="wb-template-select" options={templates.map(t => ({
      value: t.id,
      label: t.label
    }))} onChange={id => {
      if (id && typeof actions.applyTemplate === 'function') {
        actions.applyTemplate({
          templateId: id
        });
        toast('Template applied.', {
          type: 'success'
        });
      }
    }} />
          </div>
          <button type="button" className="wb-btn wb-btn-primary" data-testid="wb-save-btn" onClick={handleSave}>
            <LucideIcon name="save" size={15} color="#ffffff" /><span>{'Save Workflow'}</span>
          </button>
        </header>

        <div className="wb-viewtoggle">
          {viewMode === 'preview' ? <>
              <IconButton name="smartphone" size={16} label="Mobile" testId="wb-device-mobile" active={device === 'mobile'} onClick={() => setDevice('mobile')} />
              <IconButton name="monitor" size={16} label="Desktop" testId="wb-device-desktop" active={device === 'desktop'} onClick={() => setDevice('desktop')} />
              <IconButton name="zoom-out" size={16} label="Zoom out" testId="wb-preview-zoom-out" onClick={() => setZoom(z => Math.max(0.5, z - 0.1))} />
              <IconButton name="zoom-in" size={16} label="Zoom in" testId="wb-preview-zoom-in" onClick={() => setZoom(z => Math.min(1.4, z + 0.1))} />
            </> : null}
          <IconButton name="list" size={16} label="List view" testId="wb-view-toggle-builder" active={viewMode === 'builder'} onClick={() => {
      if (typeof actions.setViewMode === 'function') actions.setViewMode({
        viewMode: 'builder'
      });
    }} />
          <IconButton name="layout-grid" size={16} label="Canvas view" testId="wb-view-toggle-canvas" active={viewMode === 'canvas'} onClick={() => {
      if (typeof actions.setViewMode === 'function') actions.setViewMode({
        viewMode: 'canvas'
      });
    }} />
          <IconButton name="eye" size={16} label="Preview" testId="wb-view-toggle-preview" active={viewMode === 'preview'} onClick={() => {
      if (typeof actions.setViewMode === 'function') actions.setViewMode({
        viewMode: 'preview'
      });
    }} />
        </div>

        <div className="wb-main">
          <Palette viewMode={viewMode} onGated={onGated} />
          <main className="wb-center">
            {viewMode === 'canvas' ? <WorkflowCanvas onGated={onGated} /> : viewMode === 'preview' ? <ClientPreview zoomLevel={zoom} deviceMode={device} branding={{
      primaryColor: WB.BRAND.primary
    }} /> : <ListCanvas onGated={onGated} />}
          </main>
          <RightConfigPanel />
        </div>

        <FeatureLockedDialog open={!!gated} onClose={() => setGated(null)} feature={gated ? gated.label : ''} />

        <Dialog open={saveOpen} onClose={() => setSaveOpen(false)} title="Workflow payload" width={720} testId="wb-save-dialog" actions={<>
              <Button variant="text" color="inherit" onClick={() => setSaveOpen(false)}>Close</Button>
              <Button variant="outlined" startIcon="copy" onClick={copyPayload} testId="wb-save-copy">Copy</Button>
              <Button variant="contained" startIcon="download" onClick={downloadPayload} testId="wb-save-download">Download</Button>
            </>}>
          <pre className="wb-json" data-testid="wb-save-json">{payload ? JSON.stringify(payload, null, 2) : ''}</pre>
          <div className="wb-send">
            <div className="wb-field-label">Send to the deepidv API (optional)</div>
            <div className="wb-row">
              <TextField value={apiKey} onChange={setApiKey} placeholder="x-api-key" fullWidth testId="wb-api-key" type="password" />
              <Button variant="outlined" onClick={sendToApi} disabled={sending} testId="wb-send-api">{sending ? 'Sending...' : 'Send'}</Button>
            </div>
            <div className="wb-caption wb-muted">{'POSTs to api.deepidv.com/v1/workflows. The key is never stored.'}</div>
            {sendResult ? <Alert severity={sendResult.ok ? 'success' : 'error'} title={'HTTP ' + sendResult.status}>{sendResult.body}</Alert> : null}
          </div>
        </Dialog>
      </div>;
  };
  const WorkflowBuilder = () => <ToastProvider>
      <WorkflowProvider>
        <Shell />
      </WorkflowProvider>
    </ToastProvider>;
  return {
    WorkflowBuilder
  };
};

export const makePreview = ({WB, utils, store, ui, previewSteps, beats}) => {
  const {useWorkflow} = store;
  const {LucideIcon, StepIcon, IconButton, Button, Tooltip} = ui;
  const {STEP_COMPONENTS, DefaultStep} = previewSteps;
  const {BeatContext, useBeatController} = beats;
  const cx = (...p) => p.filter(Boolean).join(' ');
  const arr = v => Array.isArray(v) ? v : [];
  const V = WB.VERIFY_COLORS;
  const EXCLUDED = arr(WB.EXCLUDED_FROM_PREVIEW);
  const TOP_ALIGNED = arr(WB.TOP_ALIGNED_STEP_IDS);
  const CAMERA = arr(WB.MASTER_CAMERA_SUB_STEP_IDS);
  const injectMasterVerification = steps => {
    const camera = steps.filter(s => CAMERA.indexOf(s.id) >= 0);
    if (camera.length < 2) return steps;
    const rows = [];
    camera.forEach(s => {
      if (s.id === 'id-verification') {
        rows.push({
          key: s.instanceId + '-id',
          stepId: s.id,
          label: 'ID Verification',
          componentKey: 'id'
        });
        rows.push({
          key: s.instanceId + '-face',
          stepId: s.id,
          label: 'Face Match',
          componentKey: 'face'
        });
      } else {
        rows.push({
          key: s.instanceId || s.id,
          stepId: s.id,
          label: s.label,
          componentKey: s.id
        });
      }
    });
    const master = {
      id: 'master-verification',
      instanceId: 'master-verification',
      label: 'Verification',
      icon: 'solar:face-scan-square-bold-duotone',
      rows
    };
    return [master].concat(steps.filter(s => CAMERA.indexOf(s.id) < 0));
  };
  const pseudoQr = (seed, size) => {
    const cells = 11;
    const px = size / cells;
    let h = 0;
    for (let i = 0; i < String(seed).length; i += 1) h = h * 31 + String(seed).charCodeAt(i) >>> 0;
    const rects = [];
    for (let y = 0; y < cells; y += 1) {
      for (let x = 0; x < cells; x += 1) {
        h = h * 1103515245 + 12345 >>> 0;
        const corner = x < 3 && y < 3 || x > cells - 4 && y < 3 || x < 3 && y > cells - 4;
        const on = corner ? !((x === 1 || x === cells - 2) && (y === 1 || y === cells - 2)) : (h >>> 16 & 1) === 1;
        if (on) rects.push(<rect key={x + '-' + y} x={x * px} y={y * px} width={px} height={px} fill="#ffffff" />);
      }
    }
    return rects;
  };
  const ReceiptStamp = ({attestationId, heading, subtitle}) => {
    const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
    const W = 389;
    const H = 460;
    const holes = [];
    for (let i = 0; i < 8; i += 1) {
      holes.push({
        cx: (i + 0.5) * W / 8,
        cy: 0
      });
      holes.push({
        cx: (i + 0.5) * W / 8,
        cy: H
      });
    }
    for (let i = 0; i < 10; i += 1) {
      holes.push({
        cx: 0,
        cy: (i + 0.5) * H / 10
      });
      holes.push({
        cx: W,
        cy: (i + 0.5) * H / 10
      });
    }
    const today = new Date();
    const stamp = today.getFullYear() + '.' + String(today.getMonth() + 1).padStart(2, '0') + '.' + String(today.getDate()).padStart(2, '0');
    return <div className="wb-receipt-stamp" data-testid="wb-receipt-stamp">
        <svg viewBox={'0 0 ' + W + ' ' + H} preserveAspectRatio="none" width="100%" height="100%">
          <defs>
            <linearGradient id={'g' + uid} x1="0" y1="0" x2="1" y2="1">
              <stop offset="0%" stopColor="#1F1F23" /><stop offset="100%" stopColor="#2B2B30" />
            </linearGradient>
            <mask id={'m' + uid}>
              <rect x="0" y="0" width={W} height={H} fill="#fff" />
              {holes.map((h, i) => <circle key={i} cx={h.cx} cy={h.cy} r={6} fill="#000" />)}
            </mask>
          </defs>
          <rect x="0" y="0" width={W} height={H} rx="10" fill={'url(#g' + uid + ')'} mask={'url(#m' + uid + ')'} />
        </svg>
        <div className="wb-receipt-inner">
          <div className="wb-receipt-top">
            <svg width="60" height="60" viewBox="0 0 60 60" className="wb-receipt-qr">{pseudoQr(attestationId, 60)}</svg>
            <span className="wb-receipt-date">{stamp}</span>
          </div>
          <img src="/logo/favicon.svg" alt="" width="34" height="34" className="wb-receipt-emblem" />
          <div className="wb-receipt-heading">{heading}</div>
          <div className="wb-receipt-sub">{subtitle}</div>
          <div className="wb-receipt-foot">{utils.truncateMiddle ? utils.truncateMiddle(attestationId, 22) : attestationId}</div>
        </div>
      </div>;
  };
  const WalletBadge = ({kind, height}) => <span className="wb-wallet" style={{
    height
  }}>
      <LucideIcon name={kind === 'apple' ? 'apple' : 'wallet'} size={Math.round(height * 0.4)} color="#ffffff" />
      <span className="wb-wallet-text">
        <span className="wb-wallet-small">Add to</span>
        <span className="wb-wallet-big">{kind === 'apple' ? 'Apple Wallet' : 'Google Wallet'}</span>
      </span>
    </span>;
  const ReceiptActions = ({attestationId, branding, deviceMode}) => {
    const primary = branding && branding.primaryColor || V.primary;
    const h = deviceMode === 'mobile' ? 36 : 48;
    return <div className="wb-receipt-actions">
        <div className="wb-wallets"><WalletBadge kind="apple" height={h} /><WalletBadge kind="google" height={h} /></div>
        <div className="wb-attest-row">
          <span className="wb-attest-badge" style={{
      background: primary + '1A',
      color: primary
    }}>IDV</span>
          <span className="wb-attest-id">{utils.truncateMiddle ? utils.truncateMiddle(attestationId, 20) : attestationId}</span>
          <IconButton name="copy" size={15} label="Copy attestation id" />
        </div>
        <div className="wb-downloads">
          {['Verification receipt', 'Proof bundle'].map(label => <div key={label} className="wb-download-row">
              <LucideIcon name="file-text" size={15} color={primary} />
              <span>{label}</span>
              <IconButton name="download" size={15} label={'Download ' + label} />
            </div>)}
        </div>
      </div>;
  };
  const ClientPreview = ({zoomLevel = 1, deviceMode = 'mobile', branding, skipWelcome}) => {
    const wf = useWorkflow() || ({});
    const state = wf.state || ({});
    const rawSteps = arr(state.workflow && state.workflow.steps);
    const steps = useMemo(() => injectMasterVerification(rawSteps.filter(s => EXCLUDED.indexOf(s.id) < 0)), [rawSteps]);
    const [index, setIndex] = useState(skipWelcome ? 0 : -1);
    const screenRef = useRef(null);
    const scrollToTop = useCallback(() => {
      try {
        if (screenRef.current) screenRef.current.scrollTop = 0;
      } catch (e) {}
    }, []);
    const ctrl = useBeatController({
      scrollToTop
    });
    const {beatState, beatHandlersRef, resetBeats} = ctrl;
    useEffect(() => {
      resetBeats();
      scrollToTop();
    }, [index, resetBeats, scrollToTop]);
    useEffect(() => {
      if (index > steps.length) setIndex(steps.length);
    }, [steps.length, index]);
    const size = WB.DEVICE_SIZES[deviceMode] || WB.DEVICE_SIZES.mobile;
    const brand = branding && branding.primaryColor || V.primary;
    const view = index < 0 ? 'welcome' : index >= steps.length ? 'complete' : 'step';
    const current = view === 'step' ? steps[index] : null;
    const canGoForward = index < steps.length;
    const canGoBack = index > (skipWelcome ? 0 : -1);
    const arrowNext = canGoForward || beatState.hasNext;
    const arrowPrev = canGoBack || beatState.hasPrev;
    const handleNext = () => {
      const h = beatHandlersRef.current;
      if (beatState.hasNext && h && typeof h.next === 'function') {
        h.next();
        return;
      }
      if (canGoForward) setIndex(index + 1);
    };
    const handlePrev = () => {
      const h = beatHandlersRef.current;
      if (beatState.hasPrev && h && typeof h.prev === 'function') {
        h.prev();
        return;
      }
      if (canGoBack) setIndex(index - 1);
    };
    const goToStep = i => setIndex(i);
    const attestationId = 'att_' + (state.workflow && state.workflow.name ? String(state.workflow.name).toLowerCase().replace(/[^a-z0-9]+/g, '') : 'preview') + '_9f2ac41b73de';
    const renderStepContent = () => {
      if (!current) return null;
      const Comp = STEP_COMPONENTS[current.id] || DefaultStep;
      return <Comp step={current} branding={{
        primaryColor: brand
      }} deviceMode={deviceMode} enterFrom="start" onContinue={() => setIndex(i => Math.min(steps.length, i + 1))} />;
    };
    const topAligned = current && TOP_ALIGNED.indexOf(current.id) >= 0;
    return <BeatContext.Provider value={ctrl.value}>
        <div className="wb-preview" data-testid="wb-preview">
          <div className="wb-device" style={{
      transform: 'scale(' + zoomLevel + ')',
      transformOrigin: 'top center'
    }}>
            <div className={cx('wb-bezel', deviceMode === 'mobile' ? 'wb-bezel-mobile' : 'wb-bezel-desktop')}>
              <div ref={screenRef} className="wb-screen" data-testid="wb-preview-screen" data-view={view} style={{
      width: size.width,
      height: size.height,
      background: branding && branding.backgroundColor || '#ffffff'
    }}>
                <div className="wb-screen-header">
                  <span className="wb-drag-pill" />
                  <div className="wb-screen-header-row">
                    <img src="/logo/favicon.svg" alt="" width="18" height="18" />
                    <span className="wb-screen-header-right">
                      <LucideIcon name="accessibility" size={14} />
                      <span className="wb-lang">EN</span>
                    </span>
                  </div>
                </div>

                <div className={cx('wb-screen-body', topAligned && 'wb-screen-body-top')}>
                  {steps.length === 0 ? <div className="wb-pv-empty">Add a service to preview the applicant experience.</div> : view === 'welcome' ? <div className="wb-pv-welcome">
                      <span className="wb-pv-org" style={{
      color: brand
    }}>deepidv</span>
                      <h3 className="wb-pv-title">You have been invited to verify your identity</h3>
                      <svg width="56" height="56" viewBox="0 0 56 56" aria-hidden="true">
                        <rect x="6" y="14" width="44" height="30" rx="4" fill="none" stroke={brand} strokeWidth="2.5" />
                        <path d="M8 17 L28 32 L48 17" fill="none" stroke={brand} strokeWidth="2.5" strokeLinecap="round" />
                      </svg>
                      <button type="button" className="wb-pv-btn" style={{
      background: brand
    }} data-testid="wb-preview-accept" onClick={() => setIndex(0)}>Accept Invitation</button>
                    </div> : view === 'complete' ? <div className="wb-pv-complete">
                      <h3 className="wb-pv-title">Verification submitted</h3>
                      <p className="wb-pv-sub">Your receipt is ready. Keep it for your records.</p>
                      <ReceiptStamp attestationId={attestationId} heading="Verification receipt" subtitle="Issued by deepidv" />
                      <ReceiptActions attestationId={attestationId} branding={{
      primaryColor: brand
    }} deviceMode={deviceMode} />
                      <button type="button" className="wb-pv-btn wb-pv-btn-full" style={{
      background: brand
    }} data-testid="wb-preview-reset" onClick={() => goToStep(skipWelcome ? 0 : -1)}>Reset</button>
                    </div> : renderStepContent()}
                </div>
              </div>
            </div>
          </div>

          {steps.length ? <div className="wb-preview-nav">
              <IconButton name="chevron-left" size={18} label="Previous" testId="wb-preview-prev" disabled={!arrowPrev} onClick={handlePrev} />
              <div className="wb-preview-dots">
                {steps.map((s, i) => <Tooltip key={s.instanceId || s.id} title={s.label}>
                    <button type="button" data-testid={'wb-preview-dot-' + i} aria-label={s.label} className="wb-preview-dot" onClick={() => goToStep(i)} style={{
      width: i === index ? 18 : 8,
      opacity: i === index ? 1 : 0.5,
      background: brand
    }} />
                  </Tooltip>)}
              </div>
              <IconButton name="chevron-right" size={18} label="Next" testId="wb-preview-next" disabled={!arrowNext} onClick={handleNext} />
            </div> : null}
          {beatState.beats.length > 1 && beatState.label ? <div className="wb-beat-caption">{beatState.label}</div> : null}
        </div>
      </BeatContext.Provider>;
  };
  return {
    ClientPreview
  };
};

export const makeBeats = () => {
  const DEFAULT_BEAT_STATE = {
    beats: ['_only'],
    currentBeatId: '_only',
    label: null,
    hasNext: false,
    hasPrev: false
  };
  const BeatContext = React.createContext(null);
  const sameShape = (a, b) => {
    if (!a || !b) return false;
    return a.currentBeatId === b.currentBeatId && a.label === b.label && a.hasNext === b.hasNext && a.hasPrev === b.hasPrev && Array.isArray(a.beats) && Array.isArray(b.beats) && a.beats.length === b.beats.length && a.beats.every((x, i) => x === b.beats[i]);
  };
  const useBeatController = ({scrollToTop} = {}) => {
    const [beatState, setBeatState] = useState(DEFAULT_BEAT_STATE);
    const beatHandlersRef = useRef({
      next: null,
      prev: null
    });
    const report = useCallback(descriptor => {
      if (!descriptor) {
        beatHandlersRef.current = {
          next: null,
          prev: null
        };
        return;
      }
      beatHandlersRef.current = {
        next: descriptor.next || null,
        prev: descriptor.prev || null
      };
      const next = {
        beats: Array.isArray(descriptor.beats) ? descriptor.beats : DEFAULT_BEAT_STATE.beats,
        currentBeatId: descriptor.currentBeatId === undefined ? DEFAULT_BEAT_STATE.currentBeatId : descriptor.currentBeatId,
        label: descriptor.label === undefined ? null : descriptor.label,
        hasNext: !!descriptor.hasNext,
        hasPrev: !!descriptor.hasPrev
      };
      setBeatState(prev => sameShape(prev, next) ? prev : next);
    }, []);
    const resetBeats = useCallback(() => {
      beatHandlersRef.current = {
        next: null,
        prev: null
      };
      setBeatState(DEFAULT_BEAT_STATE);
    }, []);
    const value = useMemo(() => ({
      report,
      scrollToTop: scrollToTop || (() => {})
    }), [report, scrollToTop]);
    return {
      beatState,
      beatHandlersRef,
      report,
      resetBeats,
      value
    };
  };
  const usePreviewBeats = descriptor => {
    const ctx = useContext(BeatContext);
    useEffect(() => {
      if (ctx && typeof ctx.report === 'function') ctx.report(descriptor || null);
    });
    return {
      isArrowDriven: !!(descriptor && Array.isArray(descriptor.beats) && descriptor.beats.length > 1),
      scrollToTop: ctx && ctx.scrollToTop || (() => {})
    };
  };
  return {
    BeatContext,
    useBeatController,
    usePreviewBeats,
    DEFAULT_BEAT_STATE
  };
};

export const makePreviewSteps = ({WB, utils, ui, beats}) => {
  const {StepIcon, LucideIcon, Button, TextField, Select, Switch, Chip, Spinner} = ui;
  const usePreviewBeats = beats && beats.usePreviewBeats || (() => ({
    isArrowDriven: false,
    scrollToTop: () => {}
  }));
  const cx = (...p) => p.filter(Boolean).join(' ');
  const arr = v => Array.isArray(v) ? v : [];
  const V = WB.VERIFY_COLORS;
  const brandOf = branding => branding && branding.primaryColor || V.primary;
  const Shell = ({title, subtitle, children, footer, icon, gradient, branding}) => <div className="wb-pv-card">
      {icon ? <span className="wb-pv-icon" style={{
    background: gradient || 'linear-gradient(135deg, #007AFF, #5AC8FA)'
  }}>
          <StepIcon icon={icon} size={26} />
        </span> : null}
      {title ? <h3 className="wb-pv-title">{title}</h3> : null}
      {subtitle ? <p className="wb-pv-sub">{subtitle}</p> : null}
      <div className="wb-pv-body">{children}</div>
      {footer ? <div className="wb-pv-footer">{footer}</div> : null}
    </div>;
  const PrimaryButton = ({branding, onClick, children, disabled, testId}) => <button type="button" className="wb-pv-btn" disabled={disabled} onClick={onClick} data-testid={testId} style={{
    background: brandOf(branding)
  }}>{children}</button>;
  const CameraFrame = ({state, branding, label}) => <div className={cx('wb-pv-camera', state === 'success' && 'wb-pv-camera-ok')} style={{
    borderColor: state === 'success' ? V.success : brandOf(branding)
  }}>
      <div className={cx('wb-pv-oval', state === 'centering' && 'wb-pulse', state === 'loading' && 'wb-shimmer')} />
      {state === 'success' ? <span className="wb-pv-check wb-check-pop" style={{
    background: V.success
  }}><LucideIcon name="check" size={22} color="#fff" /></span> : null}
      {label ? <span className="wb-pv-camera-label">{label}</span> : null}
    </div>;
  const DefaultStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    return <Shell branding={branding} icon={utils.getStepIcon(step.id)} gradient={utils.getStepGradient(step.id)} title={step.label} subtitle={utils.getStepDescription(step.id)} footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        <div className="wb-pv-placeholder" style={{
      color: brandOf(branding)
    }}>
          <StepIcon icon={utils.getStepIcon(step.id)} size={48} color={brandOf(branding)} />
        </div>
      </Shell>;
  };
  const FaceLivenessStep = ({step, onContinue, branding}) => {
    const PHASES = ['centering', 'challenge', 'loading', 'success'];
    const DURATION = {
      centering: 1200,
      challenge: 1500,
      loading: 1500,
      success: 2000
    };
    const [phase, setPhase] = useState('centering');
    const manualRef = useRef(false);
    const idx = PHASES.indexOf(phase);
    usePreviewBeats({
      beats: PHASES,
      currentBeatId: phase,
      label: phase === 'centering' ? 'Center your face' : phase === 'challenge' ? 'Turn your head' : phase === 'loading' ? 'Checking' : 'Verified',
      hasNext: idx < PHASES.length - 1,
      hasPrev: idx > 0,
      next: () => {
        manualRef.current = true;
        setPhase(PHASES[Math.min(PHASES.length - 1, idx + 1)]);
      },
      prev: () => {
        manualRef.current = true;
        setPhase(PHASES[Math.max(0, idx - 1)]);
      }
    });
    useEffect(() => {
      if (manualRef.current) return undefined;
      const ms = DURATION[phase];
      const t = window.setTimeout(() => {
        if (manualRef.current) return;
        if (phase === 'success') {
          if (typeof onContinue === 'function') onContinue();
          return;
        }
        setPhase(PHASES[Math.min(PHASES.length - 1, PHASES.indexOf(phase) + 1)]);
      }, ms);
      return () => window.clearTimeout(t);
    }, [phase, onContinue]);
    const label = phase === 'centering' ? 'Center your face in the oval' : phase === 'challenge' ? 'Slowly turn your head to the left' : phase === 'loading' ? 'Checking liveness…' : 'Liveness confirmed';
    return <Shell branding={branding} title="Face Liveness" subtitle={label} footer={phase === 'success' ? <PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton> : null}>
        <CameraFrame state={phase} branding={branding} />
        <div className="wb-pv-dots">
          {['centering', 'challenge'].map(p => <span key={p} className="wb-pv-dot" style={{
      width: p === phase ? 18 : 8,
      opacity: p === phase ? 1 : 0.5,
      background: brandOf(branding)
    }} />)}
        </div>
      </Shell>;
  };
  const PhoneVerificationStep = ({step, onContinue, branding}) => {
    const STATES = ['idle', 'in-progress', 'completed'];
    const [s, setS] = useState('idle');
    const i = STATES.indexOf(s);
    usePreviewBeats({
      beats: STATES,
      currentBeatId: s,
      label: s === 'idle' ? 'Ready to call' : s === 'in-progress' ? 'Calling…' : 'Call complete',
      hasNext: i < STATES.length - 1,
      hasPrev: i > 0,
      next: () => setS(STATES[Math.min(2, i + 1)]),
      prev: () => setS(STATES[Math.max(0, i - 1)])
    });
    useEffect(() => {
      if (s !== 'in-progress') return undefined;
      const t = window.setTimeout(() => setS('completed'), 1800);
      return () => window.clearTimeout(t);
    }, [s]);
    return <Shell branding={branding} icon="solar:phone-calling-bold-duotone" gradient={utils.getStepGradient('phone-verification')} title="Phone Verification" subtitle={s === 'idle' ? 'We will call you and ask you to repeat a phrase.' : s === 'in-progress' ? 'Calling +1 (•••) ••• 4417' : 'Voice matched successfully.'} footer={s === 'idle' ? <PrimaryButton branding={branding} onClick={() => setS('in-progress')} testId="wb-pv-start">Start call</PrimaryButton> : s === 'completed' ? <PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton> : null}>
        {s === 'in-progress' ? <div className="wb-pv-calling"><Spinner size={26} color={brandOf(branding)} /><span className="wb-pv-phrase">“seventy-two blue harbour”</span></div> : null}
        {s === 'completed' ? <span className="wb-pv-check wb-check-pop" style={{
      background: V.success
    }}><LucideIcon name="check" size={22} color="#fff" /></span> : null}
      </Shell>;
  };
  const IdVerificationStep = ({step, onContinue, branding, scope}) => {
    const isFace = scope === 'face';
    const stages = isFace ? ['capture', 'matching', 'done'] : ['front', 'back', 'review'];
    const [i, setI] = useState(0);
    usePreviewBeats({
      beats: stages,
      currentBeatId: stages[i],
      label: stages[i],
      hasNext: i < stages.length - 1,
      hasPrev: i > 0,
      next: () => setI(v => Math.min(stages.length - 1, v + 1)),
      prev: () => setI(v => Math.max(0, v - 1))
    });
    const last = i === stages.length - 1;
    const copy = isFace ? ['Take a selfie', 'Matching your face to the document', 'Face matched'] : ['Photograph the front of your ID', 'Now the back of your ID', 'Check the images are readable'];
    return <Shell branding={branding} title={isFace ? 'Face Match' : 'ID Verification'} subtitle={copy[i]} footer={<PrimaryButton branding={branding} testId="wb-pv-continue" onClick={() => last ? typeof onContinue === 'function' && onContinue() : setI(i + 1)}>{last ? 'Continue' : 'Capture'}</PrimaryButton>}>
        {isFace ? <CameraFrame state={last ? 'success' : 'centering'} branding={branding} /> : <div className="wb-pv-doc" style={{
      borderColor: brandOf(branding)
    }}><StepIcon icon="solar:user-id-bold-duotone" size={40} color={brandOf(branding)} /></div>}
      </Shell>;
  };
  const DocumentUploadStep = ({step, onContinue, branding}) => {
    const docs = ['Proof of address', 'Bank statement'];
    const [done, setDone] = useState([]);
    const stages = docs.map((d, i) => 'doc-' + i).concat(['review']);
    const idx = Math.min(done.length, stages.length - 1);
    usePreviewBeats({
      beats: stages,
      currentBeatId: stages[idx],
      label: idx < docs.length ? docs[idx] : 'Review',
      hasNext: idx < stages.length - 1,
      hasPrev: idx > 0,
      next: () => setDone(d => d.length < docs.length ? d.concat([docs[d.length]]) : d),
      prev: () => setDone(d => d.slice(0, Math.max(0, d.length - 1)))
    });
    return <Shell branding={branding} title="Document Upload" subtitle="Add the documents we asked for." footer={<PrimaryButton branding={branding} disabled={done.length < docs.length} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        {docs.map(d => {
      const on = done.indexOf(d) >= 0;
      return <button key={d} type="button" className={cx('wb-pv-upload', on && 'wb-pv-upload-on')} onClick={() => setDone(prev => prev.indexOf(d) >= 0 ? prev : prev.concat([d]))}>
              <LucideIcon name={on ? 'circle-check' : 'upload'} size={18} color={on ? V.success : brandOf(branding)} />
              <span>{d}</span>
              <span className="wb-muted">{on ? 'Uploaded' : 'Tap to add'}</span>
            </button>;
    })}
      </Shell>;
  };
  const ConsentStep = ({step, onContinue, branding}) => {
    const [ok, setOk] = useState(false);
    usePreviewBeats(null);
    return <Shell branding={branding} title="Consent" subtitle="Please review and accept before we continue." footer={<PrimaryButton branding={branding} disabled={!ok} onClick={onContinue} testId="wb-pv-continue">I agree</PrimaryButton>}>
        <div className="wb-pv-consent">By continuing you consent to identity verification and the processing of the personal data required to complete these checks.</div>
        <Switch label="I have read and agree" checked={ok} onChange={setOk} />
      </Shell>;
  };
  const CustomFormStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    const [v, setV] = useState({});
    const group = arr(step.propertyGroups).find(g => arr(g.properties).some(p => p.type === 'object'));
    const prop = group ? arr(group.properties).find(p => p.type === 'object') : null;
    const pages = prop && prop.value && Array.isArray(prop.value.pages) ? prop.value.pages : [];
    const flds = pages.length ? arr(pages[0].fields) : [{
      id: 'full-name',
      label: 'Full name',
      type: 'short-text'
    }];
    return <Shell branding={branding} title="A few questions" subtitle="This helps us complete your file." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Submit</PrimaryButton>}>
        {flds.map((f, i) => {
      const opts = f && f.options ? Object.keys(f.options).map(k => ({
        value: f.options[k],
        label: f.options[k]
      })) : [];
      if (f.type === 'dropdown' && opts.length) return <Select key={i} label={f.label} value={v[f.id]} onChange={x => setV({
        ...v,
        [f.id]: x
      })} options={opts} fullWidth />;
      if (f.type === 'yes-no') return <Switch key={i} label={f.label} checked={!!v[f.id]} onChange={x => setV({
        ...v,
        [f.id]: x
      })} />;
      return <TextField key={i} label={f.label || 'Answer'} value={v[f.id] || ''} onChange={x => setV({
        ...v,
        [f.id]: x
      })} fullWidth />;
    })}
      </Shell>;
  };
  const ESignatureStep = ({step, onContinue, branding}) => {
    const [signed, setSigned] = useState(false);
    usePreviewBeats(null);
    return <Shell branding={branding} title="Sign the document" subtitle="Draw your signature to continue." footer={<PrimaryButton branding={branding} disabled={!signed} onClick={onContinue} testId="wb-pv-continue">Submit signature</PrimaryButton>}>
        <button type="button" className={cx('wb-pv-sign', signed && 'wb-pv-sign-on')} onClick={() => setSigned(true)}>
          {signed ? <svg viewBox="0 0 200 60" className="wb-pv-sign-svg"><path d="M10 45 C40 5, 60 55, 90 25 S150 5, 190 35" fill="none" stroke={brandOf(branding)} strokeWidth="3" strokeLinecap="round" /></svg> : <span className="wb-muted">Tap to sign</span>}
        </button>
      </Shell>;
  };
  const ScreeningStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    return <Shell branding={branding} icon={utils.getStepIcon(step.id)} gradient={utils.getStepGradient(step.id)} title={step.label} subtitle="We are running this check in the background." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        <div className="wb-pv-rows">
          {['Identity matched', 'Watchlists searched', 'No adverse results'].map((r, i) => <div key={r} className="wb-pv-row"><LucideIcon name="circle-check" size={16} color={V.success} /><span>{r}</span></div>)}
        </div>
      </Shell>;
  };
  const IntakeStep = ({step, onContinue, branding}) => {
    const [f, setF] = useState({
      name: '',
      dob: ''
    });
    usePreviewBeats(null);
    return <Shell branding={branding} title={step.label} subtitle="Confirm your details to run this check." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        <TextField label="Full legal name" value={f.name} onChange={v => setF({
      ...f,
      name: v
    })} fullWidth />
        <TextField label="Date of birth" value={f.dob} onChange={v => setF({
      ...f,
      dob: v
    })} placeholder="YYYY-MM-DD" fullWidth />
      </Shell>;
  };
  const KybStep = ({step, onContinue, branding}) => {
    const [q, setQ] = useState('');
    usePreviewBeats(null);
    return <Shell branding={branding} title="Find your business" subtitle="Search the registry to link your company." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        <TextField value={q} onChange={setQ} placeholder="Company name or number" startIcon="search" fullWidth />
        <div className="wb-pv-rows">
          {['Deep Identity Inc.', 'Deep Identity Holdings Ltd.'].map(n => <div key={n} className="wb-pv-row"><LucideIcon name="building-2" size={16} color={brandOf(branding)} /><span>{n}</span></div>)}
        </div>
      </Shell>;
  };
  const AgeEstimationStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    return <Shell branding={branding} title="Age Estimation" subtitle="Look at the camera — no ID needed." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        <CameraFrame state="centering" branding={branding} label="Estimating age" />
      </Shell>;
  };
  const BankStatementStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    return <Shell branding={branding} title="Connect your bank" subtitle="We use read-only open banking access." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Connect</PrimaryButton>}>
        <div className="wb-pv-rows">
          {['RBC Royal Bank', 'TD Canada Trust', 'Scotiabank'].map(b => <div key={b} className="wb-pv-row"><LucideIcon name="landmark" size={16} color={brandOf(branding)} /><span>{b}</span></div>)}
        </div>
      </Shell>;
  };
  const CustomPromptStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    const group = arr(step.propertyGroups).find(g => arr(g.properties).some(p => p.type === 'text-list'));
    const prop = group ? arr(group.properties).find(p => p.type === 'text-list') : null;
    const prompt = prop && arr(prop.value).length ? arr(prop.value)[0].text : 'Photograph the front of your home';
    return <Shell branding={branding} title="Custom photo" subtitle={prompt} footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Take photo</PrimaryButton>}>
        <div className="wb-pv-doc" style={{
      borderColor: brandOf(branding)
    }}><LucideIcon name="camera" size={38} color={brandOf(branding)} /></div>
      </Shell>;
  };
  const AddressVerificationStep = ({step, onContinue, branding}) => {
    usePreviewBeats(null);
    const [a, setA] = useState('');
    return <Shell branding={branding} title="Confirm your address" subtitle="Start typing and pick your address." footer={<PrimaryButton branding={branding} onClick={onContinue} testId="wb-pv-continue">Continue</PrimaryButton>}>
        <TextField value={a} onChange={setA} placeholder="Street address" startIcon="map-pin" fullWidth />
      </Shell>;
  };
  const MasterVerificationStep = ({step, onContinue, branding, deviceMode}) => {
    usePreviewBeats(null);
    const rows = arr(step.rows);
    const [open, setOpen] = useState(0);
    const active = rows[open];
    const Child = active && active.componentKey === 'id' ? IdVerificationStep : active && active.componentKey === 'face' ? IdVerificationStep : null;
    return <div className="wb-pv-master">
        <div className="wb-pv-master-rows">
          {rows.map((r, i) => <button key={r.key} type="button" className={cx('wb-pv-master-row', i === open && 'wb-pv-master-row-open')} onClick={() => setOpen(i)}>
              <span className="wb-tile wb-tile-sm" style={{
      background: utils.getStepGradient(r.stepId)
    }}><StepIcon icon={utils.getStepIcon(r.stepId)} size={14} /></span>
              <span>{r.label}</span>
              <LucideIcon name={i === open ? 'chevron-down' : 'chevron-right'} size={14} />
            </button>)}
        </div>
        {Child ? <Child step={step} branding={branding} scope={active.componentKey === 'face' ? 'face' : 'id'} deviceMode={deviceMode} onContinue={() => open < rows.length - 1 ? setOpen(open + 1) : typeof onContinue === 'function' && onContinue()} /> : <Shell branding={branding} title={active ? active.label : 'Verification'} subtitle="Hold still while we capture." footer={<PrimaryButton branding={branding} testId="wb-pv-continue" onClick={() => open < rows.length - 1 ? setOpen(open + 1) : typeof onContinue === 'function' && onContinue()}>
                {open < rows.length - 1 ? 'Next' : 'Continue'}</PrimaryButton>}>
              <CameraFrame state="centering" branding={branding} />
            </Shell>}
      </div>;
  };
  const STEP_COMPONENTS = {
    'anti-cheat': ScreeningStep,
    'address-verification': AddressVerificationStep,
    'id-verification': IdVerificationStep,
    'face-liveness': FaceLivenessStep,
    'deepfake-detection': ScreeningStep,
    'custom-form': CustomFormStep,
    'document-upload': DocumentUploadStep,
    'custom-prompt': CustomPromptStep,
    'e-signature': ESignatureStep,
    consent: ConsentStep,
    'credit-check': IntakeStep,
    'pep-sanctions': ScreeningStep,
    'adverse-media': ScreeningStep,
    'background-check': IntakeStep,
    'criminal-background-check': ScreeningStep,
    'financial-crime-check': ScreeningStep,
    'vulnerable-sector-check': ScreeningStep,
    kyb: KybStep,
    'education-confirmation': ScreeningStep,
    'age-estimation': AgeEstimationStep,
    'bank-statement-upload': BankStatementStep,
    'title-search': ScreeningStep,
    proofcall: PhoneVerificationStep,
    'phone-verification': PhoneVerificationStep,
    'master-verification': MasterVerificationStep,
    'injection-detection': ScreeningStep
  };
  return {
    STEP_COMPONENTS,
    DefaultStep,
    MasterVerificationStep
  };
};

export const makeConfigPanel = ({WB, utils, store, ui, fields}) => {
  const {useWorkflow} = store;
  const {renderField} = fields;
  const {LucideIcon, StepIcon, Alert, Divider, Tabs, IconButton, EmptyState} = ui;
  const cx = (...p) => p.filter(Boolean).join(' ');
  const arr = v => Array.isArray(v) ? v : [];
  const stepKeyOf = s => s && (s.instanceId || s.id) || null;
  const REMOVED = arr(WB.REMOVED_PROPERTY_GROUPS);
  const GUARD = arr(WB.STEP_UP_GUARD_STEPS);
  const PHONE_INTEL = arr(WB.PHONE_INTEL_BLOCK_IDS);
  const allPropsOf = step => {
    const out = [];
    arr(step && step.propertyGroups).forEach(g => arr(g && g.properties).forEach(p => out.push(p)));
    return out;
  };
  const requirementMet = (prop, all) => {
    if (utils && typeof utils.isRequirementMet === 'function') {
      try {
        return utils.isRequirementMet(prop, all);
      } catch (e) {}
    }
    const find = id => all.find(p => p && p.id === id);
    const req = prop && prop.requirement;
    let ok = true;
    if (typeof req === 'string') {
      const r = find(req);
      ok = !!(r && r.value === true);
    } else if (req && typeof req === 'object') {
      const r = find(req.id);
      if (('equals' in req)) ok = !!(r && r.value === req.equals); else if (('notEquals' in req)) ok = !!(r && r.value !== req.notEquals);
    }
    if (ok && prop && typeof prop.requirementInverse === 'string') {
      const inv = find(prop.requirementInverse);
      ok = !(inv && inv.value === true);
    }
    return ok;
  };
  const StepHeader = ({step}) => <div className="wb-panel-head">
      <div className="wb-panel-head-row">
        <span className="wb-tile wb-tile-lg" style={{
    background: utils.getStepGradient(step.id)
  }}>
          <StepIcon icon={utils.getStepIcon(step.id)} size={24} />
        </span>
        <div className="wb-panel-head-text">
          <div className="wb-panel-step">{step.label}</div>
          <div className="wb-muted wb-caption">{utils.getStepDescription(step.id)}</div>
        </div>
      </div>
      <h6 className="wb-panel-title">Configure Settings</h6>
      <Divider />
      {GUARD.indexOf(step.id) >= 0 ? <Alert severity="warning" title="Step-up escalation">
          A failure here escalates the session to step-up verification rather than declining it outright.
        </Alert> : null}
    </div>;
  const GroupBlock = ({step, group, allProps, onValue, onValues, onWeights}) => {
    const visible = arr(group.properties).filter(p => p && p.type !== 'hidden');
    if (!visible.length) return null;
    return <section className="wb-group" data-testid={'wb-group-' + group.groupId}>
        <header className="wb-group-head">
          {group.groupIcon ? <LucideIcon name={group.groupIcon} size={15} /> : null}
          <span className="wb-group-name">{group.groupName}</span>
          {group.groupTooltip ? <span className="wb-group-tip" title={group.groupTooltip}><LucideIcon name="info" size={13} /></span> : null}
        </header>
        <div className="wb-group-body">
          {visible.map(prop => {
      const met = requirementMet(prop, allProps);
      const isDisabled = !!(prop.locked || prop.disabled || !met);
      return renderField({
        prop,
        group,
        step,
        isDisabled,
        allProps,
        onChange: value => onValue(group.groupId, prop.id, value),
        onChangeMany: values => onValues(group.groupId, values),
        onLinkedWeight: (changedId, value) => onWeights(group.groupId, changedId, value)
      });
    })}
        </div>
      </section>;
  };
  const GenericSettings = ({step, onValue, onValues, onWeights}) => {
    const allProps = allPropsOf(step);
    const groups = arr(step.propertyGroups).filter(g => g && REMOVED.indexOf(g.groupId) < 0).filter(g => {
      if (!g.parentToggle) return true;
      const parent = allProps.find(p => p && p.id === g.parentToggle);
      return !!(parent && parent.value);
    });
    const forced = useRef({});
    useEffect(() => {
      const key = stepKeyOf(step);
      arr(step.propertyGroups).forEach(g => arr(g && g.properties).forEach(p => {
        if (!p || p.type !== 'boolean' || p.locked) return;
        const met = requirementMet(p, allProps);
        const shouldDisable = !!(p.disabled || !met);
        if (shouldDisable && p.value === true) {
          const marker = key + '|' + g.groupId + '|' + p.id;
          if (forced.current[marker]) return;
          forced.current[marker] = true;
          onValue(g.groupId, p.id, false);
        }
      }));
    }, [step, allProps, onValue]);
    if (!groups.length) {
      return <div className="wb-panel-empty">
          <span className="wb-tile wb-tile-lg" style={{
        background: utils.getStepGradient(step.id)
      }}><StepIcon icon={utils.getStepIcon(step.id)} size={24} /></span>
          <div className="wb-panel-step">{step.label}</div>
          <div className="wb-muted">No configurable options</div>
        </div>;
    }
    return <div className="wb-groups">
        {groups.map(g => <GroupBlock key={g.groupId} step={step} group={g} allProps={allProps} onValue={onValue} onValues={onValues} onWeights={onWeights} />)}
      </div>;
  };
  const PhoneVerificationSettings = ({step, onValue, onValues, onWeights}) => {
    const [tab, setTab] = useState('call');
    const allProps = allPropsOf(step);
    const groups = arr(step.propertyGroups).filter(g => g && REMOVED.indexOf(g.groupId) < 0);
    const tabs = [{
      value: 'call',
      label: 'Call'
    }, {
      value: 'script',
      label: 'Script'
    }, {
      value: 'advanced',
      label: 'Advanced'
    }];
    const pick = (g, i) => {
      if (tab === 'call') return i === 0;
      if (tab === 'script') return i === 1 || groups.length === 1 && i === 0;
      return i >= 2;
    };
    const shown = groups.filter(pick);
    return <div className="wb-groups" data-testid="wb-phone-settings">
        <Tabs value={tab} onChange={setTab} tabs={tabs} testIdPrefix="wb-phone-tab-" />
        {shown.length ? shown.map(g => <GroupBlock key={g.groupId} step={step} group={g} allProps={allProps} onValue={onValue} onValues={onValues} onWeights={onWeights} />) : <div className="wb-muted wb-pad">Nothing to configure in this tab.</div>}
      </div>;
  };
  const PhoneIntelBlock = ({step, onValue, onValues, onWeights}) => <div className="wb-groups" data-testid="wb-phone-intel">
      <Alert severity="info" title="Phone intelligence">Signals from this block feed the shared phone-intelligence score.</Alert>
      <GenericSettings step={step} onValue={onValue} onValues={onValues} onWeights={onWeights} />
    </div>;
  const RightConfigPanel = () => {
    const wf = useWorkflow() || ({});
    const selectors = wf.selectors || ({});
    const actions = wf.actions || ({});
    const step = selectors.selectedStep || null;
    const [expanded, setExpanded] = useState(false);
    const canExpand = !!(step && step.id === 'id-verification');
    const effectiveExpanded = expanded && canExpand;
    const width = effectiveExpanded ? WB.PANEL.WIDTH_EXPANDED : WB.PANEL.WIDTH;
    useEffect(() => {
      if (!canExpand && expanded) setExpanded(false);
    }, [canExpand, expanded]);
    const key = stepKeyOf(step);
    const onValue = useCallback((groupId, propId, value) => {
      if (typeof actions.updatePropertyValue === 'function') actions.updatePropertyValue({
        stepKey: key,
        groupId,
        propId,
        value
      });
    }, [actions, key]);
    const onValues = useCallback((groupId, values) => {
      if (typeof actions.updatePropertyValues === 'function') actions.updatePropertyValues({
        stepKey: key,
        groupId,
        values
      });
    }, [actions, key]);
    const onWeights = useCallback((groupId, changedId, value) => {
      if (typeof actions.updateLinkedWeights === 'function') actions.updateLinkedWeights({
        stepKey: key,
        groupId,
        changedId,
        value
      });
    }, [actions, key]);
    let body;
    if (!step) {
      body = <div className="wb-panel-empty" data-testid="wb-config-empty">
          <LucideIcon name="settings" size={48} className="wb-muted" />
          <div className="wb-panel-step">Configure Settings</div>
          <div className="wb-muted" style={{
        whiteSpace: 'pre-line'
      }}>{'Select a step in the workflow\nto configure how it runs.'}</div>
        </div>;
    } else if (step.id === 'phone-verification') {
      body = <PhoneVerificationSettings step={step} onValue={onValue} onValues={onValues} onWeights={onWeights} />;
    } else if (PHONE_INTEL.indexOf(step.id) >= 0) {
      body = <PhoneIntelBlock step={step} onValue={onValue} onValues={onValues} onWeights={onWeights} />;
    } else {
      body = <GenericSettings step={step} onValue={onValue} onValues={onValues} onWeights={onWeights} />;
    }
    return <aside className="wb-panel" data-testid="wb-config-panel" data-expanded={effectiveExpanded ? 'true' : 'false'} style={{
      width,
      transition: 'width 0.25s ease'
    }}>
        {canExpand ? <button type="button" className="wb-panel-expand" data-testid="wb-panel-expand" aria-label={effectiveExpanded ? 'Collapse panel' : 'Expand panel'} onClick={() => setExpanded(v => !v)}>
            <LucideIcon name={effectiveExpanded ? 'chevron-right' : 'chevron-left'} size={16} />
          </button> : null}
        <div className="wb-panel-body">
          {step ? <StepHeader step={step} /> : null}
          {body}
        </div>
      </aside>;
  };
  return {
    RightConfigPanel
  };
};

export const makeFields = ({WB, utils, ui}) => {
  const {TextField, Select, Switch, Slider, RangeSlider, Chip, Button, IconButton, Dialog, Alert, Divider, LucideIcon} = ui;
  const cx = (...p) => p.filter(Boolean).join(' ');
  const clone = v => utils && utils.deepClone ? utils.deepClone(v) : JSON.parse(JSON.stringify(v === undefined ? null : v));
  const arr = v => Array.isArray(v) ? v : [];
  const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
  const slug = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'item';
  const COUNTRIES = [{
    code: 'CA',
    name: 'Canada'
  }, {
    code: 'US',
    name: 'United States'
  }, {
    code: 'GB',
    name: 'United Kingdom'
  }, {
    code: 'IE',
    name: 'Ireland'
  }, {
    code: 'FR',
    name: 'France'
  }, {
    code: 'DE',
    name: 'Germany'
  }, {
    code: 'ES',
    name: 'Spain'
  }, {
    code: 'PT',
    name: 'Portugal'
  }, {
    code: 'IT',
    name: 'Italy'
  }, {
    code: 'NL',
    name: 'Netherlands'
  }, {
    code: 'BE',
    name: 'Belgium'
  }, {
    code: 'LU',
    name: 'Luxembourg'
  }, {
    code: 'CH',
    name: 'Switzerland'
  }, {
    code: 'AT',
    name: 'Austria'
  }, {
    code: 'DK',
    name: 'Denmark'
  }, {
    code: 'SE',
    name: 'Sweden'
  }, {
    code: 'NO',
    name: 'Norway'
  }, {
    code: 'FI',
    name: 'Finland'
  }, {
    code: 'PL',
    name: 'Poland'
  }, {
    code: 'CZ',
    name: 'Czechia'
  }, {
    code: 'RO',
    name: 'Romania'
  }, {
    code: 'GR',
    name: 'Greece'
  }, {
    code: 'TR',
    name: 'Turkiye'
  }, {
    code: 'UA',
    name: 'Ukraine'
  }, {
    code: 'AE',
    name: 'United Arab Emirates'
  }, {
    code: 'SA',
    name: 'Saudi Arabia'
  }, {
    code: 'IL',
    name: 'Israel'
  }, {
    code: 'IN',
    name: 'India'
  }, {
    code: 'PK',
    name: 'Pakistan'
  }, {
    code: 'SG',
    name: 'Singapore'
  }, {
    code: 'HK',
    name: 'Hong Kong'
  }, {
    code: 'JP',
    name: 'Japan'
  }, {
    code: 'KR',
    name: 'South Korea'
  }, {
    code: 'CN',
    name: 'China'
  }, {
    code: 'AU',
    name: 'Australia'
  }, {
    code: 'NZ',
    name: 'New Zealand'
  }, {
    code: 'ZA',
    name: 'South Africa'
  }, {
    code: 'NG',
    name: 'Nigeria'
  }, {
    code: 'KE',
    name: 'Kenya'
  }, {
    code: 'BR',
    name: 'Brazil'
  }, {
    code: 'MX',
    name: 'Mexico'
  }, {
    code: 'AR',
    name: 'Argentina'
  }];
  const INSTITUTIONS = [{
    id: 'uoft',
    name: 'University of Toronto',
    country: 'CA'
  }, {
    id: 'ubc',
    name: 'University of British Columbia',
    country: 'CA'
  }, {
    id: 'mcgill',
    name: 'McGill University',
    country: 'CA'
  }, {
    id: 'waterloo',
    name: 'University of Waterloo',
    country: 'CA'
  }, {
    id: 'queens',
    name: "Queen's University",
    country: 'CA'
  }, {
    id: 'mit',
    name: 'MIT',
    country: 'US'
  }, {
    id: 'stanford',
    name: 'Stanford University',
    country: 'US'
  }, {
    id: 'harvard',
    name: 'Harvard University',
    country: 'US'
  }, {
    id: 'berkeley',
    name: 'UC Berkeley',
    country: 'US'
  }, {
    id: 'nyu',
    name: 'New York University',
    country: 'US'
  }];
  const ESIGN_TEMPLATES = [{
    id: 'tpl-nda',
    name: 'Mutual NDA',
    pages: 3,
    fields: 6
  }, {
    id: 'tpl-msa',
    name: 'Master Services Agreement',
    pages: 11,
    fields: 14
  }, {
    id: 'tpl-consent',
    name: 'Data Processing Consent',
    pages: 2,
    fields: 4
  }];
  const FIELD_TYPES = ['boolean', 'text', 'country-multiselect', 'select', 'object', 'doc-upload', 'data-extraction', 'text-list', 'multiselect-table', 'country-id-table', 'age-restriction-country-category', 'slider', 'range', 'questionnaire-template-toggles', 'esign-template-select', 'esign-document-setup', 'proofcall-questions', 'education-institution-picker', 'country-customize', 'jurisdiction-picker', 'weight-display'];
  const RowShell = ({prop, children, testId}) => <div className="wb-editor" data-testid={testId}>
      <div className="wb-field-label">{prop.label}</div>
      {prop.sublabel ? <div className="wb-field-help">{prop.sublabel}</div> : null}
      {children}
    </div>;
  const AddButton = ({onClick, children, disabled, testId}) => <Button variant="outlined" size="small" startIcon="plus" onClick={onClick} disabled={disabled} testId={testId}>{children}</Button>;
  const ListRow = ({children, onRemove, removeLabel, disabled, testId}) => <div className="wb-list-row" data-testid={testId}>
      <div className="wb-list-row-main">{children}</div>
      {onRemove ? <IconButton name="trash-2" size={15} label={removeLabel || 'Remove'} onClick={onRemove} disabled={disabled} /> : null}
    </div>;
  const CountryPicker = ({open, onClose, selected, onToggle, title}) => {
    const [q, setQ] = useState('');
    const list = COUNTRIES.filter(c => !q || c.name.toLowerCase().indexOf(q.toLowerCase()) >= 0 || c.code.toLowerCase() === q.toLowerCase());
    return <Dialog open={open} onClose={onClose} title={title || 'Select countries'} width={520} actions={<Button variant="contained" onClick={onClose}>Done</Button>}>
        <TextField value={q} onChange={setQ} placeholder="Search countries" startIcon="search" fullWidth />
        <div className="wb-picker-grid">
          {list.map(c => {
      const on = selected.indexOf(c.code) >= 0;
      return <button key={c.code} type="button" className={cx('wb-picker-item', on && 'wb-picker-item-on')} onClick={() => onToggle(c.code)}>
                <span className="wb-picker-code">{c.code}</span>
                <span className="wb-picker-name">{c.name}</span>
                {on ? <LucideIcon name="check" size={14} /> : null}
              </button>;
    })}
        </div>
      </Dialog>;
  };
  const TextListEditor = ({prop, value, onChange, disabled, testId}) => {
    const items = arr(value);
    const set = (i, text) => {
      const next = clone(items);
      next[i] = {
        ...obj(next[i]),
        text
      };
      onChange(next);
    };
    return <RowShell prop={prop} testId={testId}>
        {items.map((it, i) => <ListRow key={i} onRemove={disabled ? null : () => onChange(items.filter((_, j) => j !== i))}>
            <span className="wb-num">{i + 1}</span>
            <TextField value={obj(it).text || ''} onChange={t => set(i, t)} disabled={disabled} fullWidth placeholder="Prompt text" />
          </ListRow>)}
        <AddButton disabled={disabled} onClick={() => onChange(items.concat([{
      text: ''
    }]))}>Add Prompt</AddButton>
      </RowShell>;
  };
  const DocUploadEditor = ({prop, value, onChange, disabled, testId}) => {
    const docs = arr(value);
    const patch = (i, p) => {
      const next = clone(docs);
      next[i] = {
        ...obj(next[i]),
        ...p
      };
      onChange(next);
    };
    return <RowShell prop={prop} testId={testId}>
        {docs.map((d, i) => <div key={i} className="wb-card-row">
            <div className="wb-row">
              <TextField value={obj(d).label || ''} onChange={v => patch(i, {
      label: v,
      id: obj(d).id || slug(v)
    })} placeholder="Document name" fullWidth disabled={disabled} />
              {disabled ? null : <IconButton name="trash-2" size={15} label="Remove document" onClick={() => onChange(docs.filter((_, j) => j !== i))} />}
            </div>
            <div className="wb-row wb-row-wrap">
              <Switch label="Required" checked={!!obj(d).required} onChange={v => patch(i, {
      required: v
    })} disabled={disabled} />
              <Switch label="Allow photo" checked={obj(d).allowPhoto !== false} onChange={v => patch(i, {
      allowPhoto: v
    })} disabled={disabled} />
              <Switch label="Fraud scan" checked={!!obj(d).fraudScan} onChange={v => patch(i, {
      fraudScan: v
    })} disabled={disabled} />
            </div>
          </div>)}
        <AddButton disabled={disabled} onClick={() => onChange(docs.concat([{
      id: 'document-' + (docs.length + 1),
      label: '',
      required: true,
      allowPhoto: true,
      fraudScan: false
    }]))}>Add Document</AddButton>
      </RowShell>;
  };
  const DataExtractionEditor = ({prop, value, onChange, disabled, testId}) => {
    const rules = arr(value);
    const patch = (i, p) => {
      const next = clone(rules);
      next[i] = {
        ...obj(next[i]),
        ...p
      };
      onChange(next);
    };
    return <RowShell prop={prop} testId={testId}>
        {rules.map((r, i) => <div key={i} className="wb-card-row">
            <div className="wb-row">
              <TextField value={obj(r).document || ''} onChange={v => patch(i, {
      document: v
    })} placeholder="Document" fullWidth disabled={disabled} />
              {disabled ? null : <IconButton name="trash-2" size={15} label="Remove rule" onClick={() => onChange(rules.filter((_, j) => j !== i))} />}
            </div>
            <TextField value={arr(obj(r).fields).join(', ')} onChange={v => patch(i, {
      fields: v.split(',').map(s => s.trim()).filter(Boolean)
    })} placeholder="Fields to extract, comma separated" fullWidth disabled={disabled} helperText="e.g. full name, issue date, account number" />
          </div>)}
        <AddButton disabled={disabled} onClick={() => onChange(rules.concat([{
      document: '',
      fields: []
    }]))}>Add Extraction Rule</AddButton>
      </RowShell>;
  };
  const MultiSelectTable = ({prop, value, onChange, disabled, testId}) => {
    const opts = arr(prop.options);
    const sel = arr(value);
    const toggle = v => onChange(sel.indexOf(v) >= 0 ? sel.filter(x => x !== v) : sel.concat([v]));
    return <RowShell prop={prop} testId={testId}>
        <div className="wb-mst">
          {opts.map(o => {
      const on = sel.indexOf(o.value) >= 0;
      return <button key={String(o.value)} type="button" disabled={disabled || o.disabled} className={cx('wb-mst-row', on && 'wb-mst-row-on')} onClick={() => toggle(o.value)}>
                <span className={cx('wb-check', on && 'wb-check-on')} aria-hidden="true">{on ? <LucideIcon name="check" size={12} /> : null}</span>
                <span className="wb-mst-label">{o.flag ? o.flag + ' ' : ''}{o.label}</span>
                {o.hint ? <span className="wb-mst-hint">{o.hint}</span> : null}
                {o.cost || o.cost === 0 ? <span className="wb-mst-cost">{utils.fCurrency(o.cost)}</span> : null}
              </button>;
    })}
        </div>
      </RowShell>;
  };
  const CountryIdTable = ({prop, value, onChange, disabled, testId}) => {
    const rows = arr(value);
    const patch = (i, p) => {
      const next = clone(rows);
      next[i] = {
        ...obj(next[i]),
        ...p
      };
      onChange(next);
    };
    return <RowShell prop={prop} testId={testId}>
        <div className="wb-table">
          <div className="wb-table-head"><span>Country</span><span>Passport</span><span>Licence</span><span>National ID</span><span /></div>
          {rows.map((r, i) => <div key={i} className="wb-table-row">
              <Select value={obj(r).country || 'CA'} onChange={v => patch(i, {
      country: v
    })} disabled={disabled} options={COUNTRIES.map(c => ({
      value: c.code,
      label: c.name
    }))} />
              <Switch checked={!!obj(r).passport} onChange={v => patch(i, {
      passport: v
    })} disabled={disabled} label="" />
              <Switch checked={!!obj(r).licence} onChange={v => patch(i, {
      licence: v
    })} disabled={disabled} label="" />
              <Switch checked={!!obj(r).nationalId} onChange={v => patch(i, {
      nationalId: v
    })} disabled={disabled} label="" />
              {disabled ? <span /> : <IconButton name="trash-2" size={15} label="Remove row" onClick={() => onChange(rows.filter((_, j) => j !== i))} />}
            </div>)}
        </div>
        <AddButton disabled={disabled} onClick={() => onChange(rows.concat([{
      country: 'CA',
      passport: true,
      licence: true,
      nationalId: false
    }]))}>Add Country</AddButton>
      </RowShell>;
  };
  const CountryMultiSelect = ({prop, value, onChange, disabled, testId}) => {
    const [open, setOpen] = useState(false);
    const sel = arr(value);
    const toggle = code => onChange(sel.indexOf(code) >= 0 ? sel.filter(c => c !== code) : sel.concat([code]));
    return <RowShell prop={prop} testId={testId}>
        <div className="wb-chips">
          {sel.length === 0 ? <span className="wb-muted">No countries selected</span> : null}
          {sel.map(c => {
      const hit = COUNTRIES.find(x => x.code === c);
      return <Chip key={c} color="primary">{hit ? hit.name : c}</Chip>;
    })}
        </div>
        <Button variant="outlined" size="small" startIcon="globe" disabled={disabled} onClick={() => setOpen(true)}>Choose countries</Button>
        <CountryPicker open={open} onClose={() => setOpen(false)} selected={sel} onToggle={toggle} title={prop.label} />
      </RowShell>;
  };
  const CountryCustomize = ({prop, value, onChange, disabled, testId}) => <CountryMultiSelect prop={prop} value={value} onChange={onChange} disabled={disabled} testId={testId} />;
  const JurisdictionPicker = ({prop, value, onChange, disabled, testId}) => {
    const sel = arr(value);
    const groups = [{
      id: 'na',
      label: 'North America',
      codes: ['CA', 'US', 'MX']
    }, {
      id: 'eu',
      label: 'European Union',
      codes: ['FR', 'DE', 'ES', 'IT', 'NL', 'PL']
    }, {
      id: 'uk',
      label: 'United Kingdom & Ireland',
      codes: ['GB', 'IE']
    }, {
      id: 'apac',
      label: 'Asia Pacific',
      codes: ['SG', 'HK', 'JP', 'AU', 'NZ']
    }];
    const toggle = code => onChange(sel.indexOf(code) >= 0 ? sel.filter(c => c !== code) : sel.concat([code]));
    return <RowShell prop={prop} testId={testId}>
        {groups.map(g => <div key={g.id} className="wb-jur-group">
            <div className="wb-jur-head">{g.label}</div>
            <div className="wb-chips">
              {g.codes.map(code => {
      const on = sel.indexOf(code) >= 0;
      const hit = COUNTRIES.find(x => x.code === code);
      return <button key={code} type="button" disabled={disabled} onClick={() => toggle(code)} className={cx('wb-jur-chip', on && 'wb-jur-chip-on')}>{hit ? hit.name : code}</button>;
    })}
            </div>
          </div>)}
      </RowShell>;
  };
  const WeightDisplay = ({prop, allProps, testId}) => {
    const weights = arr(allProps).filter(p => p && p.linkedGroup === 'env-weights');
    const total = weights.reduce((n, p) => n + (Number(p.value) || 0), 0);
    return <RowShell prop={prop} testId={testId}>
        {weights.map(p => {
      const pct = Number(p.value) || 0;
      return <div key={p.id} className="wb-weight">
              <div className="wb-weight-head"><span>{p.label}</span><span>{pct}%</span></div>
              <div className="wb-weight-rail"><div className="wb-weight-fill" style={{
        width: pct + '%'
      }} /></div>
            </div>;
    })}
        <div className={cx('wb-weight-total', total !== 100 && 'wb-weight-total-bad')}>Total {total}%</div>
      </RowShell>;
  };
  const AgeRestrictionCountryCategory = ({prop, value, onChange, onChangeMany, group, disabled, testId}) => {
    const rows = arr(value);
    const patch = (i, p) => {
      const next = clone(rows);
      next[i] = {
        ...obj(next[i]),
        ...p
      };
      const map = {};
      next.forEach(r => {
        if (r && r.country) map[r.country] = Number(r.minimumAge) || 18;
      });
      if (typeof onChangeMany === 'function') onChangeMany({
        [prop.id]: next,
        'country-category-minimum-age': map
      }); else onChange(next);
    };
    return <RowShell prop={prop} testId={testId}>
        {rows.map((r, i) => <div key={i} className="wb-row">
            <Select value={obj(r).country || 'CA'} onChange={v => patch(i, {
      country: v
    })} disabled={disabled} options={COUNTRIES.map(c => ({
      value: c.code,
      label: c.name
    }))} />
            <Select value={obj(r).category || 'general'} onChange={v => patch(i, {
      category: v
    })} disabled={disabled} options={[{
      value: 'general',
      label: 'General'
    }, {
      value: 'alcohol',
      label: 'Alcohol'
    }, {
      value: 'gaming',
      label: 'Gaming'
    }, {
      value: 'tobacco',
      label: 'Tobacco'
    }]} />
            <TextField type="number" min={13} max={25} value={obj(r).minimumAge === undefined ? 18 : obj(r).minimumAge} onChange={v => patch(i, {
      minimumAge: Number(v)
    })} disabled={disabled} />
            {disabled ? null : <IconButton name="trash-2" size={15} label="Remove" onClick={() => {
      const next = rows.filter((_, j) => j !== i);
      const map = {};
      next.forEach(x => {
        if (x && x.country) map[x.country] = Number(x.minimumAge) || 18;
      });
      if (typeof onChangeMany === 'function') onChangeMany({
        [prop.id]: next,
        'country-category-minimum-age': map
      }); else onChange(next);
    }} />}
          </div>)}
        <AddButton disabled={disabled} onClick={() => {
      const next = rows.concat([{
        country: 'CA',
        category: 'general',
        minimumAge: 18
      }]);
      const map = {};
      next.forEach(x => {
        if (x && x.country) map[x.country] = Number(x.minimumAge) || 18;
      });
      if (typeof onChangeMany === 'function') onChangeMany({
        [prop.id]: next,
        'country-category-minimum-age': map
      }); else onChange(next);
    }}>Add Country Rule</AddButton>
      </RowShell>;
  };
  const OptionsListEditor = ({options, onChange, disabled}) => {
    const o = obj(options);
    const keys = Object.keys(o).filter(k => (/^option\d+$/).test(k)).sort((a, b) => Number(a.slice(6)) - Number(b.slice(6)));
    const set = (k, v) => onChange({
      ...o,
      [k]: v
    });
    return <div className="wb-options">
        {keys.map((k, i) => <ListRow key={k} onRemove={disabled ? null : () => {
      const next = {};
      let n = 1;
      keys.filter(x => x !== k).forEach(x => {
        next['option' + n] = o[x];
        n += 1;
      });
      onChange(next);
    }}>
            <span className="wb-num">{i + 1}</span>
            <TextField value={o[k] || ''} onChange={v => set(k, v)} disabled={disabled} fullWidth placeholder={'Option ' + (i + 1)} />
          </ListRow>)}
        <AddButton disabled={disabled || keys.length >= WB.MAX_OPTIONS} onClick={() => onChange({
      ...o,
      ['option' + (keys.length + 1)]: ''
    })}>
          {keys.length >= WB.MAX_OPTIONS ? 'Max ' + WB.MAX_OPTIONS + ' options' : 'Add Option'}
        </AddButton>
      </div>;
  };
  const FormFieldEditor = ({field, onChange, onRemove, disabled}) => {
    const f = obj(field);
    const needsOptions = f.type === 'dropdown' || f.type === 'checkbox';
    return <div className="wb-card-row">
        <div className="wb-row">
          <TextField value={f.label || ''} onChange={v => onChange({
      ...f,
      label: v,
      id: f.id || slug(v)
    })} placeholder="Question label" fullWidth disabled={disabled} />
          <Select value={f.type || 'short-text'} onChange={v => onChange({
      ...f,
      type: v
    })} disabled={disabled} options={arr(WB.CUSTOM_FORM_FIELD_TYPES).map(t => ({
      value: t,
      label: t.replace(/-/g, ' ')
    }))} />
          {disabled ? null : <IconButton name="trash-2" size={15} label="Remove field" onClick={onRemove} />}
        </div>
        <div className="wb-row">
          <Switch label="Required" checked={!!f.required} onChange={v => onChange({
      ...f,
      required: v
    })} disabled={disabled} />
        </div>
        {needsOptions ? <OptionsListEditor options={f.options} onChange={o => onChange({
      ...f,
      options: o
    })} disabled={disabled} /> : null}
      </div>;
  };
  const CustomFormEditor = ({prop, value, onChange, disabled, testId}) => {
    const v = obj(value);
    const pages = arr(v.pages);
    const setPages = next => onChange({
      ...v,
      pages: next
    });
    return <RowShell prop={prop} testId={testId}>
        {pages.map((pg, pi) => {
      const fields = arr(obj(pg).fields);
      return <div key={pi} className="wb-page">
              <div className="wb-page-head">
                <span>Page {pi + 1}</span>
                {disabled ? null : <IconButton name="trash-2" size={15} label="Remove page" onClick={() => setPages(pages.filter((_, j) => j !== pi))} />}
              </div>
              {fields.map((f, fi) => <FormFieldEditor key={fi} field={f} disabled={disabled} onChange={nf => {
        const np = clone(pages);
        np[pi] = {
          ...obj(np[pi]),
          fields: arr(obj(np[pi]).fields).map((x, j) => j === fi ? nf : x)
        };
        setPages(np);
      }} onRemove={() => {
        const np = clone(pages);
        np[pi] = {
          ...obj(np[pi]),
          fields: arr(obj(np[pi]).fields).filter((_, j) => j !== fi)
        };
        setPages(np);
      }} />)}
              <AddButton disabled={disabled || fields.length >= WB.MAX_FIELDS_PER_PAGE} onClick={() => {
        const np = clone(pages);
        np[pi] = {
          ...obj(np[pi]),
          fields: arr(obj(np[pi]).fields).concat([{
            id: 'field-' + (fields.length + 1),
            label: '',
            type: 'short-text',
            required: false
          }])
        };
        setPages(np);
      }}>
                {fields.length >= WB.MAX_FIELDS_PER_PAGE ? 'Max ' + WB.MAX_FIELDS_PER_PAGE + ' fields' : 'Add Field'}
              </AddButton>
            </div>;
    })}
        <AddButton disabled={disabled} onClick={() => setPages(pages.concat([{
      id: 'page-' + (pages.length + 1),
      fields: []
    }]))}>Add Page</AddButton>
      </RowShell>;
  };
  const ProofCallQuestions = ({prop, value, onChange, disabled, testId}) => {
    const qs = arr(value);
    const minutes = Math.max(1, qs.length);
    return <RowShell prop={prop} testId={testId}>
        {qs.map((q, i) => <ListRow key={i} onRemove={disabled ? null : () => onChange(qs.filter((_, j) => j !== i))}>
            <span className="wb-num">{i + 1}</span>
            <TextField value={obj(q).text || ''} onChange={t => {
      const next = clone(qs);
      next[i] = {
        ...obj(next[i]),
        text: t
      };
      onChange(next);
    }} disabled={disabled} fullWidth placeholder="Question the agent will ask" />
          </ListRow>)}
        <AddButton disabled={disabled} onClick={() => onChange(qs.concat([{
      text: ''
    }]))}>Add Question</AddButton>
        <Alert severity="info">{'Estimated call: ~' + minutes + ' min · ' + utils.fCurrency(minutes * 0.5)}</Alert>
      </RowShell>;
  };
  const ESignTemplateSelect = ({prop, value, onChange, onChangeMany, disabled, testId}) => <RowShell prop={prop} testId={testId}>
      <div className="wb-esign-list">
        {ESIGN_TEMPLATES.map(t => {
    const on = String(value) === t.id;
    return <button key={t.id} type="button" disabled={disabled} data-selected={on ? 'true' : 'false'} className={cx('wb-esign-template', on && 'selected')} onClick={() => {
      if (typeof onChangeMany === 'function') onChangeMany({
        [prop.id]: t.id,
        'template-name': t.name
      }); else onChange(t.id);
    }}>
              <span className="wb-esign-thumb"><span /><span /><span /></span>
              <span className="wb-esign-meta">
                <span className="wb-esign-name">{t.name}</span>
                <span className="wb-muted">{t.pages} pages · {t.fields} fields</span>
              </span>
              {on ? <LucideIcon name="check" size={16} /> : null}
            </button>;
  })}
      </div>
    </RowShell>;
  const ESignDocumentSetup = ({prop, value, onChange, disabled, testId}) => {
    const fields = arr(value);
    const patch = (i, p) => {
      const next = clone(fields);
      next[i] = {
        ...obj(next[i]),
        ...p
      };
      onChange(next);
    };
    return <RowShell prop={prop} testId={testId}>
        <div className="wb-esign-canvas" aria-hidden="true">
          {fields.map((f, i) => <span key={i} className="wb-esign-marker" style={{
      left: (obj(f).x || 10) + '%',
      top: (obj(f).y || 10) + '%'
    }}>{obj(f).kind || 'signature'}</span>)}
        </div>
        {fields.map((f, i) => <div key={i} className="wb-row">
            <Select value={obj(f).kind || 'signature'} onChange={v => patch(i, {
      kind: v
    })} disabled={disabled} options={[{
      value: 'signature',
      label: 'Signature'
    }, {
      value: 'initials',
      label: 'Initials'
    }, {
      value: 'date',
      label: 'Date'
    }, {
      value: 'text',
      label: 'Text'
    }]} />
            <TextField type="number" min={0} max={95} value={obj(f).x === undefined ? 10 : obj(f).x} onChange={v => patch(i, {
      x: Number(v)
    })} disabled={disabled} label="X%" />
            <TextField type="number" min={0} max={95} value={obj(f).y === undefined ? 10 : obj(f).y} onChange={v => patch(i, {
      y: Number(v)
    })} disabled={disabled} label="Y%" />
            {disabled ? null : <IconButton name="trash-2" size={15} label="Remove field" onClick={() => onChange(fields.filter((_, j) => j !== i))} />}
          </div>)}
        <AddButton disabled={disabled} onClick={() => onChange(fields.concat([{
      kind: 'signature',
      x: 12,
      y: 70
    }]))}>Add Signature Field</AddButton>
      </RowShell>;
  };
  const InstitutionPicker = ({prop, value, onChange, disabled, testId}) => {
    const [open, setOpen] = useState(false);
    const [q, setQ] = useState('');
    const sel = arr(value);
    const toggle = id => onChange(sel.indexOf(id) >= 0 ? sel.filter(x => x !== id) : sel.concat([id]));
    const list = INSTITUTIONS.filter(i => !q || i.name.toLowerCase().indexOf(q.toLowerCase()) >= 0);
    return <RowShell prop={prop} testId={testId}>
        <div className="wb-chips">
          {sel.length === 0 ? <span className="wb-muted">Any accredited institution</span> : null}
          {sel.map(id => {
      const hit = INSTITUTIONS.find(x => x.id === id);
      return <Chip key={id} color="primary">{hit ? hit.name : id}</Chip>;
    })}
        </div>
        <Button variant="outlined" size="small" startIcon="graduation-cap" disabled={disabled} onClick={() => setOpen(true)}>Choose institutions</Button>
        <Dialog open={open} onClose={() => setOpen(false)} title="Institutions" width={520} actions={<Button variant="contained" onClick={() => setOpen(false)}>Done</Button>}>
          <TextField value={q} onChange={setQ} placeholder="Search" startIcon="search" fullWidth />
          <div className="wb-picker-grid">
            {list.map(i => {
      const on = sel.indexOf(i.id) >= 0;
      return <button key={i.id} type="button" className={cx('wb-picker-item', on && 'wb-picker-item-on')} onClick={() => toggle(i.id)}>
                  <span className="wb-picker-code">{i.country}</span><span className="wb-picker-name">{i.name}</span>{on ? <LucideIcon name="check" size={14} /> : null}
                </button>;
    })}
          </div>
        </Dialog>
      </RowShell>;
  };
  const QuestionnaireToggles = ({prop, value, onChange, disabled, testId}) => {
    const v = obj(value);
    const opts = arr(prop.options).length ? arr(prop.options) : [{
      value: 'source-of-funds',
      label: 'Source of funds'
    }, {
      value: 'pep-declaration',
      label: 'PEP declaration'
    }, {
      value: 'tax-residency',
      label: 'Tax residency'
    }];
    return <RowShell prop={prop} testId={testId}>
        {opts.map(o => <Switch key={String(o.value)} label={o.label} sublabel={o.hint} disabled={disabled} checked={!!v[o.value]} onChange={on => onChange({
      ...v,
      [o.value]: on
    })} />)}
      </RowShell>;
  };
  const renderField = args => {
    const a = args || ({});
    const prop = obj(a.prop);
    const group = obj(a.group);
    const isDisabled = !!a.isDisabled;
    const onChange = typeof a.onChange === 'function' ? a.onChange : () => {};
    const onChangeMany = a.onChangeMany;
    const onLinkedWeight = a.onLinkedWeight;
    const allProps = arr(a.allProps);
    const testId = 'wb-field-' + (group.groupId || 'g') + '-' + (prop.id || 'p');
    const inputId = 'wb-input-' + (group.groupId || 'g') + '-' + (prop.id || 'p');
    const t = prop.type;
    let body = null;
    if (t === 'boolean') {
      body = <Switch label={prop.label} sublabel={prop.sublabel} flag={prop.flag} checked={!!prop.value} disabled={isDisabled} onChange={onChange} testId={inputId} />;
    } else if (t === 'text') {
      body = <TextField label={prop.label} value={prop.value === undefined || prop.value === null ? '' : prop.value} onChange={onChange} multiline rows={3} fullWidth disabled={isDisabled} testId={inputId} helperText={prop.sublabel} placeholder={prop.placeholder} />;
    } else if (t === 'select') {
      body = <Select label={prop.label} value={prop.value} onChange={onChange} options={arr(prop.options)} fullWidth disabled={isDisabled} testId={inputId} />;
    } else if (t === 'slider') {
      const handler = prop.linkedGroup === 'env-weights' && typeof onLinkedWeight === 'function' ? v => onLinkedWeight(prop.id, v) : onChange;
      body = <Slider label={prop.label} value={prop.value} onChange={handler} min={prop.min === undefined ? 0 : prop.min} max={prop.max === undefined ? 100 : prop.max} step={prop.step || 1} marks={prop.marks} showValue={prop.showValue !== false} unit={prop.unit} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'range') {
      body = <RangeSlider label={prop.label} value={prop.value} onChange={onChange} min={prop.min === undefined ? 0 : prop.min} max={prop.max === undefined ? 100 : prop.max} step={prop.step || 1} lowerLabel={prop.lowerLabel} upperLabel={prop.upperLabel} lowerColor={prop.lowerColor} middleColor={prop.middleColor} upperColor={prop.upperColor} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'country-multiselect') {
      body = <CountryMultiSelect prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'country-customize') {
      body = <CountryCustomize prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'jurisdiction-picker') {
      body = <JurisdictionPicker prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'text-list') {
      body = <TextListEditor prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'doc-upload') {
      body = <DocUploadEditor prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'data-extraction') {
      body = <DataExtractionEditor prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'multiselect-table') {
      body = <MultiSelectTable prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'country-id-table') {
      body = <CountryIdTable prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'age-restriction-country-category') {
      body = <AgeRestrictionCountryCategory prop={prop} group={group} value={prop.value} onChange={onChange} onChangeMany={onChangeMany} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'object') {
      body = <CustomFormEditor prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'proofcall-questions') {
      body = <ProofCallQuestions prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'esign-template-select') {
      body = <ESignTemplateSelect prop={prop} value={prop.value} onChange={onChange} onChangeMany={onChangeMany} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'esign-document-setup') {
      body = <ESignDocumentSetup prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'education-institution-picker') {
      body = <InstitutionPicker prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'questionnaire-template-toggles') {
      body = <QuestionnaireToggles prop={prop} value={prop.value} onChange={onChange} disabled={isDisabled} testId={inputId} />;
    } else if (t === 'weight-display') {
      body = <WeightDisplay prop={prop} allProps={allProps} testId={inputId} />;
    } else {
      body = <TextField label={prop.label} value={typeof prop.value === 'string' ? prop.value : JSON.stringify(prop.value === undefined ? '' : prop.value)} onChange={onChange} fullWidth disabled={isDisabled} testId={inputId} helperText={'Unsupported field type: ' + String(t)} />;
    }
    return <div key={prop.id} data-testid={testId} data-field-type={String(t)} className="wb-field-block" style={{
      opacity: isDisabled ? 0.5 : 1
    }}>
        {body}
        {prop.locked ? <span className="wb-lock-note"><LucideIcon name="lock" size={12} /> Locked</span> : null}
      </div>;
  };
  return {
    renderField,
    FIELD_TYPES,
    COUNTRIES
  };
};

export const makeCanvas = ({WB, utils, geometry, store, ui}) => {
  const {useWorkflow} = store;
  const {StepIcon, LucideIcon, IconButton, EmptyState} = ui;
  const C = WB.CANVAS;
  const START = WB.START_NODE_ID;
  const cx = (...p) => p.filter(Boolean).join(' ');
  const arr = v => Array.isArray(v) ? v : [];
  const keyOf = s => s && (s.instanceId || s.id) || '';
  const SIDES = ['top', 'right', 'bottom', 'left'];
  const START_SIDES = ['bottom', 'right'];
  const startPos = positions => positions && positions[START] || (geometry.DEFAULT_START_POSITION || ({
    x: 320,
    y: 20
  }));
  const nodeAnchor = (id, side, positions, isStart) => {
    const p = isStart ? startPos(positions) : positions && positions[id] || ({
      x: 0,
      y: 0
    });
    const w = isStart ? C.START_NODE_WIDTH : C.NODE_WIDTH;
    const h = isStart ? C.START_NODE_HEIGHT : C.NODE_HEIGHT;
    if (side === 'top') return {
      x: p.x + w / 2,
      y: p.y
    };
    if (side === 'bottom') return {
      x: p.x + w / 2,
      y: p.y + h
    };
    if (side === 'left') return {
      x: p.x,
      y: p.y + h / 2
    };
    return {
      x: p.x + w,
      y: p.y + h / 2
    };
  };
  const routePath = (from, fromSide, to, toSide) => {
    try {
      if (geometry.computeOrthogonalRoute && geometry.buildRoundedPolyline) {
        const pts = geometry.computeOrthogonalRoute(from, fromSide, to, toSide);
        const d = geometry.buildRoundedPolyline(pts, C.CORNER_RADIUS);
        if (d) return d;
      }
    } catch (e) {}
    const midY = (from.y + to.y) / 2;
    return 'M ' + from.x + ' ' + from.y + ' L ' + from.x + ' ' + midY + ' L ' + to.x + ' ' + midY + ' L ' + to.x + ' ' + to.y;
  };
  const WorkflowCanvas = ({onGated}) => {
    const wf = useWorkflow() || ({});
    const state = wf.state || ({});
    const actions = wf.actions || ({});
    const selectors = wf.selectors || ({});
    const workflow = state.workflow || ({});
    const steps = arr(workflow.steps);
    const canvasData = workflow.canvasData || ({});
    const positions = canvasData.nodePositions || ({});
    const connections = arr(canvasData.connections);
    const viewport = canvasData.viewport || ({
      x: 0,
      y: 0,
      zoom: 1
    });
    const selectedStepId = state.selectedStepId || null;
    const selectedConnectionId = state.selectedConnectionId || null;
    const resolvedOrder = selectors.resolvedOrder || ({});
    const svgRef = useRef(null);
    const wrapRef = useRef(null);
    const act = useRef(null);
    const [hoverId, setHoverId] = useState(null);
    const [drawing, setDrawing] = useState(null);
    const fitted = useRef(false);
    const rect = () => {
      try {
        return svgRef.current ? svgRef.current.getBoundingClientRect() : {
          left: 0,
          top: 0,
          width: 1,
          height: 1
        };
      } catch (e) {
        return {
          left: 0,
          top: 0,
          width: 1,
          height: 1
        };
      }
    };
    const toCanvas = (cxp, cyp) => {
      const r = rect();
      return {
        x: (cxp - r.left - viewport.x) / (viewport.zoom || 1),
        y: (cyp - r.top - viewport.y) / (viewport.zoom || 1)
      };
    };
    const snap = v => geometry.snap ? geometry.snap(v) : Math.round(v / C.GRID_SIZE) * C.GRID_SIZE;
    useEffect(() => {
      const missing = steps.filter(s => !positions[keyOf(s)]);
      if (missing.length && typeof actions.setNodePositions === 'function') {
        const next = {
          ...positions
        };
        if (!next[START]) next[START] = startPos(positions);
        let i = Object.keys(next).length - 1;
        missing.forEach(s => {
          next[keyOf(s)] = {
            x: snap(280),
            y: snap(120 + i * C.ROW_GAP)
          };
          i += 1;
        });
        actions.setNodePositions({
          positions: next
        });
      }
    }, [steps, positions, actions]);
    useEffect(() => {
      if (fitted.current || !steps.length || typeof actions.setViewport !== 'function') return;
      if (steps.some(s => !positions[keyOf(s)])) return;
      fitted.current = true;
      try {
        const r = rect();
        const vp = geometry.fitToContent(positions, steps.map(keyOf), r.width, r.height);
        if (vp && Number.isFinite(vp.zoom)) actions.setViewport({
          viewport: vp
        });
      } catch (e) {}
    }, [steps, positions, actions]);
    const beginPan = e => {
      act.current = {
        kind: 'pan',
        sx: e.clientX,
        sy: e.clientY,
        ox: viewport.x,
        oy: viewport.y
      };
      if (typeof actions.selectStep === 'function') actions.selectStep({
        instanceId: null
      });
      if (typeof actions.selectConnection === 'function') actions.selectConnection({
        id: null
      });
    };
    const beginNodeDrag = (e, id) => {
      e.stopPropagation();
      const p = positions[id] || ({
        x: 0,
        y: 0
      });
      act.current = {
        kind: 'node',
        id,
        sx: e.clientX,
        sy: e.clientY,
        ox: p.x,
        oy: p.y
      };
      if (typeof actions.selectStep === 'function') actions.selectStep({
        instanceId: id
      });
    };
    const beginDraw = (e, id, side, isStart) => {
      e.stopPropagation();
      e.preventDefault();
      const from = nodeAnchor(id, side, positions, isStart);
      act.current = {
        kind: 'draw',
        id,
        side
      };
      setDrawing({
        from,
        to: from,
        sourceId: id,
        sourceAnchor: side
      });
    };
    useEffect(() => {
      const move = e => {
        const a = act.current;
        if (!a) return;
        if (a.kind === 'pan') {
          if (typeof actions.setViewport === 'function') {
            actions.setViewport({
              viewport: {
                ...viewport,
                x: a.ox + (e.clientX - a.sx),
                y: a.oy + (e.clientY - a.sy)
              }
            });
          }
        } else if (a.kind === 'node') {
          const z = viewport.zoom || 1;
          const nx = snap(a.ox + (e.clientX - a.sx) / z);
          const ny = snap(a.oy + (e.clientY - a.sy) / z);
          if (typeof actions.setNodePosition === 'function') actions.setNodePosition({
            instanceId: a.id,
            x: nx,
            y: ny
          });
        } else if (a.kind === 'draw') {
          setDrawing(d => d ? {
            ...d,
            to: toCanvas(e.clientX, e.clientY)
          } : d);
        }
      };
      const up = e => {
        const a = act.current;
        act.current = null;
        if (!a) return;
        if (a.kind === 'draw') {
          const pt = toCanvas(e.clientX, e.clientY);
          let hit = null;
          steps.forEach(s => {
            const id = keyOf(s);
            if (id === a.id) return;
            SIDES.forEach(side => {
              const ap = nodeAnchor(id, side, positions, false);
              const dist = Math.hypot(ap.x - pt.x, ap.y - pt.y);
              if (dist <= C.ANCHOR_HIT_RADIUS && (!hit || dist < hit.dist)) hit = {
                id,
                side,
                dist
              };
            });
          });
          if (hit && typeof actions.addConnection === 'function') {
            actions.addConnection({
              connection: {
                id: 'conn-' + a.id + '-' + hit.id,
                sourceId: a.id,
                sourceAnchor: a.side,
                targetId: hit.id,
                targetAnchor: hit.side,
                waypoints: [],
                label: ''
              }
            });
          }
          setDrawing(null);
        }
      };
      window.addEventListener('mousemove', move);
      window.addEventListener('mouseup', up);
      return () => {
        window.removeEventListener('mousemove', move);
        window.removeEventListener('mouseup', up);
      };
    }, [viewport, positions, steps, actions]);
    useEffect(() => {
      const el = wrapRef.current;
      if (!el) return undefined;
      const onWheel = e => {
        e.preventDefault();
        const r = rect();
        const z = viewport.zoom || 1;
        const factor = e.deltaY < 0 ? 1 + C.ZOOM_STEP : 1 - C.ZOOM_STEP;
        const nz = Math.min(C.MAX_ZOOM, Math.max(C.MIN_ZOOM, z * factor));
        const px = e.clientX - r.left;
        const py = e.clientY - r.top;
        const nx = px - (px - viewport.x) / z * nz;
        const ny = py - (py - viewport.y) / z * nz;
        if (typeof actions.setViewport === 'function') actions.setViewport({
          viewport: {
            x: nx,
            y: ny,
            zoom: nz
          }
        });
      };
      el.addEventListener('wheel', onWheel, {
        passive: false
      });
      return () => el.removeEventListener('wheel', onWheel);
    }, [viewport, actions]);
    useEffect(() => {
      const onKey = e => {
        if (e.key !== 'Delete' && e.key !== 'Backspace') return;
        const t = e.target;
        const tag = t && t.tagName ? String(t.tagName).toLowerCase() : '';
        if (tag === 'input' || tag === 'textarea' || tag === 'select' || t && t.isContentEditable) return;
        if (selectedConnectionId && typeof actions.removeConnection === 'function') {
          e.preventDefault();
          actions.removeConnection({
            id: selectedConnectionId
          });
          return;
        }
        if (selectedStepId && selectedStepId !== START && typeof actions.removeStep === 'function') {
          e.preventDefault();
          actions.removeStep({
            instanceId: selectedStepId
          });
        }
      };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [selectedStepId, selectedConnectionId, actions]);
    const onDragOver = e => {
      e.preventDefault();
      try {
        e.dataTransfer.dropEffect = 'copy';
      } catch (err) {}
    };
    const onDrop = e => {
      e.preventDefault();
      let stepId = '';
      try {
        stepId = e.dataTransfer.getData('application/workflow-step') || e.dataTransfer.getData('text/plain') || '';
      } catch (err) {
        stepId = '';
      }
      if (!stepId) return;
      const def = utils.getStepById(stepId);
      if (!def) return;
      if (utils.isStepGated && utils.isStepGated(def)) {
        if (typeof onGated === 'function') onGated(def);
        return;
      }
      const pt = toCanvas(e.clientX, e.clientY);
      const position = {
        x: snap(pt.x - C.NODE_WIDTH / 2),
        y: snap(pt.y - C.NODE_HEIGHT / 2)
      };
      if (typeof actions.addStep === 'function') actions.addStep({
        stepId,
        mode: 'canvas',
        position
      });
    };
    const zoomPct = Math.round((viewport.zoom || 1) * 100);
    const gridOffX = (viewport.x % (C.GRID_SIZE * (viewport.zoom || 1)) + C.GRID_SIZE * (viewport.zoom || 1)) % (C.GRID_SIZE * (viewport.zoom || 1));
    const gridOffY = (viewport.y % (C.GRID_SIZE * (viewport.zoom || 1)) + C.GRID_SIZE * (viewport.zoom || 1)) % (C.GRID_SIZE * (viewport.zoom || 1));
    const renderConnection = conn => {
      const isStartSrc = conn.sourceId === START;
      const from = nodeAnchor(conn.sourceId, conn.sourceAnchor || 'bottom', positions, isStartSrc);
      const to = nodeAnchor(conn.targetId, conn.targetAnchor || 'top', positions, false);
      const d = routePath(from, conn.sourceAnchor || 'bottom', to, conn.targetAnchor || 'top');
      const sel = conn.id === selectedConnectionId;
      return <g key={conn.id} data-testid={'wb-connection-' + conn.id} className="wb-conn">
          <path d={d} stroke="transparent" strokeWidth={16} fill="none" style={{
        cursor: 'pointer'
      }} onMouseDown={e => {
        e.stopPropagation();
        if (typeof actions.selectConnection === 'function') actions.selectConnection({
          id: conn.id
        });
      }} />
          <path d={d} className={cx('wb-conn-line', sel && 'wb-conn-line-sel')} fill="none" stroke={sel ? '#1E7FE0' : '#9AA4B2'} strokeWidth={sel ? 2.5 : 2} filter={sel ? 'url(#wb-conn-glow)' : undefined} markerEnd="url(#wb-arrow)" />
        </g>;
    };
    const renderAnchors = (id, sides, isStart) => {
      const show = hoverId === id || selectedStepId === id || !!drawing;
      return sides.map(side => {
        const p = nodeAnchor(id, side, positions, isStart);
        return <circle key={side} cx={p.x} cy={p.y} r={C.ANCHOR_RADIUS} data-testid={'wb-anchor-' + id + '-' + side} className="wb-anchor" fill="#ffffff" stroke="#1E7FE0" strokeWidth={2} style={{
          opacity: show ? 1 : 0,
          cursor: 'crosshair',
          transition: 'opacity .12s'
        }} onMouseDown={e => beginDraw(e, id, side, isStart)} />;
      });
    };
    const renderStartNode = () => {
      const p = startPos(positions);
      return <g key="start" transform={'translate(' + p.x + ',' + p.y + ')'} data-testid="wb-canvas-start">
          <foreignObject width={C.START_NODE_WIDTH} height={C.START_NODE_HEIGHT}>
            <div className="wb-start-pill">
              <StepIcon icon={WB.ICONS.play} size={16} color="#0F7B36" />
              <span>Start</span>
            </div>
          </foreignObject>
          <g transform={'translate(' + -p.x + ',' + -p.y + ')'}>{renderAnchors(START, START_SIDES, true)}</g>
        </g>;
    };
    const renderNode = s => {
      const id = keyOf(s);
      const p = positions[id];
      if (!p) return null;
      const sel = selectedStepId === id;
      const n = resolvedOrder[id];
      const coupled = utils.isCoupledStep && utils.isCoupledStep(s.id);
      return <g key={id} data-testid={'wb-canvas-node-' + id} onMouseEnter={() => setHoverId(id)} onMouseLeave={() => setHoverId(h => h === id ? null : h)}>
          <g transform={'translate(' + p.x + ',' + p.y + ')'}>
            <foreignObject width={C.NODE_WIDTH} height={C.NODE_HEIGHT}>
              <div className={cx('wb-node', sel && 'wb-node-sel')} onMouseDown={e => beginNodeDrag(e, id)}>
                <span className="wb-tile" style={{
        background: utils.getStepGradient(s.id)
      }}>
                  <StepIcon icon={utils.getStepIcon(s.id)} size={20} />
                </span>
                <span className="wb-node-text">
                  <span className="wb-node-title-row">
                    <span className="wb-node-title">{s.label}</span>
                    <span className="wb-node-cost">{coupled ? 'included' : utils.getStepCostLabel(s)}</span>
                  </span>
                  <span className="wb-node-desc">{utils.getStepDescription(s.id)}</span>
                </span>
              </div>
            </foreignObject>
          </g>
          {n ? <g transform={'translate(' + p.x + ',' + p.y + ')'}>
              <circle cx={0} cy={0} r={12} className="wb-badge" fill="#212B36" />
              <text x={0} y={4} textAnchor="middle" fontSize={11} fill="#ffffff" fontWeight="600">{n}</text>
            </g> : null}
          <g transform={'translate(' + (p.x + C.NODE_WIDTH) + ',' + p.y + ')'}>
            <circle cx={0} cy={0} r={11} fill="#FF3B30" style={{
        cursor: 'pointer'
      }} data-testid={'wb-canvas-delete-' + id} onMouseDown={e => {
        e.stopPropagation();
      }} onClick={e => {
        e.stopPropagation();
        if (typeof actions.removeStep === 'function') actions.removeStep({
          instanceId: id
        });
      }} />
            <text x={0} y={4} textAnchor="middle" fontSize={13} fill="#ffffff" style={{
        pointerEvents: 'none'
      }}>×</text>
          </g>
          {renderAnchors(id, SIDES, false)}
        </g>;
    };
    const Minimap = () => {
      const ids = steps.map(keyOf).filter(id => positions[id]);
      if (!ids.length) return null;
      const xs = ids.map(id => positions[id].x);
      const ys = ids.map(id => positions[id].y);
      const minX = Math.min(...xs, startPos(positions).x) - 40;
      const minY = Math.min(...ys, startPos(positions).y) - 40;
      const maxX = Math.max(...xs) + C.NODE_WIDTH + 40;
      const maxY = Math.max(...ys) + C.NODE_HEIGHT + 40;
      const w = Math.max(1, maxX - minX);
      const h = Math.max(1, maxY - minY);
      const scale = Math.min(180 / w, 130 / h);
      return <div className="wb-minimap" data-testid="wb-minimap" onClick={e => {
        const b = e.currentTarget.getBoundingClientRect();
        const cxp = (e.clientX - b.left) / scale + minX;
        const cyp = (e.clientY - b.top) / scale + minY;
        const r = rect();
        if (typeof actions.setViewport === 'function') {
          actions.setViewport({
            viewport: {
              x: r.width / 2 - cxp * (viewport.zoom || 1),
              y: r.height / 2 - cyp * (viewport.zoom || 1),
              zoom: viewport.zoom || 1
            }
          });
        }
      }}>
          <svg width={180} height={130}>
            {connections.map(c => {
        const a = nodeAnchor(c.sourceId, 'bottom', positions, c.sourceId === START);
        const b = nodeAnchor(c.targetId, 'top', positions, false);
        return <line key={c.id} x1={(a.x - minX) * scale} y1={(a.y - minY) * scale} x2={(b.x - minX) * scale} y2={(b.y - minY) * scale} stroke="#C4CDD5" strokeWidth={1} />;
      })}
            {ids.map(id => <rect key={id} x={(positions[id].x - minX) * scale} y={(positions[id].y - minY) * scale} width={C.NODE_WIDTH * scale} height={C.NODE_HEIGHT * scale} rx={2} fill={id === selectedStepId ? '#1E7FE0' : '#B0B8C4'} />)}
          </svg>
        </div>;
    };
    const setZoom = nz => {
      const z = Math.min(C.MAX_ZOOM, Math.max(C.MIN_ZOOM, nz));
      if (typeof actions.setViewport === 'function') actions.setViewport({
        viewport: {
          ...viewport,
          zoom: z
        }
      });
    };
    return <div ref={wrapRef} className="wb-canvas-wrap" data-testid="wb-canvas" onDragOver={onDragOver} onDrop={onDrop}>
        <svg ref={svgRef} className="wb-canvas-svg" data-testid="wb-canvas-svg" width="100%" height="100%" onMouseDown={e => {
      if (e.target === e.currentTarget || e.target && e.target.tagName === 'rect' && e.target.getAttribute('fill') === 'url(#wb-dots)') beginPan(e);
    }}>
          <defs>
            <pattern id="wb-dots" width={C.GRID_SIZE * (viewport.zoom || 1)} height={C.GRID_SIZE * (viewport.zoom || 1)} patternUnits="userSpaceOnUse" x={gridOffX} y={gridOffY}>
              <circle cx={1} cy={1} r={1} fill="#D5DBE3" />
            </pattern>
            <filter id="wb-conn-glow" x="-40%" y="-40%" width="180%" height="180%">
              <feGaussianBlur stdDeviation="3" result="b" />
              <feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
            </filter>
            <marker id="wb-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
              <path d="M 0 0 L 10 5 L 0 10 z" fill="#9AA4B2" />
            </marker>
          </defs>
          <rect x={0} y={0} width="100%" height="100%" fill="url(#wb-dots)" />
          <g transform={'translate(' + viewport.x + ',' + viewport.y + ') scale(' + (viewport.zoom || 1) + ')'}>
            {connections.map(renderConnection)}
            {drawing ? <path d={routePath(drawing.from, 'bottom', drawing.to, 'top')} stroke="#1E7FE0" strokeWidth={2} strokeDasharray="6 4" fill="none" /> : null}
            {renderStartNode()}
            {steps.map(renderNode)}
          </g>
        </svg>

        {steps.length === 0 ? <div className="wb-canvas-empty">
            <EmptyState icon={WB.ICONS.emptyCanvas} title="Drag a service here to start" subtitle="building your workflow" />
          </div> : null}

        <Minimap />

        <div className="wb-toolbar" data-testid="wb-toolbar">
          <IconButton name="minus" size={15} label="Zoom out" testId="wb-zoom-out" onClick={() => setZoom((viewport.zoom || 1) - C.ZOOM_STEP)} />
          <span className="wb-zoom-label" data-testid="wb-zoom-label">{zoomPct}%</span>
          <IconButton name="plus" size={15} label="Zoom in" testId="wb-zoom-in" onClick={() => setZoom((viewport.zoom || 1) + C.ZOOM_STEP)} />
          <IconButton name="maximize" size={15} label="Fit to content" testId="wb-toolbar-fit" onClick={() => {
      try {
        const r = rect();
        const vp = geometry.fitToContent(positions, steps.map(keyOf), r.width, r.height);
        if (vp && Number.isFinite(vp.zoom) && typeof actions.setViewport === 'function') actions.setViewport({
          viewport: vp
        }); else setZoom(1);
      } catch (e) {
        setZoom(1);
      }
    }} />
        </div>
      </div>;
  };
  return {
    WorkflowCanvas
  };
};

export const makeListMode = deps => {
  const {WB, utils, store, ui} = deps;
  const wb = WB || ({});
  const u = utils || ({});
  const kit = ui || ({});
  const noop = () => {};
  const START_ID = wb.START_NODE_ID || '__start__';
  const NODE_WIDTH = wb.CANVAS && wb.CANVAS.NODE_WIDTH || 280;
  const BRAND = wb.BRAND || ({
    primary: '#1E7FE0',
    light: '#22B8F0',
    dark: '#1456A0'
  });
  const PALETTE_COLORS = wb.VERIFY_COLORS || ({});
  const SUCCESS = PALETTE_COLORS.success || '#22C55E';
  const DANGER = PALETTE_COLORS.error || '#FF5630';
  const ICON_IDS = wb.ICONS || ({});
  const EMPTY_ICON = ICON_IDS.emptyCanvas || 'solar:widget-add-bold-duotone';
  const PLAY_ICON = ICON_IDS.play || 'solar:play-bold';
  const FALLBACK_ICON = ICON_IDS.fallback || 'solar:widget-bold-duotone';
  const GREY_GRADIENT = 'linear-gradient(135deg, #8E8E93, #AEAEB2)';
  const MIME_STEP = 'application/workflow-step';
  const MIME_REORDER = 'application/workflow-reorder';
  const REORDER_THROTTLE_MS = 200;
  const hexToRgb = hex => {
    let h = String(hex || '').replace('#', '');
    if (h.length === 3) h = h.split('').map(c => c + c).join('');
    const n = parseInt(h, 16);
    if (Number.isNaN(n)) return '30, 127, 224';
    return `${n >> 16 & 255}, ${n >> 8 & 255}, ${n & 255}`;
  };
  const PRIMARY_RGB = hexToRgb(BRAND.primary);
  const LIST_CSS = `
.wb-lm-canvas{position:relative;flex:1 1 auto;align-self:stretch;height:100%;width:100%;min-width:0;min-height:0;overflow:auto;box-sizing:border-box;outline:none;background-image:radial-gradient(circle,rgba(145,158,171,0.32) 1px,transparent 1px);background-size:20px 20px;scrollbar-width:thin;transition:box-shadow .15s ease}
.wb-lm-canvas::-webkit-scrollbar{width:8px;height:8px}
.wb-lm-canvas::-webkit-scrollbar-thumb{background:rgba(145,158,171,0.32);border-radius:8px}
.dark .wb-lm-canvas{background-image:radial-gradient(circle,rgba(145,158,171,0.2) 1px,transparent 1px)}
.wb-lm-canvas.is-drop-active{box-shadow:inset 0 0 0 2px rgba(${PRIMARY_RGB},0.35)}
.wb-lm-inner{display:flex;flex-direction:column;align-items:center;padding:32px 24px 120px;min-height:100%;box-sizing:border-box}
.wb-lm-inner.is-empty{justify-content:center;padding-bottom:48px}
.wb-lm-start{display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 20px 0 10px;border-radius:25px;background:rgba(34,197,94,0.12);border:1px solid rgba(34,197,94,0.4);color:#118D57;font-size:14px;font-weight:700;line-height:1;user-select:none;-webkit-user-select:none;box-sizing:border-box;transition:box-shadow .15s ease}
.dark .wb-lm-start{background:rgba(34,197,94,0.16);border-color:rgba(34,197,94,0.5);color:#5BE49B}
.wb-lm-start.is-over{box-shadow:0 0 0 3px rgba(${PRIMARY_RGB},0.3)}
.wb-lm-start-icon{width:24px;height:24px;border-radius:50%;background:${SUCCESS};display:flex;align-items:center;justify-content:center;flex-shrink:0}
.wb-lm-connector-wrap{position:relative;width:${NODE_WIDTH}px;height:24px;display:flex;justify-content:center;flex-shrink:0}
.wb-lm-connector{width:2px;height:24px;background:#E2E8F0;border-radius:1px;transition:background .15s ease}
.dark .wb-lm-connector{background:#2F3944}
.wb-lm-connector-wrap.is-tail .wb-lm-connector{opacity:0}
.wb-lm-connector-wrap.is-active .wb-lm-connector{background:${BRAND.primary}}
.wb-lm-insert{position:absolute;left:0;right:0;top:50%;height:3px;margin-top:-1.5px;border-radius:2px;background:${BRAND.primary};opacity:0;transform:scaleX(0.6);transition:opacity .12s ease,transform .12s ease;pointer-events:none}
.wb-lm-insert-dot{position:absolute;left:-5px;top:-4.5px;width:12px;height:12px;border-radius:50%;background:${BRAND.primary};box-shadow:0 0 0 2px #FFFFFF}
.dark .wb-lm-insert-dot{box-shadow:0 0 0 2px #141A21}
.wb-lm-connector-wrap.is-active .wb-lm-insert{opacity:1;transform:scaleX(1)}
.wb-lm-node{position:relative;width:${NODE_WIDTH}px;box-sizing:border-box;border-radius:12px;background:#FFFFFF;border:1px solid rgba(145,158,171,0.24);padding:12px 16px;cursor:grab;user-select:none;-webkit-user-select:none;color:#1C252E;box-shadow:0 1px 2px rgba(16,24,40,0.06);transition:box-shadow .15s ease,border-color .15s ease,opacity .15s ease;outline:none;flex-shrink:0}
.wb-lm-node:hover{border-color:rgba(145,158,171,0.48);box-shadow:0 4px 12px rgba(16,24,40,0.08)}
.wb-lm-node:focus-visible{box-shadow:0 0 0 3px rgba(${PRIMARY_RGB},0.35)}
.wb-lm-node:active{cursor:grabbing}
.wb-lm-node.is-selected{border-color:${BRAND.primary};box-shadow:0 0 0 1px ${BRAND.primary},0 0 0 4px rgba(${PRIMARY_RGB},0.15)}
.wb-lm-node.is-over{border-style:dashed;border-color:${BRAND.primary}}
.wb-lm-node.is-dragging{opacity:0.4;cursor:grabbing}
.dark .wb-lm-node{background:#1C252E;border-color:rgba(145,158,171,0.2);color:#FFFFFF;box-shadow:0 1px 2px rgba(0,0,0,0.3)}
.wb-lm-node-row{display:flex;align-items:center;gap:12px;min-width:0}
.wb-lm-tile{width:40px;height:40px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.12)}
.wb-lm-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.wb-lm-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0}
.wb-lm-label{font-size:14px;font-weight:600;line-height:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.wb-lm-cost{font-size:12px;font-weight:600;line-height:18px;color:#637381;flex-shrink:0;white-space:nowrap}
.wb-lm-desc{font-size:12px;line-height:18px;color:#637381;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dark .wb-lm-cost,.dark .wb-lm-desc{color:#919EAB}
.wb-lm-badge{position:absolute;top:-10px;left:-10px;width:24px;height:24px;border-radius:50%;background:#1C252E;color:#FFFFFF;font-size:11px;font-weight:700;line-height:1;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 2px #FFFFFF;pointer-events:none}
.dark .wb-lm-badge{background:#FFFFFF;color:#1C252E;box-shadow:0 0 0 2px #1C252E}
.wb-lm-delete{position:absolute;top:-9px;right:-9px;width:22px;height:22px;border-radius:50%;border:0;padding:0;margin:0;background:${DANGER};color:#FFFFFF;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 0 0 2px #FFFFFF;opacity:0.9;transition:opacity .15s ease,transform .15s ease;font:inherit;line-height:1}
.wb-lm-delete:hover{opacity:1;transform:scale(1.08)}
.wb-lm-delete:focus-visible{outline:2px solid ${BRAND.primary};outline-offset:1px}
.dark .wb-lm-delete{box-shadow:0 0 0 2px #1C252E}
.wb-lm-empty{width:320px;max-width:100%;padding:40px 24px;border-radius:16px;border:1px dashed transparent;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;transition:border-color .15s ease,background .15s ease}
.wb-lm-empty.is-over{border-color:${BRAND.primary};background:rgba(${PRIMARY_RGB},0.06)}
.wb-lm-empty-fallback{display:flex;flex-direction:column;align-items:center;gap:8px;color:#637381}
.wb-lm-empty-title{font-size:14px;font-weight:600;color:#1C252E}
.dark .wb-lm-empty-title{color:#FFFFFF}
.wb-lm-empty-sub{font-size:12px;color:#637381}
.dark .wb-lm-empty-sub{color:#919EAB}
@media (prefers-reduced-motion: reduce){.wb-lm-canvas,.wb-lm-node,.wb-lm-delete,.wb-lm-connector,.wb-lm-insert,.wb-lm-start,.wb-lm-empty{transition:none}}
`;
  const iconifyUrl = (iconId, color) => {
    const id = String(iconId || FALLBACK_ICON);
    const i = id.indexOf(':');
    const prefix = i > 0 ? id.slice(0, i) : 'solar';
    const name = i > 0 ? id.slice(i + 1) : id;
    return `https://api.iconify.design/${prefix}/${name}.svg?color=%23${String(color || '#ffffff').replace('#', '')}`;
  };
  const iconUrl = typeof kit.iconUrl === 'function' ? kit.iconUrl : iconifyUrl;
  const FallbackStepIcon = ({icon, size = 20, color = '#ffffff', style}) => <img src={iconUrl(icon, color)} width={size} height={size} alt="" draggable={false} style={{
    display: 'block',
    ...style || ({})
  }} />;
  const StepIcon = kit.StepIcon || FallbackStepIcon;
  const FallbackEmptyState = ({icon, title, subtitle}) => <div className="wb-lm-empty-fallback">
      <StepIcon icon={icon} size={64} color="#919EAB" />
      <div className="wb-lm-empty-title">{title}</div>
      <div className="wb-lm-empty-sub">{subtitle}</div>
    </div>;
  const EmptyState = kit.EmptyState || FallbackEmptyState;
  const useToastSafe = typeof kit.useToast === 'function' ? kit.useToast : () => noop;
  const getStepById = id => {
    if (typeof u.getStepById === 'function') return u.getStepById(id) || null;
    const list = Array.isArray(wb.WORKFLOW_STEPS_OPTIONS) ? wb.WORKFLOW_STEPS_OPTIONS : [];
    return list.find(s => s.id === id) || null;
  };
  const getGradient = id => {
    const g = typeof u.getStepGradient === 'function' ? u.getStepGradient(id) : (wb.STEP_ICON_GRADIENTS || ({}))[id];
    return g || GREY_GRADIENT;
  };
  const getDescription = step => {
    if (typeof u.getStepDescription === 'function') return u.getStepDescription(step.id) || '';
    const svc = getStepById(step.id);
    return svc && svc.description || step.description || '';
  };
  const getIcon = step => {
    if (step.icon) return step.icon;
    if (typeof u.getStepIcon === 'function') return u.getStepIcon(step.id) || FALLBACK_ICON;
    const svc = getStepById(step.id);
    return svc && svc.icon || FALLBACK_ICON;
  };
  const fmtMoney = n => typeof u.fCurrency === 'function' ? u.fCurrency(n) : `$${Number(n).toFixed(2)}`;
  const getCostLabel = step => {
    const svc = getStepById(step.id);
    if (typeof u.getStepCostLabel === 'function') return u.getStepCostLabel(svc || step);
    if (svc && svc.coupled) return 'included';
    if (svc && svc.costLabel) return svc.costLabel;
    if (svc && typeof svc.cost === 'number') return fmtMoney(svc.cost);
    return '—';
  };
  const isGated = svc => {
    if (!svc) return false;
    if (typeof u.isStepGated === 'function') return !!u.isStepGated(svc, {
      whiteLabelConfigured: false
    });
    if (typeof u.isGatedStep === 'function') return !!u.isGatedStep(svc);
    return !!svc.gated;
  };
  const reorderValid = (steps, from, to) => typeof u.isReorderValidWithPinning === 'function' ? !!u.isReorderValidWithPinning(steps, from, to) : true;
  const stepKeyOf = step => step.instanceId || step.id;
  const send = (dispatch, type, payload) => {
    const p = payload || ({});
    dispatch({
      type,
      ...p,
      payload: p
    });
  };
  const readTypes = e => {
    const dt = e && e.dataTransfer;
    if (!dt || !dt.types) return [];
    try {
      return Array.from(dt.types);
    } catch (err) {
      return [];
    }
  };
  const setDropEffect = (e, effect) => {
    const dt = e && e.dataTransfer;
    if (!dt) return;
    try {
      dt.dropEffect = effect;
    } catch (err) {}
  };
  const getData = (e, type) => {
    const dt = e && e.dataTransfer;
    if (!dt) return '';
    try {
      return dt.getData(type) || '';
    } catch (err) {
      return '';
    }
  };
  const CloseGlyph = ({size = 10}) => <svg width={size} height={size} viewBox="0 0 10 10" aria-hidden="true" focusable="false">
      <path d="M1.5 1.5l7 7M8.5 1.5l-7 7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" />
    </svg>;
  const Connector = ({active, tail}) => <div className={'wb-lm-connector-wrap' + (active ? ' is-active' : '') + (tail ? ' is-tail' : '')} aria-hidden="true">
      <div className="wb-lm-connector" data-testid={tail ? 'wb-list-connector-tail' : 'wb-list-connector'} />
      <div className="wb-lm-insert">
        <span className="wb-lm-insert-dot" />
      </div>
    </div>;
  const StartPill = ({over, onDragOver, onDrop}) => <div className={'wb-lm-start' + (over ? ' is-over' : '')} data-testid="wb-list-start" data-node-id={START_ID} onDragOver={onDragOver} onDrop={onDrop}>
      <span className="wb-lm-start-icon">
        <StepIcon icon={PLAY_ICON} size={12} color="#ffffff" />
      </span>
      <span>Start</span>
    </div>;
  const SortableStepNode = ({step, index, number, selected, dragging, over, onSelect, onRemove, onDragStart, onDragEnd, onDragOver, onDrop}) => {
    const svc = getStepById(step.id);
    const label = step.label || svc && svc.label || step.id;
    const cost = getCostLabel(step);
    const desc = getDescription(step);
    const key = stepKeyOf(step);
    const cls = 'wb-lm-node' + (selected ? ' is-selected' : '') + (dragging ? ' is-dragging' : '') + (over ? ' is-over' : '');
    const handleKeyDown = e => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        onSelect(step);
      } else if (e.key === 'Delete' || e.key === 'Backspace') {
        e.preventDefault();
        onRemove(step);
      }
    };
    return <div className={cls} data-testid={`wb-list-node-${key}`} data-instance-id={key} data-step-id={step.id} data-index={index} data-selected={selected ? 'true' : 'false'} role="button" tabIndex={0} aria-pressed={selected} aria-label={`${label}, step ${number}`} draggable onClick={e => {
      e.stopPropagation();
      onSelect(step);
    }} onKeyDown={handleKeyDown} onDragStart={e => onDragStart(e, step, index)} onDragEnd={e => onDragEnd(e, step, index)} onDragOver={e => onDragOver(e, step, index)} onDrop={e => onDrop(e, step, index)}>
        <span className="wb-lm-badge" data-testid={`wb-list-number-${key}`}>
          {number}
        </span>
        <button type="button" className="wb-lm-delete" data-testid={`wb-list-delete-${key}`} aria-label={`Remove ${label}`} title="Remove step" draggable={false} onClick={e => {
      e.preventDefault();
      e.stopPropagation();
      onRemove(step);
    }}>
          <CloseGlyph />
        </button>
        <div className="wb-lm-node-row">
          <div className="wb-lm-tile" style={{
      background: getGradient(step.id)
    }}>
            <StepIcon icon={getIcon(step)} size={20} color="#ffffff" />
          </div>
          <div className="wb-lm-text">
            <div className="wb-lm-title-row">
              <span className="wb-lm-label" title={label}>
                {label}
              </span>
              <span className="wb-lm-cost" data-testid={`wb-list-cost-${key}`}>
                {cost}
              </span>
            </div>
            <div className="wb-lm-desc" title={desc}>
              {desc}
            </div>
          </div>
        </div>
      </div>;
  };
  const ListCanvas = ({onGated}) => {
    const wf = store.useWorkflow() || ({});
    const state = wf.state || ({});
    const dispatch = typeof wf.dispatch === 'function' ? wf.dispatch : noop;
    const selectors = wf.selectors || ({});
    const toastApi = useToastSafe();
    const toast = typeof toastApi === 'function' ? toastApi : toastApi && typeof toastApi.toast === 'function' ? toastApi.toast : noop;
    const steps = state.workflow && Array.isArray(state.workflow.steps) ? state.workflow.steps : [];
    const selectedStepId = state.selectedStepId || null;
    const resolvedOrder = selectors.resolvedOrder || ({});
    const count = steps.length;
    const rootRef = useRef(null);
    const stepsRef = useRef(steps);
    stepsRef.current = steps;
    const dragRef = useRef({
      kind: null,
      instanceId: null,
      lastFrom: -1,
      lastTo: -1,
      lastAt: 0
    });
    const enterCount = useRef(0);
    const [draggingId, setDraggingId] = useState(null);
    const [hover, setHover] = useState(null);
    const setHoverIf = (insertAt, nodeId) => setHover(prev => prev && prev.insertAt === insertAt && prev.nodeId === nodeId ? prev : {
      insertAt,
      nodeId
    });
    const clearHover = () => setHover(prev => prev === null ? prev : null);
    const clearDrag = useCallback(() => {
      dragRef.current = {
        kind: null,
        instanceId: null,
        lastFrom: -1,
        lastTo: -1,
        lastAt: 0
      };
      enterCount.current = 0;
      setDraggingId(prev => prev === null ? prev : null);
      setHover(prev => prev === null ? prev : null);
    }, []);
    useEffect(() => {
      const reset = () => clearDrag();
      window.addEventListener('dragend', reset);
      window.addEventListener('drop', reset);
      return () => {
        window.removeEventListener('dragend', reset);
        window.removeEventListener('drop', reset);
      };
    }, [clearDrag]);
    useEffect(() => {
      const root = rootRef.current;
      if (!root || !selectedStepId || (/["\\]/).test(selectedStepId)) return;
      const el = root.querySelector(`[data-instance-id="${selectedStepId}"]`);
      if (!el) return;
      const r = root.getBoundingClientRect();
      const n = el.getBoundingClientRect();
      if (n.top < r.top + 16) root.scrollTop -= r.top + 16 - n.top; else if (n.bottom > r.bottom - 16) root.scrollTop += n.bottom - (r.bottom - 16);
    }, [selectedStepId]);
    const readKind = e => {
      const types = readTypes(e);
      if (types.indexOf(MIME_REORDER) !== -1) return 'reorder';
      if (types.indexOf(MIME_STEP) !== -1) return 'palette';
      if (dragRef.current.kind === 'reorder') return 'reorder';
      return 'unknown';
    };
    const selectStep = step => {
      send(dispatch, 'SELECT_STEP', {
        instanceId: step ? stepKeyOf(step) : null
      });
    };
    const removeStep = step => {
      send(dispatch, 'REMOVE_STEP', {
        instanceId: stepKeyOf(step)
      });
    };
    const addStepAt = (stepId, index) => {
      const svc = getStepById(stepId);
      if (!svc) return false;
      if (isGated(svc)) {
        if (typeof onGated === 'function') onGated(svc);
        return false;
      }
      if (stepsRef.current.some(s => s.id === stepId)) {
        toast(`${svc.label} is already in this workflow`, {
          type: 'info'
        });
        return false;
      }
      const n = stepsRef.current.length;
      const at = Math.max(0, Math.min(typeof index === 'number' ? index : n, n));
      send(dispatch, 'ADD_STEP', {
        stepId,
        index: at,
        mode: 'list'
      });
      return true;
    };
    const moveStep = (instanceId, to) => {
      const list = stepsRef.current;
      const from = list.findIndex(s => stepKeyOf(s) === instanceId);
      if (from < 0 || to < 0 || to >= list.length || from === to) return false;
      if (!reorderValid(list, from, to)) return false;
      send(dispatch, 'REORDER', {
        from,
        to
      });
      return true;
    };
    const liveReorder = (e, targetIndex) => {
      const draggedId = dragRef.current.instanceId;
      if (!draggedId) return;
      const list = stepsRef.current;
      const from = list.findIndex(s => stepKeyOf(s) === draggedId);
      const to = targetIndex;
      if (from < 0 || from === to) return;
      const hasPointer = !(e.clientX === 0 && e.clientY === 0);
      if (hasPointer && e.currentTarget && typeof e.currentTarget.getBoundingClientRect === 'function') {
        const rect = e.currentTarget.getBoundingClientRect();
        const mid = rect.top + rect.height / 2;
        if (from < to && e.clientY < mid) return;
        if (from > to && e.clientY > mid) return;
      }
      const d = dragRef.current;
      const now = Date.now();
      if (d.lastFrom === from && d.lastTo === to && now - d.lastAt < REORDER_THROTTLE_MS) return;
      if (!reorderValid(list, from, to)) return;
      d.lastFrom = from;
      d.lastTo = to;
      d.lastAt = now;
      send(dispatch, 'REORDER', {
        from,
        to
      });
    };
    const handleRootDragEnter = e => {
      e.preventDefault();
      enterCount.current += 1;
    };
    const handleRootDragLeave = () => {
      enterCount.current = Math.max(0, enterCount.current - 1);
      if (enterCount.current === 0) clearHover();
    };
    const handleRootDragOver = e => {
      e.preventDefault();
      const kind = readKind(e);
      setDropEffect(e, kind === 'reorder' ? 'move' : 'copy');
      if (kind === 'palette') setHoverIf(stepsRef.current.length, null); else clearHover();
    };
    const handleRootDrop = e => {
      e.preventDefault();
      const reorderId = getData(e, MIME_REORDER) || (dragRef.current.kind === 'reorder' ? dragRef.current.instanceId : '');
      if (reorderId) {
        clearDrag();
        return;
      }
      const stepId = getData(e, MIME_STEP) || getData(e, 'text/plain');
      clearDrag();
      if (stepId) addStepAt(stepId, stepsRef.current.length);
    };
    const handleRootClick = () => {
      if (selectedStepId) selectStep(null);
    };
    const handleStartDragOver = e => {
      e.preventDefault();
      e.stopPropagation();
      const kind = readKind(e);
      setDropEffect(e, kind === 'reorder' ? 'move' : 'copy');
      if (kind === 'palette') setHoverIf(0, START_ID); else clearHover();
    };
    const handleStartDrop = e => {
      e.preventDefault();
      e.stopPropagation();
      const reorderId = getData(e, MIME_REORDER) || (dragRef.current.kind === 'reorder' ? dragRef.current.instanceId : '');
      if (reorderId) {
        moveStep(reorderId, 0);
        clearDrag();
        return;
      }
      const stepId = getData(e, MIME_STEP) || getData(e, 'text/plain');
      clearDrag();
      if (stepId) addStepAt(stepId, 0);
    };
    const handleNodeDragStart = (e, step) => {
      const id = stepKeyOf(step);
      const dt = e.dataTransfer;
      if (dt) {
        try {
          dt.setData(MIME_REORDER, id);
          dt.setData('text/plain', id);
          dt.effectAllowed = 'move';
        } catch (err) {}
      }
      dragRef.current = {
        kind: 'reorder',
        instanceId: id,
        lastFrom: -1,
        lastTo: -1,
        lastAt: 0
      };
      setTimeout(() => {
        if (dragRef.current.instanceId === id) setDraggingId(id);
      }, 0);
    };
    const handleNodeDragEnd = () => {
      clearDrag();
    };
    const handleNodeDragOver = (e, step, index) => {
      e.preventDefault();
      e.stopPropagation();
      const kind = readKind(e);
      if (kind === 'reorder') {
        setDropEffect(e, 'move');
        clearHover();
        if (dragRef.current.instanceId === stepKeyOf(step)) return;
        liveReorder(e, index);
        return;
      }
      setDropEffect(e, 'copy');
      if (kind === 'palette') setHoverIf(index + 1, stepKeyOf(step)); else clearHover();
    };
    const handleNodeDrop = (e, step, index) => {
      e.preventDefault();
      e.stopPropagation();
      const reorderId = getData(e, MIME_REORDER) || (dragRef.current.kind === 'reorder' ? dragRef.current.instanceId : '');
      if (reorderId) {
        if (reorderId !== stepKeyOf(step)) moveStep(reorderId, index);
        clearDrag();
        return;
      }
      const stepId = getData(e, MIME_STEP) || getData(e, 'text/plain');
      clearDrag();
      if (stepId) addStepAt(stepId, index + 1);
    };
    const hoverAt = hover ? hover.insertAt : -1;
    const hoverNode = hover ? hover.nodeId : null;
    const dropActive = hover !== null;
    return <div ref={rootRef} id="workflow-canvas" data-testid="wb-list-dropzone" data-drop-active={dropActive ? 'true' : 'false'} data-reordering={draggingId ? 'true' : 'false'} data-step-count={count} className={'wb-lm-canvas wb-dotgrid' + (dropActive ? ' is-drop-active' : '')} onDragEnter={handleRootDragEnter} onDragLeave={handleRootDragLeave} onDragOver={handleRootDragOver} onDrop={handleRootDrop} onClick={handleRootClick}>
        <style dangerouslySetInnerHTML={{
      __html: LIST_CSS
    }} />
        {count === 0 ? <div className="wb-lm-inner is-empty">
            <StartPill over={hoverNode === START_ID} onDragOver={handleStartDragOver} onDrop={handleStartDrop} />
            <Connector active={dropActive} />
            <div className={'wb-lm-empty' + (dropActive ? ' is-over' : '')} data-testid="wb-list-empty">
              <EmptyState icon={EMPTY_ICON} title="Drag a service here to start" subtitle="building your workflow" />
            </div>
          </div> : <div className="wb-lm-inner">
            <StartPill over={hoverNode === START_ID} onDragOver={handleStartDragOver} onDrop={handleStartDrop} />
            {steps.map((step, i) => {
      const key = stepKeyOf(step);
      const order = resolvedOrder[key];
      const number = typeof order === 'number' && order > 0 ? order : i + 1;
      return <div style={{
        display: 'contents'
      }} key={key}>
                  <Connector active={hoverAt === i} />
                  <SortableStepNode step={step} index={i} number={number} selected={selectedStepId === key} dragging={draggingId === key} over={hoverNode === key} onSelect={selectStep} onRemove={removeStep} onDragStart={handleNodeDragStart} onDragEnd={handleNodeDragEnd} onDragOver={handleNodeDragOver} onDrop={handleNodeDrop} />
                </div>;
    })}
            <Connector tail active={hoverAt === count} />
          </div>}
      </div>;
  };
  return {
    ListCanvas
  };
};

export const makePalette = deps => {
  const {WB, utils, store, ui} = deps;
  const PALETTE_WIDTH = WB.PANEL && WB.PANEL.PALETTE_WIDTH || 300;
  const CATEGORY_TABS = WB.CATEGORY_TABS || ['All', 'Verify', 'Docs', 'Screen', 'Crypto', 'Gaming'];
  const UI_ICONS = WB.UI_ICONS || ({});
  const BRAND = WB.BRAND || ({
    primary: '#1E7FE0',
    light: '#22B8F0',
    dark: '#1456A0'
  });
  const CATALOG = WB.WORKFLOW_STEPS_OPTIONS || [];
  const GREY_GRADIENT = 'linear-gradient(135deg, #8E8E93, #AEAEB2)';
  const DRAG_MIME = 'application/workflow-step';
  const {useWorkflow} = store;
  const gradientOf = stepId => typeof utils.getStepGradient === 'function' && utils.getStepGradient(stepId) || GREY_GRADIENT;
  const costLabelOf = service => {
    if (typeof utils.getStepCostLabel === 'function') return utils.getStepCostLabel(service);
    if (service.coupled) return 'included';
    if (service.costLabel) return service.costLabel;
    if (typeof service.cost === 'number') {
      return typeof utils.fCurrency === 'function' ? utils.fCurrency(service.cost) : `$${service.cost.toFixed(2)}`;
    }
    return '—';
  };
  const servicesFor = availableServices => {
    if (typeof utils.getAvailableServiceData === 'function') {
      return utils.getAvailableServiceData(availableServices) || [];
    }
    return CATALOG.filter(s => !s.advancedOnly);
  };
  const isGated = (service, gateContext) => {
    try {
      if (typeof utils.isStepGated === 'function') return !!utils.isStepGated(service, gateContext);
      if (typeof utils.isGatedStep === 'function') return !!utils.isGatedStep(service);
    } catch (err) {}
    return !!(service && service.gated);
  };
  const iconSrc = (iconId, hex) => {
    if (typeof ui.iconUrl === 'function') return ui.iconUrl(iconId, hex);
    const parts = String(iconId || '').split(':');
    const color = String(hex || '#ffffff').replace('#', '');
    return `https://api.iconify.design/${parts[0]}/${parts[1]}.svg?color=%23${color}`;
  };
  const LucideIcon = ui.LucideIcon || (() => null);
  const StepIcon = ui.StepIcon || (({icon, size = 20, color = '#ffffff', style}) => <img src={iconSrc(icon, color)} width={size} height={size} alt="" draggable={false} style={{
    display: 'block',
    ...style || ({})
  }} />);
  const Scrollbar = ui.Scrollbar || (({children, style, className}) => <div className={className} style={{
    overflow: 'auto',
    ...style || ({})
  }}>
        {children}
      </div>);
  const FallbackTextField = ({value, onChange, placeholder, testId, startIcon}) => <div className="wb-pal-ff">
      {startIcon ? <span className="wb-pal-ff-icon">{startIcon}</span> : null}
      <input type="text" value={value} placeholder={placeholder} aria-label={placeholder} data-testid={testId} onChange={e => onChange ? onChange(e.target.value) : null} />
    </div>;
  const TextField = ui.TextField || FallbackTextField;
  const FallbackTabs = ({value, onChange, tabs}) => <div role="tablist" className="wb-pal-ft">
      {(tabs || []).map(t => <button key={t.value} type="button" role="tab" aria-selected={value === t.value ? 'true' : 'false'} data-testid={t.testId} className={value === t.value ? 'is-active' : ''} onClick={() => onChange ? onChange(t.value) : null}>
          {t.label}
        </button>)}
    </div>;
  const Tabs = ui.Tabs || FallbackTabs;
  const PALETTE_CSS = `
.wb-pal-root{--wb-pal-bg:#FFFFFF;--wb-pal-surface:#FFFFFF;--wb-pal-border:rgba(145,158,171,0.20);--wb-pal-text:#1C252E;--wb-pal-muted:#637381;--wb-pal-hover-border:rgba(30,127,224,0.45);--wb-pal-shadow:0 8px 16px -4px rgba(145,158,171,0.24);--wb-pal-lock-bg:rgba(255,171,0,0.16);--wb-pal-lock-fg:#B76E00;color:var(--wb-pal-text);}
.dark .wb-pal-root{--wb-pal-bg:#141A21;--wb-pal-surface:#1C252E;--wb-pal-border:rgba(145,158,171,0.16);--wb-pal-text:#FFFFFF;--wb-pal-muted:#919EAB;--wb-pal-hover-border:rgba(34,184,240,0.55);--wb-pal-shadow:0 8px 16px -4px rgba(0,0,0,0.48);--wb-pal-lock-bg:rgba(255,171,0,0.16);--wb-pal-lock-fg:#FFD666;}
.wb-pal-tabs{overflow-x:auto;scrollbar-width:none;}
.wb-pal-tabs::-webkit-scrollbar{display:none;}
.wb-pal-list{scrollbar-width:thin;}
.wb-pal-card{position:relative;display:flex;align-items:flex-start;gap:12px;padding:12px;border:1px solid var(--wb-pal-border);border-radius:12px;background:var(--wb-pal-surface);cursor:grab;user-select:none;-webkit-user-select:none;box-sizing:border-box;transition:border-color .18s ease,box-shadow .18s ease,transform .18s ease;}
.wb-pal-card:hover{border-color:var(--wb-pal-hover-border);box-shadow:var(--wb-pal-shadow);transform:translateY(-1px);}
.wb-pal-card:active{cursor:grabbing;transform:translateY(0);}
.wb-pal-card:focus-visible{outline:2px solid ${BRAND.primary};outline-offset:2px;}
.wb-pal-card[data-gated="true"]{cursor:not-allowed;}
.wb-pal-card[data-gated="true"] .wb-pal-tile{filter:saturate(.35);opacity:.85;}
.wb-pal-cost{display:inline-block;max-width:96px;opacity:1;overflow:hidden;white-space:nowrap;flex-shrink:0;transition:max-width .2s ease,opacity .2s ease;}
.wb-pal-card:hover .wb-pal-cost{max-width:0;opacity:0;}
.wb-pal-grip{display:inline-flex;align-items:center;flex-shrink:0;max-width:0;opacity:0;overflow:hidden;color:var(--wb-pal-muted);transition:max-width .2s ease,opacity .2s ease;}
.wb-pal-card:hover .wb-pal-grip{max-width:20px;opacity:1;}
.wb-pal-desc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.wb-pal-ff{position:relative;display:flex;align-items:center;}
.wb-pal-ff-icon{position:absolute;left:10px;display:inline-flex;color:var(--wb-pal-muted);pointer-events:none;}
.wb-pal-ff input{width:100%;box-sizing:border-box;height:36px;padding:0 12px 0 32px;border:1px solid var(--wb-pal-border);border-radius:8px;background:transparent;color:var(--wb-pal-text);font:inherit;font-size:13px;outline:none;}
.wb-pal-ff input:focus{border-color:${BRAND.primary};box-shadow:0 0 0 2px rgba(30,127,224,.2);}
.wb-pal-ft{display:flex;gap:4px;}
.wb-pal-ft button{border:0;background:transparent;color:var(--wb-pal-muted);font:inherit;font-size:13px;font-weight:600;padding:4px 10px;border-radius:999px;cursor:pointer;white-space:nowrap;}
.wb-pal-ft button.is-active{background:${BRAND.primary};color:#FFFFFF;}
@media (prefers-reduced-motion:reduce){.wb-pal-card,.wb-pal-cost,.wb-pal-grip{transition:none;}}
`;
  const LockChip = ({stepId}) => <span className="wb-pal-lock" data-testid={`wb-palette-lock-${stepId}`} style={{
    display: 'inline-flex',
    alignItems: 'center',
    gap: 4,
    padding: '2px 8px',
    borderRadius: 999,
    background: 'var(--wb-pal-lock-bg)',
    color: 'var(--wb-pal-lock-fg)',
    fontSize: 11,
    fontWeight: 700,
    lineHeight: '16px',
    whiteSpace: 'nowrap',
    flexShrink: 0
  }}>
      <LucideIcon name={UI_ICONS.lock || 'lock'} size={12} />
      Locked
    </span>;
  const PaletteCard = ({service, gated, onGated}) => {
    const gradient = gradientOf(service.id);
    const costLabel = costLabelOf(service);
    const fireGated = () => {
      if (typeof onGated === 'function') onGated(service);
    };
    const handleDragStart = e => {
      if (gated) {
        e.preventDefault();
        fireGated();
        return;
      }
      const dt = e.dataTransfer;
      if (!dt) return;
      try {
        dt.setData(DRAG_MIME, service.id);
        dt.setData('text/plain', service.id);
        dt.effectAllowed = 'copy';
      } catch (err) {}
    };
    const handleClick = () => {
      if (gated) fireGated();
    };
    const handleKeyDown = e => {
      if (!gated) return;
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        fireGated();
      }
    };
    return <div className="wb-pal-card wb-palette-card" data-testid={`wb-palette-card-${service.id}`} data-step-id={service.id} data-category={service.category} data-gated={gated ? 'true' : 'false'} draggable role="button" tabIndex={0} aria-label={gated ? `${service.label} (locked)` : `Drag ${service.label} into the workflow`} aria-disabled={gated ? 'true' : undefined} onDragStart={handleDragStart} onClick={handleClick} onKeyDown={handleKeyDown}>
        <div className="wb-pal-tile" aria-hidden="true" style={{
      width: 36,
      height: 36,
      minWidth: 36,
      borderRadius: '22%',
      background: gradient,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.12)'
    }}>
          <StepIcon icon={service.icon} size={20} color="#ffffff" />
        </div>

        <div style={{
      flex: 1,
      minWidth: 0,
      display: 'flex',
      flexDirection: 'column',
      gap: 2
    }}>
          <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: 8,
      minHeight: 20
    }}>
            <div className="wb-subtitle2 wb-ellipsis" style={{
      flex: 1,
      minWidth: 0,
      fontSize: 14,
      fontWeight: 600,
      lineHeight: '20px',
      overflow: 'hidden',
      textOverflow: 'ellipsis',
      whiteSpace: 'nowrap'
    }}>
              {service.label}
            </div>
            {gated ? <LockChip stepId={service.id} /> : <span style={{
      display: 'inline-flex',
      alignItems: 'center',
      flexShrink: 0
    }}>
                <span className="wb-pal-cost" data-testid={`wb-palette-cost-${service.id}`} style={{
      fontSize: 12,
      fontWeight: 600,
      lineHeight: '18px',
      color: 'var(--wb-pal-muted)'
    }}>
                  {costLabel}
                </span>
                <span className="wb-pal-grip" aria-hidden="true">
                  <LucideIcon name={UI_ICONS.drag || 'grip-vertical'} size={16} />
                </span>
              </span>}
          </div>
          <div className="wb-pal-desc wb-caption" style={{
      fontSize: 12,
      lineHeight: '18px',
      color: 'var(--wb-pal-muted)'
    }}>
            {service.description}
          </div>
        </div>
      </div>;
  };
  const EmptyList = ({allPlaced, categoryTab}) => <div data-testid="wb-palette-empty" style={{
    padding: '32px 16px',
    textAlign: 'center',
    color: 'var(--wb-pal-muted)'
  }}>
      <div style={{
    fontSize: 13,
    fontWeight: 600,
    marginBottom: 4,
    color: 'var(--wb-pal-text)'
  }}>
        {allPlaced ? 'All services are in your workflow' : 'No services match'}
      </div>
      <div style={{
    fontSize: 12,
    lineHeight: '18px'
  }}>
        {allPlaced ? 'Remove a step to make it available again.' : `Try a different search${categoryTab !== 'All' ? ' or category' : ''}.`}
      </div>
    </div>;
  const Palette = paletteProps => {
    const {viewMode, availableServices, onGated, gateContext} = paletteProps;
    const {state} = useWorkflow();
    const steps = state && state.workflow && state.workflow.steps || [];
    const [searchQuery, setSearchQuery] = useState('');
    const [categoryTab, setCategoryTab] = useState('All');
    const availKey = Array.isArray(availableServices) ? availableServices.join('|') : '';
    const services = useMemo(() => servicesFor(availableServices), [availKey]);
    const placedIds = useMemo(() => {
      const set = new Set();
      steps.forEach(s => set.add(s.id));
      return set;
    }, [steps]);
    const filtered = useMemo(() => {
      const q = searchQuery.trim().toLowerCase();
      return services.filter(service => {
        const matchesSearch = !q || String(service.label || '').toLowerCase().includes(q);
        const matchesCategory = categoryTab === 'All' || service.category === categoryTab;
        const alreadyInWorkflow = placedIds.has(service.id);
        return matchesSearch && matchesCategory && !alreadyInWorkflow;
      });
    }, [services, searchQuery, categoryTab, placedIds]);
    const tabs = useMemo(() => CATEGORY_TABS.map(value => ({
      value,
      label: value,
      testId: `wb-palette-tab-${value}`
    })), []);
    const allPlaced = !searchQuery && categoryTab === 'All' && filtered.length === 0 && services.length > 0;
    const handleSearchChange = next => {
      if (typeof next === 'string') setSearchQuery(next); else if (next && next.target) setSearchQuery(String(next.target.value || '')); else setSearchQuery('');
    };
    return <aside className="wb-palette wb-pal-root" data-testid="wb-palette" data-viewmode={viewMode || 'builder'} data-count={filtered.length} aria-label="Available Services" style={{
      width: PALETTE_WIDTH,
      minWidth: PALETTE_WIDTH,
      maxWidth: PALETTE_WIDTH,
      flexShrink: 0,
      display: 'flex',
      flexDirection: 'column',
      height: '100%',
      minHeight: 0,
      boxSizing: 'border-box',
      background: 'var(--wb-pal-bg)',
      borderRight: '1px solid var(--wb-pal-border)'
    }}>
        <style>{PALETTE_CSS}</style>

        <div style={{
      padding: '16px 16px 8px',
      display: 'flex',
      flexDirection: 'column',
      gap: 12,
      flexShrink: 0
    }}>
          <div style={{
      display: 'flex',
      alignItems: 'baseline',
      justifyContent: 'space-between',
      gap: 8
    }}>
            <div role="heading" aria-level={2} style={{
      margin: 0,
      fontSize: 16,
      fontWeight: 700,
      lineHeight: '24px',
      color: 'var(--wb-pal-text)'
    }}>
              Available Services
            </div>
            <span className="wb-caption" data-testid="wb-palette-count" style={{
      fontSize: 12,
      lineHeight: '18px',
      color: 'var(--wb-pal-muted)'
    }}>
              {filtered.length}
            </span>
          </div>

          <TextField size="small" fullWidth placeholder="Search services…" value={searchQuery} onChange={handleSearchChange} startIcon={<LucideIcon name={UI_ICONS.search || 'search'} size={16} />} testId="wb-palette-search" />

          <div className="wb-pal-tabs">
            <Tabs value={categoryTab} onChange={setCategoryTab} tabs={tabs} pill />
          </div>
        </div>

        <Scrollbar className="wb-pal-list" style={{
      flex: 1,
      minHeight: 0
    }}>
          <div data-testid="wb-palette-list" style={{
      display: 'flex',
      flexDirection: 'column',
      gap: 8,
      padding: '4px 16px 16px'
    }}>
            {filtered.length === 0 ? <EmptyList allPlaced={allPlaced} categoryTab={categoryTab} /> : filtered.map(service => <PaletteCard key={service.id} service={service} gated={isGated(service, gateContext)} onGated={onGated} />)}
          </div>
        </Scrollbar>
      </aside>;
  };
  return {
    Palette
  };
};

export const makeStore = deps => {
  const {WB, utils, geometry} = deps;
  const START = WB && WB.START_NODE_ID || '__start__';
  const CV = WB && WB.CANVAS || ({});
  const NODE_W = CV.NODE_WIDTH || 280;
  const NODE_H = CV.NODE_HEIGHT || 100;
  const ROW_GAP = CV.ROW_GAP || 140;
  const GRID = CV.GRID_SIZE || 20;
  const STORAGE_KEY = 'deepidv-wb-workflow-v1';
  const PERSIST_DELAY = 300;
  const VIEW_MODES = ['builder', 'canvas', 'preview'];
  const IS_DEV = !!(WB && WB.IS_DEV);
  const COUPLED = WB && WB.COUPLED_STEPS || ({});
  const OVERRIDES = WB && WB.COUPLED_PROPERTY_OVERRIDES || ({});
  const PINNED = WB && WB.BOTTOM_PINNED_STEPS || [];
  const CATALOG = WB && WB.WORKFLOW_STEPS_OPTIONS || [];
  const TEMPLATES = WB && WB.TEMPLATES || [];
  const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
  const stepKey = s => s ? s.instanceId || s.id : undefined;
  const clone = v => {
    if (utils && typeof utils.deepClone === 'function') return utils.deepClone(v);
    return v === undefined ? v : JSON.parse(JSON.stringify(v));
  };
  const snapV = v => {
    if (geometry && typeof geometry.snap === 'function') return geometry.snap(v);
    return Math.round(v / GRID) * GRID;
  };
  const numOr = (v, d) => typeof v === 'number' && Number.isFinite(v) ? v : d;
  const U = (name, fallback) => utils && typeof utils[name] === 'function' ? utils[name] : fallback;
  const fbGetStepById = id => CATALOG.find(s => s.id === id) || null;
  const fbGetStepTemplate = id => {
    const def = fbGetStepById(id);
    return def ? {
      ...def
    } : null;
  };
  const fbSanitize = step => {
    if (!step) return null;
    const out = {
      id: step.id,
      label: step.label,
      icon: step.icon
    };
    if (step.propertyGroups) out.propertyGroups = clone(step.propertyGroups);
    return out;
  };
  const fbMakeInstanceId = id => `${id}-${Date.now()}`;
  const fbApplyOverrides = step => {
    const ov = OVERRIDES[step.id];
    if (!ov || !isObj(ov) || !Array.isArray(step.propertyGroups)) return step;
    let changed = false;
    const groups = step.propertyGroups.map(g => {
      const gOv = ov[g.groupId];
      if (!isObj(gOv)) return g;
      changed = true;
      return {
        ...g,
        properties: (g.properties || []).map(p => (p.id in gOv) ? {
          ...p,
          value: gOv[p.id]
        } : p)
      };
    });
    return changed ? {
      ...step,
      propertyGroups: groups
    } : step;
  };
  const fbApplyCoupled = (steps, newStep) => {
    const list = [...steps];
    const added = [];
    const wanted = COUPLED[newStep.id] || [];
    wanted.forEach(cid => {
      if (list.some(s => s.id === cid)) return;
      const tpl = U('getStepTemplate', fbGetStepTemplate)(cid);
      if (!tpl) return;
      const base = U('sanitizeStepForWorkflow', fbSanitize)(tpl) || ({});
      const inst = fbApplyOverrides({
        ...base,
        instanceId: U('makeInstanceId', fbMakeInstanceId)(cid)
      });
      list.push(inst);
      added.push(inst);
    });
    return {
      steps: list,
      added
    };
  };
  const fbEnforcePinning = steps => {
    const unpinned = steps.filter(s => !PINNED.includes(s.id));
    const pinned = steps.filter(s => PINNED.includes(s.id));
    pinned.sort((a, b) => PINNED.indexOf(a.id) - PINNED.indexOf(b.id));
    return [...unpinned, ...pinned];
  };
  const fbEnforceOverrides = steps => steps.map(fbApplyOverrides);
  const fbRemoveWithCoupled = (steps, instanceId) => {
    const target = steps.find(s => stepKey(s) === instanceId);
    if (!target) return steps;
    const cascade = new Set([target.id]);
    const wanted = COUPLED[target.id] || [];
    wanted.forEach(cid => {
      if ((COUPLED[cid] || []).includes(target.id)) cascade.add(cid);
    });
    return steps.filter(s => !cascade.has(s.id));
  };
  const fbIsReorderValid = (steps, from, to) => {
    if (from === to) return true;
    if (from < 0 || to < 0 || from >= steps.length || to >= steps.length) return false;
    const moved = steps[from];
    const list = [...steps];
    list.splice(from, 1);
    list.splice(to, 0, moved);
    const firstPinned = list.findIndex(s => PINNED.includes(s.id));
    if (firstPinned === -1) return true;
    return list.slice(firstPinned).every(s => PINNED.includes(s.id));
  };
  const fbRebuildChain = steps => {
    const ordered = fbEnforcePinning(steps);
    const conns = [];
    let prev = START;
    ordered.forEach((s, i) => {
      const key = stepKey(s);
      conns.push({
        id: `conn-${prev}-${key}-${i}`,
        sourceId: prev,
        sourceAnchor: 'bottom',
        targetId: key,
        targetAnchor: 'top',
        waypoints: [],
        label: ''
      });
      prev = key;
    });
    return conns;
  };
  const fbResolveOrder = (steps, connections) => {
    const byKey = new Map(steps.map(s => [stepKey(s), s]));
    const warnings = [];
    const orderedSteps = [];
    const seen = new Set([START]);
    let cur = START;
    for (let guard = 0; guard <= steps.length + 1; guard += 1) {
      const out = (connections || []).find(c => c.sourceId === cur);
      if (!out) break;
      if (seen.has(out.targetId)) {
        warnings.push('Cycle detected');
        break;
      }
      seen.add(out.targetId);
      const step = byKey.get(out.targetId);
      if (step) orderedSteps.push(step);
      cur = out.targetId;
    }
    return {
      orderedSteps,
      warnings
    };
  };
  const fbComputeResolvedOrder = (steps, connections) => {
    const {orderedSteps} = fbResolveOrder(steps, connections);
    const out = {};
    orderedSteps.forEach((s, i) => {
      out[stepKey(s)] = i + 1;
    });
    return out;
  };
  const fbRemoveWithBridge = (steps, connections, instanceIds) => {
    const gone = new Set(instanceIds);
    let conns = [...connections || []];
    instanceIds.forEach(id => {
      const incoming = conns.filter(c => c.targetId === id);
      const outgoing = conns.filter(c => c.sourceId === id);
      conns = conns.filter(c => c.sourceId !== id && c.targetId !== id);
      if (incoming.length === 1 && outgoing.length === 1) {
        const a = incoming[0];
        const b = outgoing[0];
        if (!gone.has(a.sourceId) && !gone.has(b.targetId) && a.sourceId !== b.targetId) {
          const already = conns.some(c => c.sourceId === a.sourceId) || conns.some(c => c.targetId === b.targetId);
          if (!already) conns.push({
            ...a,
            id: `conn-${a.sourceId}-${b.targetId}-${Date.now()}`,
            targetId: b.targetId,
            targetAnchor: b.targetAnchor,
            waypoints: [],
            label: ''
          });
        }
      }
    });
    return {
      steps: steps.filter(s => !gone.has(stepKey(s))),
      connections: conns
    };
  };
  const fbAddConnectionIfLinear = (connections, conn) => {
    if (!conn || !conn.sourceId || !conn.targetId) return null;
    if (conn.sourceId === conn.targetId || conn.targetId === START) return null;
    const list = connections || [];
    if (list.some(c => c.sourceId === conn.sourceId)) return null;
    if (list.some(c => c.targetId === conn.targetId)) return null;
    return [...list, {
      waypoints: [],
      label: '',
      ...conn
    }];
  };
  const fbComputeCost = steps => {
    const getCost = U('getStepCost', s => {
      const def = fbGetStepById(s.id);
      if (!def || def.coupled) return def && def.coupled ? 0 : null;
      return typeof def.cost === 'number' ? def.cost : null;
    });
    return steps.reduce((sum, s) => {
      const c = getCost(s);
      return typeof c === 'number' && Number.isFinite(c) ? sum + c : sum;
    }, 0);
  };
  const fbUpdatePropertyValue = (steps, key, groupId, propId, value) => steps.map(s => stepKey(s) === key ? {
    ...s,
    propertyGroups: (s.propertyGroups || []).map(g => g.groupId === groupId ? {
      ...g,
      properties: (g.properties || []).map(p => p.id === propId ? {
        ...p,
        value
      } : p)
    } : g)
  } : s);
  const fbUpdatePropertyValues = (steps, key, groupId, values) => steps.map(s => stepKey(s) === key ? {
    ...s,
    propertyGroups: (s.propertyGroups || []).map(g => g.groupId === groupId ? {
      ...g,
      properties: (g.properties || []).map(p => isObj(values) && (p.id in values) ? {
        ...p,
        value: values[p.id]
      } : p)
    } : g)
  } : s);
  const fbUpdateLinkedWeights = (steps, key, groupId, changedId, value) => steps.map(s => {
    if (stepKey(s) !== key) return s;
    return {
      ...s,
      propertyGroups: (s.propertyGroups || []).map(g => {
        if (g.groupId !== groupId) return g;
        const props = g.properties || [];
        const linked = props.filter(p => p.type === 'slider' && p.linkedGroup);
        if (!linked.some(p => p.id === changedId)) return g;
        const next = Math.max(0, Math.min(100, Math.round(numOr(value, 0) / 5) * 5));
        const others = linked.filter(p => p.id !== changedId);
        const remaining = 100 - next;
        const otherSum = others.reduce((a, p) => a + numOr(p.value, 0), 0);
        const newVals = {
          [changedId]: next
        };
        let acc = 0;
        others.forEach((p, i) => {
          let v;
          if (i === others.length - 1) v = remaining - acc; else {
            const share = otherSum > 0 ? numOr(p.value, 0) / otherSum : 1 / others.length;
            v = Math.round(remaining * share / 5) * 5;
            acc += v;
          }
          newVals[p.id] = Math.max(0, v);
        });
        return {
          ...g,
          properties: props.map(p => (p.id in newVals) ? {
            ...p,
            value: newVals[p.id]
          } : p)
        };
      })
    };
  });
  const fbInitialLayout = steps => {
    const out = {
      [START]: {
        x: snapV(NODE_W / 2 - (CV.START_NODE_WIDTH || 120) / 2),
        y: 20
      }
    };
    steps.forEach((s, i) => {
      out[stepKey(s)] = {
        x: 0,
        y: snapV(120 + i * ROW_GAP)
      };
    });
    return out;
  };
  const getStepTemplate = U('getStepTemplate', fbGetStepTemplate);
  const sanitizeStep = U('sanitizeStepForWorkflow', fbSanitize);
  const makeInstanceId = U('makeInstanceId', fbMakeInstanceId);
  const applyCoupledSteps = U('applyCoupledSteps', fbApplyCoupled);
  const enforcePinning = U('enforceBottomPinningOnSteps', fbEnforcePinning);
  const enforceOverrides = U('enforceCoupledOverridesOnSteps', fbEnforceOverrides);
  const removeWithCoupled = U('removeWithCoupled', fbRemoveWithCoupled);
  const isReorderValid = U('isReorderValidWithPinning', fbIsReorderValid);
  const rebuildChain = U('rebuildChainWithPinning', fbRebuildChain);
  const resolveOrder = U('resolveStepOrderFromConnections', fbResolveOrder);
  const computeResolvedOrder = U('computeResolvedOrder', fbComputeResolvedOrder);
  const removeWithBridge = U('removeStepsWithBridge', fbRemoveWithBridge);
  const addConnectionIfLinear = U('addConnectionIfLinear', fbAddConnectionIfLinear);
  const computeCost = U('computeWorkflowCost', fbComputeCost);
  const updatePropertyValueInSteps = U('updatePropertyValueInSteps', fbUpdatePropertyValue);
  const updatePropertyValuesInSteps = U('updatePropertyValuesInSteps', fbUpdatePropertyValues);
  const updateLinkedWeightsInSteps = U('updateLinkedWeightsInSteps', fbUpdateLinkedWeights);
  const initialLayout = geometry && typeof geometry.initialLayout === 'function' ? geometry.initialLayout : fbInitialLayout;
  const blankViewport = () => ({
    x: 0,
    y: 0,
    zoom: 1
  });
  const blankCanvasData = () => ({
    connections: [],
    nodePositions: {},
    viewport: blankViewport()
  });
  const blankWorkflow = () => ({
    name: '',
    steps: [],
    canvasData: blankCanvasData()
  });
  const initialState = {
    workflow: blankWorkflow(),
    selectedStepId: null,
    selectedConnectionId: null,
    viewMode: 'builder',
    hydrated: false,
    toasts: []
  };
  const normalizeConnection = c => {
    if (!isObj(c) || !c.sourceId || !c.targetId) return null;
    return {
      id: c.id || `conn-${c.sourceId}-${c.targetId}`,
      sourceId: String(c.sourceId),
      sourceAnchor: c.sourceAnchor || 'bottom',
      targetId: String(c.targetId),
      targetAnchor: c.targetAnchor || 'top',
      waypoints: Array.isArray(c.waypoints) ? c.waypoints.filter(p => isObj(p) && typeof p.x === 'number' && typeof p.y === 'number') : [],
      label: typeof c.label === 'string' ? c.label : ''
    };
  };
  const normalizeWorkflow = raw => {
    const w = blankWorkflow();
    if (!isObj(raw)) return w;
    if (typeof raw.name === 'string') w.name = raw.name;
    if (Array.isArray(raw.steps)) {
      const seen = new Set();
      raw.steps.forEach(s => {
        if (!isObj(s) || typeof s.id !== 'string') return;
        const step = {
          ...s
        };
        if (typeof step.instanceId !== 'string' || !step.instanceId) step.instanceId = makeInstanceId(step.id);
        if (seen.has(step.instanceId)) return;
        seen.add(step.instanceId);
        if (typeof step.label !== 'string') {
          const def = fbGetStepById(step.id);
          step.label = def ? def.label : step.id;
        }
        if (typeof step.icon !== 'string') {
          const def = fbGetStepById(step.id);
          step.icon = def ? def.icon : WB && WB.ICONS && WB.ICONS.fallback || '';
        }
        if (step.propertyGroups !== undefined && !Array.isArray(step.propertyGroups)) delete step.propertyGroups;
        w.steps.push(step);
      });
    }
    const cd = isObj(raw.canvasData) ? raw.canvasData : {};
    const keys = new Set(w.steps.map(stepKey));
    keys.add(START);
    if (Array.isArray(cd.connections)) {
      const seenIds = new Set();
      cd.connections.forEach(c => {
        const n = normalizeConnection(c);
        if (!n || seenIds.has(n.id)) return;
        if (!keys.has(n.sourceId) || !keys.has(n.targetId)) return;
        seenIds.add(n.id);
        w.canvasData.connections.push(n);
      });
    }
    if (isObj(cd.nodePositions)) {
      Object.keys(cd.nodePositions).forEach(k => {
        const p = cd.nodePositions[k];
        if (keys.has(k) && isObj(p) && typeof p.x === 'number' && typeof p.y === 'number') w.canvasData.nodePositions[k] = {
          x: p.x,
          y: p.y
        };
      });
    }
    if (isObj(cd.viewport)) {
      const minZ = CV.MIN_ZOOM || 0.15;
      const maxZ = CV.MAX_ZOOM || 2.5;
      w.canvasData.viewport = {
        x: numOr(cd.viewport.x, 0),
        y: numOr(cd.viewport.y, 0),
        zoom: Math.min(maxZ, Math.max(minZ, numOr(cd.viewport.zoom, 1)))
      };
    }
    return w;
  };
  const withWorkflow = (state, patch) => ({
    ...state,
    workflow: {
      ...state.workflow,
      ...patch
    }
  });
  const withCanvas = (state, patch) => withWorkflow(state, {
    canvasData: {
      ...state.workflow.canvasData,
      ...patch
    }
  });
  const prunePositions = (positions, steps) => {
    const keep = new Set(steps.map(stepKey));
    keep.add(START);
    const out = {};
    Object.keys(positions || ({})).forEach(k => {
      if (keep.has(k)) out[k] = positions[k];
    });
    return out;
  };
  const positionTaken = (positions, x, y, ignoreKey) => Object.keys(positions).some(k => {
    if (k === ignoreKey) return false;
    const p = positions[k];
    return p && Math.abs(p.x - x) < GRID && Math.abs(p.y - y) < GRID;
  });
  const ensurePositions = (steps, positions) => {
    const existing = prunePositions(positions, steps);
    const missing = steps.map(stepKey).filter(k => !existing[k]);
    const needStart = !existing[START];
    if (!missing.length && !needStart) return existing;
    const layout = initialLayout(steps) || ({});
    const out = {
      ...existing
    };
    if (needStart) out[START] = layout[START] || ({
      x: 0,
      y: 20
    });
    missing.forEach(k => {
      const base = layout[k] || ({
        x: 0,
        y: 120
      });
      let x = snapV(base.x);
      let y = snapV(base.y);
      let guard = 0;
      while (positionTaken(out, x, y, k) && guard < 200) {
        y += ROW_GAP;
        guard += 1;
      }
      out[k] = {
        x,
        y
      };
    });
    return out;
  };
  const buildInstance = stepId => {
    const tpl = getStepTemplate(stepId);
    if (!tpl) return null;
    const base = sanitizeStep(tpl) || ({
      id: tpl.id,
      label: tpl.label,
      icon: tpl.icon
    });
    const inst = {
      ...base,
      id: base.id || stepId,
      instanceId: makeInstanceId(stepId)
    };
    if (inst.propertyGroups === undefined) delete inst.propertyGroups;
    return inst;
  };
  const applyStepRules = (steps, newStep) => {
    const res = applyCoupledSteps(steps, newStep) || ({
      steps,
      added: []
    });
    let list = Array.isArray(res.steps) ? res.steps : Array.isArray(res) ? res : steps;
    const added = Array.isArray(res.added) ? res.added : [];
    list = enforcePinning(list) || list;
    list = enforceOverrides(list) || list;
    return {
      steps: list,
      added
    };
  };
  const clampIndex = (index, len) => {
    if (typeof index !== 'number' || !Number.isFinite(index)) return len;
    return Math.max(0, Math.min(len, Math.round(index)));
  };
  const reduceAddStep = (state, a) => {
    const stepId = a.stepId || a.id;
    if (!stepId) return state;
    const steps = state.workflow.steps;
    const existing = steps.find(s => s.id === stepId);
    if (existing) {
      return {
        ...state,
        selectedStepId: stepKey(existing),
        selectedConnectionId: null
      };
    }
    const newStep = buildInstance(stepId);
    if (!newStep) return state;
    const mode = a.mode || (state.viewMode === 'canvas' ? 'canvas' : 'list');
    const cd = state.workflow.canvasData;
    if (mode === 'canvas') {
      const {steps: nextSteps, added} = applyStepRules([...steps, newStep], newStep);
      let positions = prunePositions(cd.nodePositions, nextSteps);
      const pos = a.position;
      if (isObj(pos) && typeof pos.x === 'number' && typeof pos.y === 'number') {
        const x = snapV(pos.x - NODE_W / 2);
        const y = snapV(pos.y - NODE_H / 2);
        positions = {
          ...positions,
          [newStep.instanceId]: {
            x,
            y
          }
        };
        added.forEach((s, i) => {
          if (!positions[stepKey(s)]) positions[stepKey(s)] = {
            x,
            y: y + ROW_GAP * (i + 1)
          };
        });
      }
      positions = ensurePositions(nextSteps, positions);
      return {
        ...withCanvas({
          ...state,
          workflow: {
            ...state.workflow,
            steps: nextSteps
          }
        }, {
          nodePositions: positions
        }),
        selectedStepId: newStep.instanceId,
        selectedConnectionId: null
      };
    }
    const list = [...steps];
    list.splice(clampIndex(a.index, list.length), 0, newStep);
    const {steps: nextSteps} = applyStepRules(list, newStep);
    const connections = rebuildChain(nextSteps) || [];
    const positions = ensurePositions(nextSteps, cd.nodePositions);
    return {
      ...withCanvas({
        ...state,
        workflow: {
          ...state.workflow,
          steps: nextSteps
        }
      }, {
        connections,
        nodePositions: positions
      }),
      selectedStepId: newStep.instanceId,
      selectedConnectionId: null
    };
  };
  const reduceApplyTemplate = (state, a) => {
    const tpl = TEMPLATES.find(t => t.id === a.templateId);
    if (!tpl) return state;
    let steps = [];
    (tpl.stepIds || []).forEach(id => {
      if (steps.some(s => s.id === id)) return;
      const inst = buildInstance(id);
      if (!inst) return;
      steps = applyStepRules([...steps, inst], inst).steps;
    });
    const connections = steps.length ? rebuildChain(steps) || [] : [];
    const positions = ensurePositions(steps, {});
    return {
      ...withCanvas({
        ...state,
        workflow: {
          ...state.workflow,
          steps
        }
      }, {
        connections,
        nodePositions: positions
      }),
      selectedStepId: null,
      selectedConnectionId: null
    };
  };
  const reduceRemoveStep = (state, a) => {
    const instanceId = a.instanceId;
    const steps = state.workflow.steps;
    if (!instanceId || !steps.some(s => stepKey(s) === instanceId)) return state;
    const remaining = removeWithCoupled(steps, instanceId) || steps.filter(s => stepKey(s) !== instanceId);
    const keep = new Set(remaining.map(stepKey));
    const removedIds = steps.map(stepKey).filter(k => !keep.has(k));
    if (!removedIds.includes(instanceId)) removedIds.push(instanceId);
    const cd = state.workflow.canvasData;
    const res = removeWithBridge(steps, cd.connections, removedIds) || ({});
    const nextSteps = Array.isArray(res.steps) ? res.steps : steps.filter(s => !removedIds.includes(stepKey(s)));
    const gone = new Set(removedIds);
    const nextConns = (Array.isArray(res.connections) ? res.connections : cd.connections).filter(c => !gone.has(c.sourceId) && !gone.has(c.targetId));
    const positions = prunePositions(cd.nodePositions, nextSteps);
    const selectedStepId = gone.has(state.selectedStepId) ? null : state.selectedStepId;
    const selectedConnectionId = nextConns.some(c => c.id === state.selectedConnectionId) ? state.selectedConnectionId : null;
    return {
      ...withCanvas({
        ...state,
        workflow: {
          ...state.workflow,
          steps: nextSteps
        }
      }, {
        connections: nextConns,
        nodePositions: positions
      }),
      selectedStepId,
      selectedConnectionId
    };
  };
  const reduceReorder = (state, a) => {
    const steps = state.workflow.steps;
    const from = a.from;
    const to = a.to;
    if (typeof from !== 'number' || typeof to !== 'number') return state;
    if (from === to || from < 0 || to < 0 || from >= steps.length || to >= steps.length) return state;
    if (!isReorderValid(steps, from, to)) return state;
    const moved = steps[from];
    const displaced = steps[to];
    const list = [...steps];
    list.splice(from, 1);
    list.splice(to, 0, moved);
    const nextSteps = enforcePinning(list) || list;
    const connections = rebuildChain(nextSteps) || [];
    const cd = state.workflow.canvasData;
    const positions = {
      ...cd.nodePositions
    };
    const kA = stepKey(moved);
    const kB = stepKey(displaced);
    if (positions[kA] && positions[kB]) {
      const tmp = positions[kA];
      positions[kA] = positions[kB];
      positions[kB] = tmp;
    }
    return withCanvas({
      ...state,
      workflow: {
        ...state.workflow,
        steps: nextSteps
      }
    }, {
      connections,
      nodePositions: ensurePositions(nextSteps, positions)
    });
  };
  const reduceSyncOrder = (state, a) => {
    const order = Array.isArray(a.order) ? a.order : [];
    const steps = state.workflow.steps;
    const byKey = new Map(steps.map(s => [stepKey(s), s]));
    const next = [];
    const used = new Set();
    order.forEach(k => {
      const s = byKey.get(k);
      if (s && !used.has(k)) {
        used.add(k);
        next.push(s);
      }
    });
    steps.forEach(s => {
      if (!used.has(stepKey(s))) next.push(s);
    });
    if (next.length === steps.length && next.every((s, i) => s === steps[i])) return state;
    return withWorkflow(state, {
      steps: next
    });
  };
  const reducer = (state, action) => {
    if (!action || typeof action.type !== 'string') return state;
    const a = isObj(action.payload) ? {
      ...action.payload,
      type: action.type
    } : action;
    const cd = state.workflow.canvasData;
    switch (a.type) {
      case 'LOAD':
        {
          const workflow = normalizeWorkflow(a.workflow);
          const viewMode = VIEW_MODES.includes(a.viewMode) ? a.viewMode : state.viewMode;
          return {
            ...state,
            workflow,
            viewMode,
            hydrated: true,
            selectedStepId: null,
            selectedConnectionId: null
          };
        }
      case 'RESET':
        return {
          ...state,
          workflow: blankWorkflow(),
          selectedStepId: null,
          selectedConnectionId: null,
          viewMode: 'builder'
        };
      case 'SET_NAME':
        return withWorkflow(state, {
          name: typeof a.name === 'string' ? a.name : ''
        });
      case 'APPLY_TEMPLATE':
        return reduceApplyTemplate(state, a);
      case 'ADD_STEP':
        return reduceAddStep(state, a);
      case 'REMOVE_STEP':
        return reduceRemoveStep(state, a);
      case 'REORDER':
        return reduceReorder(state, a);
      case 'SYNC_STEP_ORDER':
        return reduceSyncOrder(state, a);
      case 'SET_CONNECTIONS':
        {
          const connections = (Array.isArray(a.connections) ? a.connections : []).map(normalizeConnection).filter(Boolean);
          const selectedConnectionId = connections.some(c => c.id === state.selectedConnectionId) ? state.selectedConnectionId : null;
          return {
            ...withCanvas(state, {
              connections
            }),
            selectedConnectionId
          };
        }
      case 'ADD_CONNECTION':
        {
          const conn = normalizeConnection(a.connection);
          if (!conn) return state;
          if (conn.sourceId === conn.targetId || conn.targetId === START) return state;
          const known = new Set(state.workflow.steps.map(stepKey));
          known.add(START);
          if (!known.has(conn.sourceId) || !known.has(conn.targetId)) return state;
          if (cd.connections.some(c => c.id === conn.id)) return state;
          const next = addConnectionIfLinear(cd.connections, conn);
          if (!Array.isArray(next)) return state;
          return withCanvas(state, {
            connections: next
          });
        }
      case 'REMOVE_CONNECTION':
        {
          if (!cd.connections.some(c => c.id === a.id)) return state;
          const connections = cd.connections.filter(c => c.id !== a.id);
          const selectedConnectionId = state.selectedConnectionId === a.id ? null : state.selectedConnectionId;
          return {
            ...withCanvas(state, {
              connections
            }),
            selectedConnectionId
          };
        }
      case 'UPDATE_CONNECTION':
        {
          if (!isObj(a.patch) || !cd.connections.some(c => c.id === a.id)) return state;
          const patch = {
            ...a.patch
          };
          delete patch.id;
          return withCanvas(state, {
            connections: cd.connections.map(c => c.id === a.id ? {
              ...c,
              ...patch
            } : c)
          });
        }
      case 'SET_NODE_POSITION':
        {
          if (!a.instanceId || typeof a.x !== 'number' || typeof a.y !== 'number') return state;
          const prev = cd.nodePositions[a.instanceId];
          if (prev && prev.x === a.x && prev.y === a.y) return state;
          return withCanvas(state, {
            nodePositions: {
              ...cd.nodePositions,
              [a.instanceId]: {
                x: a.x,
                y: a.y
              }
            }
          });
        }
      case 'SET_NODE_POSITIONS':
        {
          if (!isObj(a.positions)) return state;
          const merged = {
            ...cd.nodePositions
          };
          Object.keys(a.positions).forEach(k => {
            const p = a.positions[k];
            if (isObj(p) && typeof p.x === 'number' && typeof p.y === 'number') merged[k] = {
              x: p.x,
              y: p.y
            };
          });
          return withCanvas(state, {
            nodePositions: merged
          });
        }
      case 'SET_VIEWPORT':
        {
          if (!isObj(a.viewport)) return state;
          const vp = {
            x: numOr(a.viewport.x, cd.viewport.x),
            y: numOr(a.viewport.y, cd.viewport.y),
            zoom: numOr(a.viewport.zoom, cd.viewport.zoom)
          };
          if (vp.x === cd.viewport.x && vp.y === cd.viewport.y && vp.zoom === cd.viewport.zoom) return state;
          return withCanvas(state, {
            viewport: vp
          });
        }
      case 'SELECT_STEP':
        {
          const id = a.instanceId === undefined ? null : a.instanceId;
          const valid = id === null || state.workflow.steps.some(s => stepKey(s) === id);
          const nextId = valid ? id : null;
          if (nextId === state.selectedStepId && (nextId === null || state.selectedConnectionId === null)) return state;
          return {
            ...state,
            selectedStepId: nextId,
            selectedConnectionId: nextId === null ? state.selectedConnectionId : null
          };
        }
      case 'SELECT_CONNECTION':
        {
          const id = a.id === undefined ? null : a.id;
          const valid = id === null || cd.connections.some(c => c.id === id);
          const nextId = valid ? id : null;
          if (nextId === state.selectedConnectionId && (nextId === null || state.selectedStepId === null)) return state;
          return {
            ...state,
            selectedConnectionId: nextId,
            selectedStepId: nextId === null ? state.selectedStepId : null
          };
        }
      case 'SET_VIEW_MODE':
        {
          if (!VIEW_MODES.includes(a.viewMode) || a.viewMode === state.viewMode) return state;
          return {
            ...state,
            viewMode: a.viewMode
          };
        }
      case 'UPDATE_PROPERTY_VALUE':
        {
          if (!a.stepKey || !a.groupId || !a.propId) return state;
          return withWorkflow(state, {
            steps: updatePropertyValueInSteps(state.workflow.steps, a.stepKey, a.groupId, a.propId, a.value)
          });
        }
      case 'UPDATE_PROPERTY_VALUES':
        {
          if (!a.stepKey || !a.groupId || !isObj(a.values)) return state;
          return withWorkflow(state, {
            steps: updatePropertyValuesInSteps(state.workflow.steps, a.stepKey, a.groupId, a.values)
          });
        }
      case 'UPDATE_LINKED_WEIGHTS':
        {
          if (!a.stepKey || !a.groupId || !a.changedId) return state;
          return withWorkflow(state, {
            steps: updateLinkedWeightsInSteps(state.workflow.steps, a.stepKey, a.groupId, a.changedId, a.value)
          });
        }
      default:
        return state;
    }
  };
  const orderFromConnections = (steps, connections) => {
    if (!steps.length) return {
      ordered: [],
      warnings: []
    };
    let res;
    try {
      res = resolveOrder(steps, connections) || ({});
    } catch (e) {
      res = {};
    }
    const reachable = Array.isArray(res.orderedSteps) ? res.orderedSteps : [];
    const byKey = new Map(steps.map(s => [stepKey(s), s]));
    const used = new Set();
    const ordered = [];
    reachable.forEach(s => {
      const k = typeof s === 'string' ? s : stepKey(s);
      const real = byKey.get(k);
      if (real && !used.has(k)) {
        used.add(k);
        ordered.push(real);
      }
    });
    steps.forEach(s => {
      if (!used.has(stepKey(s))) ordered.push(s);
    });
    return {
      ordered,
      warnings: Array.isArray(res.warnings) ? res.warnings : []
    };
  };
  const ACTION_DEFS = {
    load: ['LOAD', ['workflow', 'viewMode']],
    reset: ['RESET', []],
    setName: ['SET_NAME', ['name']],
    applyTemplate: ['APPLY_TEMPLATE', ['templateId']],
    addStep: ['ADD_STEP', ['stepId', 'index', 'position', 'mode']],
    removeStep: ['REMOVE_STEP', ['instanceId']],
    reorder: ['REORDER', ['from', 'to']],
    setConnections: ['SET_CONNECTIONS', ['connections']],
    addConnection: ['ADD_CONNECTION', ['connection']],
    removeConnection: ['REMOVE_CONNECTION', ['id']],
    updateConnection: ['UPDATE_CONNECTION', ['id', 'patch']],
    setNodePosition: ['SET_NODE_POSITION', ['instanceId', 'x', 'y']],
    setNodePositions: ['SET_NODE_POSITIONS', ['positions']],
    setViewport: ['SET_VIEWPORT', ['viewport']],
    selectStep: ['SELECT_STEP', ['instanceId']],
    selectConnection: ['SELECT_CONNECTION', ['id']],
    setViewMode: ['SET_VIEW_MODE', ['viewMode']],
    updatePropertyValue: ['UPDATE_PROPERTY_VALUE', ['stepKey', 'groupId', 'propId', 'value']],
    updatePropertyValues: ['UPDATE_PROPERTY_VALUES', ['stepKey', 'groupId', 'values']],
    updateLinkedWeights: ['UPDATE_LINKED_WEIGHTS', ['stepKey', 'groupId', 'changedId', 'value']]
  };
  const bindActions = dispatch => {
    const out = {};
    Object.keys(ACTION_DEFS).forEach(name => {
      const [type, keys] = ACTION_DEFS[name];
      out[name] = (...args) => {
        const first = args[0];
        let payload;
        if (!keys.length) payload = {}; else if (args.length === 1 && isObj(first) && keys.some(k => (k in first))) payload = first; else {
          payload = {};
          keys.forEach((k, i) => {
            if (i < args.length) payload[k] = args[i];
          });
        }
        dispatch({
          type,
          ...payload
        });
      };
    });
    out.clearSelection = () => {
      dispatch({
        type: 'SELECT_STEP',
        instanceId: null
      });
      dispatch({
        type: 'SELECT_CONNECTION',
        id: null
      });
    };
    return out;
  };
  const noop = () => {};
  const inertActions = bindActions(noop);
  const emptySelectors = {
    resolvedOrder: {},
    cost: 0,
    selectedStep: null,
    orderedSteps: [],
    stepCount: 0
  };
  const fallbackContext = {
    state: initialState,
    dispatch: noop,
    actions: inertActions,
    selectors: emptySelectors
  };
  const WorkflowContext = React.createContext(null);
  const WorkflowProvider = ({children, initialWorkflow, storageKey}) => {
    const KEY = typeof storageKey === 'string' && storageKey ? storageKey : STORAGE_KEY;
    const [state, dispatch] = useReducer(reducer, initialState);
    const hydratedRef = useRef(false);
    useEffect(() => {
      if (hydratedRef.current) return;
      hydratedRef.current = true;
      let saved = null;
      try {
        const raw = window.localStorage.getItem(KEY);
        if (raw) saved = JSON.parse(raw);
      } catch (e) {
        saved = null;
      }
      const workflow = isObj(saved) && isObj(saved.workflow) ? saved.workflow : isObj(initialWorkflow) ? initialWorkflow : undefined;
      const viewMode = isObj(saved) && VIEW_MODES.includes(saved.viewMode) ? saved.viewMode : undefined;
      dispatch({
        type: 'LOAD',
        workflow,
        viewMode
      });
    }, []);
    useEffect(() => {
      if (!state.hydrated) return undefined;
      const t = setTimeout(() => {
        try {
          window.localStorage.setItem(KEY, JSON.stringify({
            workflow: state.workflow,
            viewMode: state.viewMode
          }));
        } catch (e) {}
      }, PERSIST_DELAY);
      return () => clearTimeout(t);
    }, [state.workflow, state.viewMode, state.hydrated, KEY]);
    const connections = state.workflow.canvasData.connections;
    useEffect(() => {
      const steps = state.workflow.steps;
      if (!steps.length) return;
      const {ordered} = orderFromConnections(steps, connections);
      if (ordered.length === steps.length && ordered.every((s, i) => s === steps[i])) return;
      dispatch({
        type: 'SYNC_STEP_ORDER',
        order: ordered.map(stepKey)
      });
    }, [connections]);
    const actions = useMemo(() => bindActions(dispatch), [dispatch]);
    const steps = state.workflow.steps;
    const selectedStepId = state.selectedStepId;
    const resolvedOrder = useMemo(() => {
      try {
        return computeResolvedOrder(steps, connections) || ({});
      } catch (e) {
        return {};
      }
    }, [steps, connections]);
    const cost = useMemo(() => {
      try {
        const c = computeCost(steps);
        return typeof c === 'number' && Number.isFinite(c) ? c : 0;
      } catch (e) {
        return 0;
      }
    }, [steps]);
    const selectedStep = useMemo(() => selectedStepId ? steps.find(s => stepKey(s) === selectedStepId) || null : null, [steps, selectedStepId]);
    const orderedSteps = useMemo(() => orderFromConnections(steps, connections).ordered, [steps, connections]);
    const stepCount = steps.length;
    const selectors = useMemo(() => ({
      resolvedOrder,
      cost,
      selectedStep,
      orderedSteps,
      stepCount
    }), [resolvedOrder, cost, selectedStep, orderedSteps, stepCount]);
    const value = useMemo(() => ({
      state,
      dispatch,
      actions,
      selectors
    }), [state, actions, selectors]);
    return <WorkflowContext.Provider value={value}>{children}</WorkflowContext.Provider>;
  };
  let warnedOutside = false;
  const useWorkflow = () => {
    const ctx = useContext(WorkflowContext);
    if (ctx) return ctx;
    if (IS_DEV && !warnedOutside) {
      warnedOutside = true;
      try {
        console.warn('[workflow-builder] useWorkflow() called outside <WorkflowProvider>; returning an inert store.');
      } catch (e) {}
    }
    return fallbackContext;
  };
  return {
    WorkflowProvider,
    useWorkflow
  };
};

export const makeGeometry = deps => {
  const {WB} = deps;
  const C = WB.CANVAS;
  const START_NODE_ID = WB.START_NODE_ID;
  const NODE_W = C.NODE_WIDTH;
  const NODE_H = C.NODE_HEIGHT;
  const START_W = C.START_NODE_WIDTH;
  const START_H = C.START_NODE_HEIGHT;
  const MIN_ZOOM = C.MIN_ZOOM;
  const MAX_ZOOM = C.MAX_ZOOM;
  const ZOOM_STEP = C.ZOOM_STEP;
  const ELBOW_GAP = C.ELBOW_GAP;
  const CORNER_RADIUS = C.CORNER_RADIUS;
  const GRID_SIZE = C.GRID_SIZE;
  const ROW_GAP = C.ROW_GAP;
  const FIT_PADDING = C.FIT_PADDING;
  const FIT_MAX_ZOOM = C.FIT_MAX_ZOOM;
  const ANCHOR_HIT_RADIUS = C.ANCHOR_HIT_RADIUS;
  const isFiniteNum = n => typeof n === 'number' && Number.isFinite(n);
  const num = (n, fallback) => isFiniteNum(n) ? n : fallback;
  const round2 = n => Math.round(n * 100) / 100;
  const round4 = n => Math.round(n * 10000) / 10000;
  const EPS = 1e-6;
  const DEFAULT_VIEWPORT = {
    x: 0,
    y: 0,
    zoom: 1
  };
  const LAYOUT_AXIS_X = 400;
  const LAYOUT_TOP_Y = 20;
  const LAYOUT_FIRST_STEP_Y = 120;
  const DEFAULT_START_POSITION = {
    x: LAYOUT_AXIS_X - START_W / 2,
    y: LAYOUT_TOP_Y
  };
  const ANCHOR_SIDES = ['top', 'right', 'bottom', 'left'];
  const START_ANCHOR_SIDES = ['bottom', 'right'];
  const makeOffsets = (w, h) => ({
    top: {
      x: w / 2,
      y: 0
    },
    right: {
      x: w,
      y: h / 2
    },
    bottom: {
      x: w / 2,
      y: h
    },
    left: {
      x: 0,
      y: h / 2
    }
  });
  const STEP_ANCHOR_OFFSETS = makeOffsets(NODE_W, NODE_H);
  const START_ANCHOR_OFFSETS = makeOffsets(START_W, START_H);
  const ANCHOR_OFFSETS = {
    ...STEP_ANCHOR_OFFSETS
  };
  Object.defineProperty(ANCHOR_OFFSETS, 'step', {
    value: STEP_ANCHOR_OFFSETS,
    enumerable: false
  });
  Object.defineProperty(ANCHOR_OFFSETS, 'start', {
    value: START_ANCHOR_OFFSETS,
    enumerable: false
  });
  const anchorOffsets = isStart => isStart ? START_ANCHOR_OFFSETS : STEP_ANCHOR_OFFSETS;
  const nodeSize = isStart => isStart ? {
    width: START_W,
    height: START_H
  } : {
    width: NODE_W,
    height: NODE_H
  };
  const isStartId = (nodeId, isStart) => typeof isStart === 'boolean' ? isStart : nodeId === START_NODE_ID;
  const stepKeyOf = step => {
    if (typeof step === 'string') return step;
    return step && (step.instanceId || step.id) || '';
  };
  const snap = v => Math.round(num(v, 0) / GRID_SIZE) * GRID_SIZE;
  const clampZoom = z => {
    const n = num(z, 1);
    return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, n));
  };
  const normalizeViewport = vp => ({
    x: num(vp && vp.x, 0),
    y: num(vp && vp.y, 0),
    zoom: clampZoom(vp && vp.zoom)
  });
  const rectOrigin = svgRect => ({
    left: num(svgRect && svgRect.left, 0),
    top: num(svgRect && svgRect.top, 0)
  });
  const screenToCanvas = (clientX, clientY, viewport, svgRect) => {
    const vp = normalizeViewport(viewport);
    const o = rectOrigin(svgRect);
    return {
      x: (num(clientX, 0) - o.left - vp.x) / vp.zoom,
      y: (num(clientY, 0) - o.top - vp.y) / vp.zoom
    };
  };
  const canvasToScreen = (x, y, viewport, svgRect) => {
    const vp = normalizeViewport(viewport);
    const o = rectOrigin(svgRect);
    return {
      x: num(x, 0) * vp.zoom + vp.x + o.left,
      y: num(y, 0) * vp.zoom + vp.y + o.top
    };
  };
  const visibleCanvasRect = (viewport, containerW, containerH) => {
    const vp = normalizeViewport(viewport);
    const W = Math.max(0, num(containerW, 0));
    const H = Math.max(0, num(containerH, 0));
    const tl = screenToCanvas(0, 0, vp, null);
    return {
      x: tl.x,
      y: tl.y,
      width: W / vp.zoom,
      height: H / vp.zoom
    };
  };
  const centerViewportOn = (viewport, canvasPt, containerW, containerH) => {
    const vp = normalizeViewport(viewport);
    const cx = num(canvasPt && canvasPt.x, 0);
    const cy = num(canvasPt && canvasPt.y, 0);
    return {
      x: round2(num(containerW, 0) / 2 - cx * vp.zoom),
      y: round2(num(containerH, 0) / 2 - cy * vp.zoom),
      zoom: vp.zoom
    };
  };
  const hasPosition = (nodeId, positions) => {
    const p = positions && positions[nodeId];
    return !!(p && isFiniteNum(p.x) && isFiniteNum(p.y));
  };
  const nodePosition = (nodeId, positions, isStart) => {
    if (hasPosition(nodeId, positions)) return {
      x: positions[nodeId].x,
      y: positions[nodeId].y
    };
    return isStart ? {
      x: DEFAULT_START_POSITION.x,
      y: DEFAULT_START_POSITION.y
    } : {
      x: 0,
      y: 0
    };
  };
  const nodeRect = (nodeId, positions, isStart) => {
    const start = isStartId(nodeId, isStart);
    const {x, y} = nodePosition(nodeId, positions, start);
    const {width, height} = nodeSize(start);
    return {
      x,
      y,
      width,
      height,
      right: x + width,
      bottom: y + height,
      cx: x + width / 2,
      cy: y + height / 2,
      isStart: start
    };
  };
  const anchorPoint = (nodeId, anchor, positions, isStart) => {
    const r = nodeRect(nodeId, positions, isStart);
    const table = anchorOffsets(r.isStart);
    const off = table[anchor] || (r.isStart ? table.bottom : table.top);
    return {
      x: r.x + off.x,
      y: r.y + off.y
    };
  };
  const DIRS = {
    top: {
      x: 0,
      y: -1
    },
    right: {
      x: 1,
      y: 0
    },
    bottom: {
      x: 0,
      y: 1
    },
    left: {
      x: -1,
      y: 0
    }
  };
  const dirOf = a => DIRS[a] || DIRS.bottom;
  const isVerticalAnchor = a => a === 'top' || a === 'bottom';
  const rectFromAnchor = (p, a, size) => {
    const w = size.width;
    const h = size.height;
    if (a === 'top') return {
      x1: p.x - w / 2,
      y1: p.y,
      x2: p.x + w / 2,
      y2: p.y + h
    };
    if (a === 'bottom') return {
      x1: p.x - w / 2,
      y1: p.y - h,
      x2: p.x + w / 2,
      y2: p.y
    };
    if (a === 'left') return {
      x1: p.x,
      y1: p.y - h / 2,
      x2: p.x + w,
      y2: p.y + h / 2
    };
    return {
      x1: p.x - w,
      y1: p.y - h / 2,
      x2: p.x,
      y2: p.y + h / 2
    };
  };
  const samePt = (a, b) => Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS;
  const simplifyPoints = points => {
    const out = [];
    for (const p of Array.isArray(points) ? points : []) {
      if (!p || !isFiniteNum(p.x) || !isFiniteNum(p.y)) continue;
      if (!out.length || !samePt(out[out.length - 1], p)) out.push({
        x: p.x,
        y: p.y
      });
    }
    let changed = true;
    while (changed && out.length > 2) {
      changed = false;
      for (let i = 1; i < out.length - 1; i++) {
        const a = out[i - 1];
        const b = out[i];
        const c = out[i + 1];
        const ux = b.x - a.x;
        const uy = b.y - a.y;
        const vx = c.x - b.x;
        const vy = c.y - b.y;
        const cross = ux * vy - uy * vx;
        const dot = ux * vx + uy * vy;
        if (Math.abs(cross) < EPS && dot > 0) {
          out.splice(i, 1);
          changed = true;
          break;
        }
      }
    }
    return out;
  };
  const hasUTurn = pts => {
    for (let i = 1; i < pts.length - 1; i++) {
      const a = pts[i - 1];
      const b = pts[i];
      const c = pts[i + 1];
      const ux = b.x - a.x;
      const uy = b.y - a.y;
      const vx = c.x - b.x;
      const vy = c.y - b.y;
      if (Math.abs(ux * vy - uy * vx) < EPS && ux * vx + uy * vy < 0) return true;
    }
    return false;
  };
  const isOrthogonal = pts => {
    for (let i = 1; i < pts.length; i++) {
      if (Math.abs(pts[i].x - pts[i - 1].x) > EPS && Math.abs(pts[i].y - pts[i - 1].y) > EPS) return false;
    }
    return true;
  };
  const segHitsRect = (a, b, r) => {
    const inset = 0.5;
    const x1 = r.x1 + inset;
    const y1 = r.y1 + inset;
    const x2 = r.x2 - inset;
    const y2 = r.y2 - inset;
    if (x1 >= x2 || y1 >= y2) return false;
    const minX = Math.min(a.x, b.x);
    const maxX = Math.max(a.x, b.x);
    const minY = Math.min(a.y, b.y);
    const maxY = Math.max(a.y, b.y);
    return maxX > x1 && minX < x2 && maxY > y1 && minY < y2;
  };
  const pathLength = pts => {
    let L = 0;
    for (let i = 1; i < pts.length; i++) L += Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y);
    return L;
  };
  const countBends = pts => Math.max(0, pts.length - 2);
  const computeOrthogonalRoute = (p1, a1, p2, a2, opts) => {
    const P1 = {
      x: num(p1 && p1.x, 0),
      y: num(p1 && p1.y, 0)
    };
    const P2 = {
      x: num(p2 && p2.x, 0),
      y: num(p2 && p2.y, 0)
    };
    const A1 = DIRS[a1] ? a1 : 'bottom';
    const A2 = DIRS[a2] ? a2 : 'top';
    const d1 = dirOf(A1);
    const d2 = dirOf(A2);
    const srcSize = opts && opts.sourceSize || nodeSize(false);
    const tgtSize = opts && opts.targetSize || nodeSize(false);
    const gap = Math.max(0, num(opts && opts.gap, ELBOW_GAP));
    const srcRect = rectFromAnchor(P1, A1, srcSize);
    const tgtRect = rectFromAnchor(P2, A2, tgtSize);
    let stub1 = gap;
    let stub2 = gap;
    const facing = d1.x === -d2.x && d1.y === -d2.y;
    if (facing) {
      const forward = (P2.x - P1.x) * d1.x + (P2.y - P1.y) * d1.y;
      if (forward >= 0 && forward < 2 * gap) {
        stub1 = forward / 2;
        stub2 = forward / 2;
      }
    }
    const s = {
      x: P1.x + d1.x * stub1,
      y: P1.y + d1.y * stub1
    };
    const t = {
      x: P2.x + d2.x * stub2,
      y: P2.y + d2.y * stub2
    };
    const midX = (s.x + t.x) / 2;
    const midY = (s.y + t.y) / 2;
    const outXs = [Math.max(srcRect.x2, tgtRect.x2) + gap, Math.min(srcRect.x1, tgtRect.x1) - gap];
    const outYs = [Math.max(srcRect.y2, tgtRect.y2) + gap, Math.min(srcRect.y1, tgtRect.y1) - gap];
    const candidates = [];
    candidates.push([]);
    candidates.push([{
      x: s.x,
      y: midY
    }, {
      x: t.x,
      y: midY
    }]);
    candidates.push([{
      x: midX,
      y: s.y
    }, {
      x: midX,
      y: t.y
    }]);
    candidates.push([{
      x: s.x,
      y: t.y
    }]);
    candidates.push([{
      x: t.x,
      y: s.y
    }]);
    for (const y of outYs) candidates.push([{
      x: s.x,
      y
    }, {
      x: t.x,
      y
    }]);
    for (const x of outXs) candidates.push([{
      x,
      y: s.y
    }, {
      x,
      y: t.y
    }]);
    for (const y of outYs) {
      for (const x of outXs) {
        candidates.push([{
          x: s.x,
          y
        }, {
          x,
          y
        }, {
          x,
          y: t.y
        }]);
        candidates.push([{
          x,
          y: s.y
        }, {
          x,
          y
        }, {
          x: t.x,
          y
        }]);
      }
    }
    let best = null;
    let bestScore = Infinity;
    let relaxed = null;
    let relaxedScore = Infinity;
    for (const mid of candidates) {
      const pts = simplifyPoints([P1, s, ...mid, t, P2]);
      if (pts.length < 2 || !isOrthogonal(pts) || hasUTurn(pts)) continue;
      const score = pathLength(pts) + countBends(pts) * gap;
      if (score < relaxedScore) {
        relaxed = pts;
        relaxedScore = score;
      }
      let clean = true;
      for (let i = 1; i < pts.length && clean; i++) {
        if (segHitsRect(pts[i - 1], pts[i], srcRect) || segHitsRect(pts[i - 1], pts[i], tgtRect)) clean = false;
      }
      if (clean && score < bestScore) {
        best = pts;
        bestScore = score;
      }
    }
    if (best) return best;
    if (relaxed) return relaxed;
    return simplifyPoints([P1, s, {
      x: s.x,
      y: midY
    }, {
      x: t.x,
      y: midY
    }, t, P2]);
  };
  const fmt = n => {
    const r = Math.round(n * 100) / 100;
    return Object.is(r, -0) ? '0' : String(r);
  };
  const buildRoundedPolyline = (points, radius) => {
    const R = Math.max(0, num(radius, CORNER_RADIUS));
    const pts = simplifyPoints(points);
    if (pts.length === 0) return '';
    if (pts.length === 1) return `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`;
    let d = `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`;
    for (let i = 1; i < pts.length - 1; i++) {
      const a = pts[i - 1];
      const b = pts[i];
      const c = pts[i + 1];
      const ux = b.x - a.x;
      const uy = b.y - a.y;
      const vx = c.x - b.x;
      const vy = c.y - b.y;
      const lu = Math.hypot(ux, uy);
      const lv = Math.hypot(vx, vy);
      const cross = ux * vy - uy * vx;
      const dot = ux * vx + uy * vy;
      const r = Math.min(R, lu / 2, lv / 2);
      if (r <= 0.01 || Math.abs(cross) < EPS) {
        d += ` L ${fmt(b.x)} ${fmt(b.y)}`;
        continue;
      }
      const inX = b.x - ux / lu * r;
      const inY = b.y - uy / lu * r;
      const outX = b.x + vx / lv * r;
      const outY = b.y + vy / lv * r;
      d += ` L ${fmt(inX)} ${fmt(inY)}`;
      if (Math.abs(dot) < EPS) {
        d += ` A ${fmt(r)} ${fmt(r)} 0 0 ${cross > 0 ? 1 : 0} ${fmt(outX)} ${fmt(outY)}`;
      } else {
        d += ` Q ${fmt(b.x)} ${fmt(b.y)} ${fmt(outX)} ${fmt(outY)}`;
      }
    }
    const last = pts[pts.length - 1];
    d += ` L ${fmt(last.x)} ${fmt(last.y)}`;
    return d;
  };
  const connectionEndpoints = (conn, positions) => {
    const sourceId = conn && conn.sourceId != null ? conn.sourceId : START_NODE_ID;
    const targetId = conn && conn.targetId != null ? conn.targetId : '';
    const sourceStart = sourceId === START_NODE_ID;
    const targetStart = targetId === START_NODE_ID;
    const a1 = DIRS[conn && conn.sourceAnchor] ? conn.sourceAnchor : 'bottom';
    const a2 = DIRS[conn && conn.targetAnchor] ? conn.targetAnchor : 'top';
    return {
      p1: anchorPoint(sourceId, a1, positions, sourceStart),
      a1,
      p2: anchorPoint(targetId, a2, positions, targetStart),
      a2,
      sourceSize: nodeSize(sourceStart),
      targetSize: nodeSize(targetStart)
    };
  };
  const orthogonalizeThrough = (pts, verticalFirst) => {
    const out = [];
    for (let i = 0; i < pts.length; i++) {
      const p = pts[i];
      if (out.length) {
        const q = out[out.length - 1];
        if (Math.abs(q.x - p.x) > EPS && Math.abs(q.y - p.y) > EPS) {
          out.push(verticalFirst ? {
            x: q.x,
            y: p.y
          } : {
            x: p.x,
            y: q.y
          });
        }
      }
      out.push(p);
    }
    return out;
  };
  const connectionPoints = (conn, positions) => {
    const e = connectionEndpoints(conn, positions);
    const wps = Array.isArray(conn && conn.waypoints) ? conn.waypoints.filter(w => w && isFiniteNum(w.x) && isFiniteNum(w.y)).map(w => ({
      x: w.x,
      y: w.y
    })) : [];
    if (wps.length) {
      const d1 = dirOf(e.a1);
      const d2 = dirOf(e.a2);
      const s = {
        x: e.p1.x + d1.x * ELBOW_GAP,
        y: e.p1.y + d1.y * ELBOW_GAP
      };
      const t = {
        x: e.p2.x + d2.x * ELBOW_GAP,
        y: e.p2.y + d2.y * ELBOW_GAP
      };
      return simplifyPoints(orthogonalizeThrough([e.p1, s, ...wps, t, e.p2], isVerticalAnchor(e.a1)));
    }
    return computeOrthogonalRoute(e.p1, e.a1, e.p2, e.a2, {
      sourceSize: e.sourceSize,
      targetSize: e.targetSize
    });
  };
  const buildConnectionPath = (conn, positions) => buildRoundedPolyline(connectionPoints(conn, positions), CORNER_RADIUS);
  const pathMidpoint = points => {
    const pts = simplifyPoints(points);
    if (!pts.length) return {
      x: 0,
      y: 0
    };
    const total = pathLength(pts);
    const half = total / 2;
    let acc = 0;
    for (let i = 1; i < pts.length; i++) {
      const a = pts[i - 1];
      const b = pts[i];
      const L = Math.abs(b.x - a.x) + Math.abs(b.y - a.y);
      if (acc + L >= half) {
        const f = L > 0 ? (half - acc) / L : 0;
        return {
          x: a.x + (b.x - a.x) * f,
          y: a.y + (b.y - a.y) * f
        };
      }
      acc += L;
    }
    return {
      x: pts[pts.length - 1].x,
      y: pts[pts.length - 1].y
    };
  };
  const layoutCascade = (steps, opts) => {
    const list = Array.isArray(steps) ? steps : [];
    const axisX = snap(num(opts && opts.axisX, LAYOUT_AXIS_X));
    const positions = {};
    positions[START_NODE_ID] = {
      x: snap(axisX - START_W / 2),
      y: LAYOUT_TOP_Y
    };
    list.forEach((step, i) => {
      const key = stepKeyOf(step);
      if (!key || key === START_NODE_ID) return;
      positions[key] = {
        x: snap(axisX - NODE_W / 2),
        y: snap(LAYOUT_FIRST_STEP_Y + i * ROW_GAP)
      };
    });
    return positions;
  };
  const initialLayout = (steps, opts) => layoutCascade(steps, opts);
  const autoLayout = (steps, opts) => layoutCascade(steps, opts);
  const contentBounds = (positions, stepIds, opts) => {
    const includeStart = !(opts && opts.includeStart === false);
    const ids = Array.isArray(stepIds) ? stepIds : Object.keys(positions || ({})).filter(k => k !== START_NODE_ID);
    const rects = [];
    if (includeStart) rects.push(nodeRect(START_NODE_ID, positions, true));
    for (const id of ids) {
      if (id === START_NODE_ID || !hasPosition(id, positions)) continue;
      rects.push(nodeRect(id, positions, false));
    }
    if (!rects.length) return null;
    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;
    for (const r of rects) {
      minX = Math.min(minX, r.x);
      minY = Math.min(minY, r.y);
      maxX = Math.max(maxX, r.right);
      maxY = Math.max(maxY, r.bottom);
    }
    return {
      minX,
      minY,
      maxX,
      maxY,
      width: maxX - minX,
      height: maxY - minY,
      count: rects.length
    };
  };
  const fitToContent = (positions, stepIds, containerW, containerH) => {
    const W = num(containerW, 0);
    const H = num(containerH, 0);
    const ids = (Array.isArray(stepIds) ? stepIds : []).filter(id => id !== START_NODE_ID && hasPosition(id, positions));
    if (!ids.length || W <= 0 || H <= 0) return {
      ...DEFAULT_VIEWPORT
    };
    const b = contentBounds(positions, ids);
    if (!b || !(b.width > 0) || !(b.height > 0)) return {
      ...DEFAULT_VIEWPORT
    };
    const availW = Math.max(1, W - 2 * FIT_PADDING);
    const availH = Math.max(1, H - 2 * FIT_PADDING);
    const zoom = clampZoom(Math.min(FIT_MAX_ZOOM, availW / b.width, availH / b.height));
    const x = (W - b.width * zoom) / 2 - b.minX * zoom;
    const y = (H - b.height * zoom) / 2 - b.minY * zoom;
    const out = {
      x: round2(x),
      y: round2(y),
      zoom: round4(zoom)
    };
    if (!isFiniteNum(out.x) || !isFiniteNum(out.y) || !isFiniteNum(out.zoom)) return {
      ...DEFAULT_VIEWPORT
    };
    return out;
  };
  const hitTestAnchor = (canvasPt, positions, stepIds, radius, opts) => {
    const R = num(radius, ANCHOR_HIT_RADIUS);
    const px = num(canvasPt && canvasPt.x, NaN);
    const py = num(canvasPt && canvasPt.y, NaN);
    if (!isFiniteNum(px) || !isFiniteNum(py)) return null;
    const exclude = opts && opts.excludeNodeId;
    const includeStart = !!(opts && opts.includeStart);
    const ids = (Array.isArray(stepIds) ? stepIds : []).filter(id => id !== START_NODE_ID && hasPosition(id, positions));
    let best = null;
    let bestD = R;
    const test = (nodeId, sides, isStart) => {
      if (nodeId === exclude) return;
      for (const side of sides) {
        const a = anchorPoint(nodeId, side, positions, isStart);
        const d = Math.hypot(a.x - px, a.y - py);
        if (d <= bestD) {
          bestD = d;
          best = {
            nodeId,
            anchor: side,
            x: a.x,
            y: a.y,
            distance: d
          };
        }
      }
    };
    if (includeStart) test(START_NODE_ID, START_ANCHOR_SIDES, true);
    for (const id of ids) test(id, ANCHOR_SIDES, false);
    return best;
  };
  const zoomAround = (viewport, factor, screenPt, svgRect) => {
    const vp = normalizeViewport(viewport);
    const newZoom = clampZoom(vp.zoom * num(factor, 1));
    const o = rectOrigin(svgRect);
    const sx = screenPt && isFiniteNum(screenPt.x) ? screenPt.x - o.left : num(svgRect && svgRect.width, 0) / 2;
    const sy = screenPt && isFiniteNum(screenPt.y) ? screenPt.y - o.top : num(svgRect && svgRect.height, 0) / 2;
    const cx = (sx - vp.x) / vp.zoom;
    const cy = (sy - vp.y) / vp.zoom;
    return {
      x: round2(sx - cx * newZoom),
      y: round2(sy - cy * newZoom),
      zoom: newZoom
    };
  };
  const zoomTo = (viewport, zoom, screenPt, svgRect) => {
    const vp = normalizeViewport(viewport);
    return zoomAround(vp, clampZoom(zoom) / vp.zoom, screenPt, svgRect);
  };
  const zoomByStep = (viewport, direction, containerW, containerH) => {
    const vp = normalizeViewport(viewport);
    const target = clampZoom(vp.zoom + (num(direction, 1) < 0 ? -ZOOM_STEP : ZOOM_STEP));
    return zoomTo(vp, target, {
      x: num(containerW, 0) / 2,
      y: num(containerH, 0) / 2
    }, {
      left: 0,
      top: 0
    });
  };
  const wheelZoomFactor = deltaY => num(deltaY, 0) < 0 ? 1 + ZOOM_STEP : 1 / (1 + ZOOM_STEP);
  const minimapTransform = (positions, stepIds, w, h) => {
    const W = Math.max(1, num(w, 180));
    const H = Math.max(1, num(h, 130));
    const pad = 8;
    const b = contentBounds(positions, stepIds) || ({
      minX: 0,
      minY: 0,
      maxX: NODE_W,
      maxY: NODE_H,
      width: NODE_W,
      height: NODE_H,
      count: 0
    });
    const bw = Math.max(b.width, 1);
    const bh = Math.max(b.height, 1);
    const scale = Math.max(EPS, Math.min((W - 2 * pad) / bw, (H - 2 * pad) / bh));
    const offsetX = (W - bw * scale) / 2 - b.minX * scale;
    const offsetY = (H - bh * scale) / 2 - b.minY * scale;
    const toMini = (x, y) => ({
      x: num(x, 0) * scale + offsetX,
      y: num(y, 0) * scale + offsetY
    });
    const fromMini = (mx, my) => ({
      x: (num(mx, 0) - offsetX) / scale,
      y: (num(my, 0) - offsetY) / scale
    });
    return {
      width: W,
      height: H,
      scale,
      offsetX,
      offsetY,
      bounds: b,
      toMini,
      fromMini
    };
  };
  return {
    snap,
    screenToCanvas,
    canvasToScreen,
    ANCHOR_OFFSETS,
    anchorPoint,
    nodeRect,
    computeOrthogonalRoute,
    buildRoundedPolyline,
    buildConnectionPath,
    initialLayout,
    autoLayout,
    fitToContent,
    hitTestAnchor,
    clampZoom,
    zoomAround,
    minimapTransform,
    DEFAULT_VIEWPORT,
    DEFAULT_START_POSITION,
    ANCHOR_SIDES,
    START_ANCHOR_SIDES,
    START_ANCHOR_OFFSETS,
    anchorOffsets,
    nodeSize,
    stepKeyOf,
    normalizeViewport,
    visibleCanvasRect,
    centerViewportOn,
    contentBounds,
    connectionPoints,
    pathMidpoint,
    simplifyPoints,
    zoomTo,
    zoomByStep,
    wheelZoomFactor
  };
};

export const makeUtils = deps => {
  const {WB, STEP_PROPERTY_GROUPS} = deps || ({});
  const wb = WB || ({});
  const PROPERTY_GROUPS = STEP_PROPERTY_GROUPS || ({});
  const START = wb.START_NODE_ID || '__start__';
  const CATALOG = Array.isArray(wb.WORKFLOW_STEPS_OPTIONS) ? wb.WORKFLOW_STEPS_OPTIONS : [];
  const GRADIENTS = wb.STEP_ICON_GRADIENTS || ({});
  const PINNED = Array.isArray(wb.BOTTOM_PINNED_STEPS) ? wb.BOTTOM_PINNED_STEPS : [];
  const COUPLED = wb.COUPLED_STEPS || ({});
  const OVERRIDES = wb.COUPLED_PROPERTY_OVERRIDES || ({});
  const SYSTEM = Array.isArray(wb.SYSTEM_STEPS) ? wb.SYSTEM_STEPS : [];
  const REMOVED_GROUPS = Array.isArray(wb.REMOVED_PROPERTY_GROUPS) ? wb.REMOVED_PROPERTY_GROUPS : [];
  const SERVICE_MAP = wb.WORKFLOW_STEP_TO_SERVICE_MAP || ({});
  const FALLBACK_ICON = wb.ICONS && wb.ICONS.fallback || 'solar:widget-bold-duotone';
  const FALLBACK_GRADIENT = 'linear-gradient(135deg, #8E8E93, #AEAEB2)';
  const IS_DEV = wb.IS_DEV === true;
  const CYCLE_WARNING = 'Cycle detected';
  const hasOwn = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
  const keyOf = step => step && (step.instanceId || step.id) || null;
  const idOf = stepOrId => {
    if (typeof stepOrId === 'string') return stepOrId;
    return stepOrId && stepOrId.id || null;
  };
  const isPinnedId = id => PINNED.includes(id);
  const round2 = n => Math.round(n * 100) / 100;
  const num = (v, fallback) => typeof v === 'number' && Number.isFinite(v) ? v : fallback === undefined ? 0 : fallback;
  const slugify = s => {
    const slug = String(s === undefined || s === null ? '' : s).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
    return slug || 'item';
  };
  const arrayMove = (arr, from, to) => {
    const out = arr.slice();
    const [item] = out.splice(from, 1);
    out.splice(to, 0, item);
    return out;
  };
  const deepClone = value => {
    if (value === undefined || value === null) return value;
    if (typeof value !== 'object') return value;
    try {
      return JSON.parse(JSON.stringify(value));
    } catch (e) {
      return value;
    }
  };
  const fCurrency = n => {
    const value = typeof n === 'string' && n.trim() !== '' ? Number(n) : n;
    if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
    const fixed = Math.abs(value).toFixed(2);
    const parts = fixed.split('.');
    const grouped = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    return `${value < 0 ? '-' : ''}$${grouped}.${parts[1]}`;
  };
  const truncateMiddle = (s, n = 18) => {
    const str = s === undefined || s === null ? '' : String(s);
    if (n <= 0 || str.length <= n) return str;
    if (n <= 1) return '…';
    const keep = n - 1;
    const head = Math.ceil(keep / 2);
    const tail = Math.floor(keep / 2);
    return `${str.slice(0, head)}…${tail > 0 ? str.slice(str.length - tail) : ''}`;
  };
  const parseNodeDropId = raw => {
    const str = raw === undefined || raw === null ? '' : String(raw);
    const idx = str.indexOf(':');
    if (idx > 0) {
      const kind = str.slice(0, idx);
      const id = str.slice(idx + 1);
      if (kind === 'palette' || kind === 'chart') return {
        kind,
        id
      };
    }
    return {
      kind: null,
      id: str
    };
  };
  const getStepById = id => CATALOG.find(s => s && s.id === id) || null;
  const getStepIcon = id => {
    const entry = getStepById(idOf(id));
    return entry && entry.icon || FALLBACK_ICON;
  };
  const getStepGradient = id => GRADIENTS[idOf(id)] || FALLBACK_GRADIENT;
  const getStepDescription = id => {
    const entry = getStepById(idOf(id));
    return entry && entry.description || '';
  };
  const isCoupledStep = id => {
    const entry = getStepById(idOf(id));
    return !!(entry && entry.coupled);
  };
  const getStepCost = step => {
    const id = idOf(step);
    if (!id) return null;
    if (isCoupledStep(id)) return 0;
    const entry = getStepById(id);
    let raw;
    if (entry && entry.cost !== undefined) raw = entry.cost; else if (step && typeof step === 'object') raw = step.cost;
    return typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
  };
  const getStepCostLabel = step => {
    const id = idOf(step);
    if (isCoupledStep(id)) return 'included';
    const entry = getStepById(id) || (step && typeof step === 'object' ? step : null);
    if (entry && entry.costLabel) return entry.costLabel;
    const cost = getStepCost(step);
    return typeof cost === 'number' ? fCurrency(cost) : '—';
  };
  const getStepTemplate = id => {
    const entry = getStepById(idOf(id));
    if (!entry) return null;
    const template = {
      ...entry
    };
    const groups = PROPERTY_GROUPS[entry.id];
    if (Array.isArray(groups)) template.propertyGroups = deepClone(groups);
    return template;
  };
  const sanitizeStepForWorkflow = step => {
    const src = typeof step === 'string' ? getStepTemplate(step) : step;
    if (!src || !src.id) return null;
    const entry = getStepById(src.id);
    const out = {
      id: src.id,
      label: src.label || entry && entry.label || src.id,
      icon: src.icon || entry && entry.icon || FALLBACK_ICON
    };
    if (Array.isArray(src.propertyGroups)) out.propertyGroups = deepClone(src.propertyGroups);
    return out;
  };
  let lastStamp = 0;
  const makeInstanceId = id => {
    let ts = Date.now();
    if (ts <= lastStamp) ts = lastStamp + 1;
    lastStamp = ts;
    return `${id}-${ts}`;
  };
  const createStepInstance = stepId => {
    const template = getStepTemplate(stepId);
    if (!template) return null;
    const sanitized = sanitizeStepForWorkflow(template);
    return {
      ...sanitized,
      instanceId: makeInstanceId(template.id)
    };
  };
  const resolveService = service => typeof service === 'string' ? getStepById(service) : service || null;
  const isGatedStep = service => {
    const entry = resolveService(service);
    if (!entry) return false;
    if (entry.gated) return true;
    if (entry.devOnly && !IS_DEV) return true;
    return false;
  };
  const isStepGated = (service, ctx) => {
    const entry = resolveService(service);
    if (!entry) return false;
    if (isGatedStep(entry)) return true;
    const context = ctx || ({
      whiteLabelConfigured: false
    });
    if (entry.id === 'white-label' && !context.whiteLabelConfigured) return true;
    return false;
  };
  const isWorkflowStepAvailable = (stepId, availableServices) => {
    if (!availableServices || !availableServices.length) return true;
    const serviceType = SERVICE_MAP[stepId];
    if (!serviceType) return true;
    return availableServices.includes(serviceType);
  };
  const getAvailableServiceData = availableServices => CATALOG.filter(s => s && !s.advancedOnly && isWorkflowStepAvailable(s.id, availableServices));
  const groupsOf = step => step && Array.isArray(step.propertyGroups) ? step.propertyGroups : [];
  const propsOf = group => group && Array.isArray(group.properties) ? group.properties : [];
  const flattenProps = step => groupsOf(step).reduce((acc, g) => acc.concat(propsOf(g)), []);
  const findGroup = (step, groupId) => groupsOf(step).find(g => g && g.groupId === groupId) || null;
  const findProp = (step, groupId, propId) => {
    if (groupId) {
      const group = findGroup(step, groupId);
      if (group) return propsOf(group).find(p => p && p.id === propId) || null;
    }
    return flattenProps(step).find(p => p && p.id === propId) || null;
  };
  const applyOverridesToStep = (step, overrides) => {
    if (!overrides || !step || !Array.isArray(step.propertyGroups)) return step;
    let changed = false;
    const propertyGroups = step.propertyGroups.map(g => {
      if (!g) return g;
      const groupOverrides = overrides[g.groupId];
      if (!groupOverrides || !Array.isArray(g.properties)) return g;
      let groupChanged = false;
      const properties = g.properties.map(p => {
        if (!p || !hasOwn(groupOverrides, p.id)) return p;
        const next = groupOverrides[p.id];
        if (JSON.stringify(p.value) === JSON.stringify(next)) return p;
        groupChanged = true;
        return {
          ...p,
          value: deepClone(next)
        };
      });
      if (!groupChanged) return g;
      changed = true;
      return {
        ...g,
        properties
      };
    });
    return changed ? {
      ...step,
      propertyGroups
    } : step;
  };
  const enforceCoupledOverridesOnSteps = steps => {
    const list = Array.isArray(steps) ? steps : [];
    let changed = false;
    const out = list.map(s => {
      const overrides = s ? OVERRIDES[s.id] : null;
      if (!overrides || !Object.keys(overrides).length) return s;
      const next = applyOverridesToStep(s, overrides);
      if (next !== s) changed = true;
      return next;
    });
    return changed ? out : list;
  };
  const enforceBottomPinningOnSteps = steps => {
    const list = Array.isArray(steps) ? steps : [];
    const free = list.filter(s => !isPinnedId(idOf(s)));
    const pinned = list.filter(s => isPinnedId(idOf(s)));
    if (!pinned.length) return list;
    const out = free.concat(pinned);
    const same = out.length === list.length && out.every((s, i) => s === list[i]);
    return same ? list : out;
  };
  const pinnedFormSuffix = list => {
    let seenPinned = false;
    for (let i = 0; i < list.length; i += 1) {
      if (isPinnedId(idOf(list[i]))) seenPinned = true; else if (seenPinned) return false;
    }
    return true;
  };
  const isReorderValidWithPinning = (steps, from, to) => {
    const list = Array.isArray(steps) ? steps : [];
    if (!Number.isInteger(from) || !Number.isInteger(to)) return false;
    if (from < 0 || to < 0 || from >= list.length || to >= list.length) return false;
    if (from === to) return true;
    return pinnedFormSuffix(arrayMove(list, from, to));
  };
  const applyCoupledSteps = (steps, newStep) => {
    const list = (Array.isArray(steps) ? steps : []).filter(Boolean);
    if (!newStep || !newStep.id) return {
      steps: list,
      added: []
    };
    const newKey = keyOf(newStep);
    const out = list.some(s => keyOf(s) === newKey) ? list.slice() : list.concat([newStep]);
    const added = [];
    const queue = [newStep];
    const seen = new Set();
    while (queue.length) {
      const current = queue.shift();
      if (seen.has(current.id)) continue;
      seen.add(current.id);
      const partners = COUPLED[current.id] || [];
      partners.forEach(partnerId => {
        if (out.some(s => s.id === partnerId)) return;
        const partner = createStepInstance(partnerId);
        if (!partner) return;
        const withOverrides = applyOverridesToStep(partner, OVERRIDES[partnerId]);
        const anchorIndex = out.findIndex(s => keyOf(s) === keyOf(current));
        const isFollowUp = isCoupledStep(partnerId);
        let insertAt = out.length;
        if (anchorIndex >= 0) insertAt = isFollowUp ? anchorIndex + 1 : anchorIndex;
        out.splice(insertAt, 0, withOverrides);
        added.push(withOverrides);
        queue.push(withOverrides);
      });
    }
    return {
      steps: out,
      added
    };
  };
  const removeWithCoupled = (steps, instanceId) => {
    const list = (Array.isArray(steps) ? steps : []).filter(Boolean);
    const target = list.find(s => keyOf(s) === instanceId);
    if (!target) return list;
    const toRemove = new Set([keyOf(target)]);
    const queue = [target];
    while (queue.length) {
      const current = queue.shift();
      const partners = COUPLED[current.id] || [];
      partners.forEach(partnerId => {
        const cascade = isCoupledStep(partnerId) || isCoupledStep(current.id);
        if (!cascade) return;
        list.filter(s => s.id === partnerId && !toRemove.has(keyOf(s))).forEach(s => {
          toRemove.add(keyOf(s));
          queue.push(s);
        });
      });
    }
    return list.filter(s => !toRemove.has(keyOf(s)));
  };
  const makeConnection = (sourceId, targetId, extra) => ({
    id: `conn-${sourceId}-${targetId}`,
    sourceId,
    sourceAnchor: 'bottom',
    targetId,
    targetAnchor: 'top',
    waypoints: [],
    label: '',
    ...extra || ({})
  });
  const rebuildChainWithPinning = steps => {
    const ordered = enforceBottomPinningOnSteps((Array.isArray(steps) ? steps : []).filter(Boolean));
    const connections = [];
    let prev = START;
    ordered.forEach(s => {
      const key = keyOf(s);
      if (!key) return;
      connections.push(makeConnection(prev, key));
      prev = key;
    });
    return connections;
  };
  const graphHasCycle = (nodeKeys, conns) => {
    const adjacency = new Map();
    nodeKeys.forEach(k => adjacency.set(k, []));
    conns.forEach(c => {
      if (adjacency.has(c.sourceId) && adjacency.has(c.targetId)) adjacency.get(c.sourceId).push(c.targetId);
    });
    const state = new Map();
    const visit = k => {
      state.set(k, 1);
      const nexts = adjacency.get(k) || [];
      for (let i = 0; i < nexts.length; i += 1) {
        const n = nexts[i];
        const st = state.get(n) || 0;
        if (st === 1) return true;
        if (st === 0 && visit(n)) return true;
      }
      state.set(k, 2);
      return false;
    };
    for (let i = 0; i < nodeKeys.length; i += 1) {
      if ((state.get(nodeKeys[i]) || 0) === 0 && visit(nodeKeys[i])) return true;
    }
    return false;
  };
  const resolveStepOrderFromConnections = (steps, connections) => {
    const list = (Array.isArray(steps) ? steps : []).filter(Boolean);
    const conns = (Array.isArray(connections) ? connections : []).filter(Boolean);
    const byKey = new Map();
    list.forEach(s => byKey.set(keyOf(s), s));
    const warnings = [];
    const orderedSteps = [];
    const visited = new Set([START]);
    let cycle = false;
    let current = START;
    let guard = 0;
    const limit = conns.length + list.length + 2;
    while (guard < limit) {
      guard += 1;
      const next = conns.find(c => c.sourceId === current);
      if (!next) break;
      const target = next.targetId;
      if (visited.has(target)) {
        cycle = true;
        break;
      }
      const step = byKey.get(target);
      if (!step) {
        warnings.push(`Connection ${next.id || ''} points to a step that no longer exists`.replace('  ', ' '));
        break;
      }
      visited.add(target);
      orderedSteps.push(step);
      current = target;
    }
    const allKeys = [START].concat(list.map(keyOf));
    if (!cycle && graphHasCycle(allKeys, conns)) cycle = true;
    if (cycle) warnings.unshift(CYCLE_WARNING);
    const unreachable = list.filter(s => !visited.has(keyOf(s)));
    if (unreachable.length) {
      const labels = unreachable.map(s => s.label || (getStepById(s.id) || ({})).label || s.id).join(', ');
      warnings.push(unreachable.length === 1 ? `${labels} is not connected to Start` : `${unreachable.length} steps are not connected to Start: ${labels}`);
    }
    return {
      orderedSteps,
      warnings
    };
  };
  const computeResolvedOrder = (steps, connections) => {
    const {orderedSteps} = resolveStepOrderFromConnections(steps, connections);
    const order = {};
    orderedSteps.forEach((s, i) => {
      order[keyOf(s)] = i + 1;
    });
    return order;
  };
  const removeStepsWithBridge = (steps, connections, instanceIds) => {
    const idList = Array.isArray(instanceIds) ? instanceIds : [instanceIds];
    const ids = new Set(idList.filter(Boolean));
    let conns = (Array.isArray(connections) ? connections : []).filter(Boolean).slice();
    ids.forEach(id => {
      const incoming = conns.filter(c => c.targetId === id);
      const outgoing = conns.filter(c => c.sourceId === id);
      conns = conns.filter(c => c.sourceId !== id && c.targetId !== id);
      if (!incoming.length || !outgoing.length) return;
      const pred = incoming[0];
      const succ = outgoing[0];
      if (pred.sourceId === succ.targetId) return;
      if (ids.has(succ.targetId) && ids.has(pred.sourceId)) return;
      if (conns.some(c => c.sourceId === pred.sourceId)) return;
      if (conns.some(c => c.targetId === succ.targetId)) return;
      conns.push(makeConnection(pred.sourceId, succ.targetId, {
        sourceAnchor: pred.sourceAnchor || 'bottom',
        targetAnchor: succ.targetAnchor || 'top'
      }));
    });
    const remaining = (Array.isArray(steps) ? steps : []).filter(s => s && !ids.has(keyOf(s)));
    return {
      steps: remaining,
      connections: conns
    };
  };
  const addConnectionIfLinear = (connections, conn) => {
    const conns = (Array.isArray(connections) ? connections : []).filter(Boolean);
    if (!conn || !conn.sourceId || !conn.targetId) return null;
    if (conn.sourceId === conn.targetId) return null;
    if (conn.targetId === START) return null;
    if (conns.some(c => c.sourceId === conn.sourceId)) return null;
    if (conns.some(c => c.targetId === conn.targetId)) return null;
    const full = makeConnection(conn.sourceId, conn.targetId, {
      ...conn,
      id: conn.id || `conn-${conn.sourceId}-${conn.targetId}`,
      sourceAnchor: conn.sourceAnchor || 'bottom',
      targetAnchor: conn.targetAnchor || 'top',
      waypoints: Array.isArray(conn.waypoints) ? conn.waypoints : [],
      label: typeof conn.label === 'string' ? conn.label : ''
    });
    if (conns.some(c => c.id === full.id)) return null;
    return conns.concat([full]);
  };
  const computeWorkflowCost = steps => round2((Array.isArray(steps) ? steps : []).reduce((sum, s) => {
    if (!s || SYSTEM.includes(s.id)) return sum;
    const cost = getStepCost(s);
    return typeof cost === 'number' ? sum + cost : sum;
  }, 0));
  const collectPropValues = allProps => {
    const map = new Map();
    if (!allProps) return map;
    if (Array.isArray(allProps)) {
      allProps.forEach(item => {
        if (!item) return;
        if (Array.isArray(item.properties)) {
          item.properties.forEach(p => {
            if (p && p.id !== undefined) map.set(p.id, p.value);
          });
        } else if (item.id !== undefined) {
          map.set(item.id, item.value);
        }
      });
      return map;
    }
    if (typeof allProps === 'object') {
      Object.keys(allProps).forEach(k => {
        const v = allProps[k];
        const looksLikeProp = v && typeof v === 'object' && !Array.isArray(v) && hasOwn(v, 'id') && hasOwn(v, 'type');
        map.set(k, looksLikeProp ? v.value : v);
      });
    }
    return map;
  };
  const isRequirementMet = (prop, allProps) => {
    if (!prop) return true;
    const values = collectPropValues(allProps);
    const lookup = id => ({
      found: values.has(id),
      value: values.get(id)
    });
    const req = prop.requirement;
    if (req) {
      if (typeof req === 'string') {
        const r = lookup(req);
        if (r.found && r.value !== true) return false;
      } else if (typeof req === 'object' && req.id) {
        const r = lookup(req.id);
        if (r.found) {
          if (hasOwn(req, 'equals') && r.value !== req.equals) return false;
          if (hasOwn(req, 'notEquals') && r.value === req.notEquals) return false;
        }
      }
    }
    const inv = prop.requirementInverse;
    if (inv) {
      if (typeof inv === 'string') {
        const r = lookup(inv);
        if (r.found && r.value) return false;
      } else if (typeof inv === 'object' && inv.id) {
        const r = lookup(inv.id);
        if (r.found) {
          if (hasOwn(inv, 'equals') && r.value === inv.equals) return false;
          if (hasOwn(inv, 'notEquals') && r.value !== inv.notEquals) return false;
        }
      }
    }
    return true;
  };
  const isParentToggleOn = (parentToggle, allProps) => {
    if (!parentToggle) return true;
    const values = collectPropValues(allProps);
    if (!values.has(parentToggle)) return true;
    return !!values.get(parentToggle);
  };
  const isBlank = v => v === undefined || v === null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && v.length === 0 || typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0;
  const docListOf = value => {
    if (Array.isArray(value)) return value;
    if (value && typeof value === 'object') {
      const key = ['documents', 'items', 'rows'].find(k => Array.isArray(value[k]));
      if (key) return value[key];
    }
    return [];
  };
  const docName = doc => {
    if (!doc) return '';
    if (typeof doc === 'string') return doc;
    return doc.name || doc.label || doc.title || doc.id || '';
  };
  const pagesOf = value => {
    if (Array.isArray(value)) return value;
    if (value && typeof value === 'object' && Array.isArray(value.pages)) return value.pages;
    return [];
  };
  const textListItems = value => (Array.isArray(value) ? value : []).filter(item => {
    if (item === undefined || item === null) return false;
    if (typeof item === 'string') return item.trim() !== '';
    return typeof item.text === 'string' ? item.text.trim() !== '' : !isBlank(item);
  });
  const questionsOf = value => {
    if (Array.isArray(value)) return value;
    if (value && typeof value === 'object' && Array.isArray(value.questions)) return value.questions;
    return [];
  };
  const rulesOf = value => {
    if (Array.isArray(value)) return value;
    if (value && typeof value === 'object' && Array.isArray(value.rules)) return value.rules;
    return [];
  };
  const selectedCount = value => {
    if (Array.isArray(value)) {
      const flagKeys = ['selected', 'enabled', 'checked', 'active'];
      const rows = value.filter(v => v && typeof v === 'object');
      if (rows.length && rows.length === value.length) {
        const flagged = rows.filter(row => flagKeys.some(k => hasOwn(row, k)));
        if (flagged.length) return flagged.filter(row => flagKeys.some(k => row[k] === true)).length;
        return rows.length;
      }
      return value.filter(v => v !== undefined && v !== null && v !== false && v !== '').length;
    }
    if (value && typeof value === 'object') return Object.keys(value).filter(k => !!value[k]).length;
    if (typeof value === 'string') return value.trim() ? 1 : 0;
    return 0;
  };
  const isEffectivelyEmpty = prop => {
    const value = prop.value;
    switch (prop.type) {
      case 'text-list':
        return textListItems(value).length === 0;
      case 'object':
        return pagesOf(value).reduce((n, page) => n + (page && Array.isArray(page.fields) && page.fields.length || 0), 0) === 0;
      case 'doc-upload':
        return docListOf(value).filter(d => docName(d).trim() !== '').length === 0;
      case 'proofcall-questions':
        return textListItems(questionsOf(value)).length === 0;
      case 'data-extraction':
        return rulesOf(value).length === 0;
      case 'multiselect-table':
      case 'country-multiselect':
      case 'country-customize':
      case 'jurisdiction-picker':
      case 'education-institution-picker':
        return selectedCount(value) === 0;
      case 'boolean':
        return value !== true;
      default:
        return isBlank(value);
    }
  };
  const stepDisplayLabel = step => step.label || (getStepById(step.id) || ({})).label || step.id;
  const stepValidation = workflow => {
    const errors = [];
    const wf = workflow || ({});
    const steps = (Array.isArray(wf.steps) ? wf.steps : []).filter(s => s && s.id && !SYSTEM.includes(s.id));
    if (!wf.name || !String(wf.name).trim()) errors.push('Workflow name is required');
    if (!steps.length) errors.push('Add at least one step to the workflow');
    steps.forEach(step => {
      const label = stepDisplayLabel(step);
      const all = flattenProps(step);
      if (step.id === 'id-verification') {
        const group = findGroup(step, 'document-types');
        if (group) {
          const anyDoc = propsOf(group).some(p => p && p.type === 'boolean' && p.value === true);
          if (!anyDoc) errors.push(`${label}: select at least one document type`);
        }
      }
      if (step.id === 'crypto-wallet-screening') {
        const networks = findProp(step, 'networks', 'networks');
        if (networks && selectedCount(networks.value) === 0) errors.push(`${label}: select at least one network`);
        const categories = findProp(step, 'risk', 'categories');
        if (categories && selectedCount(categories.value) === 0) errors.push(`${label}: select at least one risk category`);
      }
      if (step.id === 'e-signature') {
        const template = findProp(step, 'template', 'template-id');
        if (template && isBlank(template.value)) errors.push(`${label}: choose an e-signature template`);
      }
      groupsOf(step).forEach(group => {
        if (!group || REMOVED_GROUPS.includes(group.groupId)) return;
        if (!isParentToggleOn(group.parentToggle, all)) return;
        propsOf(group).forEach(prop => {
          if (!prop || prop.type === 'hidden') return;
          if (prop.locked || prop.disabled) return;
          if (!isRequirementMet(prop, all)) return;
          const propLabel = prop.label || prop.id;
          if (prop.required && isEffectivelyEmpty(prop)) {
            errors.push(`${label}: ${propLabel} is required`);
          }
          if (prop.type === 'data-extraction') {
            rulesOf(prop.value).forEach((rule, i) => {
              const r = rule || ({});
              const doc = r.document || r.documentId || r.documentSlug || r.documentName || r.doc;
              const fields = r.fields || r.params || r.parameters || r.extract;
              if (isBlank(doc)) errors.push(`${label}: extraction rule ${i + 1} needs a document`);
              if (selectedCount(fields) === 0) errors.push(`${label}: extraction rule ${i + 1} needs at least one field`);
            });
          }
        });
      });
    });
    return errors;
  };
  const keyDocuments = docs => {
    const out = {};
    docs.forEach((doc, i) => {
      if (doc === undefined || doc === null) return;
      const d = typeof doc === 'string' ? {
        name: doc
      } : doc;
      const base = slugify(d.slug || d.id || d.name || d.label || d.title || `document-${i + 1}`);
      let key = base;
      let n = 2;
      while (hasOwn(out, key)) {
        key = `${base}-${n}`;
        n += 1;
      }
      out[key] = {
        ...d
      };
    });
    return out;
  };
  const transformDocUpload = value => {
    if (Array.isArray(value)) return keyDocuments(value);
    if (value && typeof value === 'object') {
      const listKey = ['documents', 'items', 'rows'].find(k => Array.isArray(value[k]));
      if (listKey) return {
        ...value,
        [listKey]: keyDocuments(value[listKey])
      };
    }
    return value;
  };
  const transformPropertyValue = prop => {
    const value = prop.value;
    switch (prop.type) {
      case 'range':
        {
          if (Array.isArray(value)) return {
            lower: value[0],
            upper: value[1]
          };
          if (value && typeof value === 'object') return {
            lower: value.lower,
            upper: value.upper
          };
          return value;
        }
      case 'doc-upload':
        return transformDocUpload(value);
      case 'proofcall-questions':
        {
          const questions = questionsOf(value).map(q => typeof q === 'string' ? {
            text: q
          } : q);
          const meta = value && !Array.isArray(value) && typeof value === 'object' ? value : {};
          const perQuestion = num(meta.minutesPerQuestion, 1) || 1;
          return {
            ...meta,
            questions,
            estimatedMinutes: questions.length * perQuestion
          };
        }
      case 'object':
        {
          if (Array.isArray(value)) return {
            pages: value
          };
          if (value && typeof value === 'object') return {
            ...value,
            pages: Array.isArray(value.pages) ? value.pages : []
          };
          return {
            pages: []
          };
        }
      default:
        return value;
    }
  };
  const serializeWorkflowForSave = steps => (Array.isArray(steps) ? steps : []).filter(s => s && s.id && !SYSTEM.includes(s.id)).map(s => {
    const config = {};
    groupsOf(s).forEach(g => {
      if (!g || !g.groupId || REMOVED_GROUPS.includes(g.groupId)) return;
      const groupConfig = {};
      propsOf(g).forEach(p => {
        if (!p || !p.id || p.type === 'weight-display') return;
        const v = transformPropertyValue(p);
        if (v !== undefined) groupConfig[p.id] = deepClone(v);
      });
      config[g.groupId] = groupConfig;
    });
    return {
      id: s.id,
      instanceId: keyOf(s),
      config
    };
  });
  const serializeCanvasData = canvasData => {
    const cd = canvasData || ({});
    const connections = (Array.isArray(cd.connections) ? cd.connections : []).filter(Boolean).map(c => ({
      id: c.id || `conn-${c.sourceId}-${c.targetId}`,
      sourceId: c.sourceId,
      sourceAnchor: c.sourceAnchor || 'bottom',
      targetId: c.targetId,
      targetAnchor: c.targetAnchor || 'top',
      waypoints: (Array.isArray(c.waypoints) ? c.waypoints : []).filter(Boolean).map(w => ({
        x: Math.round(num(w.x)),
        y: Math.round(num(w.y))
      })),
      label: typeof c.label === 'string' ? c.label : ''
    }));
    const nodePositions = {};
    const positions = cd.nodePositions || ({});
    Object.keys(positions).forEach(k => {
      const p = positions[k];
      if (!p) return;
      nodePositions[k] = {
        x: Math.round(num(p.x)),
        y: Math.round(num(p.y))
      };
    });
    const vp = cd.viewport || ({});
    const zoom = num(vp.zoom, 1);
    return {
      connections,
      nodePositions,
      viewport: {
        x: round2(num(vp.x)),
        y: round2(num(vp.y)),
        zoom: zoom > 0 ? round2(zoom) : 1
      }
    };
  };
  const buildSavePayload = args => {
    const {workflow, orderedSteps, organizationId, createdBy} = args || ({});
    const wf = workflow || ({});
    const all = (Array.isArray(wf.steps) ? wf.steps : []).filter(Boolean);
    let stepsForSave = all;
    if (Array.isArray(orderedSteps) && orderedSteps.length) {
      const seen = new Set(orderedSteps.map(keyOf));
      stepsForSave = orderedSteps.concat(all.filter(s => !seen.has(keyOf(s))));
    }
    return {
      organizationId: organizationId === undefined ? null : organizationId,
      createdBy: createdBy === undefined ? null : createdBy,
      name: String(wf.name || '').trim(),
      steps: serializeWorkflowForSave(stepsForSave),
      canvasData: serializeCanvasData(wf.canvasData),
      status: 'active'
    };
  };
  const normalizeStepKey = stepKey => stepKey && typeof stepKey === 'object' ? keyOf(stepKey) : stepKey;
  const updatePropertyValuesInSteps = (steps, stepKey, groupId, values) => {
    const list = Array.isArray(steps) ? steps : [];
    const key = normalizeStepKey(stepKey);
    if (!key || !values || typeof values !== 'object') return list;
    let changed = false;
    const out = list.map(s => {
      if (!s || keyOf(s) !== key || !Array.isArray(s.propertyGroups)) return s;
      let stepChanged = false;
      const propertyGroups = s.propertyGroups.map(g => {
        if (!g || g.groupId !== groupId || !Array.isArray(g.properties)) return g;
        let groupChanged = false;
        const properties = g.properties.map(p => {
          if (!p || !hasOwn(values, p.id)) return p;
          groupChanged = true;
          return {
            ...p,
            value: values[p.id]
          };
        });
        if (!groupChanged) return g;
        stepChanged = true;
        return {
          ...g,
          properties
        };
      });
      if (!stepChanged) return s;
      changed = true;
      return {
        ...s,
        propertyGroups
      };
    });
    return changed ? out : list;
  };
  const updatePropertyValueInSteps = (steps, stepKey, groupId, propId, value) => updatePropertyValuesInSteps(steps, stepKey, groupId, {
    [propId]: value
  });
  const redistributeWeights = (weights, changedId, value) => {
    const clamp5 = v => Math.max(0, Math.min(100, Math.round((Number(v) || 0) / 5) * 5));
    const changed = clamp5(value);
    const result = {
      [changedId]: changed
    };
    const others = weights.filter(w => w.id !== changedId);
    if (!others.length) return result;
    const remaining = 100 - changed;
    const sumOthers = others.reduce((s, w) => s + (Number(w.value) || 0), 0);
    const allocated = others.map(w => sumOthers > 0 ? (Number(w.value) || 0) / sumOthers * remaining : remaining / others.length);
    const rounded = allocated.map(clamp5);
    let drift = remaining - rounded.reduce((s, v) => s + v, 0);
    let guard = 0;
    while (drift !== 0 && guard < 100) {
      guard += 1;
      const order = allocated.map((a, i) => i).sort((a, b) => allocated[b] - allocated[a]);
      let applied = false;
      for (let j = 0; j < order.length; j += 1) {
        const i = order[j];
        const delta = drift > 0 ? 5 : -5;
        const next = rounded[i] + delta;
        if (next >= 0 && next <= 100) {
          rounded[i] = next;
          drift -= delta;
          applied = true;
          break;
        }
      }
      if (!applied) break;
    }
    others.forEach((w, i) => {
      result[w.id] = rounded[i];
    });
    return result;
  };
  const updateLinkedWeightsInSteps = (steps, stepKey, groupId, changedId, value) => {
    const list = Array.isArray(steps) ? steps : [];
    const key = normalizeStepKey(stepKey);
    const step = list.find(s => s && keyOf(s) === key);
    if (!step) return list;
    const group = findGroup(step, groupId);
    if (!group) return list;
    const changedProp = propsOf(group).find(p => p && p.id === changedId);
    if (!changedProp) return list;
    const linkedGroup = changedProp.linkedGroup;
    const siblings = propsOf(group).filter(p => p && (linkedGroup ? p.linkedGroup === linkedGroup : p.type === 'slider'));
    if (siblings.length < 2) return updatePropertyValueInSteps(list, key, groupId, changedId, value);
    const next = redistributeWeights(siblings.map(p => ({
      id: p.id,
      value: p.value
    })), changedId, value);
    return updatePropertyValuesInSteps(list, key, groupId, next);
  };
  return {
    getStepById,
    getStepIcon,
    getStepGradient,
    getStepDescription,
    getStepCost,
    getStepCostLabel,
    isCoupledStep,
    getStepTemplate,
    sanitizeStepForWorkflow,
    makeInstanceId,
    isGatedStep,
    isStepGated,
    isWorkflowStepAvailable,
    getAvailableServiceData,
    applyCoupledSteps,
    removeWithCoupled,
    enforceBottomPinningOnSteps,
    enforceCoupledOverridesOnSteps,
    isReorderValidWithPinning,
    rebuildChainWithPinning,
    resolveStepOrderFromConnections,
    computeResolvedOrder,
    removeStepsWithBridge,
    addConnectionIfLinear,
    computeWorkflowCost,
    fCurrency,
    truncateMiddle,
    deepClone,
    parseNodeDropId,
    isRequirementMet,
    stepValidation,
    serializeWorkflowForSave,
    serializeCanvasData,
    buildSavePayload,
    updatePropertyValueInSteps,
    updatePropertyValuesInSteps,
    updateLinkedWeightsInSteps
  };
};

export const makeUi = () => {
  const noop = () => {};
  const cx = (...parts) => parts.filter(Boolean).join(' ');
  const clampNum = (n, lo, hi) => Number.isNaN(n) ? lo : Math.min(hi, Math.max(lo, n));
  const isEl = x => React.isValidElement(x);
  const NAMED_COLORS = {
    primary: '#1E7FE0',
    secondary: '#8E33FF',
    success: '#22C55E',
    warning: '#FFAB00',
    error: '#FF5630',
    info: '#00B8D9',
    default: '#637381',
    grey: '#637381',
    inherit: '#637381'
  };
  const toHex = c => c && NAMED_COLORS[c] || c || NAMED_COLORS.default;
  const hexToRgba = (hex, a) => {
    const h = String(hex || '').replace('#', '');
    if (!(/^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/).test(h)) return `rgba(99,115,129,${a})`;
    const full = h.length === 3 ? h.split('').map(ch => ch + ch).join('') : h;
    const n = parseInt(full, 16);
    return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
  };
  const flagEmoji = code => {
    if (!code || typeof code !== 'string') return '';
    if (!(/^[A-Za-z]{2}$/).test(code)) return code;
    const up = code.toUpperCase();
    return String.fromCodePoint(...[...up].map(ch => 127397 + ch.charCodeAt(0)));
  };
  const fmtCost = cost => {
    if (cost == null || cost === '') return '';
    if (typeof cost === 'number') return cost === 0 ? 'free' : `$${cost.toFixed(2)}`;
    return String(cost);
  };
  const UI_CSS = `
:where(:root){--wb-primary:#1E7FE0;--wb-primary-light:#22B8F0;--wb-primary-dark:#1456A0;--wb-primary-soft:rgba(30,127,224,.12);--wb-paper:#ffffff;--wb-bg:#F9FAFB;--wb-text:#1C252E;--wb-text-2:#637381;--wb-text-3:#919EAB;--wb-divider:rgba(145,158,171,.2);--wb-outline:rgba(145,158,171,.32);--wb-hover:rgba(145,158,171,.08);--wb-track:rgba(145,158,171,.32);--wb-success:#22C55E;--wb-warning:#FFAB00;--wb-error:#FF5630;--wb-info:#00B8D9;--wb-shadow:0 8px 24px rgba(145,158,171,.24);--wb-shadow-lg:0 24px 48px rgba(22,28,36,.24);--wb-backdrop:rgba(22,28,36,.48)}
:where(html.dark){--wb-paper:#1C252E;--wb-bg:#141A21;--wb-text:#FFFFFF;--wb-text-2:#919EAB;--wb-text-3:#637381;--wb-divider:rgba(145,158,171,.24);--wb-outline:rgba(145,158,171,.36);--wb-hover:rgba(145,158,171,.12);--wb-track:rgba(145,158,171,.4);--wb-shadow:0 8px 24px rgba(0,0,0,.4);--wb-shadow-lg:0 24px 48px rgba(0,0,0,.6);--wb-backdrop:rgba(0,0,0,.6)}
:where(.wb-icon){display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:0}
:where(.wb-icon svg){display:block}
:where(.wb-btn){display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:8px;font:inherit;font-weight:700;font-size:14px;line-height:1;height:36px;padding:0 16px;cursor:pointer;white-space:nowrap;text-decoration:none;transition:background .15s,box-shadow .15s,color .15s,border-color .15s,filter .15s;user-select:none;-webkit-tap-highlight-color:transparent;color:var(--wb-text);background:transparent;box-sizing:border-box;margin:0}
:where(.wb-btn-small){height:30px;padding:0 10px;font-size:13px;border-radius:6px;gap:6px}
:where(.wb-btn-large){height:44px;padding:0 22px;font-size:15px}
:where(.wb-btn-full){width:100%}
:where(.wb-btn:disabled,.wb-btn[aria-disabled="true"]){cursor:not-allowed;opacity:.48;box-shadow:none;pointer-events:none}
:where(.wb-btn:focus-visible){outline:2px solid var(--wb-primary);outline-offset:2px}
:where(.wb-btn-contained.wb-btn-primary){background:linear-gradient(135deg,var(--wb-primary),var(--wb-primary-light));color:#fff;box-shadow:0 8px 16px var(--wb-primary-soft)}
:where(.wb-btn-contained.wb-btn-primary:hover){filter:brightness(1.07)}
:where(.wb-btn-contained.wb-btn-inherit){background:var(--wb-text);color:var(--wb-paper)}
:where(.wb-btn-contained.wb-btn-inherit:hover){filter:brightness(1.15)}
:where(.wb-btn-contained.wb-btn-error){background:var(--wb-error);color:#fff}
:where(.wb-btn-contained.wb-btn-error:hover){filter:brightness(1.07)}
:where(.wb-btn-outlined.wb-btn-primary){border-color:rgba(30,127,224,.48);color:var(--wb-primary)}
:where(.wb-btn-outlined.wb-btn-primary:hover){background:var(--wb-primary-soft);border-color:var(--wb-primary)}
:where(.wb-btn-outlined.wb-btn-inherit){border-color:var(--wb-outline);color:var(--wb-text)}
:where(.wb-btn-outlined.wb-btn-inherit:hover){background:var(--wb-hover);border-color:var(--wb-text)}
:where(.wb-btn-outlined.wb-btn-error){border-color:rgba(255,86,48,.48);color:var(--wb-error)}
:where(.wb-btn-outlined.wb-btn-error:hover){background:rgba(255,86,48,.08);border-color:var(--wb-error)}
:where(.wb-btn-text.wb-btn-primary){color:var(--wb-primary)}
:where(.wb-btn-text.wb-btn-primary:hover){background:var(--wb-primary-soft)}
:where(.wb-btn-text.wb-btn-inherit){color:var(--wb-text)}
:where(.wb-btn-text.wb-btn-inherit:hover){background:var(--wb-hover)}
:where(.wb-btn-text.wb-btn-error){color:var(--wb-error)}
:where(.wb-btn-text.wb-btn-error:hover){background:rgba(255,86,48,.08)}
:where(.wb-btn-soft.wb-btn-primary){background:var(--wb-primary-soft);color:var(--wb-primary-dark)}
:where(.wb-btn-soft.wb-btn-primary:hover){background:rgba(30,127,224,.2)}
:where(.wb-btn-soft.wb-btn-inherit){background:var(--wb-hover);color:var(--wb-text)}
:where(.wb-btn-soft.wb-btn-error){background:rgba(255,86,48,.12);color:var(--wb-error)}
:where(html.dark .wb-btn-soft.wb-btn-primary){color:#8FC6FF}
:where(.wb-btn-icon){display:inline-flex;line-height:0}
:where(.wb-iconbtn){display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:0;background:transparent;color:var(--wb-text-2);cursor:pointer;padding:0;flex-shrink:0;transition:background .15s,color .15s;-webkit-tap-highlight-color:transparent;margin:0;font:inherit}
:where(.wb-iconbtn-lg){width:36px;height:36px}
:where(.wb-iconbtn:hover){background:var(--wb-hover);color:var(--wb-text)}
:where(.wb-iconbtn-active){background:var(--wb-primary-soft);color:var(--wb-primary)}
:where(.wb-iconbtn-active:hover){background:var(--wb-primary-soft);color:var(--wb-primary)}
:where(.wb-iconbtn-outlined){border:1px solid var(--wb-outline);border-radius:8px}
:where(.wb-iconbtn-primary){color:var(--wb-primary)}
:where(.wb-iconbtn-error:hover){background:rgba(255,86,48,.1);color:var(--wb-error)}
:where(.wb-iconbtn:disabled){opacity:.4;cursor:not-allowed}
:where(.wb-iconbtn:focus-visible){outline:2px solid var(--wb-primary);outline-offset:1px}
:where(.wb-textfield,.wb-select){display:inline-flex;flex-direction:column;gap:6px;min-width:0;vertical-align:top;text-align:left}
:where(.wb-textfield-full,.wb-select-full){display:flex;width:100%}
:where(.wb-field-label){font-size:12px;font-weight:600;color:var(--wb-text-2);line-height:1.4}
:where(.wb-required){color:var(--wb-error)}
:where(.wb-input-wrap,.wb-select-wrap){position:relative;display:flex;align-items:center;width:100%}
:where(.wb-input,.wb-select-input){font:inherit;font-size:14px;line-height:1.4;color:var(--wb-text);background:var(--wb-paper);border:1px solid var(--wb-outline);border-radius:8px;padding:0 12px;height:36px;width:100%;min-width:0;outline:none;transition:border-color .15s,box-shadow .15s;box-sizing:border-box;margin:0}
:where(.wb-textfield-medium .wb-input,.wb-select-medium .wb-select-input){height:44px;font-size:15px}
:where(.wb-textarea){height:auto;min-height:72px;padding:8px 12px;resize:vertical;line-height:1.5}
:where(.wb-input:hover,.wb-select-input:hover){border-color:var(--wb-text)}
:where(.wb-input:focus,.wb-select-input:focus){border-color:var(--wb-primary);box-shadow:0 0 0 3px var(--wb-primary-soft)}
:where(.wb-input::placeholder){color:var(--wb-text-3);opacity:1}
:where(.wb-input:disabled,.wb-select-input:disabled){color:var(--wb-text-3);cursor:not-allowed;background:var(--wb-hover)}
:where(.wb-input-error){border-color:var(--wb-error)}
:where(.wb-input-error:focus){border-color:var(--wb-error);box-shadow:0 0 0 3px rgba(255,86,48,.16)}
:where(.wb-input-has-icon){padding-left:36px}
:where(.wb-input-has-end){padding-right:40px}
:where(.wb-input-icon){position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--wb-text-3);display:inline-flex;pointer-events:none;line-height:0}
:where(.wb-input-end){position:absolute;right:8px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;color:var(--wb-text-2)}
:where(.wb-helper){font-size:12px;color:var(--wb-text-2);line-height:1.4}
:where(.wb-helper-error){color:var(--wb-error)}
:where(.wb-select-input){appearance:none;-webkit-appearance:none;padding-right:32px;cursor:pointer}
:where(.wb-select-input:disabled){cursor:not-allowed}
:where(.wb-select-input option){color:#1C252E;background:#fff}
:where(.wb-select-chevron){position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--wb-text-2);display:inline-flex;line-height:0}
:where(.wb-switch-row){display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:pointer;min-height:24px;padding:4px 0;-webkit-tap-highlight-color:transparent}
:where(.wb-switch-row-end){justify-content:flex-start}
:where(.wb-switch-row.wb-disabled){cursor:not-allowed}
:where(.wb-switch-text){display:flex;flex-direction:column;min-width:0;gap:2px}
:where(.wb-switch-label){font-size:14px;font-weight:500;color:var(--wb-text);line-height:1.4}
:where(.wb-switch-sublabel){font-size:12px;color:var(--wb-text-2);line-height:1.4}
:where(.wb-switch){position:relative;display:inline-flex;flex-shrink:0;width:34px;height:20px}
:where(.wb-switch-input){position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;z-index:1}
:where(.wb-switch-input:disabled){cursor:not-allowed}
:where(.wb-switch-track){position:absolute;inset:0;border-radius:10px;background:var(--wb-track);transition:background .2s}
:where(.wb-switch-thumb){position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);transition:transform .2s}
:where(.wb-switch-input:checked + .wb-switch-track){background:var(--wb-primary)}
:where(.wb-switch-input:checked + .wb-switch-track .wb-switch-thumb){transform:translateX(14px)}
:where(.wb-switch-input:focus-visible + .wb-switch-track){box-shadow:0 0 0 3px var(--wb-primary-soft)}
:where(.wb-switch-input:disabled + .wb-switch-track){opacity:.5}
:where(.wb-slider){display:flex;flex-direction:column;gap:2px;width:100%;min-width:0}
:where(.wb-slider-head){display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;color:var(--wb-text-2)}
:where(.wb-slider-value){font-size:12px;font-weight:700;color:var(--wb-text);background:var(--wb-hover);border-radius:6px;padding:2px 8px;font-variant-numeric:tabular-nums}
:where(.wb-slider-input){-webkit-appearance:none;appearance:none;width:100%;height:24px;background:transparent;margin:0;cursor:pointer;display:block;padding:0}
:where(.wb-slider-input:disabled){cursor:not-allowed;opacity:.6}
:where(.wb-slider-input:focus){outline:none}
:where(.wb-slider-input)::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:linear-gradient(to right,var(--wb-primary) var(--wb-fill,0%),var(--wb-track) var(--wb-fill,0%))}
:where(.wb-slider-input)::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:var(--wb-primary);border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.28);margin-top:-6px;transition:box-shadow .15s}
:where(.wb-slider-input:focus-visible)::-webkit-slider-thumb{box-shadow:0 0 0 4px var(--wb-primary-soft)}
:where(.wb-slider-input)::-moz-range-track{height:4px;border-radius:2px;background:var(--wb-track)}
:where(.wb-slider-input)::-moz-range-progress{height:4px;border-radius:2px;background:var(--wb-primary)}
:where(.wb-slider-input)::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--wb-primary);border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.28)}
:where(.wb-slider-marks){position:relative;height:16px;margin:0 8px;font-size:11px;color:var(--wb-text-3)}
:where(.wb-slider-mark){position:absolute;top:0;transform:translateX(-50%);white-space:nowrap;line-height:1.2}
:where(.wb-slider-mark::before){content:"";position:absolute;top:-8px;left:50%;width:2px;height:2px;border-radius:50%;background:var(--wb-text-3);transform:translateX(-50%)}
:where(.wb-range){display:flex;flex-direction:column;gap:8px;width:100%}
:where(.wb-range-track){position:relative;height:28px;display:flex;align-items:center}
:where(.wb-range-rail){position:absolute;left:8px;right:8px;height:6px;border-radius:3px;top:50%;transform:translateY(-50%)}
:where(.wb-range-input){-webkit-appearance:none;appearance:none;position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;background:transparent;pointer-events:none;outline:none}
:where(.wb-range-input)::-webkit-slider-runnable-track{height:6px;background:transparent;border:0}
:where(.wb-range-input)::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;pointer-events:auto;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid var(--wb-text);box-shadow:0 1px 4px rgba(0,0,0,.3);margin-top:-6px;cursor:grab}
:where(.wb-range-input:focus-visible)::-webkit-slider-thumb{box-shadow:0 0 0 4px var(--wb-primary-soft)}
:where(.wb-range-input)::-moz-range-track{height:6px;background:transparent;border:0}
:where(.wb-range-input)::-moz-range-thumb{pointer-events:auto;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid var(--wb-text);box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:grab}
:where(.wb-range-input:disabled)::-webkit-slider-thumb{cursor:not-allowed}
:where(.wb-range-chips){display:flex;gap:6px;flex-wrap:wrap}
:where(.wb-chip){display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 8px;border-radius:8px;font-size:12px;font-weight:600;line-height:1;white-space:nowrap;border:1px solid transparent;box-sizing:border-box;max-width:100%;color:var(--wb-chip-c,var(--wb-text))}
:where(.wb-chip-medium){height:32px;padding:0 12px;font-size:13px;border-radius:16px}
:where(.wb-chip-label){overflow:hidden;text-overflow:ellipsis}
:where(.wb-chip-soft){background:var(--wb-chip-bg,var(--wb-hover));color:var(--wb-chip-c,var(--wb-text));color:color-mix(in srgb,var(--wb-chip-c,var(--wb-text)) 78%,#000)}
:where(html.dark .wb-chip-soft){color:color-mix(in srgb,var(--wb-chip-c,var(--wb-text)) 82%,#fff)}
:where(.wb-chip-outlined){border-color:var(--wb-chip-c,var(--wb-outline));background:transparent}
:where(.wb-chip-filled){background:var(--wb-chip-c,var(--wb-text-2));color:#fff}
:where(.wb-chip-clickable){cursor:pointer;-webkit-tap-highlight-color:transparent}
:where(.wb-chip-clickable:hover){filter:brightness(.96)}
:where(.wb-chip-delete){display:inline-flex;border:0;background:transparent;padding:0;margin:0 -2px 0 0;cursor:pointer;color:inherit;opacity:.7;line-height:0}
:where(.wb-chip-delete:hover){opacity:1}
:where(.wb-alert){display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-radius:8px;font-size:13px;line-height:1.5;color:var(--wb-text)}
:where(.wb-alert-info){background:rgba(0,184,217,.12)}
:where(.wb-alert-warning){background:rgba(255,171,0,.14)}
:where(.wb-alert-error){background:rgba(255,86,48,.12)}
:where(.wb-alert-success){background:rgba(34,197,94,.12)}
:where(.wb-alert-icon){display:inline-flex;flex-shrink:0;margin-top:1px;line-height:0}
:where(.wb-alert-info .wb-alert-icon){color:var(--wb-info)}
:where(.wb-alert-warning .wb-alert-icon){color:#B76E00}
:where(.wb-alert-error .wb-alert-icon){color:var(--wb-error)}
:where(.wb-alert-success .wb-alert-icon){color:#118D57}
:where(html.dark .wb-alert-warning .wb-alert-icon){color:#FFD666}
:where(html.dark .wb-alert-success .wb-alert-icon){color:#77ED8B}
:where(.wb-alert-body){flex:1;min-width:0}
:where(.wb-alert-title){font-weight:700;margin-bottom:2px}
:where(.wb-alert-action){display:inline-flex;flex-shrink:0;align-items:center}
:where(.wb-divider){border:0;border-top:1px dashed var(--wb-outline);margin:16px 0;height:0;width:100%;flex-shrink:0}
:where(.wb-divider-solid){border-top-style:solid}
:where(.wb-divider-vertical){border-top:0;border-left:1px solid var(--wb-outline);width:0;height:auto;align-self:stretch;margin:0 8px;min-height:16px}
:where(.wb-divider-with-text){display:flex;align-items:center;gap:12px;border:0;color:var(--wb-text-2);font-size:12px}
:where(.wb-divider-with-text::before,.wb-divider-with-text::after){content:"";flex:1;border-top:1px dashed var(--wb-outline)}
:where(.wb-tooltip-wrap){position:relative;display:inline-flex;max-width:100%}
:where(.wb-tooltip){position:absolute;left:50%;transform:translateX(-50%);z-index:1500;background:#1C252E;color:#fff;font-size:12px;font-weight:500;line-height:1.4;padding:6px 8px;border-radius:6px;white-space:nowrap;max-width:260px;pointer-events:none;box-shadow:var(--wb-shadow);animation:wb-ui-fade-in .12s ease-out}
:where(.wb-tooltip-multiline){white-space:normal;width:max-content}
:where(html.dark .wb-tooltip){background:#454F5B}
:where(.wb-tooltip-top){bottom:calc(100% + 6px)}
:where(.wb-tooltip-bottom){top:calc(100% + 6px)}
:where(.wb-tooltip-right){left:calc(100% + 6px);top:50%;transform:translateY(-50%)}
:where(.wb-tooltip-left){left:auto;right:calc(100% + 6px);top:50%;transform:translateY(-50%)}
:where(.wb-dialog-overlay){position:fixed;inset:0;z-index:1300;background:var(--wb-backdrop);display:flex;align-items:center;justify-content:center;padding:16px;animation:wb-ui-fade-in .15s ease-out;box-sizing:border-box}
:where(.wb-dialog){position:relative;background:var(--wb-paper);color:var(--wb-text);border-radius:16px;box-shadow:var(--wb-shadow-lg);display:flex;flex-direction:column;max-height:calc(100vh - 32px);outline:none;animation:wb-ui-pop .18s ease-out;box-sizing:border-box;font-size:14px}
:where(.wb-dialog-header){display:flex;align-items:center;justify-content:space-between;gap:12px;padding:20px 16px 12px 24px}
:where(.wb-dialog-title){margin:0;font-size:18px;font-weight:700;line-height:1.4;color:var(--wb-text)}
:where(.wb-dialog-body){padding:4px 24px 20px;overflow:auto;min-height:0;flex:1 1 auto;line-height:1.5;color:var(--wb-text);scrollbar-width:thin}
:where(.wb-dialog-actions){display:flex;justify-content:flex-end;gap:8px;padding:0 24px 24px;flex-wrap:wrap}
:where(.wb-menu-wrap){position:relative;display:inline-block}
:where(.wb-menu){position:absolute;z-index:1200;background:var(--wb-paper);color:var(--wb-text);border-radius:10px;box-shadow:var(--wb-shadow-lg);padding:4px;display:flex;flex-direction:column;gap:2px;animation:wb-ui-pop .12s ease-out;border:1px solid var(--wb-divider)}
:where(.wb-menu-bottom){top:calc(100% + 4px)}
:where(.wb-menu-top){bottom:calc(100% + 4px)}
:where(.wb-menu-left){left:0}
:where(.wb-menu-right){right:0}
:where(.wb-menu-item){display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;border-radius:6px;padding:8px 10px;font:inherit;font-size:13px;color:var(--wb-text);cursor:pointer;text-align:left;white-space:nowrap}
:where(.wb-menu-item:hover,.wb-menu-item:focus-visible){background:var(--wb-hover);outline:none}
:where(.wb-menu-item-selected){background:var(--wb-primary-soft);color:var(--wb-primary)}
:where(.wb-menu-item-danger){color:var(--wb-error)}
:where(.wb-menu-item:disabled){opacity:.48;cursor:not-allowed}
:where(.wb-menu-item-label){flex:1}
:where(.wb-menu-item-hint){font-size:11px;color:var(--wb-text-3)}
:where(.wb-menu-divider){border-top:1px solid var(--wb-divider);margin:4px 0}
:where(.wb-scrollbar){overflow:auto;min-height:0;scrollbar-width:thin;scrollbar-color:var(--wb-outline) transparent}
:where(.wb-scrollbar)::-webkit-scrollbar{width:6px;height:6px}
:where(.wb-scrollbar)::-webkit-scrollbar-thumb{background:var(--wb-outline);border-radius:3px}
:where(.wb-scrollbar)::-webkit-scrollbar-track{background:transparent}
:where(.wb-toasts){position:fixed;z-index:1400;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:min(420px,calc(100vw - 32px))}
:where(.wb-toasts-bottom-right){right:24px;bottom:24px;align-items:flex-end}
:where(.wb-toasts-bottom-left){left:24px;bottom:24px;align-items:flex-start}
:where(.wb-toasts-top-right){right:24px;top:24px;align-items:flex-end}
:where(.wb-toast){pointer-events:auto;display:flex;align-items:flex-start;gap:10px;min-width:240px;max-width:100%;padding:10px 8px 10px 12px;border-radius:10px;background:var(--wb-paper);color:var(--wb-text);box-shadow:var(--wb-shadow-lg);border:1px solid var(--wb-divider);font-size:13px;line-height:1.45;animation:wb-ui-toast-in .22s cubic-bezier(.2,.8,.2,1)}
:where(.wb-toast-icon){display:inline-flex;flex-shrink:0;margin-top:1px;line-height:0}
:where(.wb-toast-success .wb-toast-icon){color:var(--wb-success)}
:where(.wb-toast-error .wb-toast-icon){color:var(--wb-error)}
:where(.wb-toast-info .wb-toast-icon){color:var(--wb-info)}
:where(.wb-toast-warning .wb-toast-icon){color:var(--wb-warning)}
:where(.wb-toast-msg){flex:1;min-width:0;word-break:break-word;padding-top:1px}
:where(.wb-toast-close){display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;border:0;background:transparent;color:var(--wb-text-3);cursor:pointer;padding:0;flex-shrink:0;margin-left:2px}
:where(.wb-toast-close:hover){background:var(--wb-hover);color:var(--wb-text)}
:where(.wb-spinner){display:inline-block;width:24px;height:24px;border:3px solid var(--wb-track);border-top-color:var(--wb-primary);border-radius:50%;animation:wb-ui-spin .8s linear infinite;box-sizing:border-box;flex-shrink:0}
:where(.wb-empty){display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:4px;padding:24px;color:var(--wb-text-2)}
:where(.wb-empty-icon){display:inline-flex;margin-bottom:12px;opacity:.9;line-height:0}
:where(.wb-empty-title){font-size:16px;font-weight:600;color:var(--wb-text);line-height:1.5}
:where(.wb-empty-subtitle){font-size:14px;color:var(--wb-text-2);line-height:1.5;white-space:pre-line;max-width:320px}
:where(.wb-empty-action){margin-top:12px}
:where(.wb-locked){display:flex;gap:16px;align-items:flex-start}
:where(.wb-locked-icon){display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:50%;background:rgba(255,171,0,.16);color:#B76E00;flex-shrink:0}
:where(html.dark .wb-locked-icon){color:#FFD666}
:where(.wb-locked-copy){margin:0;font-size:14px;line-height:1.6;color:var(--wb-text-2)}
:where(.wb-disabled){cursor:not-allowed}
@keyframes wb-ui-spin{to{transform:rotate(360deg)}}
@keyframes wb-ui-fade-in{from{opacity:0}to{opacity:1}}
@keyframes wb-ui-pop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
@keyframes wb-ui-toast-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){:where(.wb-spinner,.wb-toast,.wb-dialog,.wb-dialog-overlay,.wb-menu,.wb-tooltip){animation:none}}
`;
  const mintIcon = (name, size, color) => typeof color === 'string' && color ? <Icon icon={String(name)} size={Number(size) || 16} color={color} /> : <Icon icon={String(name)} size={Number(size) || 16} />;
  const fallbackGlyph = size => <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="9" />
    </svg>;
  const LucideIcon = ({name, size = 16, color, className, style, title}) => {
    let inner = null;
    try {
      inner = mintIcon(name || 'circle', size, color);
    } catch (err) {
      inner = null;
    }
    return <span className={cx('wb-icon', className)} data-icon={name} aria-hidden={title ? undefined : true} role={title ? 'img' : undefined} aria-label={title} style={{
      display: 'inline-flex',
      alignItems: 'center',
      justifyContent: 'center',
      width: size,
      height: size,
      flexShrink: 0,
      lineHeight: 0,
      color: color || 'inherit',
      ...style
    }}>
        {inner || fallbackGlyph(size)}
      </span>;
  };
  const iconUrl = iconId => {
    const raw = String(iconId || 'solar:widget-bold-duotone').trim();
    const idx = raw.indexOf(':');
    const name = idx > 0 ? raw.slice(idx + 1) : raw;
    return '/images/wb-icons/' + name + '.svg';
  };
  const StepIcon = ({icon, size = 20, color = '#ffffff', style, className, alt = '', testId}) => <span data-testid={testId} data-icon={icon} aria-label={alt || undefined} role={alt ? 'img' : undefined} aria-hidden={alt ? undefined : true} className={cx('wb-step-icon', className)} style={{
    display: 'inline-block',
    width: size,
    height: size,
    flexShrink: 0,
    backgroundColor: color || 'currentColor',
    WebkitMaskImage: 'url("' + iconUrl(icon) + '")',
    maskImage: 'url("' + iconUrl(icon) + '")',
    WebkitMaskRepeat: 'no-repeat',
    maskRepeat: 'no-repeat',
    WebkitMaskPosition: 'center',
    maskPosition: 'center',
    WebkitMaskSize: 'contain',
    maskSize: 'contain',
    ...style
  }} />;
  const renderIcon = (icon, size) => {
    if (icon === null || icon === undefined || icon === false) return null;
    if (isEl(icon)) return icon;
    const name = String(icon);
    if (name.indexOf(':') > 0) return <StepIcon icon={name} size={size} color="currentColor" />;
    return <LucideIcon name={name} size={size} />;
  };
  const Button = React.forwardRef(function WbButton(p, ref) {
    const {variant = 'contained', color = 'primary', size = 'medium', startIcon, endIcon, fullWidth, disabled, loading, onClick, children, className, style, testId, href, target, type = 'button', ...rest} = p;
    const iconSize = size === 'small' ? 14 : size === 'large' ? 18 : 16;
    const cls = cx('wb-btn', `wb-btn-${variant}`, `wb-btn-${color}`, `wb-btn-${size}`, fullWidth && 'wb-btn-full', loading && 'wb-btn-loading', className);
    const isDisabled = !!disabled || !!loading;
    const content = <>
        {loading ? <span className="wb-btn-icon"><span className="wb-spinner" style={{
      width: iconSize,
      height: iconSize,
      borderWidth: 2,
      borderTopColor: 'currentColor',
      borderColor: 'rgba(255,255,255,.35)',
      borderTopColor: 'currentColor'
    }} /></span> : startIcon ? <span className="wb-btn-icon">{renderIcon(startIcon, iconSize)}</span> : null}
        {children != null ? <span className="wb-btn-label">{children}</span> : null}
        {endIcon ? <span className="wb-btn-icon">{renderIcon(endIcon, iconSize)}</span> : null}
      </>;
    if (href && !isDisabled) {
      return <a ref={ref} href={href} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined} className={cls} style={style} onClick={onClick} data-testid={testId} {...rest}>
          {content}
        </a>;
    }
    return <button ref={ref} type={type} className={cls} style={style} disabled={isDisabled} aria-busy={loading ? true : undefined} onClick={onClick} data-testid={testId} {...rest}>
        {content}
      </button>;
  });
  const IconButton = React.forwardRef(function WbIconButton(p, ref) {
    const {name, size = 18, label, onClick, active, disabled, color, variant, className, style, testId, children, title, ...rest} = p;
    return <button ref={ref} type="button" aria-label={label} title={title === undefined ? label : title || undefined} aria-pressed={active === undefined ? undefined : !!active} disabled={disabled} onClick={onClick} data-testid={testId} className={cx('wb-iconbtn', size >= 20 && 'wb-iconbtn-lg', active && 'wb-iconbtn-active', variant && `wb-iconbtn-${variant}`, color && `wb-iconbtn-${color}`, className)} style={style} {...rest}>
        {children || <LucideIcon name={name} size={size} />}
      </button>;
  });
  const Divider = ({style, className}) => <div className={cx('wb-divider', className)} style={style} />;
  const Chip = ({label, children, color, size = 'small', style, className, testId}) => <span data-testid={testId} className={cx('wb-chip', color && `wb-chip-${color}`, size === 'tiny' && 'wb-chip-tiny', className)} style={style}>{children || label}</span>;
  const Spinner = ({size = 18, color}) => <span className="wb-spinner" style={{
    width: size,
    height: size,
    borderColor: color || 'currentColor',
    borderTopColor: 'transparent'
  }} aria-hidden="true" />;
  const Alert = ({severity = 'info', title, children, style, className, testId}) => <div role="note" data-testid={testId} className={cx('wb-alert', `wb-alert-${severity}`, className)} style={style}>
      <LucideIcon name={severity === 'warning' ? 'triangle-alert' : severity === 'error' ? 'circle-alert' : severity === 'success' ? 'circle-check' : 'info'} size={16} />
      <div className="wb-alert-body">
        {title ? <div className="wb-alert-title">{title}</div> : null}
        {children ? <div className="wb-alert-text">{children}</div> : null}
      </div>
    </div>;
  const Tooltip = ({title, children, style, className}) => <span className={cx('wb-tip', className)} style={style} title={title === undefined || title === null ? undefined : String(title)}>{children}</span>;
  const Scrollbar = ({children, style, className, testId}) => <div data-testid={testId} className={cx('wb-scroll', className)} style={style}>{children}</div>;
  const EmptyState = ({icon, title, subtitle, style, className, testId}) => <div data-testid={testId} className={cx('wb-empty', className)} style={style}>
      {icon ? <StepIcon icon={icon} size={44} color="#B0B8C4" /> : null}
      {title ? <div className="wb-empty-title">{title}</div> : null}
      {subtitle ? <div className="wb-empty-sub">{subtitle}</div> : null}
    </div>;
  const TextField = ({label, value, onChange, placeholder, multiline, rows, size = 'small', fullWidth, type = 'text', disabled, error, helperText, startIcon, testId, style, className, onKeyDown, min, max, step, ariaLabel}) => {
    const handle = e => {
      if (typeof onChange === 'function') onChange(e.target.value);
    };
    const shared = {
      value: value === undefined || value === null ? '' : value,
      onChange: handle,
      placeholder,
      disabled,
      onKeyDown,
      'data-testid': testId,
      'aria-label': ariaLabel || (typeof label === 'string' ? label : undefined),
      className: cx('wb-input', multiline && 'wb-input-multiline', error && 'wb-input-error')
    };
    return <label className={cx('wb-field', fullWidth && 'wb-field-full', size === 'tiny' && 'wb-field-tiny', className)} style={style}>
        {label ? <span className="wb-field-label">{label}</span> : null}
        <span className={cx('wb-input-wrap', startIcon && 'wb-input-has-icon')}>
          {startIcon ? <span className="wb-input-icon"><LucideIcon name={startIcon} size={15} /></span> : null}
          {multiline ? <textarea rows={rows || 3} {...shared} /> : <input type={type} min={min} max={max} step={step} {...shared} />}
        </span>
        {helperText ? <span className={cx('wb-field-help', error && 'wb-field-help-error')}>{helperText}</span> : null}
      </label>;
  };
  const Select = ({label, value, onChange, options, size = 'small', disabled, fullWidth, testId, style, className, placeholder}) => {
    const list = Array.isArray(options) ? options : [];
    const handle = e => {
      if (typeof onChange !== 'function') return;
      const raw = e.target.value;
      const hit = list.find(o => String(o && o.value) === raw);
      onChange(hit ? hit.value : raw);
    };
    return <label className={cx('wb-field', fullWidth && 'wb-field-full', size === 'tiny' && 'wb-field-tiny', className)} style={style}>
        {label ? <span className="wb-field-label">{label}</span> : null}
        <span className="wb-select-wrap">
          <select className="wb-select" disabled={disabled} value={value === undefined || value === null ? '' : String(value)} onChange={handle} data-testid={testId} aria-label={typeof label === 'string' ? label : undefined}>
            {placeholder ? <option value="">{placeholder}</option> : null}
            {list.map((o, i) => {
      const parts = [o && o.flag ? String(o.flag) + ' ' : '', String((o && o.label) === undefined ? o && o.value : o.label)];
      if (o && o.hint) parts.push('  — ' + o.hint);
      if (o && (o.cost || o.cost === 0)) parts.push('  $' + Number(o.cost).toFixed(2));
      return <option key={String((o && o.value) ?? i)} value={String(o && o.value)} disabled={!!(o && o.disabled)}>{parts.join('')}</option>;
    })}
          </select>
          <span className="wb-select-caret" aria-hidden="true"><LucideIcon name="chevron-down" size={14} /></span>
        </span>
      </label>;
  };
  const Switch = ({checked, onChange, label, sublabel, disabled, labelPlacement = 'start', testId, style, className, flag}) => {
    const toggle = () => {
      if (!disabled && typeof onChange === 'function') onChange(!checked);
    };
    const control = <button type="button" role="switch" aria-checked={!!checked} disabled={disabled} onClick={toggle} data-testid={testId} className={cx('wb-switch', checked && 'wb-switch-on', disabled && 'wb-switch-disabled')} aria-label={typeof label === 'string' ? label : 'toggle'}>
        <span className="wb-switch-thumb" />
      </button>;
    const text = <span className="wb-switch-text">
        <span className="wb-switch-label">{flag ? <span className="wb-flag">{flag} </span> : null}{label}</span>
        {sublabel ? <span className="wb-switch-sub">{sublabel}</span> : null}
      </span>;
    return <div className={cx('wb-switch-row', disabled && 'wb-row-disabled', className)} style={style}>
        {labelPlacement === 'start' ? text : null}
        {control}
        {labelPlacement === 'start' ? null : text}
      </div>;
  };
  const Slider = ({value, onChange, min = 0, max = 100, step = 1, marks, showValue, unit, disabled, testId, label, style, className}) => {
    const v = clampNum(Number(value), min, max);
    const pct = max > min ? (v - min) / (max - min) * 100 : 0;
    return <div className={cx('wb-slider-row', disabled && 'wb-row-disabled', className)} style={style}>
        {label || showValue ? <div className="wb-slider-head">
            <span className="wb-field-label">{label}</span>
            {showValue ? <span className="wb-slider-value">{v}{unit || ''}</span> : null}
          </div> : null}
        <input type="range" className="wb-slider" min={min} max={max} step={step} value={v} disabled={disabled} data-testid={testId} aria-label={typeof label === 'string' ? label : 'slider'} style={{
      '--wb-slider-pct': pct + '%'
    }} onChange={e => {
      if (typeof onChange === 'function') onChange(Number(e.target.value));
    }} />
        {Array.isArray(marks) && marks.length ? <div className="wb-slider-marks">{marks.map((m, i) => <span key={i} className="wb-slider-mark">{m && m.label !== undefined ? m.label : m && m.value}</span>)}</div> : null}
      </div>;
  };
  const RangeSlider = ({value, onChange, min = 0, max = 100, step = 1, lowerLabel, upperLabel, lowerColor = '#22C55E', middleColor = '#FF9500', upperColor = '#FF3B30', disabled, testId, label, style, className}) => {
    const pair = Array.isArray(value) ? value : [min, max];
    const lo = clampNum(Number(pair[0]), min, max);
    const hi = clampNum(Number(pair[1]), min, max);
    const span = max > min ? max - min : 1;
    const loPct = (lo - min) / span * 100;
    const hiPct = (hi - min) / span * 100;
    const emit = (nextLo, nextHi) => {
      if (typeof onChange === 'function') onChange([Math.min(nextLo, nextHi), Math.max(nextLo, nextHi)]);
    };
    return <div className={cx('wb-range', disabled && 'wb-row-disabled', className)} style={style} data-testid={testId}>
        {label ? <div className="wb-field-label">{label}</div> : null}
        <div className="wb-range-rail" style={{
      background: `linear-gradient(90deg, ${lowerColor} 0 ${loPct}%, ${middleColor} ${loPct}% ${hiPct}%, ${upperColor} ${hiPct}% 100%)`
    }} />
        <div className="wb-range-inputs">
          <input type="range" min={min} max={max} step={step} value={lo} disabled={disabled} aria-label={(lowerLabel || 'lower') + ' bound'} onChange={e => emit(Number(e.target.value), hi)} />
          <input type="range" min={min} max={max} step={step} value={hi} disabled={disabled} aria-label={(upperLabel || 'upper') + ' bound'} onChange={e => emit(lo, Number(e.target.value))} />
        </div>
        <div className="wb-range-chips">
          <Chip color="success">{(lowerLabel || 'Low') + ' < ' + lo}</Chip>
          <Chip color="warning">{lo + ' – ' + hi}</Chip>
          <Chip color="error">{(upperLabel || 'High') + ' > ' + hi}</Chip>
        </div>
      </div>;
  };
  const Tabs = ({value, onChange, tabs, pill = true, style, className, testIdPrefix}) => {
    const list = Array.isArray(tabs) ? tabs : [];
    return <div role="tablist" className={cx('wb-tabs', pill && 'wb-tabs-pill', className)} style={style}>
        {list.map(t => {
      const val = t && t.value !== undefined ? t.value : t;
      const active = String(val) === String(value);
      return <button key={String(val)} type="button" role="tab" aria-selected={active} data-testid={t && t.testId || (testIdPrefix ? testIdPrefix + String(val) : undefined)} className={cx('wb-tab', active && 'wb-tab-active')} onClick={() => {
        if (typeof onChange === 'function') onChange(val);
      }}>{t && t.label !== undefined ? t.label : String(val)}</button>;
    })}
      </div>;
  };
  const Dialog = ({open, onClose, title, children, actions, width = 520, testId, className}) => {
    useEffect(() => {
      if (!open) return undefined;
      const onKey = e => {
        if (e.key === 'Escape' && typeof onClose === 'function') onClose();
      };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [open, onClose]);
    if (!open) return null;
    return <div className="wb-dialog-overlay" onMouseDown={e => {
      if (e.target === e.currentTarget && typeof onClose === 'function') onClose();
    }}>
        <div role="dialog" aria-modal="true" aria-label={typeof title === 'string' ? title : 'dialog'} data-testid={testId} className={cx('wb-dialog', className)} style={{
      width,
      maxWidth: 'calc(100vw - 48px)'
    }}>
          <div className="wb-dialog-head">
            <div className="wb-dialog-title">{title}</div>
            <IconButton name="x" size={16} label="Close" onClick={onClose} testId={testId ? testId + '-close' : undefined} />
          </div>
          <div className="wb-dialog-body">{children}</div>
          {actions ? <div className="wb-dialog-actions">{actions}</div> : null}
        </div>
      </div>;
  };
  const Menu = ({open, onClose, items, style, className}) => {
    if (!open) return null;
    const list = Array.isArray(items) ? items : [];
    return <div className={cx('wb-menu', className)} style={style} role="menu">
        {list.map((it, i) => <button key={i} type="button" role="menuitem" className="wb-menu-item" onClick={() => {
      if (it && typeof it.onClick === 'function') it.onClick();
      if (typeof onClose === 'function') onClose();
    }}>
            {it && it.icon ? <LucideIcon name={it.icon} size={14} /> : null}{it && it.label}
          </button>)}
      </div>;
  };
  const ToastContext = React.createContext(null);
  const ToastProvider = ({children}) => {
    const [toasts, setToasts] = useState([]);
    const seq = useRef(0);
    const styleRef = useRef(false);
    useEffect(() => {
      if (styleRef.current) return;
      styleRef.current = true;
      try {
        if (!document.getElementById('wb-ui-css')) {
          const el = document.createElement('style');
          el.id = 'wb-ui-css';
          el.textContent = UI_CSS;
          document.head.appendChild(el);
        }
      } catch (e) {}
    }, []);
    const push = useCallback((message, opts) => {
      const type = opts && opts.type || 'info';
      seq.current += 1;
      const id = 'wb-toast-' + seq.current;
      setToasts(prev => prev.concat([{
        id,
        message: String(message),
        type
      }]));
      try {
        window.setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), opts && opts.duration || 3500);
      } catch (e) {}
      return id;
    }, []);
    const value = useMemo(() => ({
      toast: push
    }), [push]);
    return <ToastContext.Provider value={value}>
        {children}
        <div className="wb-toasts" data-testid="wb-toasts" aria-live="polite">
          {toasts.map(t => <div key={t.id} className={cx('wb-toast', 'wb-toast-' + t.type)} data-testid="wb-toast">
              <LucideIcon name={t.type === 'error' ? 'circle-alert' : t.type === 'success' ? 'circle-check' : 'info'} size={15} />
              <span>{t.message}</span>
            </div>)}
        </div>
      </ToastContext.Provider>;
  };
  const useToast = () => {
    const ctx = useContext(ToastContext);
    return ctx && ctx.toast || noop;
  };
  const FeatureLockedDialog = ({open, onClose, feature}) => <Dialog open={open} onClose={onClose} title="Feature not activated" width={460} testId="wb-feature-locked-dialog" actions={<>
          <Button variant="text" color="inherit" onClick={onClose}>Close</Button>
          <Button variant="contained" onClick={() => {
    try {
      window.location.href = 'mailto:sales@deepidv.com';
    } catch (e) {}
    if (typeof onClose === 'function') onClose();
  }}>Contact sales</Button>
        </>}>
      <p className="wb-dialog-text">{(feature || 'This feature') + " hasn't been activated for this account yet. Contact our sales team to enable it."}</p>
    </Dialog>;
  return {
    LucideIcon,
    StepIcon,
    Button,
    IconButton,
    TextField,
    Select,
    Switch,
    Slider,
    RangeSlider,
    Tabs,
    Chip,
    Alert,
    Divider,
    Tooltip,
    Dialog,
    Menu,
    Scrollbar,
    ToastProvider,
    useToast,
    FeatureLockedDialog,
    Spinner,
    EmptyState,
    iconUrl,
    cx
  };
};

export const STEP_PROPERTY_GROUPS = {
  'id-verification': [{
    groupId: 'document-types',
    groupName: 'Accepted document types',
    groupTooltip: 'At least one document type must stay enabled.',
    groupIcon: 'id-card',
    properties: [{
      id: 'passport',
      label: 'Passport',
      sublabel: 'ICAO 9303 machine-readable passports',
      type: 'boolean',
      value: true
    }, {
      id: 'drivers-license',
      label: "Driver's license",
      sublabel: 'Front and back are captured',
      type: 'boolean',
      value: true
    }, {
      id: 'national-id',
      label: 'National ID card',
      type: 'boolean',
      value: true
    }, {
      id: 'residence-permit',
      label: 'Residence permit',
      sublabel: 'Includes biometric residence cards',
      type: 'boolean',
      value: false
    }, {
      id: 'id-count',
      label: 'Documents required',
      type: 'select',
      value: 1,
      options: [{
        value: 1,
        label: '1 document',
        hint: 'Standard assurance'
      }, {
        value: 2,
        label: '2 documents',
        hint: 'Higher assurance',
        cost: 0.5
      }]
    }]
  }, {
    groupId: 'accepted-countries',
    groupName: 'Issuing countries',
    groupTooltip: 'Documents from any other country are rejected before capture.',
    groupIcon: 'globe',
    properties: [{
      id: 'countries',
      label: 'Accepted issuing countries',
      type: 'country-multiselect',
      value: ['US', 'CA', 'GB', 'AU', 'DE', 'FR']
    }, {
      id: 'restrict-countries',
      label: 'Restrict by country',
      sublabel: 'Turn on to configure allow/block rules below',
      type: 'boolean',
      value: false
    }]
  }, {
    groupId: 'country-restrictions',
    groupName: 'Country restrictions',
    groupTooltip: 'Shown only while "Restrict by country" is on.',
    groupIcon: 'shield-ban',
    parentToggle: 'restrict-countries',
    properties: [{
      id: 'restriction-mode',
      label: 'Restriction mode',
      type: 'select',
      value: 'allowlist',
      options: [{
        value: 'allowlist',
        label: 'Allow list',
        hint: 'Only the selected countries pass'
      }, {
        value: 'blocklist',
        label: 'Block list',
        hint: 'Everything except the selected countries passes'
      }]
    }, {
      id: 'block-unlisted',
      label: 'Block unlisted documents',
      sublabel: 'Fail instead of routing to manual review',
      type: 'boolean',
      value: true
    }, {
      id: 'restriction-message',
      label: 'Message shown to the applicant',
      type: 'text',
      value: 'We are unable to accept documents issued in your country at this time.'
    }]
  }, {
    groupId: 'capture',
    groupName: 'Capture',
    groupTooltip: 'Controls how the document image is collected.',
    groupIcon: 'camera',
    properties: [{
      id: 'require-back-side',
      label: 'Require back side',
      sublabel: 'Reads the barcode / MRZ on the reverse',
      type: 'boolean',
      value: true
    }, {
      id: 'auto-capture',
      label: 'Auto-capture',
      sublabel: 'Take the photo when the frame is steady',
      type: 'boolean',
      value: true
    }, {
      id: 'allow-upload',
      label: 'Allow file upload',
      sublabel: 'Let applicants upload a scan instead of using the camera',
      type: 'boolean',
      value: false
    }, {
      id: 'upload-fraud-check',
      label: 'Screen uploads for tampering',
      sublabel: 'Only applies to uploaded files',
      type: 'boolean',
      value: true,
      requirement: 'allow-upload'
    }, {
      id: 'min-quality',
      label: 'Minimum image quality',
      type: 'slider',
      value: 70,
      min: 0,
      max: 100,
      step: 5,
      unit: '%',
      showValue: true,
      marks: [{
        value: 0,
        label: 'Any'
      }, {
        value: 50,
        label: 'Fair'
      }, {
        value: 75,
        label: 'Good'
      }, {
        value: 100,
        label: 'Best'
      }]
    }]
  }, {
    groupId: 'id-classification-settings',
    groupName: 'ID classification (legacy)',
    groupTooltip: 'Legacy group — removed from the panel by REMOVED_PROPERTY_GROUPS.',
    groupIcon: 'tags',
    properties: [{
      id: 'auto-classify',
      label: 'Auto-classify document type',
      type: 'boolean',
      value: true
    }, {
      id: 'classifier-model',
      label: 'Classifier model',
      type: 'select',
      value: 'v2',
      options: [{
        value: 'v1',
        label: 'Classifier v1'
      }, {
        value: 'v2',
        label: 'Classifier v2'
      }]
    }]
  }, {
    groupId: 'expiry',
    groupName: 'Expiry',
    groupIcon: 'calendar',
    properties: [{
      id: 'reject-expired',
      label: 'Reject expired documents',
      type: 'boolean',
      value: true
    }, {
      id: 'grace-days',
      label: 'Grace period after expiry',
      type: 'select',
      value: 0,
      requirement: {
        id: 'reject-expired',
        equals: true
      },
      options: [{
        value: 0,
        label: 'No grace period',
        hint: 'Strict — recommended for regulated flows'
      }, {
        value: 30,
        label: '30 days',
        hint: 'Accept recently expired documents'
      }, {
        value: 90,
        label: '90 days',
        hint: 'Lenient — may not satisfy audits'
      }]
    }]
  }, {
    groupId: 'document-matrix',
    groupName: 'Supported documents by country',
    groupTooltip: 'Fine-tune which document types are accepted per issuing country.',
    groupIcon: 'list-checks',
    properties: [{
      id: 'supported-documents',
      label: 'Country / document matrix',
      type: 'country-id-table',
      value: [{
        code: 'US',
        documents: ['passport', 'drivers-license', 'national-id']
      }, {
        code: 'CA',
        documents: ['passport', 'drivers-license']
      }, {
        code: 'GB',
        documents: ['passport', 'drivers-license', 'residence-permit']
      }]
    }]
  }, {
    groupId: 'notes',
    groupName: 'Operator notes',
    groupIcon: 'message-square-text',
    properties: [{
      id: 'schema-version',
      label: 'Schema version',
      type: 'hidden',
      value: 3
    }, {
      id: 'operator-notes',
      label: 'Notes for reviewers',
      sublabel: 'Internal only — never shown to applicants',
      type: 'text',
      value: ''
    }]
  }],
  'face-liveness': [{
    groupId: 'liveness',
    groupName: 'Liveness',
    groupTooltip: 'Passive liveness runs silently on the selfie; active liveness asks for a head-turn.',
    groupIcon: 'scan-face',
    properties: [{
      id: 'mode',
      label: 'Liveness mode',
      type: 'select',
      value: 'passive',
      options: [{
        value: 'passive',
        label: 'Passive',
        hint: 'Silent capture, no user action',
        cost: 0.05
      }, {
        value: 'active',
        label: 'Active',
        hint: 'Head-turn challenge',
        cost: 0.07
      }]
    }, {
      id: 'challenge-type',
      label: 'Active challenge',
      type: 'select',
      value: 'head-turn',
      requirement: {
        id: 'mode',
        equals: 'active'
      },
      options: [{
        value: 'head-turn',
        label: 'Head turn'
      }, {
        value: 'smile',
        label: 'Smile'
      }, {
        value: 'nod',
        label: 'Nod'
      }]
    }, {
      id: 'threshold',
      label: 'Confidence threshold',
      type: 'slider',
      value: 85,
      min: 50,
      max: 99,
      step: 1,
      unit: '%',
      showValue: true
    }, {
      id: 'retry-allowed',
      label: 'Allow retries',
      sublabel: 'Up to 3 attempts per session',
      type: 'boolean',
      value: true
    }, {
      id: 'require-blink',
      label: 'Require a blink on retry',
      sublabel: 'Extra anti-replay signal for repeat attempts',
      type: 'boolean',
      value: false,
      requirement: 'retry-allowed'
    }]
  }],
  'deepfake-detection': [{
    groupId: 'env-weights',
    groupName: 'Signal weights',
    groupTooltip: 'How much each media channel contributes to the final score. The three weights always sum to 100.',
    groupIcon: 'sliders-horizontal',
    properties: [{
      id: 'image-weight',
      label: 'Image',
      type: 'slider',
      value: 40,
      min: 0,
      max: 100,
      step: 5,
      unit: '%',
      showValue: true,
      linkedGroup: 'env-weights'
    }, {
      id: 'video-weight',
      label: 'Video',
      type: 'slider',
      value: 40,
      min: 0,
      max: 100,
      step: 5,
      unit: '%',
      showValue: true,
      linkedGroup: 'env-weights'
    }, {
      id: 'audio-weight',
      label: 'Audio',
      type: 'slider',
      value: 20,
      min: 0,
      max: 100,
      step: 5,
      unit: '%',
      showValue: true,
      linkedGroup: 'env-weights'
    }, {
      id: 'summary',
      label: 'Weight distribution',
      type: 'weight-display',
      value: null,
      linkedGroup: 'env-weights'
    }]
  }, {
    groupId: 'sensitivity',
    groupName: 'Decision bands',
    groupTooltip: 'Scores below the lower bound pass, above the upper bound fail, in between go to review.',
    groupIcon: 'gauge',
    properties: [{
      id: 'sensitivity',
      label: 'Deepfake score bands',
      type: 'range',
      value: [35, 70],
      min: 0,
      max: 100,
      step: 1,
      lowerLabel: 'Likely genuine',
      upperLabel: 'Likely deepfake',
      lowerColor: '#22C55E',
      middleColor: '#F59E0B',
      upperColor: '#FF5630'
    }, {
      id: 'block-on-fail',
      label: 'Block the session on a fail',
      sublabel: 'Otherwise the result is only flagged',
      type: 'boolean',
      value: false
    }]
  }],
  'age-estimation': [{
    groupId: 'age-policy',
    groupName: 'Age policy',
    groupTooltip: 'Pick a country and a regulated category to load its statutory minimum age.',
    groupIcon: 'calendar',
    properties: [{
      id: 'country-category',
      label: 'Country and category',
      type: 'age-restriction-country-category',
      value: {
        country: 'US',
        category: 'alcohol'
      },
      options: [{
        value: 'alcohol',
        label: 'Alcohol'
      }, {
        value: 'tobacco',
        label: 'Tobacco and vaping'
      }, {
        value: 'gambling',
        label: 'Gambling'
      }, {
        value: 'adult-content',
        label: 'Adult content'
      }, {
        value: 'social-media',
        label: 'Social media'
      }]
    }, {
      id: 'country-category-minimum-age',
      label: 'Statutory minimum age',
      type: 'hidden',
      value: 21
    }, {
      id: 'default-minimum-age',
      label: 'Fallback minimum age',
      sublabel: 'Used when the country has no statutory minimum for the category',
      type: 'slider',
      value: 18,
      min: 13,
      max: 25,
      step: 1,
      unit: 'yrs',
      showValue: true,
      marks: [{
        value: 13,
        label: '13'
      }, {
        value: 16,
        label: '16'
      }, {
        value: 18,
        label: '18'
      }, {
        value: 21,
        label: '21'
      }, {
        value: 25,
        label: '25'
      }]
    }, {
      id: 'escalate-borderline',
      label: 'Escalate borderline results to ID check',
      sublabel: 'Within 3 years of the minimum age',
      type: 'boolean',
      value: true
    }]
  }],
  'phone-verification': [{
    groupId: 'call',
    groupName: 'Call',
    groupTooltip: 'An automated voice call reads the script and confirms the applicant controls the number.',
    groupIcon: 'phone-call',
    properties: [{
      id: 'language',
      label: 'Call language',
      type: 'select',
      value: 'en-US',
      options: [{
        value: 'en-US',
        label: 'English (US)',
        flag: 'US'
      }, {
        value: 'en-GB',
        label: 'English (UK)',
        flag: 'GB'
      }, {
        value: 'fr-CA',
        label: 'French (Canada)',
        flag: 'CA'
      }, {
        value: 'es-MX',
        label: 'Spanish (Mexico)',
        flag: 'MX'
      }, {
        value: 'de-DE',
        label: 'German',
        flag: 'DE'
      }]
    }, {
      id: 'script',
      label: 'Spoken script',
      sublabel: 'Use {{code}} where the one-time code should be read',
      type: 'text',
      value: 'Hello, this is a verification call. Your code is {{code}}. Please enter it on the screen to continue.',
      required: true
    }, {
      id: 'voicemail-retry',
      label: 'Retry if voicemail answers',
      type: 'boolean',
      value: true
    }, {
      id: 'max-attempts',
      label: 'Maximum call attempts',
      type: 'select',
      value: 2,
      options: [{
        value: 1,
        label: '1 attempt'
      }, {
        value: 2,
        label: '2 attempts'
      }, {
        value: 3,
        label: '3 attempts'
      }]
    }]
  }, {
    groupId: 'advanced',
    groupName: 'Advanced',
    groupIcon: 'settings',
    properties: [{
      id: 'caller-id',
      label: 'Caller ID',
      type: 'select',
      value: 'shared',
      options: [{
        value: 'shared',
        label: 'Shared deepidv number'
      }, {
        value: 'dedicated',
        label: 'Dedicated number',
        hint: 'Provisioned per organization',
        cost: 0.1
      }]
    }, {
      id: 'keypress-confirm',
      label: 'Require a keypress before reading the code',
      sublabel: 'Defeats voicemail transcription',
      type: 'boolean',
      value: false
    }, {
      id: 'record-call',
      label: 'Record calls',
      sublabel: 'Available on Enterprise plans',
      type: 'boolean',
      value: false,
      locked: true
    }]
  }],
  'address-verification': [{
    groupId: 'policy',
    groupName: 'Address policy',
    groupIcon: 'map-pin',
    properties: [{
      id: 'countries',
      label: 'Supported countries',
      type: 'country-multiselect',
      value: ['US', 'CA', 'GB']
    }, {
      id: 'geo-quiz',
      label: 'Geo-knowledge quiz',
      sublabel: 'Ask about nearby landmarks the applicant should know',
      type: 'boolean',
      value: true
    }, {
      id: 'proof-of-residency',
      label: 'Require proof of residency',
      sublabel: 'Utility bill or bank letter under 90 days old',
      type: 'boolean',
      value: false
    }, {
      id: 'max-attempts',
      label: 'Quiz attempts',
      type: 'select',
      value: 2,
      requirement: 'geo-quiz',
      options: [{
        value: 1,
        label: '1 attempt'
      }, {
        value: 2,
        label: '2 attempts'
      }, {
        value: 3,
        label: '3 attempts'
      }]
    }]
  }],
  'passport-nfc-scanner': [{
    groupId: 'chip',
    groupName: 'Chip reading',
    groupIcon: 'nfc',
    properties: [{
      id: 'passive-authentication',
      label: 'Passive authentication',
      sublabel: 'Validate the chip signature against the issuing CSCA',
      type: 'boolean',
      value: true
    }, {
      id: 'clone-detection',
      label: 'Active clone detection',
      sublabel: 'Chip authentication / active authentication',
      type: 'boolean',
      value: false,
      locked: true
    }, {
      id: 'fallback',
      label: 'If the device has no NFC',
      type: 'select',
      value: 'optical',
      options: [{
        value: 'optical',
        label: 'Fall back to optical ID scan'
      }, {
        value: 'fail',
        label: 'Fail the step'
      }]
    }]
  }],
  'tokenized-age-verification': [{
    groupId: 'token',
    groupName: 'Age token',
    groupTooltip: 'Issues a signed over/under assertion instead of sharing the document.',
    groupIcon: 'key-round',
    properties: [{
      id: 'assertion',
      label: 'Assertion',
      type: 'select',
      value: 'over-18',
      options: [{
        value: 'over-18',
        label: 'Over 18'
      }, {
        value: 'over-21',
        label: 'Over 21'
      }, {
        value: 'over-65',
        label: 'Over 65'
      }]
    }, {
      id: 'token-ttl',
      label: 'Token lifetime',
      type: 'select',
      value: '30d',
      options: [{
        value: '24h',
        label: '24 hours'
      }, {
        value: '30d',
        label: '30 days'
      }, {
        value: '1y',
        label: '1 year',
        hint: 'Enterprise only'
      }]
    }, {
      id: 'reusable',
      label: 'Allow reuse across your apps',
      type: 'boolean',
      value: true
    }]
  }],
  'accessibility-mode': [{
    groupId: 'assistance',
    groupName: 'Assistance',
    groupIcon: 'accessibility',
    properties: [{
      id: 'voice-guidance',
      label: 'Voice guidance',
      sublabel: 'Read on-screen instructions aloud',
      type: 'boolean',
      value: true
    }, {
      id: 'extended-timeouts',
      label: 'Extended timeouts',
      sublabel: 'Triple the default capture timers',
      type: 'boolean',
      value: true
    }, {
      id: 'agent-assist',
      label: 'Offer a live agent',
      sublabel: 'Coming soon',
      type: 'boolean',
      value: false,
      disabled: true
    }]
  }],
  'injection-detection': [{
    groupId: 'policy',
    groupName: 'Injection policy',
    groupTooltip: 'Detects virtual cameras, emulators and injected media streams.',
    groupIcon: 'shield-alert',
    properties: [{
      id: 'virtual-camera',
      label: 'Detect virtual cameras',
      type: 'boolean',
      value: true
    }, {
      id: 'emulator',
      label: 'Detect emulators and rooted devices',
      type: 'boolean',
      value: true
    }, {
      id: 'action',
      label: 'On detection',
      type: 'select',
      value: 'step-up',
      options: [{
        value: 'flag',
        label: 'Flag only',
        hint: 'Continue and mark the session'
      }, {
        value: 'step-up',
        label: 'Step up',
        hint: 'Require an additional check'
      }, {
        value: 'block',
        label: 'Block',
        hint: 'End the session immediately'
      }]
    }]
  }],
  proofcall: [{
    groupId: 'questions',
    groupName: 'Reference questions',
    groupTooltip: 'Estimate: about 1 minute per question at $0.50 per minute.',
    groupIcon: 'list-ordered',
    properties: [{
      id: 'questions',
      label: 'Questions the AI caller asks',
      type: 'proofcall-questions',
      minutesPerQuestion: 1,
      costPerMinute: 0.5,
      value: [{
        id: 'q1',
        text: 'Can you confirm the dates this person worked with you?',
        required: true
      }, {
        id: 'q2',
        text: 'What was their role or title?',
        required: true
      }, {
        id: 'q3',
        text: 'Would you work with them again?',
        required: false
      }]
    }, {
      id: 'leave-voicemail',
      label: 'Leave a voicemail if unanswered',
      type: 'boolean',
      value: true
    }]
  }],
  consent: [{
    groupId: 'copy',
    groupName: 'Consent copy',
    groupIcon: 'scroll-text',
    properties: [{
      id: 'consent-text',
      label: 'Consent text',
      sublabel: 'Shown before any data is collected',
      type: 'text',
      value: 'I consent to the collection and processing of my identity information for the purpose of verifying my identity, as described in the Privacy Notice.',
      required: true
    }, {
      id: 'require-scroll',
      label: 'Require scrolling to the end',
      sublabel: 'The Accept button stays disabled until the text has been read',
      type: 'boolean',
      value: true
    }]
  }],
  'custom-prompt': [{
    groupId: 'prompts',
    groupName: 'Photo prompts',
    groupTooltip: 'Each prompt asks the applicant for one photo.',
    groupIcon: 'camera',
    properties: [{
      id: 'prompts',
      label: 'Prompts',
      type: 'text-list',
      value: [{
        text: 'Take a photo of the front of your building, including the street number.'
      }, {
        text: 'Hold your ID next to your face and take a photo.'
      }],
      required: true
    }, {
      id: 'allow-gallery',
      label: 'Allow photos from the gallery',
      sublabel: 'Otherwise the camera is required',
      type: 'boolean',
      value: false
    }]
  }],
  'white-label': [{
    groupId: 'branding',
    groupName: 'Branding',
    groupIcon: 'palette',
    properties: [{
      id: 'show-powered-by',
      label: 'Show "Powered by deepidv"',
      type: 'boolean',
      value: true
    }, {
      id: 'custom-domain',
      label: 'Custom domain',
      sublabel: 'verify.yourbrand.com — Enterprise only',
      type: 'boolean',
      value: false,
      locked: true
    }, {
      id: 'support-email',
      label: 'Support email shown to applicants',
      type: 'text',
      value: 'support@example.com'
    }]
  }],
  'document-upload': [{
    groupId: 'documents',
    groupName: 'Requested documents',
    groupIcon: 'upload',
    properties: [{
      id: 'required-documents',
      label: 'Documents',
      type: 'doc-upload',
      required: true,
      value: [{
        id: 'proof-of-address',
        name: 'Proof of address',
        description: 'Utility bill or bank statement dated within the last 90 days',
        required: true,
        acceptedFormats: ['pdf', 'jpg', 'png']
      }, {
        id: 'pay-stub',
        name: 'Recent pay stub',
        description: 'Most recent pay stub or employment letter',
        required: false,
        acceptedFormats: ['pdf', 'jpg', 'png']
      }]
    }, {
      id: 'minimum-optional-uploads',
      label: 'Minimum optional uploads',
      sublabel: 'How many of the optional documents must be provided',
      type: 'slider',
      value: 0,
      min: 0,
      max: 5,
      step: 1,
      showValue: true
    }, {
      id: 'fraud-detection',
      label: 'AI fraud detection',
      sublabel: 'Screen uploads for edits and templates',
      type: 'boolean',
      value: true
    }]
  }, {
    groupId: 'extraction',
    groupName: 'Data extraction',
    groupTooltip: 'Fields are read from each document and returned in the result.',
    groupIcon: 'file-search',
    properties: [{
      id: 'rules',
      label: 'Extraction rules',
      type: 'data-extraction',
      value: [{
        id: 'rule-1',
        document: 'proof-of-address',
        fields: ['Full name', 'Address', 'Issue date']
      }]
    }]
  }],
  'e-signature': [{
    groupId: 'template',
    groupName: 'Template',
    groupIcon: 'file-text',
    properties: [{
      id: 'template-id',
      label: 'Document template',
      type: 'esign-template-select',
      value: 'tpl-nda',
      required: true,
      options: [{
        value: 'tpl-nda',
        label: 'Mutual NDA',
        hint: '3 pages'
      }, {
        value: 'tpl-lease',
        label: 'Residential lease agreement',
        hint: '12 pages'
      }, {
        value: 'tpl-dpa',
        label: 'Data processing consent',
        hint: '1 page'
      }]
    }, {
      id: 'signing-order',
      label: 'Signing order',
      type: 'select',
      value: 'applicant-first',
      options: [{
        value: 'applicant-first',
        label: 'Applicant signs first'
      }, {
        value: 'countersign-first',
        label: 'Your team countersigns first'
      }]
    }]
  }, {
    groupId: 'document',
    groupName: 'Field placement',
    groupTooltip: 'Positions are fractions of the page width and height.',
    groupIcon: 'pen-line',
    properties: [{
      id: 'fields',
      label: 'Signature fields',
      type: 'esign-document-setup',
      value: [{
        id: 'sig-1',
        type: 'signature',
        label: 'Applicant signature',
        page: 1,
        x: 0.12,
        y: 0.78,
        required: true
      }, {
        id: 'date-1',
        type: 'date',
        label: 'Date signed',
        page: 1,
        x: 0.58,
        y: 0.78,
        required: true
      }]
    }, {
      id: 'email-copy',
      label: 'Email a signed copy to the applicant',
      type: 'boolean',
      value: true
    }]
  }],
  'custom-form': [{
    groupId: 'form',
    groupName: 'Form pages',
    groupTooltip: 'Up to 5 fields per page. Dropdown options are limited to 10.',
    groupIcon: 'layout-list',
    properties: [{
      id: 'pages',
      label: 'Pages and fields',
      type: 'object',
      required: true,
      value: {
        pages: [{
          id: 'page-1',
          title: 'About you',
          fields: [{
            id: 'field-1',
            type: 'short-text',
            label: 'Full legal name',
            required: true
          }, {
            id: 'field-2',
            type: 'dropdown',
            label: 'Employment status',
            required: false,
            options: {
              option1: 'Employed',
              option2: 'Self-employed',
              option3: 'Student',
              option4: 'Retired'
            }
          }]
        }]
      }
    }, {
      id: 'show-progress',
      label: 'Show page progress',
      type: 'boolean',
      value: true
    }]
  }],
  'bank-statement-upload': [{
    groupId: 'sync',
    groupName: 'Open banking sync',
    groupIcon: 'landmark',
    properties: [{
      id: 'history-months',
      label: 'Statement history',
      type: 'select',
      value: 3,
      options: [{
        value: 1,
        label: '1 month'
      }, {
        value: 3,
        label: '3 months'
      }, {
        value: 6,
        label: '6 months'
      }, {
        value: 12,
        label: '12 months',
        cost: 0.4
      }]
    }, {
      id: 'allow-pdf-fallback',
      label: 'Allow PDF upload if the bank is unsupported',
      type: 'boolean',
      value: true
    }, {
      id: 'include-joint-accounts',
      label: 'Include joint accounts',
      type: 'boolean',
      value: false
    }]
  }],
  'biometric-document-transfer': [{
    groupId: 'transfer',
    groupName: 'Transfer',
    groupIcon: 'lock',
    properties: [{
      id: 'unlock-method',
      label: 'Unlock with',
      type: 'select',
      value: 'face',
      options: [{
        value: 'face',
        label: 'Face match'
      }, {
        value: 'face-and-id',
        label: 'Face match and ID',
        hint: 'Highest assurance'
      }]
    }, {
      id: 'expires-after',
      label: 'Link expires after',
      type: 'select',
      value: '7d',
      options: [{
        value: '24h',
        label: '24 hours'
      }, {
        value: '7d',
        label: '7 days'
      }, {
        value: '30d',
        label: '30 days'
      }]
    }, {
      id: 'watermark',
      label: 'Watermark downloads with the recipient name',
      type: 'boolean',
      value: true
    }]
  }],
  'pep-sanctions': [{
    groupId: 'lists',
    groupName: 'Screening lists',
    groupIcon: 'shield-check',
    properties: [{
      id: 'sources',
      label: 'Sources to screen',
      type: 'multiselect-table',
      value: ['ofac', 'un', 'eu', 'uk-hmt'],
      options: [{
        value: 'ofac',
        label: 'OFAC',
        hint: 'US Office of Foreign Assets Control',
        flag: 'US'
      }, {
        value: 'un',
        label: 'UN',
        hint: 'United Nations Security Council consolidated list'
      }, {
        value: 'eu',
        label: 'EU',
        hint: 'European Union consolidated financial sanctions',
        flag: 'EU'
      }, {
        value: 'uk-hmt',
        label: 'UK HMT',
        hint: 'His Majesty’s Treasury sanctions list',
        flag: 'GB'
      }, {
        value: 'interpol',
        label: 'Interpol',
        hint: 'Red and diffusion notices'
      }]
    }, {
      id: 'include-rca',
      label: 'Include relatives and close associates',
      type: 'boolean',
      value: true
    }]
  }, {
    groupId: 'matching',
    groupName: 'Matching',
    groupTooltip: 'Lower thresholds catch more spelling variants but create more false positives.',
    groupIcon: 'search-check',
    properties: [{
      id: 'match-threshold',
      label: 'Match threshold',
      type: 'slider',
      value: 85,
      min: 60,
      max: 100,
      step: 5,
      unit: '%',
      showValue: true,
      marks: [{
        value: 60,
        label: 'Broad'
      }, {
        value: 85,
        label: 'Balanced'
      }, {
        value: 100,
        label: 'Exact'
      }]
    }, {
      id: 'fuzzy',
      label: 'Fuzzy name matching',
      type: 'select',
      value: 'low',
      options: [{
        value: 'off',
        label: 'Off'
      }, {
        value: 'low',
        label: 'Low',
        hint: 'Transliteration and diacritics'
      }, {
        value: 'high',
        label: 'High',
        hint: 'Phonetic matches and reordered names'
      }]
    }, {
      id: 'ongoing-monitoring',
      label: 'Ongoing monitoring',
      sublabel: 'Re-screen automatically after onboarding',
      type: 'boolean',
      value: false
    }, {
      id: 'monitoring-frequency',
      label: 'Re-screen every',
      type: 'select',
      value: 'daily',
      requirement: 'ongoing-monitoring',
      options: [{
        value: 'daily',
        label: 'Day',
        cost: 0.02
      }, {
        value: 'weekly',
        label: 'Week',
        cost: 0.01
      }, {
        value: 'monthly',
        label: 'Month'
      }]
    }]
  }],
  'adverse-media': [{
    groupId: 'categories',
    groupName: 'Topics',
    groupIcon: 'newspaper',
    properties: [{
      id: 'topics',
      label: 'Topics to flag',
      type: 'multiselect-table',
      value: ['fraud', 'money-laundering', 'terrorism-financing', 'corruption'],
      options: [{
        value: 'fraud',
        label: 'Fraud',
        hint: 'Fraud, forgery and embezzlement'
      }, {
        value: 'money-laundering',
        label: 'Money laundering'
      }, {
        value: 'terrorism-financing',
        label: 'Terrorism financing'
      }, {
        value: 'corruption',
        label: 'Corruption and bribery'
      }, {
        value: 'organised-crime',
        label: 'Organised crime'
      }, {
        value: 'sanctions-evasion',
        label: 'Sanctions evasion'
      }, {
        value: 'cybercrime',
        label: 'Cybercrime'
      }]
    }]
  }, {
    groupId: 'lookback',
    groupName: 'Lookback window',
    groupIcon: 'clock',
    properties: [{
      id: 'years',
      label: 'Search articles from the last',
      type: 'select',
      value: 5,
      options: [{
        value: 1,
        label: '1 year'
      }, {
        value: 3,
        label: '3 years'
      }, {
        value: 5,
        label: '5 years'
      }, {
        value: 10,
        label: '10 years',
        hint: 'Slower — searches archived sources'
      }]
    }, {
      id: 'english-only',
      label: 'English-language sources only',
      type: 'boolean',
      value: false
    }]
  }],
  'aml-status': [{
    groupId: 'watchlists',
    groupName: 'Watchlists',
    groupIcon: 'shield-check',
    properties: [{
      id: 'fincen-314a',
      label: 'FinCEN 314(a)',
      type: 'boolean',
      value: true
    }, {
      id: 'fintrac',
      label: 'FINTRAC',
      type: 'boolean',
      value: true,
      flag: 'CA'
    }, {
      id: 'include-historical',
      label: 'Include delisted entries',
      sublabel: 'Not yet available',
      type: 'boolean',
      value: false,
      disabled: true
    }]
  }],
  'credit-check': [{
    groupId: 'report',
    groupName: 'Credit report',
    groupTooltip: 'A hard pull is recorded on the applicant’s credit file and requires their consent.',
    groupIcon: 'credit-card',
    properties: [{
      id: 'variant',
      label: 'Pull type',
      type: 'select',
      value: 'soft',
      options: [{
        value: 'soft',
        label: 'Soft pull',
        hint: 'No impact on the applicant’s score',
        cost: 5.0
      }, {
        value: 'hard',
        label: 'Hard pull',
        hint: 'Full bureau file, recorded inquiry',
        cost: 10.0
      }]
    }, {
      id: 'hard-pull-consent',
      label: 'Collect written consent in-flow',
      sublabel: 'Required for hard pulls in most jurisdictions',
      type: 'boolean',
      value: true,
      requirement: {
        id: 'variant',
        notEquals: 'soft'
      }
    }, {
      id: 'include-trade-lines',
      label: 'Include trade lines',
      sublabel: 'Open accounts, balances and payment history',
      type: 'boolean',
      value: true
    }, {
      id: 'bureau',
      label: 'Bureau',
      type: 'select',
      value: 'auto',
      options: [{
        value: 'auto',
        label: 'Automatic by country'
      }, {
        value: 'equifax',
        label: 'Equifax'
      }, {
        value: 'transunion',
        label: 'TransUnion'
      }]
    }]
  }],
  'business-credit-check': [{
    groupId: 'report',
    groupName: 'Business report',
    groupIcon: 'building-2',
    properties: [{
      id: 'payment-trends',
      label: 'Include payment trends',
      type: 'boolean',
      value: true
    }, {
      id: 'legal-filings',
      label: 'Include liens and judgments',
      type: 'boolean',
      value: true
    }, {
      id: 'refresh',
      label: 'Report freshness',
      type: 'select',
      value: 'cached',
      options: [{
        value: 'cached',
        label: 'Use cached report if under 30 days'
      }, {
        value: 'fresh',
        label: 'Always pull a fresh report',
        cost: 2.0
      }]
    }]
  }],
  'background-check': [{
    groupId: 'scope',
    groupName: 'Check scope',
    groupIcon: 'file-text',
    properties: [{
      id: 'include-interpol',
      label: 'Include Interpol notices',
      type: 'boolean',
      value: true
    }, {
      id: 'county-level',
      label: 'County-level court search',
      sublabel: 'Adds 1–3 business days',
      type: 'boolean',
      value: false
    }, {
      id: 'questionnaires',
      label: 'Applicant questionnaires',
      type: 'questionnaire-template-toggles',
      value: {
        'employment-history': true,
        'education-history': false,
        'professional-references': true
      },
      options: [{
        value: 'employment-history',
        label: 'Employment history',
        hint: 'Last 7 years'
      }, {
        value: 'education-history',
        label: 'Education history'
      }, {
        value: 'professional-references',
        label: 'Professional references',
        hint: 'Two contacts'
      }]
    }]
  }],
  'criminal-background-check': [{
    groupId: 'rcmp',
    groupName: 'RCMP check',
    groupTooltip: 'Name-based Canadian criminal record check through an RCMP-accredited provider.',
    groupIcon: 'shield-alert',
    properties: [{
      id: 'check-type',
      label: 'Check type',
      type: 'select',
      value: 'crjmc',
      flag: 'CA',
      options: [{
        value: 'crjmc',
        label: 'Criminal record and judicial matters',
        hint: 'Standard employment check'
      }, {
        value: 'crc',
        label: 'Criminal record check',
        hint: 'Convictions only'
      }]
    }, {
      id: 'identity-from-idv',
      label: 'Reuse identity from ID Verification',
      sublabel: 'Always on — the ID step is coupled',
      type: 'boolean',
      value: true,
      locked: true
    }, {
      id: 'applicant-consent-form',
      label: 'Collect the consent form in-flow',
      type: 'boolean',
      value: true
    }]
  }],
  'financial-crime-check': [{
    groupId: 'sources',
    groupName: 'Sources',
    groupIcon: 'banknote',
    properties: [{
      id: 'fraud-databases',
      label: 'Shared fraud databases',
      type: 'boolean',
      value: true
    }, {
      id: 'regulatory-actions',
      label: 'Regulatory enforcement actions',
      type: 'boolean',
      value: true
    }, {
      id: 'crypto-exposure',
      label: 'Crypto exchange exposure',
      sublabel: 'Requires Wallet Verification',
      type: 'boolean',
      value: false,
      disabled: true
    }]
  }],
  'vulnerable-sector-check': [{
    groupId: 'sector',
    groupName: 'Vulnerable sector',
    groupIcon: 'shield-check',
    properties: [{
      id: 'population',
      label: 'Population served',
      type: 'select',
      value: 'children',
      options: [{
        value: 'children',
        label: 'Children'
      }, {
        value: 'seniors',
        label: 'Seniors'
      }, {
        value: 'disabilities',
        label: 'Persons with disabilities'
      }]
    }, {
      id: 'pardoned-offences',
      label: 'Include pardoned sexual offences',
      sublabel: 'Requires police fingerprinting if flagged',
      type: 'boolean',
      value: true
    }, {
      id: 'employer-letter',
      label: 'Attach the employer letter',
      type: 'text',
      value: ''
    }]
  }],
  'education-confirmation': [{
    groupId: 'institution',
    groupName: 'Institutions',
    groupTooltip: 'Confirmation is requested from the registrar of each selected institution.',
    groupIcon: 'graduation-cap',
    properties: [{
      id: 'institutions',
      label: 'Institutions to contact',
      type: 'education-institution-picker',
      value: [{
        id: 'utoronto',
        name: 'University of Toronto',
        country: 'CA'
      }]
    }, {
      id: 'confirm-enrolment',
      label: 'Also confirm current enrolment',
      type: 'boolean',
      value: false
    }]
  }],
  kyb: [{
    groupId: 'scope',
    groupName: 'KYB scope',
    groupIcon: 'building-2',
    properties: [{
      id: 'jurisdictions',
      label: 'Registries to query',
      type: 'jurisdiction-picker',
      value: ['US-DE', 'CA-ON', 'GB'],
      options: [{
        value: 'US-DE',
        label: 'Delaware (US)',
        flag: 'US'
      }, {
        value: 'US-NY',
        label: 'New York (US)',
        flag: 'US'
      }, {
        value: 'CA-ON',
        label: 'Ontario (Canada)',
        flag: 'CA'
      }, {
        value: 'CA-FED',
        label: 'Canada (federal)',
        flag: 'CA'
      }, {
        value: 'GB',
        label: 'United Kingdom',
        flag: 'GB'
      }, {
        value: 'DE',
        label: 'Germany',
        flag: 'DE'
      }, {
        value: 'SG',
        label: 'Singapore',
        flag: 'SG'
      }]
    }, {
      id: 'ubo-graph',
      label: 'Resolve beneficial owners',
      sublabel: 'Build the ownership graph above 25%',
      type: 'boolean',
      value: true
    }, {
      id: 'depth',
      label: 'Ownership depth',
      type: 'select',
      value: 2,
      requirement: 'ubo-graph',
      options: [{
        value: 1,
        label: '1 level'
      }, {
        value: 2,
        label: '2 levels'
      }, {
        value: 3,
        label: '3 levels',
        hint: 'Slower for complex structures'
      }]
    }]
  }],
  'kyb-global-enhanced': [{
    groupId: 'scope',
    groupName: 'Global scope',
    groupIcon: 'globe',
    properties: [{
      id: 'jurisdictions',
      label: 'Jurisdictions',
      type: 'jurisdiction-picker',
      value: ['GB', 'DE', 'SG', 'AE'],
      options: [{
        value: 'GB',
        label: 'United Kingdom',
        flag: 'GB'
      }, {
        value: 'DE',
        label: 'Germany',
        flag: 'DE'
      }, {
        value: 'FR',
        label: 'France',
        flag: 'FR'
      }, {
        value: 'SG',
        label: 'Singapore',
        flag: 'SG'
      }, {
        value: 'AE',
        label: 'United Arab Emirates',
        flag: 'AE'
      }, {
        value: 'HK',
        label: 'Hong Kong',
        flag: 'HK'
      }, {
        value: 'AU',
        label: 'Australia',
        flag: 'AU'
      }]
    }, {
      id: 'director-adverse-media',
      label: 'Screen directors for adverse media',
      type: 'boolean',
      value: true
    }, {
      id: 'live-registry',
      label: 'Live registry validation',
      sublabel: 'Query registries in real time instead of cached filings',
      type: 'boolean',
      value: true
    }]
  }],
  'title-search': [{
    groupId: 'search',
    groupName: 'Title search',
    groupIcon: 'file-search',
    properties: [{
      id: 'include-liens',
      label: 'Include liens and encumbrances',
      type: 'boolean',
      value: true
    }, {
      id: 'chain-of-title',
      label: 'Full chain of title',
      sublabel: 'All transfers, not just the current owner',
      type: 'boolean',
      value: false
    }, {
      id: 'region',
      label: 'Registry region',
      type: 'select',
      value: 'auto',
      options: [{
        value: 'auto',
        label: 'Detect from the address'
      }, {
        value: 'ca-on',
        label: 'Ontario Land Registry',
        flag: 'CA'
      }, {
        value: 'us-county',
        label: 'US county recorder',
        flag: 'US'
      }]
    }]
  }],
  'vpn-check': [{
    groupId: 'policy',
    groupName: 'Network policy',
    groupIcon: 'wifi-off',
    properties: [{
      id: 'detect-vpn',
      label: 'Detect VPNs and proxies',
      type: 'boolean',
      value: true
    }, {
      id: 'detect-tor',
      label: 'Detect Tor exit nodes',
      type: 'boolean',
      value: true
    }, {
      id: 'detect-hosting',
      label: 'Detect hosting-provider IPs',
      type: 'boolean',
      value: false
    }, {
      id: 'action',
      label: 'On detection',
      type: 'select',
      value: 'flag',
      options: [{
        value: 'flag',
        label: 'Flag only'
      }, {
        value: 'step-up',
        label: 'Step up'
      }, {
        value: 'block',
        label: 'Block'
      }]
    }]
  }],
  'anti-cheat': [{
    groupId: 'policy',
    groupName: 'Integrity policy',
    groupIcon: 'gamepad-2',
    properties: [{
      id: 'device-attestation',
      label: 'Device attestation',
      sublabel: 'Play Integrity / App Attest',
      type: 'boolean',
      value: true
    }, {
      id: 'multi-account',
      label: 'Detect multi-accounting',
      type: 'boolean',
      value: true
    }, {
      id: 'action',
      label: 'On detection',
      type: 'select',
      value: 'flag',
      options: [{
        value: 'flag',
        label: 'Flag only'
      }, {
        value: 'step-up',
        label: 'Step up'
      }, {
        value: 'block',
        label: 'Block'
      }]
    }]
  }],
  'ip-jurisdiction': [{
    groupId: 'jurisdictions',
    groupName: 'Allowed jurisdictions',
    groupTooltip: 'Applicants outside the allowed list are stepped up or blocked.',
    groupIcon: 'map',
    properties: [{
      id: 'allowed-countries',
      label: 'Allowed countries',
      type: 'country-customize',
      value: ['US', 'CA', 'GB', 'AU', 'IE']
    }, {
      id: 'block-unknown',
      label: 'Block unresolvable locations',
      sublabel: 'When no jurisdiction can be determined',
      type: 'boolean',
      value: false
    }, {
      id: 'unknown-action',
      label: 'When the location is unknown',
      type: 'select',
      value: 'step-up',
      requirementInverse: 'block-unknown',
      options: [{
        value: 'allow',
        label: 'Allow'
      }, {
        value: 'step-up',
        label: 'Step up to ID Verification'
      }]
    }]
  }],
  'vpn-detection': [{
    groupId: 'policy',
    groupName: 'VPN policy',
    groupIcon: 'shield-alert',
    properties: [{
      id: 'detect-vpn',
      label: 'Detect VPNs',
      type: 'boolean',
      value: true
    }, {
      id: 'detect-proxy',
      label: 'Detect residential proxies',
      type: 'boolean',
      value: true
    }, {
      id: 'action',
      label: 'On detection',
      type: 'select',
      value: 'step-up',
      options: [{
        value: 'flag',
        label: 'Flag only'
      }, {
        value: 'step-up',
        label: 'Step up'
      }, {
        value: 'block',
        label: 'Block'
      }]
    }]
  }],
  'self-exclusion-check': [{
    groupId: 'registers',
    groupName: 'Registers',
    groupIcon: 'user-x',
    properties: [{
      id: 'gamstop',
      label: 'GAMSTOP (UK)',
      type: 'boolean',
      value: true,
      flag: 'GB'
    }, {
      id: 'provincial-registers',
      label: 'Canadian provincial registers',
      type: 'boolean',
      value: true,
      flag: 'CA'
    }, {
      id: 'on-match',
      label: 'On a match',
      type: 'select',
      value: 'block',
      options: [{
        value: 'block',
        label: 'Block access'
      }, {
        value: 'flag',
        label: 'Flag for the responsible-gaming team'
      }]
    }]
  }],
  'crypto-wallet-screening': [{
    groupId: 'networks',
    groupName: 'Networks',
    groupIcon: 'wallet',
    properties: [{
      id: 'networks',
      label: 'Chains to screen',
      type: 'multiselect-table',
      value: ['ethereum', 'bitcoin'],
      options: [{
        value: 'ethereum',
        label: 'Ethereum',
        hint: 'Mainnet and ERC-20 tokens'
      }, {
        value: 'bitcoin',
        label: 'Bitcoin'
      }, {
        value: 'solana',
        label: 'Solana'
      }, {
        value: 'base',
        label: 'Base'
      }, {
        value: 'polygon',
        label: 'Polygon'
      }]
    }, {
      id: 'require-signature',
      label: 'Prove ownership with a signed message',
      type: 'boolean',
      value: true
    }]
  }, {
    groupId: 'risk',
    groupName: 'Risk',
    groupTooltip: 'Exposure is traced up to five hops from the connected wallet.',
    groupIcon: 'triangle-alert',
    properties: [{
      id: 'categories',
      label: 'Risk categories',
      type: 'multiselect-table',
      value: ['sanctions', 'darknet', 'mixers', 'ransomware', 'scams'],
      options: [{
        value: 'sanctions',
        label: 'Sanctioned entities'
      }, {
        value: 'darknet',
        label: 'Darknet markets'
      }, {
        value: 'mixers',
        label: 'Mixers and tumblers'
      }, {
        value: 'ransomware',
        label: 'Ransomware'
      }, {
        value: 'scams',
        label: 'Scams and phishing'
      }, {
        value: 'gambling',
        label: 'Gambling'
      }, {
        value: 'stolen-funds',
        label: 'Stolen funds'
      }]
    }, {
      id: 'risk-threshold',
      label: 'Risk threshold',
      type: 'slider',
      value: 60,
      min: 0,
      max: 100,
      step: 5,
      showValue: true,
      marks: [{
        value: 0,
        label: 'Lenient'
      }, {
        value: 60,
        label: 'Default'
      }, {
        value: 100,
        label: 'Strict'
      }]
    }, {
      id: 'auto-block-high-risk',
      label: 'Automatically block high-risk wallets',
      type: 'boolean',
      value: true
    }, {
      id: 'review-queue',
      label: 'Route high-risk wallets to',
      type: 'select',
      value: 'compliance',
      requirementInverse: 'auto-block-high-risk',
      options: [{
        value: 'compliance',
        label: 'Compliance queue'
      }, {
        value: 'fraud-ops',
        label: 'Fraud operations queue'
      }]
    }]
  }]
};

export const WB = {
  START_NODE_ID: '__start__',
  CATEGORY_TABS: ['All', 'Verify', 'Docs', 'Screen', 'Crypto', 'Gaming'],
  BOTTOM_PINNED_STEPS: ['bank-statement-upload', 'ai-bank-statement-analysis', 'credit-check', 'background-check', 'criminal-background-check'],
  COUPLED_STEPS: {
    'bank-statement-upload': ['ai-bank-statement-analysis'],
    'ai-bank-statement-analysis': ['bank-statement-upload'],
    'background-check': ['id-verification'],
    'credit-check': ['id-verification'],
    'criminal-background-check': ['id-verification']
  },
  COUPLED_PROPERTY_OVERRIDES: {
    'ai-bank-statement-analysis': {}
  },
  SYSTEM_STEPS: ['add-step', 'start-session', 'end-session'],
  STEP_UP_GUARD_STEPS: ['ip-jurisdiction', 'vpn-detection', 'injection-detection'],
  REMOVED_PROPERTY_GROUPS: ['id-classification-settings'],
  PHONE_INTEL_BLOCK_IDS: [],
  EXCLUDED_FROM_PREVIEW: ['ai-bank-statement-analysis', 'white-label', 'ip-jurisdiction', 'vpn-detection'],
  TOP_ALIGNED_STEP_IDS: ['custom-form', 'kyb', 'proofcall', 'credit-check'],
  MASTER_CAMERA_SUB_STEP_IDS: ['id-verification', 'face-liveness', 'deepfake-detection', 'age-estimation', 'pep-sanctions', 'adverse-media', 'passport-nfc-scanner', 'custom-prompt'],
  CUSTOM_FORM_FIELD_TYPES: ['short-text', 'dropdown', 'checkbox', 'yes-no', 'date', 'number'],
  MAX_FIELDS_PER_PAGE: 5,
  MAX_OPTIONS: 10,
  CANVAS: {
    NODE_WIDTH: 280,
    NODE_HEIGHT: 100,
    START_NODE_WIDTH: 120,
    START_NODE_HEIGHT: 50,
    MIN_ZOOM: 0.15,
    MAX_ZOOM: 2.5,
    ANCHOR_RADIUS: 7,
    ZOOM_STEP: 0.08,
    ELBOW_GAP: 40,
    CORNER_RADIUS: 12,
    GRID_SIZE: 20,
    ROW_GAP: 140,
    FIT_PADDING: 80,
    FIT_MAX_ZOOM: 1.5,
    ANCHOR_HIT_RADIUS: 20
  },
  PANEL: {
    WIDTH: 350,
    WIDTH_EXPANDED: 600,
    PALETTE_WIDTH: 300
  },
  DEVICE_SIZES: {
    mobile: {
      width: 390,
      height: 760
    },
    desktop: {
      width: 700,
      height: 560
    }
  },
  VERIFY_COLORS: {
    primary: '#0782DF',
    primaryLight: '#E8F4FD',
    primaryDark: '#0676CC',
    success: '#22C55E',
    error: '#FF5630',
    text: '#1A1A1A',
    muted: '#6B7280'
  },
  BRAND: {
    primary: '#1E7FE0',
    light: '#22B8F0',
    dark: '#1456A0'
  },
  ICONS: {
    fallback: 'solar:widget-bold-duotone',
    emptyCanvas: 'solar:widget-add-bold-duotone',
    play: 'solar:play-bold',
    copy: 'solar:copy-bold',
    download: 'solar:download-bold'
  },
  UI_ICONS: {
    list: 'list',
    canvas: 'layout-grid',
    preview: 'eye',
    search: 'search',
    chevronLeft: 'chevron-left',
    chevronRight: 'chevron-right',
    gear: 'settings',
    close: 'x',
    plus: 'plus',
    minus: 'minus',
    fit: 'maximize',
    back: 'arrow-left',
    save: 'save',
    trash: 'trash-2',
    lock: 'lock',
    check: 'check',
    arrowRight: 'arrow-right',
    arrowLeft: 'arrow-left',
    copy: 'copy',
    download: 'download',
    wallet: 'wallet',
    mail: 'mail',
    zoomIn: 'zoom-in',
    zoomOut: 'zoom-out',
    warning: 'triangle-alert',
    info: 'info',
    drag: 'grip-vertical',
    phone: 'phone',
    camera: 'camera',
    upload: 'upload',
    file: 'file-text',
    user: 'user',
    building: 'building-2',
    shield: 'shield-check',
    pen: 'pen-line',
    calendar: 'calendar',
    globe: 'globe',
    key: 'key-round',
    card: 'credit-card'
  },
  STEP_ICON_GRADIENTS: {
    'id-verification': 'linear-gradient(135deg, #007AFF, #5AC8FA)',
    'face-liveness': 'linear-gradient(135deg, #AF52DE, #DA8FFF)',
    'deepfake-detection': 'linear-gradient(135deg, #FF2D55, #FF6961)',
    'age-estimation': 'linear-gradient(135deg, #FF9500, #FFBD44)',
    'phone-verification': 'linear-gradient(135deg, #5856D6, #8E8CF0)',
    'address-verification': 'linear-gradient(135deg, #0FA47F, #4ED9B4)',
    'passport-nfc-scanner': 'linear-gradient(135deg, #1C3F94, #4A78D8)',
    'tokenized-age-verification': 'linear-gradient(135deg, #6BBF3E, #9EE06A)',
    'accessibility-mode': 'linear-gradient(135deg, #FF6A3D, #FF9E7A)',
    'injection-detection': 'linear-gradient(135deg, #C62828, #EF5350)',
    proofcall: 'linear-gradient(135deg, #0A7EA4, #3FC1E6)',
    consent: 'linear-gradient(135deg, #34C759, #63E888)',
    'custom-prompt': 'linear-gradient(135deg, #BF5AF2, #E39BFF)',
    'white-label': 'linear-gradient(135deg, #FF2D92, #FF7AC0)',
    'document-upload': 'linear-gradient(135deg, #00C7BE, #63E6D4)',
    'e-signature': 'linear-gradient(135deg, #30B0C7, #64D2FF)',
    'custom-form': 'linear-gradient(135deg, #3A7BD5, #7FB2F0)',
    'bank-statement-upload': 'linear-gradient(135deg, #2E7D32, #66BB6A)',
    'ai-bank-statement-analysis': 'linear-gradient(135deg, #0F766E, #2DD4BF)',
    'biometric-document-transfer': 'linear-gradient(135deg, #6D28D9, #A78BFA)',
    'pep-sanctions': 'linear-gradient(135deg, #34C759, #63E888)',
    'adverse-media': 'linear-gradient(135deg, #FF3B30, #FF6B6B)',
    'aml-status': 'linear-gradient(135deg, #0EA5E9, #67D3FF)',
    'credit-check': 'linear-gradient(135deg, #FF9F0A, #FFBD44)',
    'business-credit-check': 'linear-gradient(135deg, #B45309, #F59E0B)',
    'background-check': 'linear-gradient(135deg, #475569, #94A3B8)',
    'criminal-background-check': 'linear-gradient(135deg, #7F1D1D, #DC2626)',
    'financial-crime-check': 'linear-gradient(135deg, #065F46, #10B981)',
    'vulnerable-sector-check': 'linear-gradient(135deg, #DB2777, #F472B6)',
    'education-confirmation': 'linear-gradient(135deg, #7E22CE, #C084FC)',
    kyb: 'linear-gradient(135deg, #8B6914, #C49B2A)',
    'kyb-global-enhanced': 'linear-gradient(135deg, #0F4C81, #2E86C1)',
    'title-search': 'linear-gradient(135deg, #8D6E63, #BCAAA4)',
    'vpn-check': 'linear-gradient(135deg, #374151, #6B7280)',
    'anti-cheat': 'linear-gradient(135deg, #00B4D8, #90E0EF)',
    'ip-jurisdiction': 'linear-gradient(135deg, #EA580C, #FB923C)',
    'vpn-detection': 'linear-gradient(135deg, #312E81, #6366F1)',
    'self-exclusion-check': 'linear-gradient(135deg, #9F1239, #F43F5E)',
    'crypto-wallet-screening': 'linear-gradient(135deg, #F7931A, #FDB84D)'
  },
  SERVICE_TYPES: {
    ID_VERIFICATION: 'ID_VERIFICATION',
    FACE_LIVENESS: 'FACE_LIVENESS',
    DEEPFAKE_DETECTION: 'DEEPFAKE_DETECTION',
    AGE_ESTIMATION: 'AGE_ESTIMATION',
    PHONE_VERIFICATION: 'PHONE_VERIFICATION',
    ADDRESS_VERIFICATION: 'ADDRESS_VERIFICATION',
    PASSPORT_NFC_SCANNER: 'PASSPORT_NFC_SCANNER',
    DOCUMENT_UPLOAD: 'DOCUMENT_UPLOAD',
    E_SIGNATURE: 'E_SIGNATURE',
    CUSTOM_FORM: 'CUSTOM_FORM',
    BANK_STATEMENT_UPLOAD: 'BANK_STATEMENT_UPLOAD',
    AI_BANK_STATEMENT_ANALYSIS: 'AI_BANK_STATEMENT_ANALYSIS',
    PEP_SANCTIONS: 'PEP_SANCTIONS',
    ADVERSE_MEDIA: 'ADVERSE_MEDIA',
    AML_STATUS: 'AML_STATUS',
    CREDIT_CHECK: 'CREDIT_CHECK',
    BUSINESS_CREDIT_CHECK: 'BUSINESS_CREDIT_CHECK',
    BACKGROUND_CHECK: 'BACKGROUND_CHECK',
    CRIMINAL_BACKGROUND_CHECK: 'CRIMINAL_BACKGROUND_CHECK',
    KYB: 'KYB',
    KYB_GLOBAL_ENHANCED: 'KYB_GLOBAL_ENHANCED',
    TITLE_SEARCH: 'TITLE_SEARCH',
    PROOFCALL: 'PROOFCALL',
    TOKENIZED_AGE_VERIFICATION: 'TOKENIZED_AGE_VERIFICATION',
    ACCESSIBILITY_MODE: 'ACCESSIBILITY_MODE'
  },
  WORKFLOW_STEP_TO_SERVICE_MAP: {
    'id-verification': 'ID_VERIFICATION',
    'face-liveness': 'FACE_LIVENESS',
    'deepfake-detection': 'DEEPFAKE_DETECTION',
    'age-estimation': 'AGE_ESTIMATION',
    'phone-verification': 'PHONE_VERIFICATION',
    'address-verification': 'ADDRESS_VERIFICATION',
    'passport-nfc-scanner': 'PASSPORT_NFC_SCANNER',
    'document-upload': 'DOCUMENT_UPLOAD',
    'e-signature': 'E_SIGNATURE',
    'custom-form': 'CUSTOM_FORM',
    'bank-statement-upload': 'BANK_STATEMENT_UPLOAD',
    'ai-bank-statement-analysis': 'AI_BANK_STATEMENT_ANALYSIS',
    'pep-sanctions': 'PEP_SANCTIONS',
    'adverse-media': 'ADVERSE_MEDIA',
    'aml-status': 'AML_STATUS',
    'credit-check': 'CREDIT_CHECK',
    'business-credit-check': 'BUSINESS_CREDIT_CHECK',
    'background-check': 'BACKGROUND_CHECK',
    'criminal-background-check': 'CRIMINAL_BACKGROUND_CHECK',
    kyb: 'KYB',
    'kyb-global-enhanced': 'KYB_GLOBAL_ENHANCED',
    'title-search': 'TITLE_SEARCH',
    proofcall: 'PROOFCALL',
    'tokenized-age-verification': 'TOKENIZED_AGE_VERIFICATION',
    'accessibility-mode': 'ACCESSIBILITY_MODE'
  },
  WORKFLOW_STEPS_OPTIONS: [{
    id: 'id-verification',
    label: 'ID Verification',
    description: 'Verify government-issued identity documents',
    icon: 'solar:user-id-bold-duotone',
    category: 'Verify',
    cost: 0.5
  }, {
    id: 'face-liveness',
    label: 'Face Liveness',
    description: 'Confirm the applicant is a live person, not a photo or replay',
    icon: 'solar:face-scan-square-bold-duotone',
    category: 'Verify',
    cost: 0.05
  }, {
    id: 'deepfake-detection',
    label: 'Deepfake Detection',
    description: 'AI-native deepfake detection across image, video, audio and documents',
    icon: 'solar:incognito-bold-duotone',
    category: 'Verify',
    cost: 0.6
  }, {
    id: 'age-estimation',
    label: 'Age Estimation',
    description: 'Estimate age from a selfie for age-gated access',
    icon: 'solar:calendar-date-bold-duotone',
    category: 'Verify',
    cost: 0.05
  }, {
    id: 'phone-verification',
    label: 'Phone Verification',
    description: 'Live call with a spoken voice prompt to verify identity',
    icon: 'solar:phone-calling-bold-duotone',
    category: 'Verify',
    cost: 0.4
  }, {
    id: 'address-verification',
    label: 'Address Verification',
    description: 'AI address validation with geo-knowledge quiz and proof of residency',
    icon: 'solar:map-point-bold-duotone',
    category: 'Verify',
    cost: 0.7
  }, {
    id: 'passport-nfc-scanner',
    label: 'NFC Passport Scan',
    description: 'Read and verify the NFC chip in e-passports and ID cards',
    icon: 'solar:passport-bold-duotone',
    category: 'Verify',
    cost: 0.5
  }, {
    id: 'tokenized-age-verification',
    label: 'Tokenized Age Verification',
    description: 'Issue a cryptographic age token without sharing the underlying ID',
    icon: 'solar:key-minimalistic-square-bold-duotone',
    category: 'Verify',
    cost: 0.01
  }, {
    id: 'accessibility-mode',
    label: 'Accessibility Mode',
    description: 'Inclusive verification for applicants with disabilities or limited mobility',
    icon: 'solar:accessibility-bold-duotone',
    category: 'Verify',
    cost: 0.75
  }, {
    id: 'injection-detection',
    label: 'Injection Detection',
    description: 'Detect virtual cameras and injected media streams',
    icon: 'solar:bug-bold-duotone',
    category: 'Verify',
    cost: null
  }, {
    id: 'proofcall',
    label: 'ProofCall',
    description: 'AI outbound reference calls that ask your questions',
    icon: 'solar:phone-rounded-bold-duotone',
    category: 'Verify',
    cost: 0.5,
    costLabel: '$0.50/min'
  }, {
    id: 'consent',
    label: 'Consent',
    description: 'Capture explicit applicant consent and disclosures',
    icon: 'solar:check-square-bold-duotone',
    category: 'Verify',
    cost: 0,
    costLabel: 'free'
  }, {
    id: 'custom-prompt',
    label: 'Custom Prompt Picture',
    description: 'Request a specific photo based on a prompt you define',
    icon: 'solar:camera-bold-duotone',
    category: 'Verify',
    cost: 0.05
  }, {
    id: 'white-label',
    label: 'White Label',
    description: 'Run the flow under your own brand — logo, colors, domain',
    icon: 'solar:palette-bold-duotone',
    category: 'Verify',
    cost: 0.1
  }, {
    id: 'document-upload',
    label: 'Document Upload',
    description: 'Collect supporting documents with AI fraud detection',
    icon: 'solar:upload-bold-duotone',
    category: 'Docs',
    cost: 0.05
  }, {
    id: 'e-signature',
    label: 'E-Signature',
    description: 'Collect a legally binding electronic signature',
    icon: 'solar:pen-new-square-bold-duotone',
    category: 'Docs',
    cost: 0.1
  }, {
    id: 'custom-form',
    label: 'Custom Form',
    description: 'Custom questions, fields and file uploads',
    icon: 'solar:clipboard-list-bold-duotone',
    category: 'Docs',
    cost: 0.05
  }, {
    id: 'bank-statement-upload',
    label: 'Bank Statement Sync',
    description: 'Pull bank statements through open banking',
    icon: 'solar:bill-list-bold-duotone',
    category: 'Docs',
    cost: 1.1
  }, {
    id: 'ai-bank-statement-analysis',
    label: 'Bank Statement Analysis',
    description: 'AI-powered analysis of bank statements',
    icon: 'solar:document-text-bold-duotone',
    category: 'Docs',
    cost: 0.95,
    coupled: true
  }, {
    id: 'biometric-document-transfer',
    label: 'Biometric Document Transfer',
    description: 'Send documents under a biometric lock only the verified recipient can open',
    icon: 'solar:lock-keyhole-bold-duotone',
    category: 'Docs',
    cost: 0.05
  }, {
    id: 'pep-sanctions',
    label: 'PEP/Sanctions',
    description: 'Check against global PEP and sanctions lists',
    icon: 'solar:shield-check-bold-duotone',
    category: 'Screen',
    cost: 0.4
  }, {
    id: 'adverse-media',
    label: 'Adverse Media',
    description: 'Screen global adverse media for negative news and risk signals',
    icon: 'solar:magnifer-zoom-in-bold-duotone',
    category: 'Screen',
    cost: 0.4
  }, {
    id: 'aml-status',
    label: 'AML Status',
    description: 'AML status checks against regulatory watchlists',
    icon: 'solar:shield-user-bold-duotone',
    category: 'Screen',
    cost: 0.25
  }, {
    id: 'credit-check',
    label: 'Credit Check',
    description: 'Credit bureau report with score, trade lines and payment history',
    icon: 'solar:card-bold-duotone',
    category: 'Screen',
    cost: 5
  }, {
    id: 'business-credit-check',
    label: 'Business Credit Check',
    description: 'Company credit score, payment trends and risk assessment',
    icon: 'solar:chart-square-bold-duotone',
    category: 'Screen',
    cost: 5
  }, {
    id: 'background-check',
    label: 'Background Check',
    description: 'US + Interpol criminal background check',
    icon: 'solar:file-check-bold-duotone',
    category: 'Screen',
    cost: 8
  }, {
    id: 'criminal-background-check',
    label: 'Criminal Record Check (CAN)',
    description: 'Canadian criminal record check through RCMP-accredited sources',
    icon: 'solar:shield-warning-bold-duotone',
    category: 'Screen',
    cost: 23.5
  }, {
    id: 'financial-crime-check',
    label: 'Financial Crime Check',
    description: 'Screen for fraud, money-laundering and financial crime records',
    icon: 'solar:money-bag-bold-duotone',
    category: 'Screen',
    cost: null
  }, {
    id: 'vulnerable-sector-check',
    label: 'Vulnerable Sector Check',
    description: 'Enhanced check for roles working with vulnerable people',
    icon: 'solar:users-group-rounded-bold-duotone',
    category: 'Screen',
    cost: null
  }, {
    id: 'education-confirmation',
    label: 'Education Confirmation',
    description: 'Confirm degrees and enrolment with the issuing institution',
    icon: 'solar:diploma-bold-duotone',
    category: 'Screen',
    cost: null
  }, {
    id: 'kyb',
    label: 'KYB Check',
    description: 'Company registration, beneficial ownership and corporate structure',
    icon: 'solar:buildings-2-bold-duotone',
    category: 'Screen',
    cost: 0.7
  }, {
    id: 'kyb-global-enhanced',
    label: 'KYB Global Enhanced',
    description: 'Cross-border KYB with ownership graphs and live registry validation',
    icon: 'solar:earth-bold-duotone',
    category: 'Screen',
    cost: 3.25
  }, {
    id: 'title-search',
    label: 'Title Search',
    description: 'Look up property title records for ownership and lien data',
    icon: 'solar:home-2-bold-duotone',
    category: 'Screen',
    cost: 0.8
  }, {
    id: 'vpn-check',
    label: 'VPN Check',
    description: 'Detect VPNs, proxies, Tor, and hosting-provider IPs',
    icon: 'solar:shield-keyhole-bold-duotone',
    category: 'Screen',
    cost: null,
    advancedOnly: true
  }, {
    id: 'anti-cheat',
    label: 'Anti-Cheat',
    description: 'Device and session integrity checks for competitive play',
    icon: 'solar:gamepad-bold-duotone',
    category: 'Gaming',
    cost: null
  }, {
    id: 'ip-jurisdiction',
    label: 'IP Jurisdiction',
    description: "Resolve the applicant's jurisdiction from network signals",
    icon: 'solar:map-bold-duotone',
    category: 'Gaming',
    cost: null
  }, {
    id: 'vpn-detection',
    label: 'VPN Detection',
    description: 'Flag VPN and proxy use during the session',
    icon: 'solar:shield-network-bold-duotone',
    category: 'Gaming',
    cost: null
  }, {
    id: 'self-exclusion-check',
    label: 'Self-Exclusion Check',
    description: 'Check self-exclusion registers before granting access',
    icon: 'solar:user-block-bold-duotone',
    category: 'Gaming',
    cost: null
  }, {
    id: 'crypto-wallet-screening',
    label: 'Wallet Verification',
    description: 'Screen connected crypto wallets across chains and exchanges',
    icon: 'solar:wallet-bold-duotone',
    category: 'Crypto',
    cost: null,
    devOnly: true
  }],
  TEMPLATES: [{
    id: 'kyc-onboarding',
    label: 'KYC Onboarding',
    stepIds: ['id-verification', 'face-liveness', 'pep-sanctions']
  }, {
    id: 'lending',
    label: 'Lending',
    stepIds: ['id-verification', 'face-liveness', 'bank-statement-upload', 'ai-bank-statement-analysis', 'credit-check']
  }, {
    id: 'age-gated',
    label: 'Age-Gated Access',
    stepIds: ['age-estimation', 'id-verification']
  }, {
    id: 'property',
    label: 'Property',
    stepIds: ['id-verification', 'title-search', 'document-upload']
  }, {
    id: 'blank',
    label: 'Blank',
    stepIds: []
  }],
  IS_DEV: true
};

export const WB_CSS = `
   1. Design tokens (light) — brand primary #1E7FE0
   ============================================================ */
:root, .wb-root {
  --wb-primary: #1E7FE0;
  --wb-primary-light: #22B8F0;
  --wb-primary-dark: #1456A0;
  --wb-primary-a08: rgba(30, 127, 224, 0.08);
  --wb-primary-a12: rgba(30, 127, 224, 0.12);
  --wb-primary-a15: rgba(30, 127, 224, 0.15);
  --wb-primary-a24: rgba(30, 127, 224, 0.24);
  --wb-primary-a32: rgba(30, 127, 224, 0.32);
  --wb-gradient: linear-gradient(135deg, #1E7FE0 0%, #22B8F0 100%);

  --wb-success: #22C55E;
  --wb-success-dark: #118D57;
  --wb-success-a16: rgba(34, 197, 94, 0.16);
  --wb-success-a32: rgba(34, 197, 94, 0.32);
  --wb-warning: #FFAB00;
  --wb-warning-dark: #B76E00;
  --wb-warning-a16: rgba(255, 171, 0, 0.16);
  --wb-error: #FF5630;
  --wb-error-dark: #B71D18;
  --wb-error-a16: rgba(255, 86, 48, 0.16);
  --wb-info: #00B8D9;
  --wb-info-dark: #006C9C;
  --wb-info-a16: rgba(0, 184, 217, 0.16);

  --wb-bg: #F4F6F8;
  --wb-canvas-bg: #F8FAFC;
  --wb-dot: #CBD5E1;
  --wb-paper: #FFFFFF;
  --wb-paper-2: #F9FAFB;
  --wb-text: #1C252E;
  --wb-text-secondary: #637381;
  --wb-text-disabled: #919EAB;
  --wb-divider: rgba(145, 158, 171, 0.24);
  --wb-border: rgba(145, 158, 171, 0.32);
  --wb-grey-a08: rgba(145, 158, 171, 0.08);
  --wb-grey-a12: rgba(145, 158, 171, 0.12);
  --wb-grey-a16: rgba(145, 158, 171, 0.16);
  --wb-grey-a24: rgba(145, 158, 171, 0.24);
  --wb-grey-a40: rgba(145, 158, 171, 0.4);
  --wb-connector: #E2E8F0;
  --wb-conn-stroke: #94A3B8;
  --wb-tooltip-bg: #1C252E;
  --wb-tooltip-fg: #FFFFFF;

  --wb-shadow-1: 0 1px 2px rgba(145, 158, 171, 0.16);
  --wb-shadow-2: 0 4px 12px -2px rgba(145, 158, 171, 0.24);
  --wb-shadow-3: 0 8px 16px -4px rgba(145, 158, 171, 0.24);
  --wb-shadow-8: 0 8px 24px -4px rgba(145, 158, 171, 0.32);
  --wb-shadow-24: 0 24px 48px -12px rgba(22, 28, 36, 0.32);
  --wb-ring: 0 0 0 3px var(--wb-primary-a15);

  --wb-radius-sm: 6px;
  --wb-radius: 8px;
  --wb-radius-md: 12px;
  --wb-radius-lg: 16px;
  --wb-z-overlay: 4;
  --wb-z-toggle: 5;
  --wb-z-menu: 40;
  --wb-z-dialog: 1300;
  --wb-z-toast: 1400;
}

   1b. Dark tokens — <html class="dark">
   ------------------------------------------------------------ */
.dark, .dark .wb-root {
  --wb-primary-a08: rgba(34, 184, 240, 0.1);
  --wb-primary-a12: rgba(34, 184, 240, 0.14);
  --wb-primary-a15: rgba(34, 184, 240, 0.18);
  --wb-primary-a24: rgba(34, 184, 240, 0.28);
  --wb-primary-a32: rgba(34, 184, 240, 0.36);
  --wb-success-dark: #5BE49B;
  --wb-warning-dark: #FFD666;
  --wb-error-dark: #FFAC82;
  --wb-info-dark: #61F3F3;

  --wb-bg: #141A21;
  --wb-canvas-bg: #10161C;
  --wb-dot: #2F3A46;
  --wb-paper: #1C252E;
  --wb-paper-2: #212B36;
  --wb-text: #FFFFFF;
  --wb-text-secondary: #919EAB;
  --wb-text-disabled: #637381;
  --wb-divider: rgba(145, 158, 171, 0.2);
  --wb-border: rgba(145, 158, 171, 0.28);
  --wb-grey-a08: rgba(145, 158, 171, 0.1);
  --wb-grey-a12: rgba(145, 158, 171, 0.14);
  --wb-grey-a16: rgba(145, 158, 171, 0.18);
  --wb-grey-a24: rgba(145, 158, 171, 0.26);
  --wb-grey-a40: rgba(145, 158, 171, 0.42);
  --wb-connector: #2F3A46;
  --wb-conn-stroke: #64748B;
  --wb-tooltip-bg: #F4F6F8;
  --wb-tooltip-fg: #1C252E;

  --wb-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.32);
  --wb-shadow-2: 0 4px 12px -2px rgba(0, 0, 0, 0.4);
  --wb-shadow-3: 0 8px 16px -4px rgba(0, 0, 0, 0.44);
  --wb-shadow-8: 0 8px 24px -4px rgba(0, 0, 0, 0.5);
  --wb-shadow-24: 0 24px 48px -12px rgba(0, 0, 0, 0.64);
}

   2. Root + base resets (scoped to the builder)
   ============================================================ */
.wb-root {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  min-height: 480px;
  overflow: hidden;
  background: var(--wb-bg);
  color: var(--wb-text);
  font-family: inherit;
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  text-align: left;
}
.wb-root *, .wb-root *::before, .wb-root *::after,
.wb-dialog-overlay *, .wb-toast-stack * { box-sizing: border-box; }
.wb-root h1, .wb-root h2, .wb-root h3, .wb-root h4, .wb-root h5, .wb-root h6,
.wb-root p, .wb-root ul, .wb-root ol, .wb-root pre, .wb-root figure,
.wb-dialog *:where(h1, h2, h3, h4, h5, h6, p, ul, ol, pre) { margin: 0; }
.wb-root ul, .wb-root ol, .wb-dialog ul, .wb-dialog ol { padding: 0; list-style: none; }
.wb-root button, .wb-dialog button, .wb-toast button {
  font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0;
  cursor: pointer; text-align: inherit; line-height: inherit; -webkit-tap-highlight-color: transparent;
}
.wb-root button:disabled, .wb-dialog button:disabled { cursor: not-allowed; }
.wb-root input, .wb-root textarea, .wb-root select,
.wb-dialog input, .wb-dialog textarea, .wb-dialog select { font: inherit; color: inherit; margin: 0; }
.wb-root img { display: inline-block; max-width: none; vertical-align: middle; }
.wb-root svg { vertical-align: middle; }
.wb-root a { color: var(--wb-primary); text-decoration: none; }
.wb-root a:hover { text-decoration: underline; }
.wb-root [hidden], .wb-dialog [hidden] { display: none !important; }
.wb-root code, .wb-root pre, .wb-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }

.wb-root :focus { outline: none; }
.wb-root :focus-visible, .wb-dialog :focus-visible, .wb-toast :focus-visible {
  outline: 2px solid var(--wb-primary);
  outline-offset: 2px;
  border-radius: 4px;
}
.wb-root .wb-input:focus-visible, .wb-dialog .wb-input:focus-visible,
.wb-root .wb-slider:focus-visible, .wb-root .wb-range-input:focus-visible { outline: none; }

.wb-root, .wb-root *, .wb-dialog, .wb-dialog * { scrollbar-width: thin; scrollbar-color: var(--wb-grey-a40) transparent; }
.wb-root ::-webkit-scrollbar, .wb-dialog ::-webkit-scrollbar { width: 6px; height: 6px; }
.wb-root ::-webkit-scrollbar-track, .wb-dialog ::-webkit-scrollbar-track { background: transparent; }
.wb-root ::-webkit-scrollbar-thumb, .wb-dialog ::-webkit-scrollbar-thumb { background: var(--wb-grey-a40); border-radius: 3px; }
.wb-root ::-webkit-scrollbar-thumb:hover, .wb-dialog ::-webkit-scrollbar-thumb:hover { background: var(--wb-text-disabled); }
.wb-scroll { overflow: auto; min-height: 0; }
.wb-scroll-y { overflow-x: hidden; overflow-y: auto; min-height: 0; }

.wb-dotgrid {
  background-color: var(--wb-canvas-bg);
  background-image: radial-gradient(circle, var(--wb-dot) 1px, transparent 1px);
  background-size: 20px 20px;
  background-position: 0 0;
}

   3. Layout — header, main row, columns
   ============================================================ */
.wb-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-shrink: 0;
  height: 64px;
  padding: 0 24px;
  background: var(--wb-paper);
  border-bottom: 1px solid var(--wb-divider);
  z-index: 6;
}
.wb-header-left, .wb-header-right { display: flex; align-items: center; gap: 12px; min-width: 0; }
.wb-header-right { justify-content: flex-end; }
.wb-header-name { width: 260px; max-width: 32vw; }
.wb-header-template { width: 200px; max-width: 24vw; }
.wb-cost {
  display: inline-flex; align-items: baseline; gap: 4px;
  padding: 6px 12px; border-radius: 999px;
  background: var(--wb-primary-a08); color: var(--wb-primary);
  font-size: 13px; font-weight: 700; white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.wb-cost-suffix { font-weight: 500; color: var(--wb-text-secondary); font-size: 12px; }
.wb-back { display: inline-flex; align-items: center; gap: 6px; color: var(--wb-text-secondary); font-weight: 600; font-size: 13px; padding: 6px 8px; border-radius: 8px; }
.wb-back:hover { background: var(--wb-grey-a08); color: var(--wb-text); text-decoration: none; }

.wb-main { display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; min-width: 0; }
.wb-palette {
  display: flex; flex-direction: column;
  width: 300px; flex: 0 0 300px; min-height: 0;
  background: var(--wb-paper);
  border-right: 1px solid var(--wb-divider);
}
.wb-center { position: relative; flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--wb-canvas-bg); }
.wb-panel {
  position: relative;
  display: flex; flex-direction: column;
  width: 350px; flex: 0 0 auto; min-height: 0;
  background: var(--wb-paper);
  border-left: 1px solid var(--wb-divider);
  overflow: visible;
  transition: width 0.25s ease;
}
.wb-panel.expanded, .wb-panel[data-expanded="true"] { width: 600px; }

.wb-view-toggle {
  position: absolute; top: 16px; right: 16px; z-index: var(--wb-z-toggle);
  display: inline-flex; align-items: center; gap: 2px;
  padding: 4px; border-radius: 12px;
  background: var(--wb-paper);
  border: 1px solid var(--wb-divider);
  box-shadow: var(--wb-shadow-8);
}
.wb-view-toggle .wb-iconbtn { border-radius: 8px; }
.wb-view-toggle-sep { width: 1px; height: 20px; background: var(--wb-divider); margin: 0 4px; }
.wb-view-toggle-label { font-size: 12px; font-weight: 600; color: var(--wb-text-secondary); padding: 0 4px; min-width: 42px; text-align: center; font-variant-numeric: tabular-nums; }

   4. Buttons
   ============================================================ */
.wb-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  height: 36px; padding: 0 16px;
  border-radius: var(--wb-radius);
  border: 1px solid transparent;
  font-size: 14px; font-weight: 700; line-height: 1;
  white-space: nowrap; cursor: pointer; user-select: none;
  color: var(--wb-text);
  background: var(--wb-grey-a08);
  transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease, opacity 0.15s ease, border-color 0.15s ease;
}
.wb-btn:hover { background: var(--wb-grey-a16); }
.wb-btn:active { transform: translateY(1px); }
.wb-btn:disabled, .wb-btn[aria-disabled="true"] { opacity: 0.48; cursor: not-allowed; pointer-events: none; }
.wb-btn-sm { height: 30px; padding: 0 10px; font-size: 13px; border-radius: 6px; gap: 6px; }
.wb-btn-lg { height: 44px; padding: 0 20px; font-size: 15px; border-radius: 10px; }
.wb-btn-full { width: 100%; }
.wb-btn-icon { display: inline-flex; align-items: center; margin-left: -2px; }

.wb-btn-primary, .wb-btn-contained.wb-btn-primary {
  color: #FFFFFF;
  background: var(--wb-gradient);
  box-shadow: 0 6px 16px -6px var(--wb-primary-a32);
}
.wb-btn-primary:hover { background: var(--wb-gradient); filter: brightness(1.06); box-shadow: 0 8px 20px -6px rgba(30, 127, 224, 0.48); }
.wb-btn-contained.wb-btn-inherit { background: var(--wb-text); color: var(--wb-paper); }
.wb-btn-contained.wb-btn-inherit:hover { background: var(--wb-text); filter: brightness(1.15); }
.wb-btn-contained.wb-btn-error { background: var(--wb-error); color: #FFFFFF; }
.wb-btn-contained.wb-btn-error:hover { background: var(--wb-error-dark); }
.wb-btn-contained.wb-btn-success { background: var(--wb-success); color: #FFFFFF; }

.wb-btn-outlined { background: transparent; border-color: var(--wb-border); color: var(--wb-text); }
.wb-btn-outlined:hover { background: var(--wb-grey-a08); border-color: var(--wb-text); }
.wb-btn-outlined.wb-btn-primary { color: var(--wb-primary); border-color: var(--wb-primary-a32); background: transparent; box-shadow: none; filter: none; }
.wb-btn-outlined.wb-btn-primary:hover { background: var(--wb-primary-a08); border-color: var(--wb-primary); }
.wb-btn-outlined.wb-btn-error { color: var(--wb-error); border-color: rgba(255, 86, 48, 0.48); }
.wb-btn-outlined.wb-btn-error:hover { background: var(--wb-error-a16); }

.wb-btn-text { background: transparent; padding: 0 8px; }
.wb-btn-text:hover { background: var(--wb-grey-a08); }
.wb-btn-text.wb-btn-primary { color: var(--wb-primary); background: transparent; box-shadow: none; filter: none; }
.wb-btn-text.wb-btn-primary:hover { background: var(--wb-primary-a08); }
.wb-btn-text.wb-btn-error { color: var(--wb-error); }
.wb-btn-text.wb-btn-error:hover { background: var(--wb-error-a16); }
.wb-btn-text.wb-btn-inherit { color: var(--wb-text-secondary); }
.wb-btn-text.wb-btn-inherit:hover { color: var(--wb-text); }

.wb-btn-dashed {
  width: 100%; height: 36px; border: 1px dashed var(--wb-border); border-radius: var(--wb-radius);
  background: transparent; color: var(--wb-text-secondary); font-weight: 600; font-size: 13px;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
}
.wb-btn-dashed:hover { border-color: var(--wb-primary); color: var(--wb-primary); background: var(--wb-primary-a08); }

.wb-iconbtn {
  display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 32px; height: 32px; padding: 0;
  border-radius: 50%; border: 0;
  color: var(--wb-text-secondary); background: transparent;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, transform 0.1s ease;
}
.wb-iconbtn:hover { background: var(--wb-grey-a08); color: var(--wb-text); }
.wb-iconbtn:active { transform: scale(0.94); }
.wb-iconbtn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.wb-iconbtn.active, .wb-iconbtn[data-active="true"], .wb-iconbtn[aria-pressed="true"] { background: var(--wb-primary-a12); color: var(--wb-primary); }
.wb-iconbtn-sm { width: 28px; height: 28px; }
.wb-iconbtn-xs { width: 24px; height: 24px; }
.wb-iconbtn-lg { width: 40px; height: 40px; }
.wb-iconbtn-outlined { border: 1px solid var(--wb-divider); background: var(--wb-paper); }
.wb-iconbtn-error:hover { background: var(--wb-error-a16); color: var(--wb-error); }

   5. Palette (spec §4)
   ============================================================ */
.wb-palette-header { display: flex; flex-direction: column; gap: 12px; padding: 16px 16px 12px; flex-shrink: 0; border-bottom: 1px solid var(--wb-divider); }
.wb-palette-title { font-size: 16px; font-weight: 700; line-height: 1.5; }
.wb-palette-search { width: 100%; }
.wb-palette-tabs { flex-wrap: wrap; }
.wb-palette-list { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.wb-palette-section { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--wb-text-disabled); padding: 8px 4px 0; }

.wb-palette-card {
  position: relative;
  display: flex; align-items: flex-start; gap: 12px;
  padding: 12px;
  border-radius: var(--wb-radius-md);
  border: 1px solid var(--wb-divider);
  background: var(--wb-paper);
  cursor: grab;
  user-select: none;
  -webkit-user-drag: element;
  transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background-color 0.15s ease;
}
.wb-palette-card:hover { border-color: var(--wb-primary-a32); box-shadow: var(--wb-shadow-2); transform: translateY(-1px); }
.wb-palette-card:active { cursor: grabbing; transform: translateY(0); }
.wb-palette-card.dragging, .wb-palette-card[data-dragging="true"] { opacity: 0.55; }
.wb-palette-card.gated, .wb-palette-card[data-gated="true"] { cursor: pointer; }
.wb-palette-card.gated .wb-palette-tile, .wb-palette-card[data-gated="true"] .wb-palette-tile { filter: grayscale(0.4) saturate(0.8); opacity: 0.8; }
.wb-palette-card.gated:hover, .wb-palette-card[data-gated="true"]:hover { border-color: rgba(255, 171, 0, 0.48); transform: none; }

.wb-palette-tile, .wb-tile {
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 36px; height: 36px; border-radius: 22%;
  color: #FFFFFF;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 2px 4px rgba(0, 0, 0, 0.12);
}
.wb-tile img, .wb-palette-tile img, .wb-node-tile img, .wb-panel-tile img { display: block; pointer-events: none; }
.wb-palette-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.wb-palette-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.wb-palette-name { font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.wb-palette-cost {
  flex-shrink: 0; max-width: 96px; overflow: hidden; white-space: nowrap;
  font-size: 12px; font-weight: 700; color: var(--wb-text-secondary);
  font-variant-numeric: tabular-nums;
  opacity: 1;
  transition: max-width 0.2s ease, opacity 0.15s ease, margin 0.2s ease;
}
.wb-palette-card:hover .wb-palette-cost { max-width: 0; opacity: 0; margin-left: -8px; }
.wb-palette-cost.free, .wb-palette-cost[data-free="true"] { color: var(--wb-success-dark); }
.wb-palette-cost.included, .wb-palette-cost[data-included="true"] { color: var(--wb-text-disabled); font-weight: 600; font-style: italic; }
.wb-palette-desc { font-size: 12px; line-height: 1.45; color: var(--wb-text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.wb-palette-empty { padding: 32px 16px; text-align: center; color: var(--wb-text-secondary); font-size: 13px; }
.wb-palette-count { font-size: 11px; color: var(--wb-text-disabled); font-weight: 600; }

.wb-lock-chip {
  display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0;
  height: 20px; padding: 0 6px; border-radius: 6px;
  background: var(--wb-warning-a16); color: var(--wb-warning-dark);
  font-size: 11px; font-weight: 700; line-height: 1;
}
.wb-lock-chip-abs { position: absolute; top: 8px; right: 8px; }

   6. List mode (spec §5) — chain of step cards
   ============================================================ */
.wb-list { position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; }
.wb-list-dropzone {
  display: flex; flex-direction: column; align-items: center;
  min-height: 100%; width: 100%;
  padding: 32px 24px 96px;
  transition: background-color 0.15s ease;
}
.wb-list-dropzone.drag-over, .wb-list-dropzone[data-drag-over="true"] { background-color: var(--wb-primary-a08); }
.wb-list-chain { display: flex; flex-direction: column; align-items: center; }

.wb-start-pill {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  height: 40px; min-width: 120px; padding: 0 18px;
  border-radius: 999px;
  background: var(--wb-success-a16);
  border: 1px solid var(--wb-success-a32);
  color: var(--wb-success-dark);
  font-size: 14px; font-weight: 700;
  user-select: none;
}
.wb-start-pill img { display: block; }
.wb-list-connector { width: 2px; height: 24px; background: var(--wb-connector); flex-shrink: 0; }
.wb-list-connector.active, .wb-list-connector[data-active="true"] { background: var(--wb-primary); }
.wb-list-insert {
  width: 280px; height: 0; border-top: 2px dashed var(--wb-primary); border-radius: 2px;
  opacity: 0; transition: opacity 0.12s ease, height 0.12s ease;
}
.wb-list-insert.visible, .wb-list-insert[data-visible="true"] { opacity: 1; height: 8px; margin: 4px 0; }

.wb-node-card, .wb-list-node, .wb-canvas-node-card {
  position: relative;
  display: flex; align-items: center; gap: 12px;
  width: 280px; min-height: 76px;
  padding: 12px 16px;
  border-radius: var(--wb-radius-md);
  background: var(--wb-paper);
  border: 1px solid var(--wb-divider);
  box-shadow: var(--wb-shadow-1);
  color: var(--wb-text);
  cursor: grab;
  user-select: none;
  transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
}
.wb-node-card:hover, .wb-list-node:hover, .wb-canvas-node-card:hover { border-color: var(--wb-border); box-shadow: var(--wb-shadow-2); }
.wb-node-card:active, .wb-list-node:active, .wb-canvas-node-card:active,
.wb-node-card.dragging, .wb-list-node.dragging, .wb-canvas-node-card.dragging { cursor: grabbing; }
.wb-list-node.dragging, .wb-list-node[data-dragging="true"] { opacity: 0.5; transform: scale(0.98); }
.wb-list-node.drag-over, .wb-list-node[data-drag-over="true"] { border-color: var(--wb-primary); box-shadow: var(--wb-ring); }
.wb-node-card.selected, .wb-list-node.selected, .wb-canvas-node-card.selected,
.wb-node-card[data-selected="true"], .wb-list-node[data-selected="true"], .wb-canvas-node-card[data-selected="true"] {
  border: 2px solid var(--wb-primary);
  padding: 11px 15px;
  box-shadow: 0 0 0 3px var(--wb-primary-a15);
}
.wb-canvas-node-card { width: 100%; height: 100%; min-height: 0; }
.wb-canvas-node-card.disabled, .wb-node-card.disabled, .wb-list-node.disabled { opacity: 0.6; cursor: default; }

.wb-node-tile {
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 40px; height: 40px; border-radius: 8px;
  color: #FFFFFF;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 2px 4px rgba(0, 0, 0, 0.12);
}
.wb-node-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.wb-node-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.wb-node-title { font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.wb-node-cost { flex-shrink: 0; font-size: 12px; font-weight: 700; color: var(--wb-text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }
.wb-node-cost.included, .wb-node-cost[data-included="true"] { color: var(--wb-text-disabled); font-weight: 600; font-style: italic; }
.wb-node-desc { font-size: 12px; line-height: 1.45; color: var(--wb-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wb-node-drag { color: var(--wb-text-disabled); display: inline-flex; flex-shrink: 0; margin-left: -6px; cursor: grab; }

.wb-node-badge {
  position: absolute; top: -10px; left: -10px; z-index: 1;
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--wb-text); color: var(--wb-paper);
  font-size: 11px; font-weight: 700; line-height: 1;
  box-shadow: 0 0 0 2px var(--wb-paper);
  pointer-events: none;
}
.wb-node-badge.unreached, .wb-node-badge[data-unreached="true"] { background: var(--wb-text-disabled); }
.wb-node-delete {
  position: absolute; top: -9px; right: -9px; z-index: 1;
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--wb-error); color: #FFFFFF;
  font-size: 14px; font-weight: 700; line-height: 1;
  box-shadow: 0 0 0 2px var(--wb-paper);
  opacity: 0; transform: scale(0.8);
  transition: opacity 0.12s ease, transform 0.12s ease, background-color 0.12s ease;
  cursor: pointer;
}
.wb-list-node:hover .wb-node-delete, .wb-node-card:hover .wb-node-delete,
.wb-list-node.selected .wb-node-delete, .wb-node-card.selected .wb-node-delete,
.wb-node-delete:focus-visible { opacity: 1; transform: scale(1); }
.wb-node-delete:hover { background: var(--wb-error-dark); }
.wb-list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1 1 auto; min-height: 320px; padding: 32px; text-align: center; }
.wb-pinned-hint { font-size: 11px; color: var(--wb-text-disabled); display: inline-flex; align-items: center; gap: 4px; }

   7. Canvas mode (spec §6) — SVG free canvas
   ============================================================ */
.wb-canvas {
  position: absolute; inset: 0;
  overflow: hidden;
  background: var(--wb-canvas-bg);
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}
.wb-canvas.panning, .wb-canvas[data-panning="true"] { cursor: grabbing; }
.wb-canvas.drawing, .wb-canvas[data-drawing="true"] { cursor: crosshair; }
.wb-canvas.drag-over, .wb-canvas[data-drag-over="true"] { box-shadow: inset 0 0 0 2px var(--wb-primary-a32); }
.wb-canvas-svg { display: block; width: 100%; height: 100%; user-select: none; overflow: visible; }
.wb-canvas-svg text { user-select: none; pointer-events: none; }
.wb-canvas-dot { fill: var(--wb-dot); }

.wb-canvas-node { cursor: grab; }
.wb-canvas-node.dragging, .wb-canvas-node[data-dragging="true"] { cursor: grabbing; }
.wb-canvas-node foreignObject { overflow: visible; }
.wb-canvas-node foreignObject > div { width: 100%; height: 100%; }
.wb-node-number { fill: var(--wb-text); }
.wb-node-number-text { fill: var(--wb-paper); font-size: 11px; font-weight: 700; text-anchor: middle; dominant-baseline: central; }
.wb-node-x { fill: var(--wb-error); cursor: pointer; opacity: 0; transition: opacity 0.12s ease; }
.wb-node-x-text { fill: #FFFFFF; font-size: 14px; font-weight: 700; text-anchor: middle; dominant-baseline: central; pointer-events: none; opacity: 0; transition: opacity 0.12s ease; }
.wb-canvas-node:hover .wb-node-x, .wb-canvas-node:hover .wb-node-x-text,
.wb-canvas-node.selected .wb-node-x, .wb-canvas-node.selected .wb-node-x-text,
.wb-canvas-node[data-selected="true"] .wb-node-x, .wb-canvas-node[data-selected="true"] .wb-node-x-text { opacity: 1; }
.wb-node-x:hover { fill: var(--wb-error-dark); }

.wb-anchor {
  fill: var(--wb-paper); stroke: var(--wb-primary); stroke-width: 2;
  opacity: 0; cursor: crosshair;
  transition: opacity 0.12s ease, r 0.12s ease, fill 0.12s ease;
}
.wb-canvas-node:hover .wb-anchor, .wb-start-node:hover .wb-anchor,
.wb-canvas-node.selected .wb-anchor, .wb-canvas-node[data-selected="true"] .wb-anchor,
.wb-canvas.drawing .wb-anchor, .wb-canvas[data-drawing="true"] .wb-anchor,
.wb-anchor.visible, .wb-anchor[data-visible="true"] { opacity: 1; }
.wb-anchor:hover, .wb-anchor.hot, .wb-anchor[data-hot="true"] { fill: var(--wb-primary); r: 9; }
.wb-anchor-hit { fill: transparent; stroke: none; cursor: crosshair; }

.wb-start-node { cursor: grab; }
.wb-start-node-card {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; height: 100%;
  border-radius: 25px;
  background: var(--wb-success-a16);
  border: 1px solid var(--wb-success-a32);
  color: var(--wb-success-dark);
  font-size: 14px; font-weight: 700;
  user-select: none;
}
.wb-start-node-card img { display: block; }

.wb-conn-hit { fill: none; stroke: transparent; stroke-width: 16; cursor: pointer; pointer-events: stroke; }
.wb-conn { fill: none; stroke: var(--wb-conn-stroke); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; transition: stroke 0.12s ease; }
.wb-conn-hit:hover + .wb-conn, .wb-conn.hover { stroke: var(--wb-text-secondary); }
.wb-conn.selected, .wb-conn[data-selected="true"] { stroke: var(--wb-primary); stroke-width: 2.5; filter: url(#conn-glow); }
.wb-conn-preview { fill: none; stroke: var(--wb-primary); stroke-width: 2; stroke-dasharray: 6 6; stroke-linecap: round; pointer-events: none; animation: wb-dash 0.6s linear infinite; }
.wb-arrow-marker { fill: var(--wb-conn-stroke); }
.wb-arrow-marker.selected { fill: var(--wb-primary); }
.wb-conn-label-bg { fill: var(--wb-paper); stroke: var(--wb-divider); }
.wb-conn-label { fill: var(--wb-text-secondary); font-size: 11px; font-weight: 600; text-anchor: middle; dominant-baseline: central; }
.wb-waypoint { fill: var(--wb-paper); stroke: var(--wb-primary); stroke-width: 2; cursor: move; }

.wb-minimap {
  position: absolute; right: 16px; bottom: 16px; z-index: var(--wb-z-overlay);
  width: 180px; height: 130px;
  border-radius: var(--wb-radius);
  background: var(--wb-paper);
  border: 1px solid var(--wb-divider);
  box-shadow: var(--wb-shadow-8);
  overflow: hidden; cursor: pointer;
  opacity: 0.94; transition: opacity 0.15s ease;
}
.wb-minimap:hover { opacity: 1; }
.wb-minimap svg { display: block; width: 100%; height: 100%; }
.wb-minimap-node { fill: var(--wb-grey-a40); stroke: none; }
.wb-minimap-node.selected, .wb-minimap-node[data-selected="true"] { fill: var(--wb-primary); }
.wb-minimap-start { fill: var(--wb-success); }
.wb-minimap-conn { fill: none; stroke: var(--wb-conn-stroke); stroke-width: 1; }
.wb-minimap-viewport { fill: var(--wb-primary-a12); stroke: var(--wb-primary); stroke-width: 1; }

.wb-toolbar {
  position: absolute; left: 50%; bottom: 16px; z-index: var(--wb-z-overlay);
  transform: translateX(-50%);
  display: inline-flex; align-items: center; gap: 2px;
  padding: 4px; border-radius: 999px;
  background: var(--wb-paper);
  border: 1px solid var(--wb-divider);
  box-shadow: var(--wb-shadow-8);
}
.wb-toolbar .wb-iconbtn { width: 30px; height: 30px; }
.wb-toolbar-zoom { min-width: 48px; text-align: center; font-size: 12px; font-weight: 700; color: var(--wb-text-secondary); font-variant-numeric: tabular-nums; user-select: none; }
.wb-toolbar-sep { width: 1px; height: 18px; background: var(--wb-divider); margin: 0 4px; }
.wb-toolbar-btn { height: 30px; padding: 0 12px; border-radius: 999px; font-size: 12px; font-weight: 700; color: var(--wb-text-secondary); display: inline-flex; align-items: center; gap: 6px; }
.wb-toolbar-btn:hover { background: var(--wb-grey-a08); color: var(--wb-text); }

.wb-canvas-empty {
  position: absolute; inset: 0; z-index: 1;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  pointer-events: none; text-align: center; padding: 32px;
}
.wb-canvas-hint { position: absolute; left: 16px; bottom: 16px; z-index: var(--wb-z-overlay); font-size: 11px; color: var(--wb-text-disabled); pointer-events: none; }

   8. Right config panel (spec §7)
   ============================================================ */
.wb-panel-body { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 24px 0; }
.wb-panel-expand {
  position: absolute; left: 0; top: 50%; z-index: 2;
  transform: translate(-100%, -50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 48px;
  border-radius: 10px 0 0 10px;
  background: var(--wb-paper);
  border: 1px solid var(--wb-divider); border-right: 0;
  box-shadow: -4px 0 12px -6px rgba(22, 28, 36, 0.24);
  color: var(--wb-text-secondary); cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}
.wb-panel-expand:hover { background: var(--wb-grey-a08); color: var(--wb-primary); }
.wb-panel-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  flex: 1 1 auto; min-height: 320px; padding: 32px 24px; text-align: center;
  color: var(--wb-text-secondary);
}
.wb-panel-empty-icon { color: var(--wb-text-disabled); margin-bottom: 16px; display: inline-flex; }
.wb-panel-empty-title { font-size: 16px; font-weight: 600; color: var(--wb-text); margin-bottom: 4px; }
.wb-panel-empty-hint { font-size: 14px; white-space: pre-line; line-height: 1.6; }

.wb-panel-header { display: flex; align-items: center; gap: 12px; padding: 0 24px 16px; }
.wb-panel-tile {
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 50px; height: 50px; border-radius: 12px;
  color: #FFFFFF;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 4px 10px -2px rgba(0, 0, 0, 0.18);
}
.wb-panel-header-text { min-width: 0; display: flex; flex-direction: column; }
.wb-panel-step-label { font-size: 12px; font-weight: 600; color: var(--wb-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wb-panel-heading { font-size: 18px; font-weight: 700; line-height: 1.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wb-panel-alert { margin: 0 24px 16px; }
.wb-panel-nogroups { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; color: var(--wb-text-secondary); gap: 8px; }

.wb-group { padding: 16px 24px; border-bottom: 1px dashed var(--wb-divider); }
.wb-group:last-child { border-bottom: 0; }
.wb-group-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.wb-group-title { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 700; min-width: 0; }
.wb-group-icon { display: inline-flex; color: var(--wb-text-secondary); }
.wb-group-tooltip { display: inline-flex; color: var(--wb-text-disabled); cursor: help; }
.wb-group-body { display: flex; flex-direction: column; gap: 12px; }
.wb-group-collapsed .wb-group-body { display: none; }

.wb-field { position: relative; transition: opacity 0.15s ease; }
.wb-field.disabled, .wb-field[data-disabled="true"] { opacity: 0.5; }
.wb-field.disabled *, .wb-field[data-disabled="true"] * { cursor: not-allowed; }
.wb-field-label { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.wb-field-sublabel { font-size: 12px; color: var(--wb-text-secondary); line-height: 1.45; }
.wb-field-help { font-size: 12px; color: var(--wb-text-secondary); margin-top: 6px; line-height: 1.45; }
.wb-field-lock { display: inline-flex; color: var(--wb-text-disabled); }
.wb-field-flag { font-size: 14px; line-height: 1; }
.wb-field-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.wb-field-cost { font-size: 12px; font-weight: 700; color: var(--wb-primary); font-variant-numeric: tabular-nums; }

   9. Field controls (ui primitives)
   ============================================================ */
.wb-textfield { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.wb-textfield-full { width: 100%; }
.wb-label { font-size: 12px; font-weight: 600; color: var(--wb-text-secondary); line-height: 1.4; }
.wb-label-required::after { content: " *"; color: var(--wb-error); }
.wb-input-wrap { position: relative; display: flex; align-items: center; min-width: 0; }
.wb-input-wrap .wb-input-icon { position: absolute; left: 12px; display: inline-flex; color: var(--wb-text-disabled); pointer-events: none; }
.wb-input-wrap .wb-input-icon + .wb-input, .wb-input-wrap.has-icon .wb-input { padding-left: 36px; }
.wb-input-wrap .wb-input-end { position: absolute; right: 8px; display: inline-flex; align-items: center; gap: 4px; }
.wb-input {
  display: block; width: 100%; min-width: 0;
  height: 40px; padding: 0 12px;
  border-radius: var(--wb-radius);
  border: 1px solid var(--wb-border);
  background: var(--wb-paper);
  color: var(--wb-text);
  font-size: 14px; line-height: 1.5;
  transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
  appearance: none; -webkit-appearance: none;
}
.wb-input::placeholder { color: var(--wb-text-disabled); opacity: 1; }
.wb-input:hover { border-color: var(--wb-text); }
.wb-input:focus, .wb-input:focus-visible { border-color: var(--wb-primary); box-shadow: var(--wb-ring); outline: none; }
.wb-input:disabled, .wb-input[readonly].disabled { background: var(--wb-grey-a08); color: var(--wb-text-disabled); border-color: var(--wb-divider); cursor: not-allowed; }
.wb-input-sm { height: 36px; padding: 0 10px; font-size: 13px; border-radius: 6px; }
.wb-input-xs { height: 30px; padding: 0 8px; font-size: 12px; border-radius: 6px; }
textarea.wb-input { height: auto; min-height: 80px; padding: 10px 12px; resize: vertical; line-height: 1.5; }
textarea.wb-input.wb-input-sm { min-height: 64px; padding: 8px 10px; }
.wb-input.error, .wb-input[aria-invalid="true"] { border-color: var(--wb-error); }
.wb-input.error:focus, .wb-input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px var(--wb-error-a16); }
.wb-helper { font-size: 12px; color: var(--wb-text-secondary); line-height: 1.4; margin-top: 2px; }
.wb-helper-error, .wb-helper.error { color: var(--wb-error); }
.wb-input-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
.wb-input-number { width: 88px; text-align: right; font-variant-numeric: tabular-nums; }

.wb-select-wrap { position: relative; display: block; min-width: 0; }
select.wb-input, .wb-select {
  display: block; width: 100%; height: 40px; padding: 0 36px 0 12px;
  border-radius: var(--wb-radius); border: 1px solid var(--wb-border);
  background-color: var(--wb-paper); color: var(--wb-text);
  font-size: 14px; line-height: 1.5; cursor: pointer;
  appearance: none; -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23637381' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 12px center; background-size: 16px 16px;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
select.wb-input:hover, .wb-select:hover { border-color: var(--wb-text); }
select.wb-input:focus, .wb-select:focus { border-color: var(--wb-primary); box-shadow: var(--wb-ring); outline: none; }
select.wb-input:disabled, .wb-select:disabled { background-color: var(--wb-grey-a08); color: var(--wb-text-disabled); cursor: not-allowed; }
select.wb-input.wb-input-sm, .wb-select.wb-select-sm { height: 36px; padding: 0 32px 0 10px; font-size: 13px; border-radius: 6px; }
select.wb-input option, .wb-select option { color: #1C252E; background: #FFFFFF; }
.dark select.wb-input option, .dark .wb-select option { color: #FFFFFF; background: #1C252E; }
.dark select.wb-input, .dark .wb-select {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23919EAB' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}

.wb-checkbox, .wb-radio { width: 18px; height: 18px; margin: 0; accent-color: var(--wb-primary); cursor: pointer; flex-shrink: 0; }
.wb-checkbox:disabled, .wb-radio:disabled { cursor: not-allowed; opacity: 0.6; }
.wb-check-row { display: flex; align-items: center; gap: 10px; font-size: 14px; cursor: pointer; }
.wb-check-row.disabled { cursor: not-allowed; opacity: 0.5; }

.wb-switch { display: inline-flex; align-items: center; gap: 12px; cursor: pointer; user-select: none; min-width: 0; }
.wb-switch.wb-switch-start { flex-direction: row-reverse; justify-content: space-between; width: 100%; }
.wb-switch.disabled, .wb-switch[data-disabled="true"], .wb-switch[aria-disabled="true"] { cursor: not-allowed; opacity: 0.6; }
.wb-switch input[type="checkbox"] { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; opacity: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.wb-switch-track {
  position: relative; display: inline-block; flex-shrink: 0;
  width: 34px; height: 20px; border-radius: 10px;
  background: var(--wb-grey-a40);
  transition: background-color 0.18s ease, box-shadow 0.15s ease;
}
.wb-switch-thumb, .wb-switch-track::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 16px; height: 16px; border-radius: 50%;
  background: #FFFFFF;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  transition: transform 0.18s ease;
}
.wb-switch-track.checked, .wb-switch-track[data-checked="true"],
.wb-switch input:checked + .wb-switch-track, .wb-switch.checked .wb-switch-track, .wb-switch[data-checked="true"] .wb-switch-track,
.wb-switch[aria-checked="true"] .wb-switch-track { background: var(--wb-primary); }
.wb-switch-track.checked::after, .wb-switch-track[data-checked="true"]::after,
.wb-switch input:checked + .wb-switch-track::after, .wb-switch.checked .wb-switch-track::after, .wb-switch[data-checked="true"] .wb-switch-track::after,
.wb-switch[aria-checked="true"] .wb-switch-track::after,
.wb-switch-track.checked .wb-switch-thumb, .wb-switch-track[data-checked="true"] .wb-switch-thumb { transform: translateX(14px); }
.wb-switch-track:has(.wb-switch-thumb)::after { display: none; }
.wb-switch input:focus-visible + .wb-switch-track, .wb-switch:focus-visible .wb-switch-track { box-shadow: 0 0 0 3px var(--wb-primary-a24); }
.wb-switch-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.wb-switch-label { font-size: 14px; font-weight: 500; line-height: 1.4; display: inline-flex; align-items: center; gap: 6px; }
.wb-switch-sublabel { font-size: 12px; color: var(--wb-text-secondary); line-height: 1.4; }
.wb-switch-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 10px 12px; border-radius: var(--wb-radius);
  background: var(--wb-grey-a08);
  transition: background-color 0.15s ease;
}
.wb-switch-row:hover { background: var(--wb-grey-a12); }
.wb-switch-row.checked, .wb-switch-row[data-checked="true"] { background: var(--wb-primary-a08); }

.wb-slider-wrap { display: flex; flex-direction: column; gap: 6px; }
.wb-slider-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
.wb-slider-row .wb-slider { flex: 1 1 auto; }
.wb-slider {
  --wb-fill: 0%;
  display: block; width: 100%; height: 20px; margin: 0; padding: 0;
  background: transparent; cursor: pointer;
  appearance: none; -webkit-appearance: none;
}
.wb-slider:disabled { cursor: not-allowed; opacity: 0.5; }
.wb-slider::-webkit-slider-runnable-track {
  height: 4px; border-radius: 2px;
  background: linear-gradient(to right, var(--wb-primary) 0%, var(--wb-primary) var(--wb-fill), var(--wb-grey-a24) var(--wb-fill), var(--wb-grey-a24) 100%);
}
.wb-slider::-moz-range-track { height: 4px; border-radius: 2px; background: var(--wb-grey-a24); }
.wb-slider::-moz-range-progress { height: 4px; border-radius: 2px; background: var(--wb-primary); }
.wb-slider::-webkit-slider-thumb {
  appearance: none; -webkit-appearance: none;
  width: 16px; height: 16px; margin-top: -6px; border-radius: 50%;
  background: var(--wb-primary); border: 2px solid #FFFFFF;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
  transition: box-shadow 0.15s ease, transform 0.1s ease;
}
.wb-slider::-moz-range-thumb {
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--wb-primary); border: 2px solid #FFFFFF;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
}
.wb-slider:hover::-webkit-slider-thumb, .wb-slider:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 6px var(--wb-primary-a15), 0 1px 4px rgba(0, 0, 0, 0.24); }
.wb-slider:active::-webkit-slider-thumb { transform: scale(1.1); box-shadow: 0 0 0 8px var(--wb-primary-a24); }
.wb-slider:hover::-moz-range-thumb, .wb-slider:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 6px var(--wb-primary-a15); }
.wb-slider-value {
  flex-shrink: 0; min-width: 44px; height: 24px; padding: 0 8px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 6px; background: var(--wb-primary-a08); color: var(--wb-primary);
  font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.wb-slider-marks { position: relative; display: flex; justify-content: space-between; padding: 0 7px; font-size: 11px; color: var(--wb-text-disabled); font-variant-numeric: tabular-nums; }
.wb-slider-mark { position: relative; text-align: center; min-width: 0; }
.wb-slider-mark.active { color: var(--wb-primary); font-weight: 700; }
.wb-slider-mark-abs { position: absolute; top: 0; transform: translateX(-50%); white-space: nowrap; }

.wb-range { display: flex; flex-direction: column; gap: 8px; }
.wb-range-track { position: relative; height: 24px; }
.wb-range-rail { position: absolute; left: 8px; right: 8px; top: 10px; height: 4px; border-radius: 2px; overflow: hidden; display: flex; background: var(--wb-grey-a24); }
.wb-range-rail-seg { height: 100%; }
.wb-range-input {
  position: absolute; inset: 0; width: 100%; height: 24px; margin: 0; padding: 0;
  background: transparent; appearance: none; -webkit-appearance: none;
  pointer-events: none; cursor: pointer;
}
.wb-range-input::-webkit-slider-runnable-track { height: 4px; background: transparent; }
.wb-range-input::-moz-range-track { height: 4px; background: transparent; }
.wb-range-input::-webkit-slider-thumb {
  appearance: none; -webkit-appearance: none; pointer-events: auto;
  width: 16px; height: 16px; margin-top: -6px; border-radius: 50%;
  background: var(--wb-paper); border: 2px solid var(--wb-primary);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
}
.wb-range-input::-moz-range-thumb {
  pointer-events: auto; width: 16px; height: 16px; border-radius: 50%;
  background: var(--wb-paper); border: 2px solid var(--wb-primary);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
}
.wb-range-input:hover::-webkit-slider-thumb, .wb-range-input:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 6px var(--wb-primary-a15); }
.wb-range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--wb-text-secondary); }
.wb-range-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.wb-range-chip {
  display: inline-flex; align-items: center; gap: 6px; flex: 1 1 0; min-width: 0;
  height: 28px; padding: 0 10px; border-radius: 8px;
  font-size: 12px; font-weight: 700; white-space: nowrap;
  color: #FFFFFF; background: var(--wb-chip-color, var(--wb-primary));
  font-variant-numeric: tabular-nums;
}
.wb-range-chip-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255, 255, 255, 0.8); flex-shrink: 0; }

.wb-tabs { display: inline-flex; align-items: center; gap: 4px; padding: 4px; border-radius: 10px; background: var(--wb-grey-a08); max-width: 100%; }
.wb-tabs-full { display: flex; width: 100%; }
.wb-tabs-full .wb-tab { flex: 1 1 0; justify-content: center; }
.wb-tab {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  height: 28px; padding: 0 12px; border-radius: 8px;
  font-size: 13px; font-weight: 600; color: var(--wb-text-secondary);
  white-space: nowrap; cursor: pointer; user-select: none;
  transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.wb-tab:hover { color: var(--wb-text); }
.wb-tab.active, .wb-tab[aria-selected="true"], .wb-tab[data-active="true"] { background: var(--wb-paper); color: var(--wb-text); box-shadow: var(--wb-shadow-1); }
.wb-tab:disabled { opacity: 0.5; cursor: not-allowed; }
.wb-tab-count { font-size: 11px; font-weight: 700; color: var(--wb-text-disabled); }
.wb-tabs-underline { display: flex; gap: 20px; padding: 0; border-radius: 0; background: transparent; border-bottom: 1px solid var(--wb-divider); }
.wb-tabs-underline .wb-tab { height: 40px; padding: 0 2px; border-radius: 0; box-shadow: none; background: transparent; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.wb-tabs-underline .wb-tab.active, .wb-tabs-underline .wb-tab[aria-selected="true"] { background: transparent; box-shadow: none; color: var(--wb-text); border-bottom-color: var(--wb-text); }
.wb-tabpanel { padding-top: 16px; }

.wb-chip {
  display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0;
  height: 24px; padding: 0 8px; border-radius: 6px;
  font-size: 12px; font-weight: 700; line-height: 1; white-space: nowrap;
  background: var(--wb-grey-a16); color: var(--wb-text);
  font-variant-numeric: tabular-nums;
}
.wb-chip-sm { height: 20px; padding: 0 6px; font-size: 11px; }
.wb-chip-primary { background: var(--wb-primary-a12); color: var(--wb-primary); }
.wb-chip-success { background: var(--wb-success-a16); color: var(--wb-success-dark); }
.wb-chip-warning { background: var(--wb-warning-a16); color: var(--wb-warning-dark); }
.wb-chip-error { background: var(--wb-error-a16); color: var(--wb-error-dark); }
.wb-chip-info { background: var(--wb-info-a16); color: var(--wb-info-dark); }
.wb-chip-outlined { background: transparent; border: 1px solid var(--wb-border); }
.wb-chip-clickable { cursor: pointer; transition: filter 0.15s ease; }
.wb-chip-clickable:hover { filter: brightness(0.96); }
.wb-chip-delete { display: inline-flex; margin-right: -2px; border-radius: 50%; opacity: 0.7; cursor: pointer; }
.wb-chip-delete:hover { opacity: 1; }
.wb-chips { display: flex; flex-wrap: wrap; gap: 6px; }

.wb-alert {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 12px 16px; border-radius: var(--wb-radius);
  font-size: 13px; line-height: 1.5;
  background: var(--wb-info-a16); color: var(--wb-info-dark);
}
.wb-alert-icon { display: inline-flex; flex-shrink: 0; margin-top: 1px; }
.wb-alert-body { min-width: 0; flex: 1 1 auto; }
.wb-alert-title { font-weight: 700; margin-bottom: 2px; }
.wb-alert-info { background: var(--wb-info-a16); color: var(--wb-info-dark); }
.wb-alert-success { background: var(--wb-success-a16); color: var(--wb-success-dark); }
.wb-alert-warning { background: var(--wb-warning-a16); color: var(--wb-warning-dark); }
.wb-alert-error { background: var(--wb-error-a16); color: var(--wb-error-dark); }
.wb-alert-outlined { background: transparent; border: 1px solid currentColor; }

.wb-divider { height: 1px; width: 100%; background: var(--wb-divider); border: 0; margin: 16px 0; flex-shrink: 0; }
.wb-divider-tight { margin: 8px 0; }
.wb-divider-dashed { height: 0; background: transparent; border-top: 1px dashed var(--wb-divider); }
.wb-divider-vertical { width: 1px; height: auto; align-self: stretch; background: var(--wb-divider); margin: 0 8px; }
.wb-divider-text { display: flex; align-items: center; gap: 12px; font-size: 11px; font-weight: 700; color: var(--wb-text-disabled); text-transform: uppercase; letter-spacing: 0.06em; margin: 16px 0; }
.wb-divider-text::before, .wb-divider-text::after { content: ""; flex: 1 1 auto; height: 1px; background: var(--wb-divider); }

.wb-tooltip { position: relative; display: inline-flex; }
.wb-tooltip-bubble {
  position: absolute; left: 50%; bottom: calc(100% + 6px); z-index: var(--wb-z-menu);
  transform: translateX(-50%) translateY(2px);
  max-width: 240px; padding: 6px 8px; border-radius: 6px;
  background: var(--wb-tooltip-bg); color: var(--wb-tooltip-fg);
  font-size: 12px; font-weight: 500; line-height: 1.4; white-space: normal; text-align: center;
  box-shadow: var(--wb-shadow-2);
  opacity: 0; pointer-events: none;
  transition: opacity 0.12s ease, transform 0.12s ease;
}
.wb-tooltip-bubble.nowrap { white-space: nowrap; }
.wb-tooltip-bubble::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--wb-tooltip-bg); }
.wb-tooltip-bottom .wb-tooltip-bubble { bottom: auto; top: calc(100% + 6px); transform: translateX(-50%) translateY(-2px); }
.wb-tooltip-bottom .wb-tooltip-bubble::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: var(--wb-tooltip-bg); }
.wb-tooltip:hover .wb-tooltip-bubble, .wb-tooltip:focus-within .wb-tooltip-bubble,
.wb-tooltip-bubble.visible, .wb-tooltip-bubble[data-visible="true"] { opacity: 1; transform: translateX(-50%) translateY(0); }

.wb-menu-anchor { position: relative; display: inline-flex; }
.wb-menu {
  position: absolute; top: calc(100% + 4px); right: 0; z-index: var(--wb-z-menu);
  min-width: 180px; max-height: 320px; overflow-y: auto;
  padding: 4px; border-radius: 10px;
  background: var(--wb-paper); border: 1px solid var(--wb-divider);
  box-shadow: var(--wb-shadow-24);
  animation: wb-fade-in 0.12s ease;
}
.wb-menu-left { right: auto; left: 0; }
.wb-menu-item {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 8px 10px; border-radius: 6px;
  font-size: 13px; font-weight: 500; color: var(--wb-text); text-align: left;
  cursor: pointer; white-space: nowrap;
}
.wb-menu-item:hover { background: var(--wb-grey-a08); }
.wb-menu-item.active, .wb-menu-item[aria-selected="true"] { background: var(--wb-primary-a08); color: var(--wb-primary); font-weight: 600; }
.wb-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
.wb-menu-item.danger { color: var(--wb-error); }
.wb-menu-sep { height: 1px; background: var(--wb-divider); margin: 4px 0; }

.wb-table-wrap { width: 100%; overflow-x: auto; border: 1px solid var(--wb-divider); border-radius: var(--wb-radius); }
.wb-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.wb-table th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--wb-text-secondary); background: var(--wb-grey-a08); border-bottom: 1px solid var(--wb-divider); white-space: nowrap; }
.wb-table td { padding: 8px 12px; border-bottom: 1px solid var(--wb-divider); vertical-align: middle; }
.wb-table tr:last-child td { border-bottom: 0; }
.wb-table tbody tr { transition: background-color 0.12s ease; }
.wb-table tbody tr:hover { background: var(--wb-grey-a08); }
.wb-table tbody tr.selected, .wb-table tbody tr[data-selected="true"] { background: var(--wb-primary-a08); }
.wb-table tbody tr.clickable { cursor: pointer; }
.wb-table td.num, .wb-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.wb-table-empty { padding: 24px; text-align: center; color: var(--wb-text-secondary); font-size: 13px; }

.wb-card { background: var(--wb-paper); border: 1px solid var(--wb-divider); border-radius: var(--wb-radius-md); padding: 16px; }
.wb-card-sm { padding: 12px; border-radius: var(--wb-radius); }
.wb-card-tint { background: var(--wb-grey-a08); border-color: transparent; }
.wb-card-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.wb-card-title { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.wb-editor-list { display: flex; flex-direction: column; gap: 8px; }
.wb-editor-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.wb-editor-row > .wb-input, .wb-editor-row > .wb-textfield, .wb-editor-row > .wb-select-wrap, .wb-editor-row > .wb-grow { flex: 1 1 auto; min-width: 0; }
.wb-editor-index {
  display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--wb-grey-a16); color: var(--wb-text-secondary);
  font-size: 11px; font-weight: 700;
}
.wb-editor-block { border: 1px solid var(--wb-divider); border-radius: var(--wb-radius); padding: 12px; display: flex; flex-direction: column; gap: 10px; background: var(--wb-paper); }
.wb-editor-block.active, .wb-editor-block[data-active="true"] { border-color: var(--wb-primary-a32); background: var(--wb-primary-a08); }
.wb-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--wb-text-secondary); }
.wb-page-tab { font-size: 12px; font-weight: 700; }
.wb-kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; }
.wb-kv dt { color: var(--wb-text-secondary); font-weight: 500; }
.wb-kv dd { margin: 0; font-weight: 600; }

.wb-weight-list { display: flex; flex-direction: column; gap: 8px; }
.wb-weight-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
.wb-weight-name { width: 64px; flex-shrink: 0; color: var(--wb-text-secondary); font-weight: 600; }
.wb-weight-bar { flex: 1 1 auto; height: 8px; border-radius: 4px; background: var(--wb-grey-a16); overflow: hidden; }
.wb-weight-fill { height: 100%; border-radius: 4px; background: var(--wb-gradient); transition: width 0.25s ease; }
.wb-weight-value { width: 36px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.wb-weight-total { font-size: 12px; font-weight: 700; color: var(--wb-text-secondary); text-align: right; }
.wb-weight-total.ok { color: var(--wb-success-dark); }
.wb-weight-total.bad { color: var(--wb-error); }
.wb-estimate { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: var(--wb-radius); background: var(--wb-primary-a08); color: var(--wb-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.wb-estimate-label { font-weight: 500; color: var(--wb-text-secondary); }

.wb-country-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.wb-country-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; cursor: pointer; min-width: 0; }
.wb-country-item:hover { background: var(--wb-grey-a08); }
.wb-country-item.selected, .wb-country-item[data-selected="true"] { background: var(--wb-primary-a08); }
.wb-country-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wb-country-code { font-size: 11px; font-weight: 700; color: var(--wb-text-disabled); font-variant-numeric: tabular-nums; }
.wb-flag { font-size: 16px; line-height: 1; flex-shrink: 0; }
.wb-picker-list { max-height: 320px; overflow-y: auto; border: 1px solid var(--wb-divider); border-radius: var(--wb-radius); padding: 4px; display: flex; flex-direction: column; gap: 2px; }
.wb-picker-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; font-size: 12px; color: var(--wb-text-secondary); }
.wb-option-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--wb-divider); font-size: 13px; }
.wb-option-row.selected, .wb-option-row[data-selected="true"] { border-color: var(--wb-primary-a32); background: var(--wb-primary-a08); }
.wb-option-hint { font-size: 12px; color: var(--wb-text-secondary); font-style: italic; }
.wb-option-cost { font-size: 12px; font-weight: 700; color: var(--wb-primary); font-variant-numeric: tabular-nums; }

.wb-esign-doc { position: relative; aspect-ratio: 8.5 / 11; width: 100%; max-width: 320px; margin: 0 auto; background: #FFFFFF; border: 1px solid var(--wb-border); border-radius: 6px; box-shadow: var(--wb-shadow-2); overflow: hidden; color: #1C252E; }
.wb-esign-line { height: 6px; border-radius: 3px; background: rgba(145, 158, 171, 0.24); margin: 0 16px; }
.wb-esign-field { position: absolute; display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px; border-radius: 4px; border: 1px dashed var(--wb-primary); background: var(--wb-primary-a12); color: var(--wb-primary); font-size: 10px; font-weight: 700; cursor: move; white-space: nowrap; }
.wb-esign-field.selected, .wb-esign-field[data-selected="true"] { border-style: solid; box-shadow: 0 0 0 2px var(--wb-primary-a24); }
.wb-esign-template { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--wb-radius); border: 1px solid var(--wb-divider); cursor: pointer; }
.wb-esign-template:hover { border-color: var(--wb-border); background: var(--wb-grey-a08); }
.wb-esign-template.selected, .wb-esign-template[data-selected="true"] { border-color: var(--wb-primary); background: var(--wb-primary-a08); }
.wb-esign-thumb { width: 36px; height: 46px; border-radius: 4px; background: #FFFFFF; border: 1px solid var(--wb-border); flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; padding: 6px 5px; }
.wb-esign-thumb span { display: block; height: 3px; border-radius: 2px; background: rgba(145, 158, 171, 0.4); }


.wb-header-mid { display: flex; align-items: center; gap: 12px; flex: 1 1 auto; min-width: 0; }
.wb-header-mid .wb-field { margin: 0; }
.wb-header-mid .wb-field:first-child { flex: 0 1 320px; }
.wb-header-mid .wb-field:last-child { flex: 0 1 240px; }
.wb-viewtoggle { position: absolute; top: 64px; right: 16px; z-index: 6; display: flex; align-items: center; gap: 4px; padding: 4px; border-radius: 10px; background: var(--wb-paper, #fff); border: 1px solid var(--wb-border, #E5E8EC); box-shadow: 0 4px 14px rgba(16, 24, 40, 0.10); }
.wb-canvas-wrap { position: relative; flex: 1 1 auto; min-width: 0; height: 100%; overflow: hidden; background: var(--wb-canvas-bg, #FAFBFC); }
.wb-canvas-svg { display: block; width: 100%; height: 100%; }
.wb-canvas-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.wb-json { max-height: 320px; overflow: auto; background: #0F1722; color: #D7E1EE; border-radius: 8px; padding: 12px 14px; font-size: 11.5px; line-height: 1.55; white-space: pre; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.wb-send { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.wb-preview { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 24px 12px 40px; height: 100%; overflow: auto; }
.wb-device { flex: 0 0 auto; }
.wb-bezel { background: #1E1E1E; padding: 12px; box-shadow: 0 18px 44px rgba(16, 24, 40, 0.30); }
.wb-bezel-mobile { border-radius: 40px; }
.wb-bezel-desktop { border-radius: 16px; }
.wb-screen { position: relative; overflow: hidden; display: flex; flex-direction: column; border-radius: 28px; }
.wb-bezel-desktop .wb-screen { border-radius: 8px; }
.wb-screen::-webkit-scrollbar { display: none; }
.wb-screen-header { position: absolute; top: 0; left: 0; right: 0; z-index: 0; background: #fff; box-shadow: 0 1px 3px rgba(16,24,40,0.10); padding: 8px 14px 10px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.wb-drag-pill { width: 80px; height: 8px; border-radius: 999px; background: #E3E7EC; }
.wb-screen-header-row { width: 100%; display: flex; align-items: center; justify-content: space-between; }
.wb-screen-header-right { display: inline-flex; align-items: center; gap: 8px; color: #6B7280; }
.wb-lang { font-size: 11px; font-weight: 600; }
.wb-screen-body { flex: 1 1 auto; overflow: auto; padding: 76px 18px 24px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
.wb-screen-body-top { justify-content: flex-start; }
.wb-pv-card { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
.wb-pv-icon { width: 56px; height: 56px; border-radius: 16px; display: inline-flex; align-items: center; justify-content: center; }
.wb-pv-title { margin: 0; font-size: 17px; font-weight: 700; color: #1A1A1A; }
.wb-pv-sub { margin: 0; font-size: 13px; color: #6B7280; line-height: 1.5; }
.wb-pv-body { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.wb-pv-footer { width: 100%; margin-top: 6px; }
.wb-pv-btn { width: 100%; border: 0; color: #fff; border-radius: 10px; padding: 12px 16px; font-size: 14px; font-weight: 600; cursor: pointer; }
.wb-pv-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.wb-pv-btn-full { margin-top: 14px; }
.wb-pv-camera { position: relative; width: 190px; height: 190px; border-radius: 50%; border: 3px solid; display: flex; align-items: center; justify-content: center; background: #F4F7FB; }
.wb-pv-oval { width: 120px; height: 150px; border-radius: 50%; background: rgba(30,127,224,0.10); }
.wb-pv-check { position: absolute; width: 46px; height: 46px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.wb-pv-camera-label { position: absolute; bottom: -26px; font-size: 12px; color: #6B7280; }
.wb-pv-doc { width: 210px; height: 132px; border: 2px dashed; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: #F8FAFC; }
.wb-pv-dots { display: flex; gap: 6px; }
.wb-pv-dot { height: 8px; border-radius: 999px; }
.wb-pv-rows, .wb-pv-consent { width: 100%; display: flex; flex-direction: column; gap: 8px; text-align: left; }
.wb-pv-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E5E8EC; border-radius: 10px; font-size: 13px; }
.wb-pv-consent { font-size: 12.5px; color: #4B5563; line-height: 1.6; background: #F8FAFC; padding: 12px; border-radius: 10px; }
.wb-pv-upload { width: 100%; display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px dashed #CBD5E1; border-radius: 10px; background: #fff; cursor: pointer; font-size: 13px; }
.wb-pv-upload-on { border-style: solid; border-color: #22C55E; }
.wb-pv-upload .wb-muted { margin-left: auto; font-size: 11.5px; }
.wb-pv-sign { width: 100%; height: 110px; border: 2px dashed #CBD5E1; border-radius: 12px; background: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.wb-pv-sign-svg { width: 88%; height: 70px; }
.wb-pv-calling { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.wb-pv-phrase { font-size: 14px; font-weight: 600; color: #1A1A1A; }
.wb-pv-welcome, .wb-pv-complete { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
.wb-pv-org { font-size: 13px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
.wb-pv-empty, .wb-pv-placeholder { color: #98A2B3; font-size: 13px; text-align: center; padding: 20px; }
.wb-pv-master { width: 100%; display: flex; flex-direction: column; gap: 12px; }
.wb-pv-master-rows { display: flex; flex-direction: column; gap: 6px; }
.wb-pv-master-row { display: flex; align-items: center; gap: 8px; padding: 9px 10px; border: 1px solid #E5E8EC; border-radius: 10px; background: #fff; cursor: pointer; font-size: 13px; text-align: left; }
.wb-pv-master-row-open { border-color: var(--wb-primary, #1E7FE0); background: rgba(30,127,224,0.06); }
.wb-pv-master-row span:nth-child(2) { flex: 1 1 auto; }
.wb-preview-nav { display: flex; align-items: center; gap: 12px; }
.wb-preview-dots { display: flex; align-items: center; gap: 6px; }
.wb-preview-dot { height: 8px; border-radius: 999px; border: 0; cursor: pointer; padding: 0; }
.wb-beat-caption { font-size: 12px; color: #6B7280; }
.wb-receipt-stamp { position: relative; width: 100%; max-width: 300px; aspect-ratio: 389 / 460; }
.wb-receipt-inner { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 22px 20px; color: #fff; }
.wb-receipt-top { width: 100%; display: flex; align-items: flex-start; justify-content: space-between; }
.wb-receipt-qr { background: transparent; }
.wb-receipt-date { font-size: 11px; letter-spacing: 0.08em; border: 1px solid rgba(255,255,255,0.35); border-radius: 6px; padding: 3px 7px; }
.wb-receipt-emblem { margin-top: 12px; filter: brightness(0) invert(1); }
.wb-receipt-heading { font-size: 15px; font-weight: 700; }
.wb-receipt-sub { font-size: 12px; opacity: 0.75; }
.wb-receipt-foot { margin-top: auto; font-size: 10.5px; opacity: 0.6; font-family: ui-monospace, Menlo, monospace; }
.wb-receipt-actions { width: 100%; display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.wb-wallets { display: flex; gap: 8px; justify-content: center; }
.wb-wallet { display: inline-flex; align-items: center; gap: 8px; background: #000; color: #fff; border-radius: 8px; padding: 0 14px; }
.wb-wallet-text { display: flex; flex-direction: column; line-height: 1.1; text-align: left; }
.wb-wallet-small { font-size: 8.5px; opacity: 0.8; }
.wb-wallet-big { font-size: 12px; font-weight: 600; }
.wb-attest-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E5E8EC; border-radius: 10px; }
.wb-attest-badge { font-size: 10px; font-weight: 700; border-radius: 5px; padding: 3px 6px; }
.wb-attest-id { flex: 1 1 auto; font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; color: #6B7280; }
.wb-downloads { display: flex; flex-direction: column; gap: 6px; }
.wb-download-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E5E8EC; border-radius: 10px; font-size: 13px; }
.wb-download-row span { flex: 1 1 auto; }
.wb-field-block { display: flex; flex-direction: column; gap: 6px; }
.wb-editor { display: flex; flex-direction: column; gap: 8px; }
.wb-list-row { display: flex; align-items: center; gap: 8px; }
.wb-list-row-main { flex: 1 1 auto; display: flex; align-items: center; gap: 8px; min-width: 0; }
.wb-num { width: 20px; text-align: right; color: #98A2B3; font-size: 12px; flex-shrink: 0; }
.wb-card-row { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid #E5E8EC; border-radius: 10px; }
.wb-row { display: flex; align-items: flex-end; gap: 8px; }
.wb-row-wrap { flex-wrap: wrap; }
.wb-mst { display: flex; flex-direction: column; gap: 4px; }
.wb-mst-row { display: flex; align-items: center; gap: 8px; padding: 9px 10px; border: 1px solid #E5E8EC; border-radius: 8px; background: #fff; cursor: pointer; font-size: 13px; text-align: left; }
.wb-mst-row-on { border-color: var(--wb-primary, #1E7FE0); background: rgba(30,127,224,0.06); }
.wb-mst-label { flex: 1 1 auto; }
.wb-mst-hint { font-size: 11.5px; color: #98A2B3; font-style: italic; }
.wb-mst-cost { font-size: 11.5px; color: #6B7280; }
.wb-check { width: 16px; height: 16px; border-radius: 4px; border: 1.5px solid #CBD5E1; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.wb-check-on { border-color: var(--wb-primary, #1E7FE0); background: var(--wb-primary, #1E7FE0); color: #fff; }
.wb-picker-grid { max-height: 320px; overflow: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 10px; }
.wb-picker-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #E5E8EC; border-radius: 8px; background: #fff; cursor: pointer; font-size: 12.5px; text-align: left; }
.wb-picker-item-on { border-color: var(--wb-primary, #1E7FE0); background: rgba(30,127,224,0.06); }
.wb-picker-code { font-size: 10.5px; font-weight: 700; color: #98A2B3; width: 22px; flex-shrink: 0; }
.wb-picker-name { flex: 1 1 auto; }
.wb-esign-list { display: flex; flex-direction: column; gap: 8px; }
.wb-esign-meta { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; text-align: left; }
.wb-esign-name { font-size: 13px; font-weight: 600; }
.wb-esign-canvas { position: relative; height: 130px; border: 1px solid #E5E8EC; border-radius: 8px; background: #F8FAFC; margin-bottom: 8px; }
.wb-esign-marker { position: absolute; font-size: 10px; background: var(--wb-primary, #1E7FE0); color: #fff; border-radius: 4px; padding: 2px 5px; }
.wb-table { display: flex; flex-direction: column; gap: 4px; }
.wb-table-head, .wb-table-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 32px; gap: 6px; align-items: center; }
.wb-table-head { font-size: 11px; color: #98A2B3; font-weight: 600; }
.wb-page { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid #E5E8EC; border-radius: 10px; }
.wb-page-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 600; color: #6B7280; }
.wb-options { display: flex; flex-direction: column; gap: 6px; padding-left: 10px; border-left: 2px solid #EEF1F5; }
.wb-jur-group { display: flex; flex-direction: column; gap: 6px; }
.wb-jur-head { font-size: 11.5px; font-weight: 600; color: #6B7280; }
.wb-jur-chip { border: 1px solid #E5E8EC; background: #fff; border-radius: 999px; padding: 5px 10px; font-size: 12px; cursor: pointer; }
.wb-jur-chip-on { border-color: var(--wb-primary, #1E7FE0); background: rgba(30,127,224,0.08); color: var(--wb-primary, #1E7FE0); }
.wb-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.wb-weight { display: flex; flex-direction: column; gap: 4px; }
.wb-weight-head { display: flex; justify-content: space-between; font-size: 12px; }
.wb-weight-rail { height: 6px; border-radius: 999px; background: #EEF1F5; overflow: hidden; }
.wb-weight-fill { height: 100%; background: var(--wb-primary, #1E7FE0); }
.wb-weight-total { font-size: 11.5px; color: #6B7280; }
.wb-weight-total-bad { color: #FF3B30; font-weight: 600; }
.wb-lock-note { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #98A2B3; }
.wb-pad { padding: 12px; }
.wb-boot-error { padding: 24px; color: #FF3B30; }
.wb-start-pill { display: flex; align-items: center; justify-content: center; gap: 6px; height: 50px; border-radius: 25px; background: #E9F9EF; border: 1px solid #B7E4C7; color: #0F7B36; font-size: 13px; font-weight: 600; }
:root.dark .wb-screen-header { background: #1a1a2e; }
:root.dark .wb-pv-row, :root.dark .wb-attest-row, :root.dark .wb-download-row, :root.dark .wb-card-row, :root.dark .wb-page, :root.dark .wb-mst-row, :root.dark .wb-picker-item { border-color: #2d2d44; background: #16162a; }


.wb-root.wb-root { display: flex; flex-direction: column; position: relative; overflow: hidden; background: var(--wb-bg, #FAFBFC); color: var(--wb-text, #1A1A1A); font-size: 14px; }
.wb-root.wb-root .wb-header { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; height: 60px; padding: 0 16px; border-bottom: 1px solid var(--wb-border, #E5E8EC); background: var(--wb-paper, #fff); }
.wb-root.wb-root .wb-main { display: flex; flex: 1 1 auto; min-height: 0; height: auto; overflow: hidden; }
.wb-root.wb-root .wb-center { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; }
.wb-root.wb-root .wb-palette { flex: 0 0 300px; width: 300px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-right: 1px solid var(--wb-border, #E5E8EC); background: var(--wb-paper, #fff); }
.wb-root.wb-root .wb-panel { flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-left: 1px solid var(--wb-border, #E5E8EC); background: var(--wb-paper, #fff); position: relative; }
.wb-root.wb-root .wb-panel-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 20px 18px; display: flex; flex-direction: column; gap: 18px; }
.wb-root.wb-root .wb-back { display: inline-flex; align-items: center; gap: 6px; color: var(--wb-primary, #1E7FE0); font-weight: 600; text-decoration: none; font-size: 13px; flex: 0 0 auto; }
.wb-root.wb-root .wb-cost { font-weight: 700; font-size: 13px; color: var(--wb-primary, #1E7FE0); flex: 0 0 auto; white-space: nowrap; }
.wb-root.wb-root .wb-btn { display: inline-flex; align-items: center; gap: 7px; border: 0; border-radius: 9px; padding: 9px 15px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.wb-root.wb-root .wb-btn-primary { background: linear-gradient(135deg, #1E7FE0, #22B8F0); color: #fff; }
.wb-root.wb-root .wb-viewtoggle { top: 74px; }
.wb-root.wb-root .wb-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 14px 10px; }
.wb-root.wb-root .wb-tab { border: 0; background: transparent; border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 600; color: #6B7280; cursor: pointer; }
.wb-root.wb-root .wb-tab-active { background: rgba(30,127,224,0.10); color: var(--wb-primary, #1E7FE0); }
.wb-root.wb-root .wb-palette-head { padding: 14px 14px 10px; display: flex; flex-direction: column; gap: 10px; }
.wb-root.wb-root .wb-palette-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 12px 16px; display: flex; flex-direction: column; gap: 8px; }
.wb-root.wb-root .wb-iconbtn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: 0; background: transparent; color: #6B7280; cursor: pointer; }
.wb-root.wb-root .wb-iconbtn:hover { background: rgba(145,158,171,0.12); }
.wb-root.wb-root .wb-iconbtn-active { background: rgba(30,127,224,0.12); color: var(--wb-primary, #1E7FE0); }
.wb-root.wb-root .wb-field { display: flex; flex-direction: column; gap: 5px; }
.wb-root.wb-root .wb-field-label { font-size: 12px; font-weight: 600; color: #6B7280; }
.wb-root.wb-root .wb-input, .wb-root.wb-root .wb-select { width: 100%; border: 1px solid var(--wb-border, #E5E8EC); border-radius: 8px; padding: 8px 10px; font-size: 13px; background: #fff; color: inherit; font-family: inherit; }
.wb-root.wb-root .wb-input-wrap { position: relative; display: flex; align-items: center; }
.wb-root.wb-root .wb-input-has-icon .wb-input { padding-left: 30px; }
.wb-root.wb-root .wb-input-icon { position: absolute; left: 9px; display: inline-flex; color: #98A2B3; }
.wb-root.wb-root .wb-select-wrap { position: relative; display: flex; align-items: center; }
.wb-root.wb-root .wb-select { appearance: none; padding-right: 28px; }
.wb-root.wb-root .wb-select-caret { position: absolute; right: 9px; pointer-events: none; color: #98A2B3; display: inline-flex; }
.wb-root.wb-root .wb-switch { position: relative; width: 36px; height: 20px; border-radius: 999px; border: 0; background: #D3D8DF; cursor: pointer; flex-shrink: 0; padding: 0; }
.wb-root.wb-root .wb-switch-on { background: var(--wb-primary, #1E7FE0); }
.wb-root.wb-root .wb-switch-thumb { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .16s; }
.wb-root.wb-root .wb-switch-on .wb-switch-thumb { transform: translateX(16px); }
.wb-root.wb-root .wb-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 9px; border-radius: 8px; background: rgba(145,158,171,0.06); }
.wb-root.wb-root .wb-switch-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.wb-root.wb-root .wb-switch-label { font-size: 13px; }
.wb-root.wb-root .wb-switch-sub { font-size: 11.5px; color: #98A2B3; }
.wb-root.wb-root .wb-group { display: flex; flex-direction: column; gap: 10px; }
.wb-root.wb-root .wb-group-head { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #98A2B3; }
.wb-root.wb-root .wb-group-body { display: flex; flex-direction: column; gap: 12px; }
.wb-root.wb-root .wb-groups { display: flex; flex-direction: column; gap: 22px; }
.wb-root.wb-root .wb-panel-empty { flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; color: #6B7280; padding: 24px; }
.wb-root.wb-root .wb-panel-expand { position: absolute; left: 0; top: 50%; transform: translate(-100%, -50%); width: 28px; height: 48px; border-radius: 10px 0 0 10px; border: 1px solid var(--wb-border, #E5E8EC); border-right: 0; background: var(--wb-paper, #fff); cursor: pointer; display: flex; align-items: center; justify-content: center; color: #6B7280; }
.wb-root.wb-root .wb-slider { width: 100%; }
.wb-root.wb-root .wb-toolbar { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 4px; padding: 4px 6px; border-radius: 999px; background: var(--wb-paper, #fff); border: 1px solid var(--wb-border, #E5E8EC); box-shadow: 0 4px 14px rgba(16,24,40,0.10); z-index: 5; }
.wb-root.wb-root .wb-zoom-label { font-size: 12px; font-weight: 600; color: #6B7280; min-width: 42px; text-align: center; }
.wb-root.wb-root .wb-minimap { position: absolute; right: 16px; bottom: 16px; width: 180px; height: 130px; border-radius: 10px; border: 1px solid var(--wb-border, #E5E8EC); background: rgba(255,255,255,0.92); overflow: hidden; z-index: 4; cursor: pointer; }
:root.dark .wb-root.wb-root { background: #0f0f1a; color: #E7EAF0; }
:root.dark .wb-root.wb-root .wb-header, :root.dark .wb-root.wb-root .wb-palette, :root.dark .wb-root.wb-root .wb-panel, :root.dark .wb-root.wb-root .wb-viewtoggle, :root.dark .wb-root.wb-root .wb-toolbar { background: #16162a; border-color: #2d2d44; }
:root.dark .wb-root.wb-root .wb-input, :root.dark .wb-root.wb-root .wb-select { background: #10101f; border-color: #2d2d44; color: #E7EAF0; }


.wb-root .wb-icon { color: inherit; }
.wb-root .wb-icon svg { background-color: currentColor !important; }
.wb-root .wb-iconbtn { color: #6B7280; }
.wb-root .wb-iconbtn-active { color: var(--wb-primary, #1E7FE0); }
.wb-root .wb-back .wb-icon, .wb-root .wb-btn-primary .wb-icon { color: inherit; }
.wb-root .wb-input-icon { color: #98A2B3; }


.wb-root .wb-panel { z-index: 3; }
.wb-root .wb-panel-expand { z-index: 6; box-shadow: -2px 0 6px rgba(16,24,40,0.06); }


.wb-root.wb-root .wb-center { z-index: 1; }
.wb-root.wb-root .wb-palette { z-index: 2; }
.wb-root.wb-root .wb-panel { z-index: 5; position: relative; }
.wb-root.wb-root .wb-panel-expand { z-index: 7; }


.wb-root.wb-root .wb-panel-expand { left: 0; transform: translateY(-50%); border-radius: 0 10px 10px 0; border-left: 0; border-right: 1px solid var(--wb-border, #E5E8EC); z-index: 8; }


.wb-node { box-sizing: border-box; width: 280px; height: 100px; display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; background: #FFFFFF; border: 1px solid #E5E8EC; cursor: grab; overflow: hidden; font-family: inherit; }
.wb-node:active { cursor: grabbing; }
.wb-node-sel { border: 2px solid #1E7FE0; box-shadow: 0 0 0 3px rgba(30,127,224,0.15); }
.wb-node .wb-tile { width: 40px; height: 40px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.wb-node-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; }
.wb-node-title-row { display: flex; align-items: baseline; gap: 8px; }
.wb-node-title { font-size: 14px; font-weight: 600; color: #1A1A1A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; }
.wb-node-cost { font-size: 12px; color: #6B7280; flex-shrink: 0; }
.wb-node-desc { font-size: 12px; color: #98A2B3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
:root.dark .wb-node { background: #16162a; border-color: #2d2d44; }
:root.dark .wb-node-title { color: #E7EAF0; }

.wb-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
`;

<WorkflowBuilderApp deps={{ WB, WB_CSS, STEP_PROPERTY_GROUPS, makeUi, makeUtils, makeGeometry, makeStore, makePalette, makeListMode, makeCanvas, makeFields, makeConfigPanel, makePreviewSteps, makeBeats, makePreview, makeApp }} />
