diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4193,6 +4193,7 @@ dependencies = [
 name = "soundcraft-vst3-host"
 version = "0.13.1"
 dependencies = [
+ "libc",
  "libloading 0.9.0",
  "log",
  "objc2 0.6.5",
@@ -4203,6 +4204,7 @@ dependencies = [
  "thiserror 2.0.21",
  "vst3",
  "windows-sys 0.61.2",
+ "x11rb",
 ]
 
 [[package]]
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
@@ -27,6 +27,11 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
 [target.'cfg(windows)'.dependencies]
 windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
 
+# The Linux editor host window (a top-level X11 window the plugin view's child window lives in).
+[target.'cfg(target_os = "linux")'.dependencies]
+x11rb = "0.13"
+libc = "0.2"
+
 [[test]]
 name = "editor_main_thread"
 harness = false
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
@@ -2319,8 +2319,171 @@ mod window {
     }
 }
 
+/// The Linux (X11) host window: a top-level window whose content area is the plugin view's
+/// `X11EmbedWindowID` parent. The plugin creates its own child window inside it and draws there.
+/// Closing the window unmaps or destroys it; the editor link notices in `idle` and tears the view
+/// down. Window calls run on the UI thread (the crate's threading model), and the one process-wide
+/// X11 connection is opened on first use and shared.
+#[cfg(target_os = "linux")]
+mod window {
+    use std::collections::HashMap;
+    use std::ffi::{c_char, c_void};
+    use std::sync::atomic::{AtomicBool, Ordering};
+    use std::sync::{Arc, Mutex, OnceLock, PoisonError};
+    use x11rb::connection::Connection;
+    use x11rb::protocol::Event;
+    use x11rb::protocol::xproto::{Atom, AtomEnum, ConfigureWindowAux, ConnectionExt as _, CreateWindowAux, PropMode, WindowClass};
+    use x11rb::rust_connection::RustConnection;
+    use x11rb::wrapper::ConnectionExt as _;
+
+    pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeX11EmbedWindowID);
+
+    pub fn on_main_thread() -> bool {
+        // On Linux the main thread's kernel TID equals the process PID (gettid() == getpid());
+        // any other thread differs. VST3 drives its controllers and views on one UI thread, and
+        // this is the portable probe for "are we on it".
+        // SAFETY: both are plain libc calls with no preconditions.
+        unsafe { libc::gettid() == libc::getpid() }
+    }
+
+    /// The process-wide X11 display (one connection per app), opened on first use. It owns the
+    /// shared connection and a registry of every editor window's visible flag, so one event drain
+    /// routes a close signal to the right window even when several editors are open at once.
+    struct Display {
+        conn: RustConnection,
+        screen: usize,
+        wm_protocols: Atom,
+        wm_delete_window: Atom,
+        windows: Mutex<HashMap<u32, Arc<AtomicBool>>>,
+    }
+
+    impl Display {
+        /// Drains pending events, routing close signals (WM_DELETE_WINDOW, Unmap, Destroy) to the
+        /// matching window's visible flag. Every editor shares this connection, so any window's
+        /// `is_visible` drains for all of them.
+        fn drain(&self) {
+            let conn = &self.conn;
+            while let Ok(Some(event)) = conn.poll_for_event() {
+                match event {
+                    Event::ClientMessage(e) if e.type_ == self.wm_protocols && e.data.as_data32()[0] == self.wm_delete_window => {
+                        let _ = conn.destroy_window(e.window);
+                        let _ = conn.flush();
+                        self.mark(e.window, false);
+                    }
+                    Event::UnmapNotify(e) => self.mark(e.window, false),
+                    Event::DestroyNotify(e) => self.mark(e.window, false),
+                    _ => {}
+                }
+            }
+        }
+
+        fn mark(&self, window: u32, visible: bool) {
+            let windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
+            if let Some(flag) = windows.get(&window) {
+                flag.store(visible, Ordering::Relaxed);
+            }
+        }
+    }
+
+    fn display() -> Result<Arc<Display>, String> {
+        static DISPLAY: OnceLock<Result<Arc<Display>, String>> = OnceLock::new();
+        DISPLAY.get_or_init(init_display).clone()
+    }
+
+    fn init_display() -> Result<Arc<Display>, String> {
+        let (conn, screen) = x11rb::connect(None).map_err(|e| format!("cannot open the X11 display: {e}"))?;
+        let wm_protocols = intern(&conn, b"WM_PROTOCOLS")?;
+        let wm_delete_window = intern(&conn, b"WM_DELETE_WINDOW")?;
+        Ok(Arc::new(Display { conn, screen, wm_protocols, wm_delete_window, windows: Mutex::new(HashMap::new()) }))
+    }
+
+    fn intern(conn: &RustConnection, name: &[u8]) -> Result<Atom, String> {
+        conn.intern_atom(false, name).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string()).map(|r| r.atom)
+    }
+
+    pub struct HostWindow {
+        display: Arc<Display>,
+        window: u32,
+        visible: Arc<AtomicBool>,
+    }
+
+    impl HostWindow {
+        pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {
+            let d = display()?;
+            let conn = &d.conn;
+            let root = conn.setup().roots[d.screen].root;
+            let window = conn.generate_id().map_err(|e| e.to_string())?;
+            let (w, h) = (w.max(1.0).round() as u16, h.max(1.0).round() as u16);
+            conn.create_window(
+                x11rb::COPY_DEPTH_FROM_PARENT,
+                window,
+                root,
+                0,
+                0,
+                w,
+                h,
+                0,
+                WindowClass::INPUT_OUTPUT,
+                x11rb::COPY_FROM_PARENT,
+                &CreateWindowAux::new(),
+            )
+            .map_err(|e| e.to_string())?;
+            conn.change_property8(PropMode::REPLACE, window, AtomEnum::WM_NAME, AtomEnum::STRING, title.as_bytes()).map_err(|e| e.to_string())?;
+            // Ask the window manager for a graceful close (WM_DELETE_WINDOW) instead of a kill.
+            conn.change_property32(PropMode::REPLACE, window, d.wm_protocols, AtomEnum::ATOM, &[d.wm_delete_window]).map_err(|e| e.to_string())?;
+            conn.map_window(window).map_err(|e| e.to_string())?;
+            conn.flush().map_err(|e| e.to_string())?;
+            let visible = Arc::new(AtomicBool::new(true));
+            d.windows.lock().unwrap_or_else(PoisonError::into_inner).insert(window, visible.clone());
+            Ok(HostWindow { display: d, window, visible })
+        }
+
+        /// The raw window id (for the plug frame's resizes).
+        pub fn raw(&self) -> *mut c_void {
+            self.window as usize as *mut c_void
+        }
+
+        /// The window id the plugin view attaches to (its child window's parent).
+        pub fn parent(&self) -> *mut c_void {
+            self.raw()
+        }
+
+        pub fn raise(&self) {
+            let _ = self.display.conn.map_window(self.window);
+            let _ = self.display.conn.flush();
+        }
+
+        /// Whether the window is still shown. The flag is set on map and cleared only by a close
+        /// signal (WM_DELETE_WINDOW, Unmap or Destroy), never by a map that a window manager has
+        /// not finished yet.
+        pub fn is_visible(&self) -> bool {
+            self.display.drain();
+            self.visible.load(Ordering::Relaxed)
+        }
+
+        pub fn close(self) {
+            let _ = self.display.conn.destroy_window(self.window);
+            let _ = self.display.conn.flush();
+            self.visible.store(false, Ordering::Relaxed);
+            self.display.windows.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.window);
+        }
+    }
+
+    /// Resizes a host window (`window` from [`HostWindow::raw`]).
+    pub fn resize(window: *mut c_void, w: f64, h: f64) {
+        let Ok(d) = display() else { return };
+        let window = window as usize as u32;
+        if window == 0 {
+            return;
+        }
+        let values = ConfigureWindowAux::new().width(w.max(1.0).round() as u32).height(h.max(1.0).round() as u32);
+        let _ = d.conn.configure_window(window, &values);
+        let _ = d.conn.flush();
+    }
+}
+
 /// Other platforms: no editor windows yet (opening reports "unsupported").
-#[cfg(not(any(target_os = "macos", windows)))]
+#[cfg(not(any(target_os = "macos", windows, target_os = "linux")))]
 mod window {
     use std::ffi::{c_char, c_void};
 
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
@@ -32,8 +32,8 @@
 //! 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
 //! (`IComponentHandler::performEdit`) are reported by the handle's `idle` so the host can write
-//! them into the session. Linux (X11) editor windows are not hosted yet: opening reports
-//! "unsupported".
+//! them into the session. On Linux the editor is a floating X11 window (`X11EmbedWindowID`) that
+//! the plugin's view attaches to.
 //!
 //! Not hosted yet: `IMessage`/`IAttributeList` creation through the host (plugins that need it
 //! for processor ↔ controller messaging may lose those messages), sidechain (aux buses are kept
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
@@ -14,12 +14,9 @@ fn main() {
     let dir = common::fixture_dir();
     let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
     let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
-    // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported".
-    if cfg!(any(target_os = "macos", windows)) {
-        assert_eq!(p.editor_size(), Ok((320, 240)));
-    } else {
-        assert!(p.editor_size().is_err());
-    }
+    // Editor windows are hosted on macOS, Windows and Linux; the probe reads the size without
+    // opening a window, so it needs no display.
+    assert_eq!(p.editor_size(), Ok((320, 240)));
     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");
@@ -36,6 +33,23 @@ fn main() {
     assert_eq!(ed.value_text("7", 1.5), None);
     assert_eq!(ed.value_text("3", 1.0), None);
     assert_eq!(ed.value_text("nope", 0.5), None);
+    // On Linux with a live X server, actually open the editor window and check it maps and tears
+    // down. Skipped headlessly, or when `DISPLAY` is set but not connectable (e.g. over SSH);
+    // other platforms are exercised manually.
+    #[cfg(target_os = "linux")]
+    if std::env::var_os("DISPLAY").is_some() {
+        match ed.open() {
+            Ok(()) => {
+                assert!(ed.is_open(), "the editor window is mapped");
+                ed.open().expect("opening again just raises it");
+                assert!(ed.is_open());
+                ed.close();
+                assert!(!ed.is_open(), "closing unmaps the editor window");
+            }
+            Err(e) if e.starts_with("cannot open the X11 display") => eprintln!("skipped the editor window check: {e}"),
+            Err(e) => panic!("open the editor on X11: {e}"),
+        }
+    }
     drop(p);
     assert_eq!(ed.value_text("13", 0.375), None, "the plugin is gone");
     assert!(!ed.is_open());
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
@@ -64,11 +64,8 @@ fn identity_mapped_param_text_is_read_on_the_main_thread_only() {
     assert_eq!((level.min, level.max, level.unit), (0.0, 1.0, Unit::Db));
     let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
-    if cfg!(any(target_os = "macos", windows)) {
-        assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread");
-    } else {
-        assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB"));
-    }
+    // Test threads are not the process main thread, so the controller is not read.
+    assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread");
 }
 
 #[test]
@@ -315,21 +312,14 @@ fn host_set_controller_param_survives_state_round_trip() {
 fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
     let id = id_of("Fixture Gain");
     let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
-    // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported".
-    if cfg!(any(target_os = "macos", windows)) {
-        assert_eq!(p.editor_size(), Ok((320, 240)));
-    } else {
-        assert!(p.editor_size().is_err());
-    }
+    // Editor windows are hosted on macOS, Windows and Linux; the probe reads the size without
+    // opening a window, so it needs no display on any of them.
+    assert_eq!(p.editor_size(), Ok((320, 240)));
     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
     // The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler.
     assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]);
     assert!(ed.idle().is_empty());
-    if cfg!(any(target_os = "macos", windows)) {
-        assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread");
-    } else {
-        assert!(ed.open().is_err(), "unsupported elsewhere");
-    }
+    assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread");
     assert!(!ed.is_open());
     // The synth's controller has no view.
     let synth = soundcraft_vst3_host::instantiate_plugin(&id_of("Fixture Synth")).unwrap();
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -119,8 +119,9 @@ launch #142; Linux #45, #107.
 - **Missing:** the `com.apple.security.cs.disable-library-validation` entitlement in the signed
   build, so hardened runtime refuses third-party VST3 bundles (#153, #149). AU editor views
   (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). Embedded CLAP
-  GUIs; VST3 editors on Windows/Linux.
-- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it).
+  GUIs (macOS/Linux); VST3 editors on Windows/Linux were completed later (Windows 2026-10-12,
+  Linux 2026-10-27).
+- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views most of it).
 
 ### G-06. Memory-bound media
 
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-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; 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
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (VST3 editors on Linux; previously trivial: Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; 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
@@ -45,7 +45,7 @@ User report #152: a saved instrument level does not reach the bounce.
 | 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: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | 🟡 Linux: a floating X11 window the plugin view attaches to (`X11EmbedWindowID`), verified with the test fixture; not yet against a commercial plugin |
 | 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 |
@@ -67,4 +67,5 @@ User report #152: a saved instrument level does not reach the bounce.
 | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |
 | 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) |
 | 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
+| 2026-10-27 | minor | Editors: VST3 on Linux — a floating X11 window the plugin view attaches to (`X11EmbedWindowID`), verified with the test fixture |
 | 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -182,7 +182,7 @@ Weights by what Pro Tools users actually spend time on. **Breadth** = the featur
 | Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 |
 | Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 |
 | Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 27 processors + 3 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) |
-| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) |
+| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS, Windows and Linux. Missing: AU editors, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) |
 | MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (no piano, organ or wavetable instrument), bug #98 |
 | Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) |
 | Elastic Audio, TCE, Beat Detective, pitch | 5 % | 70 % | **35 %** | 40–60 | Pitch-preserving warp on Elastic tracks, TCE, conform to tempo, Beat Detective and Identify Beat, audio-to-MIDI. Missing: per-algorithm choice (polyphonic/rhythmic/monophonic/varispeed/X-Form), warp markers UI depth, Elastic Pitch, quality vs Pro Tools unmeasured |
