diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs
--- a/crates/clap-host/src/ffi.rs
+++ b/crates/clap-host/src/ffi.rs
@@ -40,7 +40,7 @@ use soundcraft_dsp::Transport;
 use std::collections::HashMap;
 use std::ffi::{CStr, CString, c_char, c_void};
 use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
 use std::sync::{Arc, Mutex, OnceLock, PoisonError};
 
 /// Most plugins one factory may report (hostile-input cap).
@@ -279,7 +279,16 @@ impl Bundle {
         inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
         let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
         inst.gui = Arc::new(GuiLink {
-            inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
+            inner: Mutex::new(GuiInner {
+                plugin,
+                gui,
+                state: inst.state_ext,
+                alive: true,
+                created: false,
+                visible: false,
+                window: None,
+                sizer: None,
+            }),
             host: Arc::clone(&inst.state),
         });
         Ok(inst)
@@ -742,6 +751,197 @@ fn window_api() -> Option<&'static CStr> {
     }
 }
 
+/// Negotiates user resizes of an embedded editor's host window with the plugin (CLAP: when
+/// `can_resize`, the host asks `adjust_size`, sizes the window to the answer, then `set_size`).
+///
+/// Shared by the editor link and the host window's procedure. Everything runs on the main thread;
+/// the lock only guards against the instance clearing the plugin from another thread while a
+/// resize calls into it. Loops are cut by `applying`: while the host applies a size (a plugin's
+/// `request_resize`, or a user resize being constrained and handed to `set_size`), the window
+/// notifications that this causes are ignored rather than negotiated again.
+pub(crate) struct GuiSizer {
+    target: Mutex<Option<SizeTarget>>,
+    /// The thread inside a call into the plugin under `target`'s lock (see `with_target`).
+    holder: Mutex<Option<std::thread::ThreadId>>,
+    /// A target change requested re-entrantly by the holder thread, applied when its call returns.
+    pending: Mutex<Option<Option<SizeTarget>>>,
+    /// The plugin said `can_resize`: user resizes are negotiated (the window has a sizing border).
+    resizable: AtomicBool,
+    /// Nesting depth of resizes being applied by the host.
+    applying: AtomicU32,
+    /// The size last given to the plugin (`width << 32 | height`), to skip repeated `set_size`.
+    last: AtomicU64,
+}
+
+// User resizes come from the Windows host window only; elsewhere the plugin side is unused.
+#[cfg_attr(not(windows), allow(dead_code))]
+/// The plugin and the two `clap.gui` calls a user resize needs.
+#[derive(Clone, Copy)]
+struct SizeTarget {
+    plugin: *const clap_plugin,
+    adjust_size: Option<unsafe extern "C" fn(*const clap_plugin, *mut u32, *mut u32) -> bool>,
+    set_size: Option<unsafe extern "C" fn(*const clap_plugin, u32, u32) -> bool>,
+}
+
+// SAFETY: the plugin pointer is only called through on the main thread (the window procedure),
+// under the sizer's lock, and the editor link clears it under that lock (or, re-entrantly, right
+// after the call holding it) before the GUI goes.
+unsafe impl Send for SizeTarget {}
+
+/// Ends an "applying" section of a [`GuiSizer`] when dropped.
+pub(crate) struct Applying<'a>(&'a AtomicU32);
+
+impl Drop for Applying<'_> {
+    fn drop(&mut self) {
+        // Paired with the increment in `GuiSizer::applying`: never below zero.
+        self.0.fetch_sub(1, Ordering::Relaxed);
+    }
+}
+
+// User resizes come from the Windows host window only; elsewhere the plugin side is unused.
+#[cfg_attr(not(windows), allow(dead_code))]
+/// A host-window size in whole pixels (non-finite or out-of-range sizes are clamped).
+fn px(v: f64) -> u32 {
+    if v.is_finite() { v.round().clamp(1.0, 16_384.0) as u32 } else { 1 }
+}
+
+// User resizes come from the Windows host window only; elsewhere the plugin side is unused.
+#[cfg_attr(not(windows), allow(dead_code))]
+impl GuiSizer {
+    pub fn new(w: u32, h: u32) -> GuiSizer {
+        let s = GuiSizer {
+            target: Mutex::new(None),
+            holder: Mutex::new(None),
+            pending: Mutex::new(None),
+            resizable: AtomicBool::new(false),
+            applying: AtomicU32::new(0),
+            last: AtomicU64::new(0),
+        };
+        s.set_last(w, h);
+        s
+    }
+
+    fn holder(&self) -> std::sync::MutexGuard<'_, Option<std::thread::ThreadId>> {
+        self.holder.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    fn pending(&self) -> std::sync::MutexGuard<'_, Option<Option<SizeTarget>>> {
+        self.pending.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    /// Runs `f` on the plugin under the target lock (`None` without one). A target change
+    /// requested from inside `f` on this thread (a plugin pumping a nested event loop that closes
+    /// the editor) is deferred until `f` returns instead of deadlocking on the lock.
+    fn with_target<R>(&self, f: impl FnOnce(&SizeTarget) -> R) -> Option<R> {
+        let mut g = self.target.lock().unwrap_or_else(PoisonError::into_inner);
+        *self.holder() = Some(std::thread::current().id());
+        let out = g.as_ref().map(f);
+        *self.holder() = None;
+        if let Some(t) = self.pending().take() {
+            *g = t;
+        }
+        out
+    }
+
+    /// Hands the plugin over once its GUI is parented and shown (`can_resize` decides whether
+    /// user resizes are negotiated); `None` takes it away again (before the GUI is destroyed).
+    fn set_target(&self, target: Option<SizeTarget>, resizable: bool) {
+        self.resizable.store(resizable && target.is_some(), Ordering::Relaxed);
+        if *self.holder() == Some(std::thread::current().id()) {
+            *self.pending() = Some(target);
+            return;
+        }
+        *self.target.lock().unwrap_or_else(PoisonError::into_inner) = target;
+    }
+
+    pub fn resizable(&self) -> bool {
+        self.resizable.load(Ordering::Relaxed)
+    }
+
+    /// Marks a host-applied resize; window notifications are ignored until the guard drops.
+    pub fn applying(&self) -> Applying<'_> {
+        self.applying.fetch_add(1, Ordering::Relaxed);
+        Applying(&self.applying)
+    }
+
+    fn is_applying(&self) -> bool {
+        self.applying.load(Ordering::Relaxed) > 0
+    }
+
+    fn set_last(&self, w: u32, h: u32) {
+        self.last.store((u64::from(w) << 32) | u64::from(h), Ordering::Relaxed);
+    }
+
+    fn last(&self) -> (u32, u32) {
+        let v = self.last.load(Ordering::Relaxed);
+        ((v >> 32) as u32, v as u32)
+    }
+
+    /// The size the plugin accepts instead of `w`×`h` (`adjust_size`), or `None` when no resize
+    /// should be negotiated (it can't resize, is gone, or a resize is being applied).
+    pub fn constrain(&self, w: f64, h: f64) -> Option<(f64, f64)> {
+        if !self.resizable() || self.is_applying() {
+            return None;
+        }
+        let _guard = self.applying();
+        let (w, h) = self.with_target(|t| adjust(t, px(w), px(h)))?;
+        Some((f64::from(w), f64::from(h)))
+    }
+
+    /// The window's client area was resized to `w`×`h` by the user (or the window manager):
+    /// settles on the plugin's adjusted size, makes the window follow it through `set_window`
+    /// when it differs, and tells the plugin with `set_size`. Ignored while the host applies a
+    /// resize itself.
+    pub fn user_resized(&self, w: f64, h: f64, set_window: impl FnOnce(f64, f64)) {
+        if !self.resizable() || self.is_applying() {
+            return;
+        }
+        let _guard = self.applying();
+        self.with_target(|t| {
+            let (w, h) = (px(w), px(h));
+            let (aw, ah) = adjust(t, w, h);
+            let last = self.last();
+            let accepted = last == (aw, ah)
+                || match t.set_size {
+                    // SAFETY: live plugin with a created, parented GUI (the editor link takes the
+                    // target away before destroying it, deferred to here when that happens inside
+                    // this call); main thread.
+                    Some(f) => unsafe { f(t.plugin, aw, ah) },
+                    None => false,
+                };
+            if accepted {
+                self.set_last(aw, ah);
+                if (aw, ah) != (w, h) {
+                    set_window(f64::from(aw), f64::from(ah));
+                }
+            } else {
+                // The plugin refused the size: the window goes back to the last one it took.
+                set_window(f64::from(last.0), f64::from(last.1));
+            }
+        });
+    }
+}
+
+// User resizes come from the Windows host window only; elsewhere the plugin side is unused.
+#[cfg_attr(not(windows), allow(dead_code))]
+/// `adjust_size` for a proposed `w`×`h`; the proposal stands when the plugin can't adjust it.
+fn adjust(t: &SizeTarget, w: u32, h: u32) -> (u32, u32) {
+    let Some(f) = t.adjust_size else { return (w, h) };
+    let (mut aw, mut ah) = (w, h);
+    // SAFETY: live plugin with a created GUI (see `user_resized`); both out-pointers are locals.
+    if unsafe { f(t.plugin, &mut aw, &mut ah) } && aw > 0 && ah > 0 { (aw.min(16_384), ah.min(16_384)) } else { (w, h) }
+}
+
+/// The window rect while the user drags `edge` (a Windows `WMSZ_*` value): moves only the dragged
+/// edges so the outer size becomes `w`×`h`. Pure, so it's testable on every platform.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn sizing_rect(edge: usize, (left, top, right, bottom): (i32, i32, i32, i32), (w, h): (i32, i32)) -> (i32, i32, i32, i32) {
+    // WMSZ_LEFT = 1, WMSZ_TOP = 3, WMSZ_TOPLEFT = 4, WMSZ_TOPRIGHT = 5, WMSZ_BOTTOMLEFT = 7.
+    let (l, r) = if matches!(edge, 1 | 4 | 7) { (right.saturating_sub(w), right) } else { (left, left.saturating_add(w)) };
+    let (t, b) = if matches!(edge, 3..=5) { (bottom.saturating_sub(h), bottom) } else { (top, top.saturating_add(h)) };
+    (l, t, r, b)
+}
+
 struct GuiInner {
     plugin: *const clap_plugin,
     gui: *const clap_plugin_gui,
@@ -752,6 +952,8 @@ struct GuiInner {
     visible: bool,
     /// The host window of an embedded editor (`None` for floating editors).
     window: Option<hostwin::HostWindow>,
+    /// Negotiates user resizes of `window` (`None` for floating editors).
+    sizer: Option<Arc<GuiSizer>>,
 }
 
 /// The link between an instance and its editor and state handles. The instance may process on
@@ -776,6 +978,7 @@ impl Default for GuiLink {
                 created: false,
                 visible: false,
                 window: None,
+                sizer: None,
             }),
             host: Arc::new(HostState::default()),
         }
@@ -911,14 +1114,30 @@ impl GuiLink {
             GuiLink::destroy(g);
             return Err("the plugin could not show its editor".into());
         }
-        if let Some(win) = &g.window {
+        // The window follows `can_resize`: a sizing border, with user resizes negotiated through
+        // `adjust_size` and `set_size`.
+        // SAFETY: live plugin with a created, parented GUI; main thread.
+        let resizable = vt.can_resize.is_some_and(|f| unsafe { f(g.plugin) });
+        let sizer = Arc::new(GuiSizer::new(w, h));
+        sizer.set_target(Some(SizeTarget { plugin: g.plugin, adjust_size: vt.adjust_size, set_size: vt.set_size }), resizable);
+        if let Some(win) = &mut g.window {
+            win.set_sizer(&sizer);
+            // The frame change of the new sizing border is not a user resize: the window gives
+            // the client area back to the plugin's size, with nothing negotiated meanwhile.
+            let _applying = sizer.applying();
+            win.set_resizable(resizable);
             win.raise();
         }
+        g.sizer = Some(sizer);
         g.visible = true;
         Ok(())
     }
 
     fn destroy(g: &mut GuiInner) {
+        // No more user resizes reach the plugin once its GUI is going.
+        if let Some(s) = g.sizer.take() {
+            s.set_target(None, false);
+        }
         if g.created
             && let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy)
         {
@@ -985,7 +1204,12 @@ impl GuiLink {
                 && let Some(win) = &g.window
             {
                 let (w, h) = ((resize >> 32) as u32, resize as u32);
+                // The window notifications this resize causes are not user resizes.
+                let _applying = g.sizer.as_ref().map(|s| s.applying());
                 hostwin::resize(win.raw(), f64::from(w), f64::from(h));
+                if let Some(s) = &g.sizer {
+                    s.set_last(w, h);
+                }
                 if let Some(f) = GuiLink::vt(&g).and_then(|v| v.set_size) {
                     // SAFETY: live plugin with a created embedded GUI, main thread.
                     unsafe { f(g.plugin, w, h) };
@@ -1030,8 +1254,12 @@ impl GuiLink {
             } else {
                 log::warn!("clap: editor still open while its plugin is destroyed off the main thread");
                 g.created = false;
-                // Window calls are main-thread only: leave the host window rather than crash.
+                // Window calls are main-thread only: leave the host window rather than crash, but
+                // cut its resize handling off from the plugin.
                 g.window = None;
+                if let Some(s) = g.sizer.take() {
+                    s.set_target(None, false);
+                }
             }
         }
         g.alive = false;
@@ -1443,15 +1671,18 @@ impl Drop for Instance {
 /// Messages are dispatched by the app's own event loop on the main thread.
 #[cfg(windows)]
 mod hostwin {
+    use super::{GuiSizer, sizing_rect};
     use std::ffi::c_void;
-    use std::sync::OnceLock;
+    use std::sync::{Arc, OnceLock};
     use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
     use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
     use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;
     use windows_sys::Win32::UI::WindowsAndMessaging::{
         AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,
-        GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,
-        SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,
+        GWLP_USERDATA, GetClientRect, GetWindowLongPtrW, GetWindowRect, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW,
+        SIZE_MINIMIZED, SW_HIDE, SW_SHOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetForegroundWindow,
+        SetWindowLongPtrW, SetWindowPos, ShowWindow, WM_CLOSE, WM_NCDESTROY, WM_SIZE, WM_SIZING, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN,
+        WS_OVERLAPPED, WS_SYSMENU, WS_THICKFRAME,
     };
 
     pub const SUPPORTED: bool = true;
@@ -1467,11 +1698,83 @@ mod hostwin {
 
     const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN;
 
+    /// The sizer stored in the window's user data by `set_sizer` (one `Arc` reference, released
+    /// on `WM_NCDESTROY`), borrowed for one message.
+    fn window_sizer(hwnd: HWND) -> Option<Arc<GuiSizer>> {
+        // SAFETY: `hwnd` is the window the procedure was called for; its user data is 0 or a
+        // pointer from `Arc::into_raw`, kept alive until `WM_NCDESTROY` (the last message).
+        let p = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const GuiSizer;
+        if p.is_null() {
+            return None;
+        }
+        // SAFETY: as above; this message takes a reference of its own, so the sizer outlives it
+        // even when a plugin destroys the window from a nested event loop meanwhile.
+        unsafe { Arc::increment_strong_count(p) };
+        // SAFETY: balances the increment just made.
+        Some(unsafe { Arc::from_raw(p) })
+    }
+
+    /// The window's frame (outer minus client size), measured: right at any monitor's DPI, unlike
+    /// `AdjustWindowRectEx` (system DPI). `None` when the window can't be measured.
+    fn frame_size(hwnd: HWND) -> Option<(i32, i32)> {
+        let (mut w, mut c) = (RECT { left: 0, top: 0, right: 0, bottom: 0 }, RECT { left: 0, top: 0, right: 0, bottom: 0 });
+        // SAFETY: a window of this thread (or a stale handle, which fails); both rects are writable.
+        if unsafe { GetWindowRect(hwnd, &mut w) } == 0 || unsafe { GetClientRect(hwnd, &mut c) } == 0 {
+            return None;
+        }
+        let fw = w.right.saturating_sub(w.left).saturating_sub(c.right.saturating_sub(c.left));
+        let fh = w.bottom.saturating_sub(w.top).saturating_sub(c.bottom.saturating_sub(c.top));
+        Some((fw.max(0), fh.max(0)))
+    }
+
+    /// Outer size for a client area of `w`×`h`: measured frame, else `AdjustWindowRectEx`.
+    fn outer_for(hwnd: HWND, w: f64, h: f64) -> (i32, i32) {
+        match frame_size(hwnd) {
+            Some((fw, fh)) => ((w as i32).saturating_add(fw), (h as i32).saturating_add(fh)),
+            None => {
+                // SAFETY: a window of this thread; style values are only read.
+                let (style, ex) = unsafe { (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32, GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32) };
+                outer_size(style, ex, w, h)
+            }
+        }
+    }
+
     unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
-        if msg == WM_CLOSE {
-            // SAFETY: `hwnd` is the window this procedure was called for.
-            unsafe { ShowWindow(hwnd, SW_HIDE) };
-            return 0;
+        match msg {
+            WM_CLOSE => {
+                // SAFETY: `hwnd` is the window this procedure was called for.
+                unsafe { ShowWindow(hwnd, SW_HIDE) };
+                return 0;
+            }
+            // Live user resizes: keep the dragged edges at a size the plugin accepts.
+            WM_SIZING => {
+                // SAFETY: for WM_SIZING, `lparam` points to the window's writable drag rect.
+                if let (Some(s), Some(r), Some((fw, fh))) = (window_sizer(hwnd), unsafe { (lparam as *mut RECT).as_mut() }, frame_size(hwnd)) {
+                    let (w, h) = (r.right.saturating_sub(r.left).saturating_sub(fw), r.bottom.saturating_sub(r.top).saturating_sub(fh));
+                    if let Some((cw, ch)) = s.constrain(f64::from(w.max(1)), f64::from(h.max(1))) {
+                        let (ow, oh) = ((cw as i32).saturating_add(fw), (ch as i32).saturating_add(fh));
+                        (r.left, r.top, r.right, r.bottom) = sizing_rect(wparam, (r.left, r.top, r.right, r.bottom), (ow, oh));
+                        return 1;
+                    }
+                }
+            }
+            // Every completed resize: settle on the adjusted size and tell the plugin.
+            WM_SIZE if wparam != SIZE_MINIMIZED as usize => {
+                if let Some(s) = window_sizer(hwnd) {
+                    let (w, h) = (f64::from((lparam & 0xFFFF) as u16), f64::from(((lparam >> 16) & 0xFFFF) as u16));
+                    s.user_resized(w, h, |cw, ch| resize(hwnd, cw, ch));
+                }
+            }
+            WM_NCDESTROY => {
+                // SAFETY: the window's last message: take the user data back and release the
+                // `Arc` reference `set_sizer` stored there; nothing reads it afterwards.
+                let p = unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) } as *const GuiSizer;
+                if !p.is_null() {
+                    // SAFETY: `p` came from `Arc::into_raw` in `set_sizer`, released exactly once.
+                    drop(unsafe { Arc::from_raw(p) });
+                }
+            }
+            _ => {}
         }
         // SAFETY: default handling of a message for the window it was sent to.
         unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
@@ -1560,6 +1863,8 @@ mod hostwin {
             if hwnd.is_null() {
                 return Err("cannot create the editor window".into());
             }
+            // `AdjustWindowRectEx` assumed the system DPI; size the client area by the real frame.
+            resize(hwnd, w, h);
             Ok(HostWindow { hwnd })
         }
 
@@ -1568,6 +1873,42 @@ mod hostwin {
             self.hwnd
         }
 
+        /// Routes the window's resizes through `sizer` (the window procedure reads it from the
+        /// window's user data). Once per window.
+        pub fn set_sizer(&mut self, sizer: &Arc<GuiSizer>) {
+            let Some(h) = self.live() else { return };
+            if window_sizer(h).is_some() {
+                return;
+            }
+            let p = Arc::into_raw(sizer.clone());
+            // SAFETY: a live window of this thread; the pointer stays valid until `WM_NCDESTROY`
+            // releases it.
+            unsafe { SetWindowLongPtrW(h, GWLP_USERDATA, p as _) };
+        }
+
+        /// Gives the window a sizing border (the plugin said `can_resize`), or takes it away,
+        /// keeping the client size.
+        pub fn set_resizable(&self, resizable: bool) {
+            let Some(h) = self.live() else { return };
+            // SAFETY: a live window of this thread; the style is only read.
+            if ((unsafe { GetWindowLongPtrW(h, GWL_STYLE) } as u32 & WS_THICKFRAME) != 0) == resizable {
+                return;
+            }
+            let mut c = RECT { left: 0, top: 0, right: 0, bottom: 0 };
+            // SAFETY: a live window of this thread; `c` is writable.
+            unsafe { GetClientRect(h, &mut c) };
+            // SAFETY: as above; only the sizing-border bit of the style changes.
+            unsafe {
+                let style = GetWindowLongPtrW(h, GWL_STYLE) as u32;
+                let style = if resizable { style | WS_THICKFRAME } else { style & !WS_THICKFRAME };
+                SetWindowLongPtrW(h, GWL_STYLE, style as _);
+            }
+            // SAFETY: as above; the frame is recomputed for the new style, nothing else changes.
+            unsafe { SetWindowPos(h, std::ptr::null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) };
+            // The new frame took room from the client area: give it back.
+            resize(h, f64::from(c.right), f64::from(c.bottom));
+        }
+
         /// The `HWND` the plugin creates its child window in.
         pub fn parent(&self) -> *mut c_void {
             self.live().unwrap_or(std::ptr::null_mut())
@@ -1602,10 +1943,8 @@ mod hostwin {
         if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {
             return;
         }
-        // SAFETY: a live window of this thread; style values are read, not changed.
-        let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };
-        let (ow, oh) = outer_size(style, ex, w, h);
-        // SAFETY: as above; only the size changes.
+        let (ow, oh) = outer_for(window, w, h);
+        // SAFETY: a live window of this thread; only the size changes.
         unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };
     }
 }
@@ -1626,6 +1965,8 @@ mod hostwin {
         pub fn raw(&self) -> *mut c_void {
             std::ptr::null_mut()
         }
+        pub fn set_sizer(&mut self, _sizer: &std::sync::Arc<super::GuiSizer>) {}
+        pub fn set_resizable(&self, _resizable: bool) {}
         pub fn parent(&self) -> *mut c_void {
             std::ptr::null_mut()
         }
@@ -1643,6 +1984,113 @@ mod hostwin {
 mod tests {
     use super::*;
 
+    /// `set_size` calls of the fake plugin below (one test uses it).
+    static SET_SIZES: Mutex<Vec<(u32, u32)>> = Mutex::new(Vec::new());
+    /// When set, the fake `set_size` calls back into the sizer, the way a plugin resizing its
+    /// parent from `set_size` makes the window notify the host again.
+    static NESTED: Mutex<Option<Arc<GuiSizer>>> = Mutex::new(None);
+    /// When set, the fake `set_size` closes the editor from inside the call (takes the target
+    /// away), the way a plugin pumping a nested event loop can.
+    static CLOSE_INSIDE: Mutex<Option<Arc<GuiSizer>>> = Mutex::new(None);
+    /// The fake `set_size` refuses every size.
+    static REFUSE: AtomicBool = AtomicBool::new(false);
+
+    /// Accepts 200..=800 × 100..=600.
+    unsafe extern "C" fn fake_adjust(_p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {
+        // SAFETY: the sizer passes valid out-pointers.
+        unsafe {
+            *w = (*w).clamp(200, 800);
+            *h = (*h).clamp(100, 600);
+        }
+        true
+    }
+
+    unsafe extern "C" fn fake_set_size(_p: *const clap_plugin, w: u32, h: u32) -> bool {
+        SET_SIZES.lock().unwrap().push((w, h));
+        if let Some(s) = NESTED.lock().unwrap().clone() {
+            s.user_resized(999.0, 999.0, |_, _| panic!("a nested notification is not negotiated"));
+        }
+        if let Some(s) = CLOSE_INSIDE.lock().unwrap().take() {
+            s.set_target(None, false);
+        }
+        !REFUSE.load(Ordering::Relaxed)
+    }
+
+    #[test]
+    fn user_resizes_are_negotiated_with_the_plugin_without_loops() {
+        let target = SizeTarget { plugin: std::ptr::null(), adjust_size: Some(fake_adjust), set_size: Some(fake_set_size) };
+        let sizes = || SET_SIZES.lock().unwrap().clone();
+        let sizer = Arc::new(GuiSizer::new(320, 240));
+        // `can_resize` false: nothing is negotiated.
+        sizer.set_target(Some(target), false);
+        assert_eq!(sizer.constrain(500.0, 400.0), None);
+        sizer.user_resized(500.0, 400.0, |_, _| panic!("not resizable"));
+        assert!(sizes().is_empty());
+
+        sizer.set_target(Some(target), true);
+        assert_eq!(sizer.constrain(50.0, 5000.0), Some((200.0, 600.0)), "adjust_size shapes the drag");
+        let mut set = None;
+        sizer.user_resized(1000.0, 50.0, |w, h| set = Some((w, h)));
+        assert_eq!(set, Some((800.0, 100.0)), "the window follows the adjusted size");
+        assert_eq!(sizes(), vec![(800, 100)]);
+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));
+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));
+        assert_eq!(sizes(), vec![(800, 100), (640, 480)], "the same size is not repeated");
+
+        // A plugin `request_resize` being applied: its window notifications are ignored.
+        {
+            let _applying = sizer.applying();
+            assert_eq!(sizer.constrain(300.0, 300.0), None);
+            sizer.user_resized(300.0, 300.0, |_, _| panic!("ignored while applying"));
+            sizer.set_last(300, 300);
+        }
+        sizer.user_resized(300.0, 300.0, |_, _| panic!("accepted as is"));
+        assert_eq!(sizes().len(), 2);
+
+        // A plugin that resizes its parent from `set_size` doesn't start a loop (or deadlock).
+        *NESTED.lock().unwrap() = Some(sizer.clone());
+        sizer.user_resized(400.0, 400.0, |_, _| panic!("accepted as is"));
+        *NESTED.lock().unwrap() = None;
+        assert_eq!(sizes(), vec![(800, 100), (640, 480), (400, 400)]);
+
+        // A plugin that refuses the size: the window goes back to the last accepted one.
+        REFUSE.store(true, Ordering::Relaxed);
+        let mut set = None;
+        sizer.user_resized(500.0, 500.0, |w, h| set = Some((w, h)));
+        REFUSE.store(false, Ordering::Relaxed);
+        assert_eq!(set, Some((400.0, 400.0)));
+        assert_eq!(sizer.last(), (400, 400));
+        SET_SIZES.lock().unwrap().pop();
+
+        // Closing the editor from inside `set_size` neither deadlocks nor leaves the plugin
+        // reachable: the removal is applied when the call returns.
+        *CLOSE_INSIDE.lock().unwrap() = Some(sizer.clone());
+        sizer.user_resized(450.0, 450.0, |_, _| panic!("accepted as is"));
+        assert!(!sizer.resizable());
+        assert!(sizer.target.lock().unwrap().is_none());
+        sizer.set_target(Some(target), true);
+
+        // A plugin without `adjust_size` gets the user's size as is.
+        sizer.set_target(Some(SizeTarget { adjust_size: None, ..target }), true);
+        assert_eq!(sizer.constrain(5000.0, 10.0), Some((5000.0, 10.0)));
+
+        // Once the GUI goes, nothing reaches the plugin any more.
+        sizer.set_target(None, true);
+        assert!(!sizer.resizable());
+        sizer.user_resized(500.0, 500.0, |_, _| panic!("no plugin"));
+        assert_eq!(sizes().len(), 4);
+    }
+
+    #[test]
+    fn sizing_moves_only_the_dragged_edges() {
+        let r = (100, 100, 500, 400);
+        assert_eq!(sizing_rect(8, r, (300, 200)), (100, 100, 400, 300), "bottom-right");
+        assert_eq!(sizing_rect(4, r, (300, 200)), (200, 200, 500, 400), "top-left");
+        assert_eq!(sizing_rect(5, r, (300, 200)), (100, 200, 400, 400), "top-right");
+        assert_eq!(sizing_rect(7, r, (300, 200)), (200, 100, 500, 300), "bottom-left");
+        assert_eq!(sizing_rect(2, (i32::MAX - 1, 0, i32::MAX, 10), (100, 10)), (i32::MAX - 1, 0, i32::MAX, 10), "saturates");
+    }
+
     #[test]
     fn the_transport_reaches_clap_in_fixed_point() {
         let t = Transport {
diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs
--- a/crates/clap-host/src/lib.rs
+++ b/crates/clap-host/src/lib.rs
@@ -22,8 +22,10 @@
 //! floating window ([`Plugin::open_editor`], or [`Plugin::editor`] for a handle usable on the main
 //! thread while the instance processes on the audio thread). Plugins that offer only an embedded
 //! editor get a host window of SoundCraft's on Windows (resized on `request_resize`, closed by
-//! its title bar); elsewhere they are not hosted yet, and SoundCraft's generic parameter editor
-//! still works for them.
+//! its title bar; when the plugin `can_resize` the window gets a sizing border, and user resizes
+//! are shaped by `adjust_size` and applied with `set_size`, the window going back to the last
+//! size when the plugin refuses one); elsewhere they are not hosted yet, and SoundCraft's
+//! generic parameter editor still works for them.
 //! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes
 //! and are reported by the editor's `idle`.
 
diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml
--- a/crates/vst3-host/Cargo.toml
+++ b/crates/vst3-host/Cargo.toml
@@ -20,7 +20,7 @@ libloading = "0.9"
 # The macOS editor host window (an NSWindow whose content view the plugin's IPlugView attaches to).
 [target.'cfg(target_os = "macos")'.dependencies]
 objc2 = "0.6"
-objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] }
+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSNotification", "NSObject", "NSString"] }
 objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSView", "NSWindow", "NSGraphics"] }
 
 # The Windows editor host window (a top-level HWND the plugin view's child window lives in).
diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs
--- a/crates/vst3-host/src/ffi.rs
+++ b/crates/vst3-host/src/ffi.rs
@@ -1763,6 +1763,8 @@ fn split_state(data: &[u8]) -> Option<(&[u8], &[u8])> {
 pub(crate) struct PlugFrame {
     /// The host window (an `NSWindow*` on macOS), not owned; null when there is none.
     window: std::sync::atomic::AtomicPtr<c_void>,
+    /// Shared with the host window's resize handling (delegate / window procedure).
+    sizer: Arc<ViewSizer>,
 }
 
 impl Class for PlugFrame {
@@ -1771,11 +1773,21 @@ impl Class for PlugFrame {
 
 impl IPlugFrameTrait for PlugFrame {
     unsafe fn resizeView(&self, view: *mut sb::IPlugView, new_size: *mut sb::ViewRect) -> sb::tresult {
+        // Like Steinberg's editorhost: only the attached view may resize the window, and not
+        // again from inside its own resize (a view that re-requests a size from `onSize` would
+        // otherwise recurse until the stack overflows).
+        if view.is_null() || view != self.sizer.attached() {
+            return sb::kInvalidArgument;
+        }
         // SAFETY: in-pointer from the plugin (or null, handled).
         let Some(r) = (unsafe { new_size.as_mut() }) else { return sb::kInvalidArgument };
+        let Some(_resizing) = self.sizer.plugin_resize() else { return sb::kResultFalse };
         let (w, h) = rect_size(r);
+        // The window notifications this resize causes are not user resizes: don't negotiate them.
+        let _guard = self.sizer.applying();
         window::resize(self.window.load(Ordering::Relaxed), w, h);
-        // SAFETY: the plugin passes its own live view (or null, handled).
+        self.sizer.set_last(w, h);
+        // SAFETY: the plugin passes its own live view (checked against the attached one above).
         if let Some(v) = unsafe { vst3::ComRef::from_raw(view) } {
             // SAFETY: valid view; `r` is the plugin's own writable rect.
             unsafe { v.onSize(r) };
@@ -1791,6 +1803,220 @@ fn rect_size(r: &sb::ViewRect) -> (f64, f64) {
     (f64::from(w), f64::from(h))
 }
 
+/// A view rect of `w`×`h` at the origin (non-finite or out-of-range sizes are clamped).
+fn size_rect(w: f64, h: f64) -> sb::ViewRect {
+    let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 16_384.0) as i32 } else { 1 };
+    sb::ViewRect { left: 0, top: 0, right: px(w), bottom: px(h) }
+}
+
+type ViewSlot = Option<ComPtr<sb::IPlugView>>;
+
+/// Negotiates user resizes of a host window with its plugin view (VST3: the host asks
+/// `checkSizeConstraint`, sizes the window to the answer, then calls `onSize`).
+///
+/// Shared by the plug frame (plugin-initiated `resizeView`) and the platform window's resize
+/// handling (an `NSWindowDelegate` on macOS, the window procedure on Windows). Everything runs on
+/// the main thread; the view lock only guards against the instance clearing the view from another
+/// thread while a resize calls into it. Loops are cut in two places: while the host applies a
+/// size (a plugin `resizeView`, or a user resize being constrained and handed to `onSize`), the
+/// window notifications this causes are ignored rather than negotiated again (`applying`), and a
+/// `resizeView` from inside another one is refused (`plugin_resizing`).
+pub(crate) struct ViewSizer {
+    view: Mutex<ViewSlot>,
+    /// The thread inside a call into the view under `view`'s lock (see `with_view`).
+    holder: Mutex<Option<std::thread::ThreadId>>,
+    /// A view change requested re-entrantly by the holder thread, applied when its call returns.
+    pending: Mutex<Option<ViewSlot>>,
+    /// The attached view (identity only, never dereferenced): the one allowed to `resizeView`.
+    attached: std::sync::atomic::AtomicPtr<sb::IPlugView>,
+    /// The view said `canResize`: user resizes are negotiated (the window has a resize handle).
+    resizable: AtomicBool,
+    /// Nesting depth of resizes being applied by the host.
+    applying: AtomicU32,
+    /// A plugin `resizeView` is in progress.
+    plugin_resizing: AtomicBool,
+    /// The size last given to the view (`width << 32 | height`), to skip repeated `onSize`.
+    last: AtomicU64,
+}
+
+/// Ends an "applying" section of a [`ViewSizer`] when dropped.
+pub(crate) struct Applying<'a>(&'a AtomicU32);
+
+impl Drop for Applying<'_> {
+    fn drop(&mut self) {
+        // Paired with the increment in `ViewSizer::applying`: never below zero.
+        self.0.fetch_sub(1, Ordering::Relaxed);
+    }
+}
+
+/// Ends a plugin `resizeView` of a [`ViewSizer`] when dropped.
+pub(crate) struct PluginResize<'a>(&'a AtomicBool);
+
+impl Drop for PluginResize<'_> {
+    fn drop(&mut self) {
+        self.0.store(false, Ordering::Relaxed);
+    }
+}
+
+impl ViewSizer {
+    pub fn new(w: f64, h: f64) -> ViewSizer {
+        let s = ViewSizer {
+            view: Mutex::new(None),
+            holder: Mutex::new(None),
+            pending: Mutex::new(None),
+            attached: std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()),
+            resizable: AtomicBool::new(false),
+            applying: AtomicU32::new(0),
+            plugin_resizing: AtomicBool::new(false),
+            last: AtomicU64::new(0),
+        };
+        s.set_last(w, h);
+        s
+    }
+
+    fn holder(&self) -> std::sync::MutexGuard<'_, Option<std::thread::ThreadId>> {
+        self.holder.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    fn pending(&self) -> std::sync::MutexGuard<'_, Option<ViewSlot>> {
+        self.pending.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    /// Runs `f` on the view under its lock (`None` without a view). A view change requested from
+    /// inside `f` on this thread (a plugin pumping a nested event loop that closes the editor) is
+    /// deferred until `f` returns instead of deadlocking on the lock.
+    fn with_view<R>(&self, f: impl FnOnce(&ComPtr<sb::IPlugView>) -> R) -> Option<R> {
+        let mut g = self.view.lock().unwrap_or_else(PoisonError::into_inner);
+        *self.holder() = Some(std::thread::current().id());
+        let out = g.as_ref().map(f);
+        *self.holder() = None;
+        if let Some(v) = self.pending().take() {
+            *g = v;
+        }
+        out
+    }
+
+    /// Replaces the view, or defers that when this thread is inside `with_view` (see there).
+    /// Returns the previous view when it was replaced right away.
+    fn replace_view(&self, view: ViewSlot) -> ViewSlot {
+        self.attached.store(view.as_ref().map_or(std::ptr::null_mut(), |v| v.as_ptr()), Ordering::Relaxed);
+        if *self.holder() == Some(std::thread::current().id()) {
+            *self.pending() = Some(view);
+            return None;
+        }
+        let mut g = self.view.lock().unwrap_or_else(PoisonError::into_inner);
+        std::mem::replace(&mut *g, view)
+    }
+
+    /// Names the view about to be attached: from now on it may `resizeView` (plugins often do
+    /// from `attached`), before it is handed over with [`ViewSizer::set_view`].
+    pub fn expect_view(&self, view: &ComPtr<sb::IPlugView>) {
+        self.attached.store(view.as_ptr(), Ordering::Relaxed);
+    }
+
+    fn attached(&self) -> *mut sb::IPlugView {
+        self.attached.load(Ordering::Relaxed)
+    }
+
+    /// Hands the attached view over (or takes it away again: `None`).
+    pub fn set_view(&self, view: ViewSlot, resizable: bool) {
+        self.resizable.store(resizable && view.is_some(), Ordering::Relaxed);
+        drop(self.replace_view(view));
+    }
+
+    /// Takes the view away (stops negotiating) and hands its reference to the caller (`None`
+    /// when the removal had to be deferred, see `with_view`).
+    pub fn take_view(&self) -> ViewSlot {
+        self.resizable.store(false, Ordering::Relaxed);
+        self.replace_view(None)
+    }
+
+    pub fn resizable(&self) -> bool {
+        self.resizable.load(Ordering::Relaxed)
+    }
+
+    /// Marks a host-applied resize; window notifications are ignored until the guard drops.
+    pub fn applying(&self) -> Applying<'_> {
+        self.applying.fetch_add(1, Ordering::Relaxed);
+        Applying(&self.applying)
+    }
+
+    fn is_applying(&self) -> bool {
+        self.applying.load(Ordering::Relaxed) > 0
+    }
+
+    /// Starts a plugin `resizeView`; `None` while one is already in progress.
+    fn plugin_resize(&self) -> Option<PluginResize<'_>> {
+        (!self.plugin_resizing.swap(true, Ordering::Relaxed)).then_some(PluginResize(&self.plugin_resizing))
+    }
+
+    fn set_last(&self, w: f64, h: f64) {
+        let r = size_rect(w, h);
+        self.last.store((u64::from(r.right as u32) << 32) | u64::from(r.bottom as u32), Ordering::Relaxed);
+    }
+
+    fn last(&self) -> (f64, f64) {
+        let v = self.last.load(Ordering::Relaxed);
+        (f64::from((v >> 32) as u32), f64::from(v as u32))
+    }
+
+    /// The size the view accepts instead of `w`×`h` (`checkSizeConstraint`), or `None` when no
+    /// resize should be negotiated (the view can't resize, is gone, or a resize is being applied).
+    pub fn constrain(&self, w: f64, h: f64) -> Option<(f64, f64)> {
+        if !self.resizable() || self.is_applying() {
+            return None;
+        }
+        let _guard = self.applying();
+        self.with_view(|view| constrain_view(view, w, h))
+    }
+
+    /// The window's content was resized to `w`×`h` by the user (or the window manager): settles
+    /// on the view's constrained size, makes the window follow it through `set_window` when it
+    /// differs, and tells the view with `onSize`. Ignored while the host applies a resize itself.
+    // Called by the macOS and Windows host windows only (and the tests).
+    #[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))]
+    pub fn user_resized(&self, w: f64, h: f64, set_window: impl FnOnce(f64, f64)) {
+        if !self.resizable() || self.is_applying() {
+            return;
+        }
+        let _guard = self.applying();
+        self.with_view(|view| {
+            let (cw, ch) = constrain_view(view, w, h);
+            let want = size_rect(w, h);
+            let mut r = size_rect(cw, ch);
+            if (r.right, r.bottom) != (want.right, want.bottom) {
+                set_window(cw, ch);
+            }
+            if self.last() != (cw, ch) {
+                self.set_last(cw, ch);
+                // SAFETY: valid, attached view (the editor link takes it away before `removed`,
+                // deferred to here when that happens inside this call); main thread; `r` is a
+                // writable local rect.
+                unsafe { view.onSize(&mut r) };
+            }
+        });
+    }
+}
+
+/// `checkSizeConstraint` for a proposed `w`×`h`: the plugin adjusts the rect in place to a size it
+/// supports. A plugin that doesn't implement it leaves the rect alone (the proposal stands).
+fn constrain_view(view: &ComPtr<sb::IPlugView>, w: f64, h: f64) -> (f64, f64) {
+    let mut r = size_rect(w, h);
+    // SAFETY: valid view; `r` is a writable local rect.
+    unsafe { view.checkSizeConstraint(&mut r) };
+    rect_size(&r)
+}
+
+/// The window rect while the user drags `edge` (a Windows `WMSZ_*` value): moves only the dragged
+/// edges so the outer size becomes `w`×`h`. Pure, so it's testable on every platform.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn sizing_rect(edge: usize, (left, top, right, bottom): (i32, i32, i32, i32), (w, h): (i32, i32)) -> (i32, i32, i32, i32) {
+    // WMSZ_LEFT = 1, WMSZ_TOP = 3, WMSZ_TOPLEFT = 4, WMSZ_TOPRIGHT = 5, WMSZ_BOTTOMLEFT = 7.
+    let (l, r) = if matches!(edge, 1 | 4 | 7) { (right.saturating_sub(w), right) } else { (left, left.saturating_add(w)) };
+    let (t, b) = if matches!(edge, 3..=5) { (bottom.saturating_sub(h), bottom) } else { (top, top.saturating_add(h)) };
+    (l, t, r, b)
+}
+
 #[derive(Default)]
 struct EditorInner {
     /// `None` once the instance is gone.
@@ -1849,6 +2075,23 @@ impl EditorLink {
         Ok((w as u32, h as u32))
     }
 
+    /// Creates the view and asks it how it would take a user resize to `w`×`h`, without
+    /// attaching it: `None` when it can't resize (`canResize`), else the size `checkSizeConstraint`
+    /// settles on. The same negotiation the host window runs, except that `canResize` and
+    /// `checkSizeConstraint` are asked before `attached` (the window asks once attached), which
+    /// some plugins answer differently. For diagnostics and tests.
+    pub fn probe_resize(&self, w: u32, h: u32) -> Result<Option<(u32, u32)>, String> {
+        let g = self.lock();
+        let view = EditorLink::create_view(&g)?;
+        // SAFETY: valid view.
+        let resizable = unsafe { view.canResize() } == sb::kResultTrue;
+        let sizer = ViewSizer::new(f64::from(w), f64::from(h));
+        sizer.set_view(Some(view), resizable);
+        let out = sizer.constrain(f64::from(w), f64::from(h)).map(|(a, b)| (a as u32, b as u32));
+        sizer.set_view(None, false);
+        Ok(out)
+    }
+
     /// Opens (or raises) the editor in a host window. Main thread only.
     pub fn open(&self, title: &str) -> Result<(), String> {
         if !window::on_main_thread() {
@@ -1873,13 +2116,16 @@ impl EditorLink {
         // SAFETY: valid view; `r` is writable. On failure the default size stays.
         unsafe { view.getSize(&mut r) };
         let (w, h) = rect_size(&r);
-        let win = window::HostWindow::new(title, w, h)?;
-        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(win.raw()) });
+        let mut win = window::HostWindow::new(title, w, h)?;
+        let sizer = Arc::new(ViewSizer::new(w, h));
+        win.set_sizer(&sizer);
+        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(win.raw()), sizer: sizer.clone() });
         if let Some(f) = frame.as_com_ref::<sb::IPlugFrame>() {
             // SAFETY: valid view; the frame outlives the view's attachment (kept in `g.frame`
             // and detached in `teardown`).
             unsafe { view.setFrame(f.as_ptr()) };
         }
+        sizer.expect_view(&view);
         // SAFETY: valid view; the parent is the live content view of `win`, of the type named.
         if !ok(unsafe { view.attached(win.parent(), platform) }) {
             // SAFETY: valid view; detach our frame again.
@@ -1887,6 +2133,13 @@ impl EditorLink {
             win.close();
             return Err("the editor could not attach to its window".into());
         }
+        // Asked once attached: some plugins only know whether their editor resizes by then.
+        // SAFETY: valid, attached view.
+        let resizable = unsafe { view.canResize() } == sb::kResultTrue;
+        // The resize border first: the frame change it causes is not a user resize (the sizer
+        // negotiates only once it has the view).
+        win.set_resizable(resizable);
+        sizer.set_view(Some(view.clone()), resizable);
         win.raise();
         g.view = Some(view);
         g.frame = Some(frame);
@@ -1895,6 +2148,10 @@ impl EditorLink {
     }
 
     fn teardown(g: &mut EditorInner) {
+        // No more user resizes reach the view once it is being removed.
+        if let Some(f) = &g.frame {
+            f.sizer.set_view(None, false);
+        }
         if let Some(view) = g.view.take() {
             // SAFETY: valid, attached view; main thread (callers check). `removed` before the
             // window goes, and the frame is detached before it is released.
@@ -2033,9 +2290,11 @@ impl EditorLink {
                     std::mem::forget(v);
                     leaked = true;
                 }
-                // `HostWindow` has no destructor: dropping it leaks its retain on purpose.
+                // `HostWindow` has no destructor: dropping it leaks its retain on purpose. The
+                // leaked window keeps its resize handling, so cut it off from the view first.
                 g.window = None;
                 if let Some(f) = g.frame.take() {
+                    std::mem::forget(f.sizer.take_view());
                     std::mem::forget(f);
                 }
             }
@@ -2049,11 +2308,85 @@ impl EditorLink {
 /// The platform host window for plugin views.
 #[cfg(target_os = "macos")]
 mod window {
+    use super::ViewSizer;
     use objc2::rc::Retained;
-    use objc2::{MainThreadMarker, MainThreadOnly};
-    use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask};
-    use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
+    use objc2::runtime::ProtocolObject;
+    use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
+    use objc2_app_kit::{
+        NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowDelegate, NSWindowStyleMask,
+    };
+    use objc2_foundation::{NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
     use std::ffi::{c_char, c_void};
+    use std::sync::Arc;
+
+    define_class!(
+        // SAFETY: NSObject has no subclassing requirements, and `ResizeDelegate` implements no
+        // `Drop` (its ivar, an `Arc`, is dropped by objc2 when the object is deallocated).
+        // No `#[name]`: objc2 then generates a crate- and version-specific name and reuses an
+        // already registered class of that name (e.g. a second copy of this crate in the process)
+        // instead of panicking.
+        #[unsafe(super(NSObject))]
+        #[thread_kind = MainThreadOnly]
+        #[ivars = Arc<ViewSizer>]
+        struct ResizeDelegate;
+
+        // SAFETY: NSObjectProtocol has no safety requirements.
+        unsafe impl NSObjectProtocol for ResizeDelegate {}
+
+        // SAFETY: the methods below match the NSWindowDelegate selectors' signatures.
+        unsafe impl NSWindowDelegate for ResizeDelegate {
+            /// Live user resizes: the window only ever takes a size the view accepts.
+            #[unsafe(method(windowWillResize:toSize:))]
+            fn window_will_resize(&self, sender: &NSWindow, frame_size: NSSize) -> NSSize {
+                // Our own reference: the sizer stays alive even if the plugin closes the editor
+                // from a nested event loop during this call.
+                let sizer = self.ivars().clone();
+                constrained_frame(&sizer, sender, NSRect::new(NSPoint::new(0.0, 0.0), frame_size)).size
+            }
+
+            /// The zoom button: zoom to the largest size the view accepts within the default
+            /// (screen) frame, keeping the window's top edge.
+            #[unsafe(method(windowWillUseStandardFrame:defaultFrame:))]
+            fn window_standard_frame(&self, window: &NSWindow, default_frame: NSRect) -> NSRect {
+                let sizer = self.ivars().clone();
+                let mut frame = constrained_frame(&sizer, window, default_frame);
+                frame.origin.y = default_frame.origin.y + default_frame.size.height - frame.size.height;
+                frame
+            }
+
+            /// Every completed resize (drags, zoom): settles on a constrained size and tells the
+            /// view. Resizes the host applies itself are ignored by the sizer.
+            #[unsafe(method(windowDidResize:))]
+            fn window_did_resize(&self, notification: &NSNotification) {
+                let sizer = self.ivars().clone();
+                let Some(window) = notification.object().and_then(|o| o.downcast::<NSWindow>().ok()) else { return };
+                let Some(view) = window.contentView() else { return };
+                let size = view.frame().size;
+                sizer.user_resized(size.width, size.height, |w, h| window.setContentSize(NSSize::new(w, h)));
+            }
+        }
+    );
+
+    /// `frame` with its content size constrained by the view (unchanged when nothing is
+    /// negotiated); the origin stays.
+    fn constrained_frame(sizer: &ViewSizer, window: &NSWindow, frame: NSRect) -> NSRect {
+        let content = window.contentRectForFrameRect(frame);
+        match sizer.constrain(content.size.width, content.size.height) {
+            Some((w, h)) => {
+                let size = window.frameRectForContentRect(NSRect::new(content.origin, NSSize::new(w, h))).size;
+                NSRect::new(frame.origin, size)
+            }
+            None => frame,
+        }
+    }
+
+    impl ResizeDelegate {
+        fn new(mtm: MainThreadMarker, sizer: Arc<ViewSizer>) -> Retained<ResizeDelegate> {
+            let this = ResizeDelegate::alloc(mtm).set_ivars(sizer);
+            // SAFETY: `init` of NSObject on a freshly allocated instance whose ivars are set.
+            unsafe { msg_send![super(this), init] }
+        }
+    }
 
     pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeNSView);
 
@@ -2065,10 +2398,12 @@ mod window {
     /// the plugin view's parent. Holds one retain of the window; main thread only.
     pub struct HostWindow {
         window: *mut NSWindow,
+        /// The window's delegate (one retain; the window only keeps a weak reference), or null.
+        delegate: *mut ResizeDelegate,
     }
 
-    // SAFETY: the pointer is only dereferenced on the main thread (every method checks
-    // `MainThreadMarker`); the struct merely carries the retain between threads' locks.
+    // SAFETY: the pointers are only dereferenced on the main thread (every method checks
+    // `MainThreadMarker`); the struct merely carries the retains between threads' locks.
     unsafe impl Send for HostWindow {}
 
     impl HostWindow {
@@ -2099,7 +2434,38 @@ mod window {
             window.setExcludedFromWindowsMenu(true);
             window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);
             window.center();
-            Ok(HostWindow { window: Retained::into_raw(window) })
+            Ok(HostWindow { window: Retained::into_raw(window), delegate: std::ptr::null_mut() })
+        }
+
+        /// Routes the window's resizes through `sizer` (an `NSWindowDelegate`). Once per window.
+        pub fn set_sizer(&mut self, sizer: &Arc<ViewSizer>) {
+            let (Some(mtm), Some(w)) = (MainThreadMarker::new(), self.get()) else { return };
+            if !self.delegate.is_null() {
+                return;
+            }
+            let delegate = ResizeDelegate::new(mtm, sizer.clone());
+            w.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
+            self.delegate = Retained::into_raw(delegate);
+        }
+
+        /// Gives the window a resize handle (the view said `canResize`), or takes it away.
+        pub fn set_resizable(&self, resizable: bool) {
+            let Some(w) = self.get() else { return };
+            let style = w.styleMask();
+            let style = if resizable { style | NSWindowStyleMask::Resizable } else { style & !NSWindowStyleMask::Resizable };
+            if style == w.styleMask() {
+                return;
+            }
+            let size = w.contentView().map(|v| v.frame().size);
+            w.setStyleMask(style);
+            // A resizable window gets a zoom button; keep it out of full screen (the editor is a
+            // transient panel, and the sizer would fight a full-screen size the view refuses).
+            if resizable {
+                w.setCollectionBehavior(w.collectionBehavior() | NSWindowCollectionBehavior::FullScreenNone);
+            }
+            if let Some(size) = size {
+                w.setContentSize(size);
+            }
         }
 
         /// The raw window pointer (for the plug frame's resizes).
@@ -2124,12 +2490,20 @@ mod window {
 
         pub fn close(self) {
             if let Some(w) = self.get() {
+                w.setDelegate(None);
                 w.orderOut(None);
                 w.close();
             }
             if MainThreadMarker::new().is_some() {
                 // SAFETY: balances the retain taken in `new`; nothing uses the pointer after this.
                 drop(unsafe { Retained::from_raw(self.window) });
+                // SAFETY: null, or balances the retain taken in `set_sizer`; the window no longer
+                // refers to it (delegate cleared above).
+                if let Some(d) = unsafe { Retained::from_raw(self.delegate) } {
+                    // Released when the event loop's autorelease pool drains, not now: `close` may
+                    // run inside one of the delegate's own methods (a plugin pumping a nested loop).
+                    let _ = Retained::autorelease_ptr(d);
+                }
             }
         }
     }
@@ -2152,15 +2526,18 @@ mod window {
 /// the window. Messages are dispatched by the app's own event loop on the main thread.
 #[cfg(windows)]
 mod window {
+    use super::{ViewSizer, sizing_rect};
     use std::ffi::{c_char, c_void};
-    use std::sync::OnceLock;
+    use std::sync::{Arc, OnceLock};
     use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
     use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
     use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;
     use windows_sys::Win32::UI::WindowsAndMessaging::{
         AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,
-        GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,
-        SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,
+        GWLP_USERDATA, GetClientRect, GetWindowLongPtrW, GetWindowRect, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW,
+        SIZE_MINIMIZED, SW_HIDE, SW_SHOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetForegroundWindow,
+        SetWindowLongPtrW, SetWindowPos, ShowWindow, WM_CLOSE, WM_NCDESTROY, WM_SIZE, WM_SIZING, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN,
+        WS_OVERLAPPED, WS_SYSMENU, WS_THICKFRAME,
     };
 
     pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND);
@@ -2176,11 +2553,83 @@ mod window {
 
     const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN;
 
+    /// The sizer stored in the window's user data by `set_sizer` (one `Arc` reference, released
+    /// on `WM_NCDESTROY`), borrowed for one message.
+    fn window_sizer(hwnd: HWND) -> Option<Arc<ViewSizer>> {
+        // SAFETY: `hwnd` is the window the procedure was called for; its user data is 0 or a
+        // pointer from `Arc::into_raw`, kept alive until `WM_NCDESTROY` (the last message).
+        let p = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const ViewSizer;
+        if p.is_null() {
+            return None;
+        }
+        // SAFETY: as above; this message takes a reference of its own, so the sizer outlives it
+        // even when a plugin destroys the window from a nested event loop meanwhile.
+        unsafe { Arc::increment_strong_count(p) };
+        // SAFETY: balances the increment just made.
+        Some(unsafe { Arc::from_raw(p) })
+    }
+
+    /// The window's frame (outer minus client size), measured: right at any monitor's DPI, unlike
+    /// `AdjustWindowRectEx` (system DPI). `None` when the window can't be measured.
+    fn frame_size(hwnd: HWND) -> Option<(i32, i32)> {
+        let (mut w, mut c) = (RECT { left: 0, top: 0, right: 0, bottom: 0 }, RECT { left: 0, top: 0, right: 0, bottom: 0 });
+        // SAFETY: a window of this thread (or a stale handle, which fails); both rects are writable.
+        if unsafe { GetWindowRect(hwnd, &mut w) } == 0 || unsafe { GetClientRect(hwnd, &mut c) } == 0 {
+            return None;
+        }
+        let fw = w.right.saturating_sub(w.left).saturating_sub(c.right.saturating_sub(c.left));
+        let fh = w.bottom.saturating_sub(w.top).saturating_sub(c.bottom.saturating_sub(c.top));
+        Some((fw.max(0), fh.max(0)))
+    }
+
+    /// Outer size for a client area of `w`×`h`: measured frame, else `AdjustWindowRectEx`.
+    fn outer_for(hwnd: HWND, w: f64, h: f64) -> (i32, i32) {
+        match frame_size(hwnd) {
+            Some((fw, fh)) => ((w as i32).saturating_add(fw), (h as i32).saturating_add(fh)),
+            None => {
+                // SAFETY: a window of this thread; style values are only read.
+                let (style, ex) = unsafe { (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32, GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32) };
+                outer_size(style, ex, w, h)
+            }
+        }
+    }
+
     unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
-        if msg == WM_CLOSE {
-            // SAFETY: `hwnd` is the window this procedure was called for.
-            unsafe { ShowWindow(hwnd, SW_HIDE) };
-            return 0;
+        match msg {
+            WM_CLOSE => {
+                // SAFETY: `hwnd` is the window this procedure was called for.
+                unsafe { ShowWindow(hwnd, SW_HIDE) };
+                return 0;
+            }
+            // Live user resizes: keep the dragged edges at a size the view accepts.
+            WM_SIZING => {
+                // SAFETY: for WM_SIZING, `lparam` points to the window's writable drag rect.
+                if let (Some(s), Some(r), Some((fw, fh))) = (window_sizer(hwnd), unsafe { (lparam as *mut RECT).as_mut() }, frame_size(hwnd)) {
+                    let (w, h) = (r.right.saturating_sub(r.left).saturating_sub(fw), r.bottom.saturating_sub(r.top).saturating_sub(fh));
+                    if let Some((cw, ch)) = s.constrain(f64::from(w.max(1)), f64::from(h.max(1))) {
+                        let (ow, oh) = ((cw as i32).saturating_add(fw), (ch as i32).saturating_add(fh));
+                        (r.left, r.top, r.right, r.bottom) = sizing_rect(wparam, (r.left, r.top, r.right, r.bottom), (ow, oh));
+                        return 1;
+                    }
+                }
+            }
+            // Every completed resize: settle on the constrained size and tell the view.
+            WM_SIZE if wparam != SIZE_MINIMIZED as usize => {
+                if let Some(s) = window_sizer(hwnd) {
+                    let (w, h) = (f64::from((lparam & 0xFFFF) as u16), f64::from(((lparam >> 16) & 0xFFFF) as u16));
+                    s.user_resized(w, h, |cw, ch| resize(hwnd, cw, ch));
+                }
+            }
+            WM_NCDESTROY => {
+                // SAFETY: the window's last message: take the user data back and release the
+                // `Arc` reference `set_sizer` stored there; nothing reads it afterwards.
+                let p = unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) } as *const ViewSizer;
+                if !p.is_null() {
+                    // SAFETY: `p` came from `Arc::into_raw` in `set_sizer`, released exactly once.
+                    drop(unsafe { Arc::from_raw(p) });
+                }
+            }
+            _ => {}
         }
         // SAFETY: default handling of a message for the window it was sent to.
         unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
@@ -2269,6 +2718,8 @@ mod window {
             if hwnd.is_null() {
                 return Err("cannot create the editor window".into());
             }
+            // `AdjustWindowRectEx` assumed the system DPI; size the client area by the real frame.
+            resize(hwnd, w, h);
             Ok(HostWindow { hwnd })
         }
 
@@ -2277,6 +2728,42 @@ mod window {
             self.hwnd
         }
 
+        /// Routes the window's resizes through `sizer` (the window procedure reads it from the
+        /// window's user data). Once per window.
+        pub fn set_sizer(&mut self, sizer: &Arc<ViewSizer>) {
+            let Some(h) = self.live() else { return };
+            if window_sizer(h).is_some() {
+                return;
+            }
+            let p = Arc::into_raw(sizer.clone());
+            // SAFETY: a live window of this thread; the pointer stays valid until `WM_NCDESTROY`
+            // releases it.
+            unsafe { SetWindowLongPtrW(h, GWLP_USERDATA, p as _) };
+        }
+
+        /// Gives the window a sizing border (the view said `canResize`), or takes it away,
+        /// keeping the client size.
+        pub fn set_resizable(&self, resizable: bool) {
+            let Some(h) = self.live() else { return };
+            // SAFETY: a live window of this thread; the style is only read.
+            if ((unsafe { GetWindowLongPtrW(h, GWL_STYLE) } as u32 & WS_THICKFRAME) != 0) == resizable {
+                return;
+            }
+            let mut c = RECT { left: 0, top: 0, right: 0, bottom: 0 };
+            // SAFETY: a live window of this thread; `c` is writable.
+            unsafe { GetClientRect(h, &mut c) };
+            // SAFETY: as above; only the sizing-border bit of the style changes.
+            unsafe {
+                let style = GetWindowLongPtrW(h, GWL_STYLE) as u32;
+                let style = if resizable { style | WS_THICKFRAME } else { style & !WS_THICKFRAME };
+                SetWindowLongPtrW(h, GWL_STYLE, style as _);
+            }
+            // SAFETY: as above; the frame is recomputed for the new style, nothing else changes.
+            unsafe { SetWindowPos(h, std::ptr::null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) };
+            // The new frame took room from the client area: give it back.
+            resize(h, f64::from(c.right), f64::from(c.bottom));
+        }
+
         /// The `HWND` the plugin view creates its child window in.
         pub fn parent(&self) -> *mut c_void {
             self.live().unwrap_or(std::ptr::null_mut())
@@ -2311,10 +2798,8 @@ mod window {
         if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {
             return;
         }
-        // SAFETY: a live window of this thread; style values are read, not changed.
-        let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };
-        let (ow, oh) = outer_size(style, ex, w, h);
-        // SAFETY: as above; only the size changes.
+        let (ow, oh) = outer_for(window, w, h);
+        // SAFETY: a live window of this thread; only the size changes.
         unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };
     }
 }
@@ -2339,6 +2824,8 @@ mod window {
         pub fn raw(&self) -> *mut c_void {
             std::ptr::null_mut()
         }
+        pub fn set_sizer(&mut self, _sizer: &std::sync::Arc<super::ViewSizer>) {}
+        pub fn set_resizable(&self, _resizable: bool) {}
         pub fn parent(&self) -> *mut c_void {
             std::ptr::null_mut()
         }
@@ -2407,6 +2894,199 @@ mod tests {
         assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());
     }
 
+    /// A plugin view that accepts 200..=800 × 100..=600 and records what the host tells it. With
+    /// `nested`, it calls back into the sizer from `onSize`, the way a plugin calling
+    /// `resizeView` from `onSize` makes the window notify the host again.
+    struct SizedView {
+        sizes: Mutex<Vec<(i32, i32)>>,
+        nested: Mutex<Option<Arc<ViewSizer>>>,
+        /// Closes the editor (takes the view away) from inside `onSize`, once.
+        close_inside: Mutex<Option<Arc<ViewSizer>>>,
+        /// Re-requests its size through this frame from inside `onSize` (as `view`, once per
+        /// `onSize`), recording what the host answered.
+        frame: Mutex<Option<(ComPtr<sb::IPlugFrame>, usize)>>,
+        answers: Mutex<Vec<sb::tresult>>,
+    }
+
+    impl SizedView {
+        fn new() -> SizedView {
+            SizedView {
+                sizes: Mutex::new(Vec::new()),
+                nested: Mutex::new(None),
+                close_inside: Mutex::new(None),
+                frame: Mutex::new(None),
+                answers: Mutex::new(Vec::new()),
+            }
+        }
+    }
+
+    impl Class for SizedView {
+        type Interfaces = (sb::IPlugView,);
+    }
+
+    impl IPlugViewTrait for SizedView {
+        unsafe fn isPlatformTypeSupported(&self, _t: sb::FIDString) -> sb::tresult {
+            sb::kResultTrue
+        }
+        unsafe fn attached(&self, _p: *mut c_void, _t: sb::FIDString) -> sb::tresult {
+            sb::kResultOk
+        }
+        unsafe fn removed(&self) -> sb::tresult {
+            sb::kResultOk
+        }
+        unsafe fn onWheel(&self, _d: f32) -> sb::tresult {
+            sb::kResultFalse
+        }
+        unsafe fn onKeyDown(&self, _k: sb::char16, _c: i16, _m: i16) -> sb::tresult {
+            sb::kResultFalse
+        }
+        unsafe fn onKeyUp(&self, _k: sb::char16, _c: i16, _m: i16) -> sb::tresult {
+            sb::kResultFalse
+        }
+        unsafe fn getSize(&self, _s: *mut sb::ViewRect) -> sb::tresult {
+            sb::kResultOk
+        }
+        unsafe fn onSize(&self, s: *mut sb::ViewRect) -> sb::tresult {
+            let r = unsafe { &*s };
+            self.sizes.lock().unwrap().push((r.right - r.left, r.bottom - r.top));
+            if let Some(sizer) = self.nested.lock().unwrap().clone() {
+                sizer.user_resized(999.0, 999.0, |_, _| panic!("a nested notification is not negotiated"));
+            }
+            if let Some(sizer) = self.close_inside.lock().unwrap().take() {
+                sizer.set_view(None, false);
+            }
+            let frame = self.frame.lock().unwrap().clone();
+            if let Some((f, me)) = frame {
+                let mut r = sb::ViewRect { left: 0, top: 0, right: 333, bottom: 222 };
+                let answer = unsafe { f.resizeView(me as *mut sb::IPlugView, &mut r) };
+                self.answers.lock().unwrap().push(answer);
+            }
+            sb::kResultOk
+        }
+        unsafe fn onFocus(&self, _s: sb::TBool) -> sb::tresult {
+            sb::kResultOk
+        }
+        unsafe fn setFrame(&self, _f: *mut sb::IPlugFrame) -> sb::tresult {
+            sb::kResultOk
+        }
+        unsafe fn canResize(&self) -> sb::tresult {
+            sb::kResultTrue
+        }
+        unsafe fn checkSizeConstraint(&self, r: *mut sb::ViewRect) -> sb::tresult {
+            let r = unsafe { &mut *r };
+            r.right = r.left + (r.right - r.left).clamp(200, 800);
+            r.bottom = r.top + (r.bottom - r.top).clamp(100, 600);
+            sb::kResultTrue
+        }
+    }
+
+    #[test]
+    fn user_resizes_are_negotiated_with_the_view_without_loops() {
+        let fake = ComWrapper::new(SizedView::new());
+        let view = fake.to_com_ptr::<sb::IPlugView>().unwrap();
+        let sizer = Arc::new(ViewSizer::new(320.0, 240.0));
+        // Not resizable (yet): nothing is negotiated and the view isn't told.
+        sizer.set_view(Some(view.clone()), false);
+        assert_eq!(sizer.constrain(500.0, 400.0), None);
+        sizer.user_resized(500.0, 400.0, |_, _| panic!("not resizable"));
+        assert!(fake.sizes.lock().unwrap().is_empty());
+
+        sizer.set_view(Some(view), true);
+        assert_eq!(sizer.constrain(500.0, 400.0), Some((500.0, 400.0)));
+        assert_eq!(sizer.constrain(50.0, 5000.0), Some((200.0, 600.0)), "checkSizeConstraint adjusts the drag");
+        // A size the view refuses: the window is set back to the constrained size, then `onSize`.
+        let mut set = None;
+        sizer.user_resized(1000.0, 50.0, |w, h| set = Some((w, h)));
+        assert_eq!(set, Some((800.0, 100.0)));
+        assert_eq!(*fake.sizes.lock().unwrap(), vec![(800, 100)]);
+        // An accepted size leaves the window alone; the same size again is not repeated.
+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));
+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));
+        assert_eq!(*fake.sizes.lock().unwrap(), vec![(800, 100), (640, 480)]);
+
+        // Notifications caused by a resize the host applies (a plugin `resizeView`) are ignored…
+        {
+            let _applying = sizer.applying();
+            assert_eq!(sizer.constrain(300.0, 300.0), None);
+            sizer.user_resized(300.0, 300.0, |_, _| panic!("ignored while applying"));
+            sizer.set_last(300.0, 300.0);
+        }
+        assert_eq!(fake.sizes.lock().unwrap().len(), 2);
+        // …and the plugin's new size is remembered: the window notification for it is a no-op.
+        sizer.user_resized(300.0, 300.0, |_, _| panic!("accepted as is"));
+        assert_eq!(fake.sizes.lock().unwrap().len(), 2);
+
+        // A view that resizes itself from `onSize` doesn't start a loop (or deadlock).
+        *fake.nested.lock().unwrap() = Some(sizer.clone());
+        sizer.user_resized(400.0, 400.0, |_, _| panic!("accepted as is"));
+        assert_eq!(fake.sizes.lock().unwrap().last(), Some(&(400, 400)));
+        assert_eq!(fake.sizes.lock().unwrap().len(), 3, "the nested notification was not negotiated");
+        *fake.nested.lock().unwrap() = None;
+
+        // Closing the editor from inside `onSize` (a plugin pumping a nested event loop) neither
+        // deadlocks nor leaves the view reachable: the removal is applied when the call returns.
+        *fake.close_inside.lock().unwrap() = Some(sizer.clone());
+        sizer.user_resized(450.0, 450.0, |_, _| panic!("accepted as is"));
+        assert_eq!(fake.sizes.lock().unwrap().len(), 4);
+        assert!(!sizer.resizable());
+        assert!(sizer.view.lock().unwrap().is_none(), "removed once onSize returned");
+        assert!(sizer.attached().is_null());
+        sizer.set_view(fake.to_com_ptr::<sb::IPlugView>(), true);
+
+        // Once the view is taken away (editor closing), nothing reaches it any more.
+        assert!(sizer.take_view().is_some());
+        assert_eq!(sizer.constrain(500.0, 500.0), None);
+        sizer.user_resized(500.0, 500.0, |_, _| panic!("no view"));
+        assert_eq!(fake.sizes.lock().unwrap().len(), 4);
+    }
+
+    #[test]
+    fn plugin_resizes_come_from_the_attached_view_and_do_not_recurse() {
+        let fake = ComWrapper::new(SizedView::new());
+        let view = fake.to_com_ptr::<sb::IPlugView>().unwrap();
+        let other = ComWrapper::new(SizedView::new());
+        let other = other.to_com_ptr::<sb::IPlugView>().unwrap();
+        let sizer = Arc::new(ViewSizer::new(320.0, 240.0));
+        // No window (null): only the negotiation runs.
+        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()), sizer: sizer.clone() });
+        let f = frame.to_com_ptr::<sb::IPlugFrame>().unwrap();
+        let mut r = sb::ViewRect { left: 0, top: 0, right: 500, bottom: 400 };
+        // SAFETY: calls through our own implementation with valid pointers (or null, handled).
+        unsafe {
+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kInvalidArgument, "nothing attached yet");
+            sizer.expect_view(&view);
+            assert_eq!(f.resizeView(other.as_ptr(), &mut r), sb::kInvalidArgument, "not the attached view");
+            assert_eq!(f.resizeView(std::ptr::null_mut(), &mut r), sb::kInvalidArgument);
+            assert_eq!(f.resizeView(view.as_ptr(), std::ptr::null_mut()), sb::kInvalidArgument);
+            // The attached view resizes; asking again from inside its `onSize` is refused instead
+            // of recursing.
+            *fake.frame.lock().unwrap() = Some((f.clone(), view.as_ptr() as usize));
+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kResultOk);
+            assert_eq!(*fake.sizes.lock().unwrap(), vec![(500, 400)]);
+            assert_eq!(*fake.answers.lock().unwrap(), vec![sb::kResultFalse]);
+            assert_eq!(sizer.last(), (500.0, 400.0));
+            *fake.frame.lock().unwrap() = None;
+            // The guard is released: the next request goes through.
+            let mut r = sb::ViewRect { left: 0, top: 0, right: 640, bottom: 480 };
+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kResultOk);
+            assert_eq!(sizer.last(), (640.0, 480.0));
+            // Detached: refused again.
+            sizer.set_view(None, false);
+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kInvalidArgument);
+        }
+    }
+
+    #[test]
+    fn sizing_moves_only_the_dragged_edges() {
+        let r = (100, 100, 500, 400);
+        assert_eq!(sizing_rect(8, r, (300, 200)), (100, 100, 400, 300), "bottom-right");
+        assert_eq!(sizing_rect(4, r, (300, 200)), (200, 200, 500, 400), "top-left");
+        assert_eq!(sizing_rect(1, r, (300, 300)), (200, 100, 500, 400), "left (height follows the constraint downwards)");
+        assert_eq!(sizing_rect(5, r, (300, 200)), (100, 200, 400, 400), "top-right");
+        assert_eq!(sizing_rect(7, r, (300, 200)), (200, 100, 500, 300), "bottom-left");
+        assert_eq!(sizing_rect(2, (i32::MAX - 1, 0, i32::MAX, 10), (100, 10)), (i32::MAX - 1, 0, i32::MAX, 10), "saturates");
+    }
+
     #[test]
     fn the_transport_fills_the_process_context() {
         use sv::ProcessContext_::StatesAndFlags_::*;
diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs
--- a/crates/vst3-host/src/lib.rs
+++ b/crates/vst3-host/src/lib.rs
@@ -27,7 +27,15 @@
 //! Plugin editors (`IPlugView`): on macOS the view is attached to the content `NSView` of a host
 //! `NSWindow` (titled and closable, floating above the app, resized on `IPlugFrame::resizeView`);
 //! on Windows it is attached to a host `HWND` owned by the app's window (no taskbar button,
-//! closing it hides it until the link tears it down).
+//! closing it hides it until the link tears it down). When the view `canResize`, the window gets
+//! a resize handle: user resizes are constrained live by `checkSizeConstraint` (the window
+//! delegate's `windowWillResize:toSize:` / `WM_SIZING`) and applied with `onSize` once the window
+//! has its new size (`windowDidResize:` / `WM_SIZE`); the zoom button zooms to a constrained
+//! size. Window notifications caused by the host's own resizes (a plugin's `resizeView`, or
+//! snapping the window to a constrained size) are not negotiated again, and like Steinberg's
+//! editorhost, `resizeView` is accepted only from the attached view and refused (`kResultFalse`)
+//! while another one is in progress, so neither side can make resizes recurse. Content scaling
+//! (`IPlugViewContentScaleSupport`) is not hosted yet.
 //! [`Plugin::open_editor`] opens it directly; [`Plugin::editor`] returns a handle usable on the
 //! main thread while the instance processes on the audio thread (VST3's own threading model:
 //! the controller and its views live on the UI thread). Parameter edits made in the editor
diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs
--- a/crates/vst3-host/src/plugin.rs
+++ b/crates/vst3-host/src/plugin.rs
@@ -149,6 +149,16 @@ impl Vst3Plugin {
         self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe()
     }
 
+    /// How the editor would take a user resize of its window to `w`×`h`, without opening it:
+    /// `None` when it can't be resized (`IPlugView::canResize`), else the size it constrains the
+    /// request to (`checkSizeConstraint`), as the host window negotiates it. For diagnostics and
+    /// tests; real plugins expect this on the main thread. The view is not attached, so a plugin
+    /// that only knows its constraints once attached may accept any size here.
+    #[doc(hidden)]
+    pub fn editor_resize(&self, w: u32, h: u32) -> Result<Option<(u32, u32)>, String> {
+        self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe_resize(w, h)
+    }
+
     /// Re-reads every parameter value from the controller (after a state load) and drops
     /// changes still queued from before it.
     fn refresh_values(&mut self) {
diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs
--- a/crates/vst3-host/tests/editor_main_thread.rs
+++ b/crates/vst3-host/tests/editor_main_thread.rs
@@ -15,6 +15,9 @@ fn main() {
     } else {
         assert!(p.editor_size().is_err());
     }
+    // A resizable view: user resizes of the host window are negotiated with the view.
+    assert_eq!(p.editor_resize(640, 480), Ok(Some((640, 480))), "a size the view accepts stands");
+    assert_eq!(p.editor_resize(50, 2000), Ok(Some((200, 900))), "checkSizeConstraint clamps the request");
     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
     assert!(!ed.is_open());
     assert_eq!(ed.idle().len(), 1, "the fixture's setup edit");
diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs
--- a/crates/vst3-host/tests/fixture/src/lib.rs
+++ b/crates/vst3-host/tests/fixture/src/lib.rs
@@ -6,7 +6,8 @@
 //!   9 hidden, 11 bypass. Starts at gain 0.5 (its state) although the declared default is 1.0.
 //!   Reports 3 samples of latency. State: the component stores the gain (one LE f64), the
 //!   controller the Freq value (one LE f64). Its controller has an editor view (`IPlugView`,
-//!   320×240, no real drawing) supporting NSView/HWND/X11.
+//!   320×240, no real drawing) supporting NSView/HWND/X11, resizable between 200×150 and
+//!   1200×900 (`canResize`, `checkSizeConstraint` clamps into that range).
 //! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also
 //!   its edit controller). No audio input, one stereo output, one event input; every output
 //!   sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and
@@ -266,10 +267,14 @@ impl IPlugViewTrait for View {
         kResultOk
     }
     unsafe fn canResize(&self) -> tresult {
-        kResultFalse
+        kResultTrue
     }
-    unsafe fn checkSizeConstraint(&self, _r: *mut ViewRect) -> tresult {
-        kResultOk
+    /// Resizable between 200×150 and 1200×900 (a proposal outside is clamped into that range).
+    unsafe fn checkSizeConstraint(&self, r: *mut ViewRect) -> tresult {
+        let Some(r) = (unsafe { r.as_mut() }) else { return kInvalidArgument };
+        r.right = r.left + (r.right - r.left).clamp(200, 1200);
+        r.bottom = r.top + (r.bottom - r.top).clamp(150, 900);
+        kResultTrue
     }
 }
 
diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs
--- a/crates/vst3-host/tests/it/fixture_e2e.rs
+++ b/crates/vst3-host/tests/it/fixture_e2e.rs
@@ -334,4 +334,5 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
     // The synth's controller has no view.
     let synth = soundcraft_vst3_host::instantiate_plugin(&id_of("Fixture Synth")).unwrap();
     assert!(synth.editor_size().is_err());
+    assert!(synth.editor_resize(640, 480).is_err());
 }
diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md
--- a/docs/plugin-parity.md
+++ b/docs/plugin-parity.md
@@ -1,6 +1,6 @@
 # Plugin parity: built-in processors and plugin hosting
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (resizable plugin editor windows; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
 cover the rest. This doc tracks both. Our plugins have our own names and are written from
@@ -44,8 +44,8 @@ User report #152: a saved instrument level does not reach the bounce.
 | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) |
 | State saved in sessions, presets | ✅ | ✅ | |
 | Create/destroy off the audio thread | ✅ | ✅ | |
-| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows | Embedded on macOS/Linux missing |
-| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | Linux views missing |
+| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows (resizable when the plugin `can_resize`: `adjust_size`/`set_size`; unit-tested, not yet run on Windows) | Embedded on macOS/Linux missing |
+| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor); resizable views (`canResize`) get a resizable window, user resizes negotiated through `checkSizeConstraint`/`onSize` (fixture-tested; not yet resized by hand with a commercial plugin) | Linux views missing; content scaling (`IPlugViewContentScaleSupport`) missing |
 | Editors: AU | — | ❌ | Cocoa view hosting missing |
 | Multiple editor pages | ✅ | 🟡 | #6 |
 | macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 |
@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Editors: resizable VST3 editor windows (macOS, Windows) and embedded CLAP editor windows (Windows) follow the plugin's size constraints (fixture- and unit-tested) |
 | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu |
 | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) |
 | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |
