git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit60aeba47218ebe88d56ac46daa19d523791847c7
parent80a56412cc
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 15:02
feat(params): the params pane is a HUD on the scene, under every plate

The params pane leaves the dock system. It is laid out from the viewport
alone (top right, its own width, the viewport's height), drawn right
after the scene and before every plate, and no plate sizes or moves it.
Where a plate covers it, the plate takes the pointer and the HUD paints
nothing: it is painted into what the plates leave of it, so its labels
cannot show through. The right dock starts empty; an older save's params
dock entry is dropped on load, and its params width becomes the HUD's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md         |  42 +++++++++-
 src/app.rs        | 176 +++++++++++++++++++++++++++++++++---------
 src/main.rs       | 225 ++++++++++++++++++++++++++++++++++++++++--------------
 src/plate_menu.rs |  20 ++++-
 src/project.rs    |  15 +++-
 src/render.rs     |  78 +++++++++++++++----
 6 files changed, 444 insertions(+), 112 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 131328a..e5c53fc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -223,7 +223,7 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
 `cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
 gone from cce-ui with the wgpu path).
 
-- `src/app.rs` (~11.9k lines) — the heart: `State` (the entire app model), `McpAction` /
+- `src/app.rs` (~13.1k lines) — the heart: `State` (the entire app model), `McpAction` /
   `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
   two halves of the old render loop. GPU mesh updates are staged CPU-side
@@ -2689,6 +2689,46 @@ and zooms the scene, and `on_param_resize_edge` runs only as far as the
 rows. `a_plateless_params_pane_is_its_rows` and
 `a_point_number_under_a_plate_is_not_drawn` are the tests.
 
+### The params pane is a HUD on the scene (since 2026-10-06)
+
+The params pane is not a dock pane: it lives on the scene viewer, drawn
+right after it and under every plate, and its size has no relation to any
+plate. Until this it was the right dock's pane — as wide as that dock, its
+bottom raised by a spreadsheet tucked under it, tabbable and movable.
+
+- **Laid out from the viewport alone** (`State::params_hud_rect`): the
+  viewport's top-right corner a gap in, `params_hud_width` wide (its own
+  field, apart from the right dock's `floating_param_width`; saved as
+  `PlateGeometry::hud_width`, and an older save's `params_width` is read
+  as it), as tall as the viewport. The playbar and the spreadsheet cover
+  its bottom; nothing shortens it. Its left edge drags its width
+  (`AppDrag::HudResize`, `on_param_resize_edge`), as far down as it claims.
+  The right dock's own edge is `on_right_dock_resize_edge` /
+  `AppDrag::RightDockResize`, asked first, its plate being on top.
+- **Out of the docks.** The right dock starts EMPTY (`dock_panes`
+  `[network, NO_PANE, spreadsheet]`); `TAB_CANDIDATES` no longer lists
+  params, its plate menu has no tab, Move To or Collapse rows (Detach and
+  the pins stay), `set_pane_collapsed` refuses it, and the pane-state load
+  takes it out of an older save's tab lists — the dock it fronted fronts
+  its next tab or empties. A plate moved into the right dock is drawn over
+  the HUD. The legacy column branches (circular network, detached circular
+  window) still place it in their right column.
+- **Under every plate.** Draw order: viewport (-7), the HUD (-6), the
+  network plates (-5), then the rest. `plates_over_params` is the plates
+  over it (the network's while it has one, the second editor's, the
+  spreadsheet, the playbar, stubs included); `params_claims` takes them
+  out, so a press, the wheel, a row's right-click (`param_row_at`) and
+  `plate_at` there are the plate's. Text is the hard part: the engine lays
+  ALL text out after all geometry, so a HUD label under a plate would be
+  drawn over it. The render arm paints the HUD into what the plates leave
+  of it, a rect at a time (`render::uncovered`, one rect most of the
+  time), so its labels and controls stop at a plate's edge.
+
+`the_params_hud_is_under_the_plates_and_sized_by_none` and
+`uncovered_takes_the_covers_out_of_a_rect` are the tests. Checked in a
+shadow session: the spreadsheet and playbar over the HUD's lower rows, no
+label through them, the HUD's size unmoved.
+
 ### Deselecting has to stick
 
 The selection IS whatever sits in the grid cursor's cell — that is what
diff --git a/src/app.rs b/src/app.rs
index 289fdfa..0727b1f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -370,6 +370,11 @@ pub struct PlateGeometry {
     /// (0 = flush beside it) — see `floating_spreadsheet_inset_left`.
     pub spreadsheet_inset_left: f32,
     pub spreadsheet_inset_right: f32,
+    /// The params HUD's width (since 2026-10-06, when it left the right
+    /// dock). Absent in an older save, whose `params_width` was the params
+    /// pane's and is read as the HUD's.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub hud_width: Option<f32>,
 }
 
 fn default_camera() -> String {
@@ -586,6 +591,9 @@ pub struct NodeDragGroup {
 /// pointer: a press just below a row is the row's, not the scene's.
 pub const PARAMS_CLAIM_PAD: f32 = 8.0;
 
+/// The narrowest the params HUD may be dragged.
+pub const PARAMS_HUD_MIN_W: f32 = 150.0;
+
 pub const NETWORK_MENU_COMMANDS: &[Option<&'static str>] = &[
     Some("add_node"),
     None,
@@ -2665,7 +2673,10 @@ pub const NO_PANE: usize = usize::MAX;
 #[derive(Clone, Copy, Debug, PartialEq)]
 pub enum AppDrag {
     NetworkResize { dir: ResizeDirection, start_rect: (f32, f32, f32, f32), start_mouse: (f32, f32) },
-    ParamResize { start_w: f32, start_mouse_x: f32 },
+    /// The right dock's left edge — the plate docked there, if any.
+    RightDockResize { start_w: f32, start_mouse_x: f32 },
+    /// The params HUD's left edge: its own width, which no plate shares.
+    HudResize { start_w: f32, start_mouse_x: f32 },
     SpreadsheetResize { start_h: f32, start_mouse_y: f32 },
     /// The spreadsheet's left edge drag, as an inset past the flush position
     /// beside the network pane: a positive inset tucks the spreadsheet UNDER
@@ -3071,6 +3082,10 @@ pub struct State {
     /// The active app-mode drag (pane edge resize), if any. See [`AppDrag`].
     pub app_drag: Option<AppDrag>,
     pub floating_param_width: f32,
+    /// The params HUD's width as asked for (`params_hud_rect` fits it to
+    /// the viewport). Its own, apart from the right dock's: the HUD lives
+    /// on the scene, not in a dock.
+    pub params_hud_width: f32,
     pub floating_spreadsheet_height: f32,
     /// How far the spreadsheet's left/right edge reaches INTO the neighboring
     /// pane's span past its flush position (0 = glued beside the neighbor).
@@ -3433,6 +3448,7 @@ impl State {
         let plates = (
             self.floating_network_layout.2.round(),
             self.floating_param_width.round(),
+            self.params_hud_width.round(),
             self.floating_spreadsheet_height.round(),
             self.floating_spreadsheet_inset_left.round(),
             self.floating_spreadsheet_inset_right.round(),
@@ -3971,7 +3987,7 @@ impl State {
     /// all of it, the rows filling it.
     pub fn params_claim(&self) -> (f32, f32, f32, f32) {
         let (x, y, w, h) = self.positions[PARAM_IDX];
-        if self.params_plate || w <= 0.0 || h <= 0.0 {
+        if self.params_plate || w <= 0.0 || h <= 0.0 || self.pane_is_stubbed(PARAM_IDX) {
             return (x, y, w, h);
         }
         let last = self
@@ -3983,11 +3999,16 @@ impl State {
         (x, y, w, (last + PARAMS_CLAIM_PAD).min(y + h) - y)
     }
 
-    /// Whether (px, py) is the params pane's: inside `params_claim`, or on
-    /// a dropdown it has open, which grows past its rows.
+    /// Whether (px, py) is the params pane's: inside `params_claim` where
+    /// no plate covers it, or on a dropdown it has open, which grows past
+    /// its rows and is drawn over everything.
     pub fn params_claims(&self, px: f32, py: f32) -> bool {
+        if !self.slots.param.visible() {
+            return false;
+        }
         let inside = |(x, y, w, h): (f32, f32, f32, f32)| w > 0.0 && h > 0.0 && px >= x && px < x + w && py >= y && py < y + h;
-        inside(self.params_claim()) || self.slots.param.popover_rect().is_some_and(inside)
+        (inside(self.params_claim()) && !self.plate_over_params_at(px, py))
+            || self.slots.param.popover_rect().is_some_and(inside)
     }
 
     /// Whether (px, py) is under a plate drawn over the scene: a floating
@@ -4241,20 +4262,81 @@ impl State {
         }
     }
 
-    /// Whether (cx, cy) is on the parameter pane's left edge-resize hotspot.
+    /// Whether (cx, cy) is on the params HUD's left edge-resize hotspot:
+    /// the edge as far down as the HUD claims (its rows, without its
+    /// plate), and nowhere a plate covers it.
     pub fn on_param_resize_edge(&self, cx: f32, cy: f32) -> bool {
-        if !self.show_parameters {
+        if !self.show_parameters || self.pane_is_stubbed(PARAM_IDX) {
             return false;
         }
-        let gap = 18.0_f32;
-        let param_w = self.right_dock_width();
-        let param_x = self.width - gap - param_w;
-        let param_y = HEADER_H + gap;
-        let param_h = (self.height - HEADER_H - STATUS_H - 2.0 * gap).max(100.0);
-        // Without its plate the pane's edge runs as far as its rows do.
-        let param_h = if self.params_plate { param_h } else { self.params_claim().3.min(param_h) };
+        let (x, y, w, h) = self.params_claim();
+        let margin = 8.0_f32;
+        w > 0.0
+            && cx >= x - margin
+            && cx <= x + margin
+            && cy >= y - margin
+            && cy <= y + h + margin
+            && !self.plate_over_params_at(cx, cy)
+    }
+
+    /// Whether (cx, cy) is on the right dock's left edge-resize hotspot —
+    /// the edge of whatever plate is docked there.
+    pub fn on_right_dock_resize_edge(&self, cx: f32, cy: f32) -> bool {
+        if self.circular_network_pane || !self.dock_shown(Dock::Right) {
+            return false;
+        }
+        let pane = self.pane_in_dock(Dock::Right);
+        if self.pane_is_stubbed(pane) {
+            return false;
+        }
+        let (x, y, w, h) = self.positions[pane];
         let margin = 8.0_f32;
-        cx >= param_x - margin && cx <= param_x + margin && cy >= param_y - margin && cy <= param_y + param_h + margin
+        w > 0.0 && cx >= x - margin && cx <= x + margin && cy >= y - margin && cy <= y + h + margin
+    }
+
+    /// The params HUD's rect: laid out from the VIEWPORT alone — its top
+    /// right corner, a gap in, as wide as `params_hud_width` asks and as
+    /// tall as the viewport. No plate moves or sizes it; plates are drawn
+    /// over it (`plates_over_params`).
+    pub fn params_hud_rect(&self) -> (f32, f32, f32, f32) {
+        let gap = 18.0_f32;
+        let (vx, vy, vw, vh) = self.positions[VIEWPORT_IDX];
+        let (vx, vy, vw, vh) = if vw > 0.0 && vh > 0.0 { (vx, vy, vw, vh) } else { (0.0, 0.0, self.width, self.height - STATUS_H) };
+        let w = self.params_hud_width.clamp(PARAMS_HUD_MIN_W, (vw - 2.0 * gap).max(PARAMS_HUD_MIN_W));
+        (vx + vw - gap - w, vy + gap, w, (vh - 2.0 * gap).max(100.0))
+    }
+
+    /// The plates drawn over the params HUD, as rects: the network's
+    /// (while it has one), the second editor's, the spreadsheet's, the
+    /// playbar's — collapsed and detached stubs included, since those are
+    /// the slots' rects too. The HUD is UNDER all of them: what it shows,
+    /// and what it takes of the pointer, is what they leave.
+    pub fn plates_over_params(&self) -> Vec<(f32, f32, f32, f32)> {
+        let mut out = Vec::new();
+        let mut take = |idx: usize| {
+            let r = self.positions[idx];
+            if r.2 > 0.0 && r.3 > 0.0 && self.slots.get_dyn(idx).visible() {
+                out.push(r);
+            }
+        };
+        if self.network_plate && !self.circular_network_pane {
+            take(NETWORK_PANEL_IDX);
+            take(crate::slots::NETWORK_PANEL2_IDX);
+        }
+        take(SPREADSHEET_IDX);
+        take(PLAYBAR_IDX);
+        if self.circular_network_pane && self.show_network && !self.detached_circular_network {
+            let l = &self.circular_network_layout;
+            out.push((l.x - l.r, l.y - l.r, 2.0 * l.r, 2.0 * l.r));
+        }
+        out
+    }
+
+    /// Whether a plate covers the HUD at (px, py).
+    pub fn plate_over_params_at(&self, px: f32, py: f32) -> bool {
+        self.plates_over_params()
+            .iter()
+            .any(|&(x, y, w, h)| px >= x && px < x + w && py >= y && py < y + h)
     }
 
     /// The floating spreadsheet pane's rect (the single derivation the layout pass
@@ -4463,7 +4545,7 @@ impl State {
         if self.show_network && !self.circular_network_pane && in_v && cx >= ss_x - margin && cx <= ss_x + margin {
             return Some(ResizeDirection { left: true, right: false, top: false, bottom: false });
         }
-        if self.show_parameters && in_v && cx >= ss_x + ss_w - margin && cx <= ss_x + ss_w + margin {
+        if self.dock_shown(Dock::Right) && in_v && cx >= ss_x + ss_w - margin && cx <= ss_x + ss_w + margin {
             return Some(ResizeDirection { left: false, right: true, top: false, bottom: false });
         }
         if cx >= ss_x && cx <= ss_x + ss_w && cy >= ss_y - margin && cy <= ss_y + margin {
@@ -4482,7 +4564,7 @@ impl State {
         if let Some(drag) = self.app_drag {
             return Some(match drag {
                 AppDrag::NetworkResize { dir, .. } => dir_cursor(dir),
-                AppDrag::ParamResize { .. } => CursorIcon::EwResize,
+                AppDrag::RightDockResize { .. } | AppDrag::HudResize { .. } => CursorIcon::EwResize,
                 AppDrag::SpreadsheetResize { .. } => CursorIcon::NsResize,
                 AppDrag::SpreadsheetResizeLeft { .. } | AppDrag::SpreadsheetResizeRight { .. } => {
                     CursorIcon::EwResize
@@ -4492,7 +4574,7 @@ impl State {
         if let Some(dir) = self.network_resize_edge_at(cx, cy) {
             return Some(dir_cursor(dir));
         }
-        if self.on_param_resize_edge(cx, cy) {
+        if self.on_right_dock_resize_edge(cx, cy) || self.on_param_resize_edge(cx, cy) {
             return Some(CursorIcon::EwResize);
         }
         if let Some(dir) = self.spreadsheet_resize_edge_at(cx, cy) {
@@ -6027,8 +6109,8 @@ impl State {
         if !self.show_parameters || self.is_detached_network {
             return None;
         }
-        let (px, py, pw, ph) = self.positions[crate::slots::PARAM_IDX];
-        if x < px || x > px + pw || y < py || y > py + ph {
+        // A row under a plate is the plate's, not the HUD's.
+        if !self.params_claims(x, y) {
             return None;
         }
         let slot = self.param_editor_selected()?;
@@ -8244,10 +8326,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             plate_menu_slot: None,
             plate_menu_actions: Vec::new(),
             collapsed_panes: [false; WIDGET_COUNT],
-            dock_panes: [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX],
+            // The right dock starts empty: the params HUD is not a dock
+            // pane, and a plate moved there is drawn over it.
+            dock_panes: [NETWORK_PANEL_IDX, NO_PANE, SPREADSHEET_IDX],
             dock_tabs: [
                 vec![NETWORK_PANEL_IDX],
-                vec![PARAM_IDX],
+                Vec::new(),
                 vec![SPREADSHEET_IDX],
             ],
             drag_widget: None,
@@ -8351,6 +8435,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             floating_network_layout: (18.0, 44.0, 400.0, 710.0),
             app_drag: None,
             floating_param_width: 300.0,
+            params_hud_width: 300.0,
             floating_spreadsheet_height: 250.0,
             floating_spreadsheet_inset_left: 0.0,
             floating_spreadsheet_inset_right: 0.0,
@@ -9237,11 +9322,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 }
                 self.positions[SPLITTER1_IDX] = (0.0, 0.0, 0.0, 0.0);
                 self.positions[SPLITTER2_IDX] = (0.0, 0.0, 0.0, 0.0);
-                let p_rect = rect_for(PARAM_IDX, self);
+                self.positions[VIEWPORT_IDX] = (col_c_x, vp_y, col_c_w, vp_h);
+                // The params HUD is not docked: it lives on the scene, laid
+                // out from the viewport alone, under every plate.
+                let p_rect = if right_visible { self.params_hud_rect() } else { (0.0, 0.0, 0.0, 0.0) };
                 self.positions[PARAM_IDX] = p_rect;
                 self.slots.param.set_rect(p_rect.0, p_rect.1, p_rect.2, p_rect.3);
-
-                self.positions[VIEWPORT_IDX] = (col_c_x, vp_y, col_c_w, vp_h);
                 self.positions[RIGHT_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
                 // The raised run floats clear of the plate's edge: offset past
                 // the plate's rolled lip (bevel_width) plus the run's own boss
@@ -9300,7 +9386,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // does not stop the text pass, so a waiting editor's labels
                 // would paint over whichever pane fronted.
                 let net_active = self.dock_of_pane(NETWORK_PANEL_IDX).is_some();
-                let par_active = self.dock_of_pane(PARAM_IDX).is_some();
                 let ss_active = self.dock_of_pane(SPREADSHEET_IDX).is_some();
                 self.slots.header.set_visible(false);
                 self.slots.status.set_visible(false);
@@ -9313,7 +9398,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 self.slots.splitter2.set_visible(false);
                 self.slots.viewport.set_visible(viewport_visible);
                 self.slots.right_menubar.set_visible(false);
-                self.slots.param.set_visible(right_visible && par_active);
+                self.slots.param.set_visible(right_visible);
                 self.slots.param_menubar.set_visible(false);
                 self.slots.spreadsheet.set_visible(spreadsheet_visible && ss_active);
                 self.slots.spreadsheet_menubar.set_visible(false);
@@ -10835,7 +10920,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 self.sync_grid_settings();
                                 changed = true;
                             }
-                            AppDrag::ParamResize { start_w, start_mouse_x } => {
+                            AppDrag::RightDockResize { start_w, start_mouse_x } => {
                                 let dx = self.cursor_x - start_mouse_x;
                                 let new_w = (start_w - dx).max(150.0);
                                 self.floating_param_width = new_w;
@@ -10844,6 +10929,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 self.apply_layout();
                                 changed = true;
                             }
+                            AppDrag::HudResize { start_w, start_mouse_x } => {
+                                let dx = self.cursor_x - start_mouse_x;
+                                self.params_hud_width = (start_w - dx).max(PARAMS_HUD_MIN_W);
+                                self.params_hud_width = self.params_hud_rect().2;
+                                self.rebuild_positions();
+                                self.apply_layout();
+                                changed = true;
+                            }
                             AppDrag::SpreadsheetResize { start_h, start_mouse_y } => {
                                 let dy = self.cursor_y - start_mouse_y;
                                 let new_h = (start_h - dy).max(100.0);
@@ -11039,9 +11132,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         } else {
                             false
                         }
-                    } else if i == PARAM_IDX && !state.params_plate {
-                        // Without its plate the pane is its rows: a press
-                        // under them is the scene's.
+                    } else if i == PARAM_IDX {
+                        // The HUD is under every plate, and without its own
+                        // plate it is its rows: a press anywhere else is the
+                        // plate's or the scene's.
                         state.params_claims(x, y) && state.slots.get_dyn(i).hit_test(x, y, &state.ui_context)
                     } else if state.network_overlay() && i == CONTENT_IDX {
                         // The graph's RECT spans the window in overlay mode,
@@ -11244,13 +11338,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             }
                         }
 
+                        // The right dock's plate is over the HUD, so its edge
+                        // is asked first.
+                        if *button == MouseButton::Left && self.on_right_dock_resize_edge(self.cursor_x, self.cursor_y) {
+                            self.app_drag = Some(AppDrag::RightDockResize {
+                                start_w: self.right_dock_width(),
+                                start_mouse_x: self.cursor_x,
+                            });
+                            if let Some(old) = self.focused_widget {
+                                self.slots.get_dyn_mut(old).unfocus();
+                                self.focused_widget = None;
+                            }
+                            return true;
+                        }
                         if *button == MouseButton::Left && self.show_parameters {
                             let cx = self.cursor_x;
                             let cy = self.cursor_y;
 
                             if self.on_param_resize_edge(cx, cy) {
-                                self.app_drag = Some(AppDrag::ParamResize {
-                                    start_w: self.right_dock_width(),
+                                self.app_drag = Some(AppDrag::HudResize {
+                                    start_w: self.params_hud_rect().2,
                                     start_mouse_x: cx,
                                 });
                                 self.focused_pane = PARAM_MENUBAR_IDX;
@@ -11637,7 +11744,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             // a drag in resize mode, so its commit/cancel hook is a no-op.
                             let idx = match drag {
                                 AppDrag::NetworkResize { .. } => NETWORK_PANEL_IDX,
-                                AppDrag::ParamResize { .. } => PARAM_IDX,
+                                AppDrag::HudResize { .. } => PARAM_IDX,
+                                AppDrag::RightDockResize { .. } => self.pane_in_dock(Dock::Right),
                                 AppDrag::SpreadsheetResize { .. }
                                 | AppDrag::SpreadsheetResizeLeft { .. }
                                 | AppDrag::SpreadsheetResizeRight { .. } => SPREADSHEET_IDX,
diff --git a/src/main.rs b/src/main.rs
index dc8f5f6..60938d7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -413,16 +413,29 @@ mod tests {
         state.apply_layout();
         let collapse = "Collapse".to_string();
 
-        // Params, off a row (its bottom edge), and the spreadsheet: the plate
-        // menu alone.
-        for idx in [PARAM_IDX, SPREADSHEET_IDX] {
-            let (x, y, w, h) = state.slots.get_dyn(idx).rect();
-            press_at(&mut state, x + w - 6.0, y + h - 6.0, MouseButton::Right);
-            assert_eq!(state.plate_menu_slot, Some(idx), "{}", crate::plate_menu::plate_title(idx));
-            assert!(state.plate_menu_actions.contains(&PlateMenuAction::Collapse));
-            press_at(&mut state, 2.0, 2.0, MouseButton::Left);
-            assert!(!state.plate_menu_open(), "a press outside dismisses it");
-        }
+        // The spreadsheet: the plate menu alone.
+        let (x, y, w, h) = state.slots.get_dyn(SPREADSHEET_IDX).rect();
+        press_at(&mut state, x + w - 6.0, y + h - 6.0, MouseButton::Right);
+        assert_eq!(state.plate_menu_slot, Some(SPREADSHEET_IDX));
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::Collapse));
+        press_at(&mut state, 2.0, 2.0, MouseButton::Left);
+        assert!(!state.plate_menu_open(), "a press outside dismisses it");
+
+        // The params HUD, with its plate on, off a row and where no plate
+        // covers it: its menu alone, which detaches and does not collapse.
+        state.params_plate = true;
+        let (x, y, w, h) = state.slots.get_dyn(PARAM_IDX).rect();
+        let free = (0..(h as i32))
+            .rev()
+            .map(|dy| (x + w - 6.0, y + dy as f32))
+            .find(|&(px, py)| state.params_claims(px, py) && state.param_row_at(px, py).is_none())
+            .expect("a free spot on the HUD");
+        press_at(&mut state, free.0, free.1, MouseButton::Right);
+        assert_eq!(state.plate_menu_slot, Some(PARAM_IDX));
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::Detach));
+        assert!(!state.plate_menu_actions.contains(&PlateMenuAction::Collapse), "the HUD does not collapse");
+        press_at(&mut state, 2.0, 2.0, MouseButton::Left);
+        state.params_plate = false;
 
         // The network: its own rows, then the plate's.
         let (cx, cy, cw, ch) = state.positions[crate::slots::CONTENT_IDX];
@@ -456,16 +469,21 @@ mod tests {
     fn move_to_swaps_a_pane_into_another_dock() {
         use crate::app::Dock;
         use crate::plate_menu::PlateMenuAction;
-        use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX};
+        use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
-        state.open_plate_menu(PARAM_IDX);
-        assert!(state.plate_menu_actions.contains(&PlateMenuAction::MoveTo(Dock::Left)));
-        assert!(!state.plate_menu_actions.contains(&PlateMenuAction::MoveTo(Dock::Right)), "not to its own dock");
+        state.show_spreadsheet = true;
+        state.open_plate_menu(SPREADSHEET_IDX);
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::MoveTo(Dock::Right)));
+        assert!(!state.plate_menu_actions.contains(&PlateMenuAction::MoveTo(Dock::Bottom)), "not to its own dock");
         state.close_plate_menu();
-        state.run_plate_menu_action(PARAM_IDX, PlateMenuAction::MoveTo(Dock::Left), (0.0, 0.0));
-        assert_eq!(state.dock_of_pane(PARAM_IDX), Some(Dock::Left));
-        assert_eq!(state.dock_of_pane(NETWORK_PANEL_IDX), Some(Dock::Right));
+        state.run_plate_menu_action(SPREADSHEET_IDX, PlateMenuAction::MoveTo(Dock::Left), (0.0, 0.0));
+        assert_eq!(state.dock_of_pane(SPREADSHEET_IDX), Some(Dock::Left));
+        assert_eq!(state.dock_of_pane(NETWORK_PANEL_IDX), Some(Dock::Bottom));
+        // The params HUD is in no dock and moves to none.
+        state.open_plate_menu(PARAM_IDX);
+        assert!(!state.plate_menu_actions.iter().any(|a| matches!(a, PlateMenuAction::MoveTo(_))));
+        assert_eq!(state.dock_of_pane(PARAM_IDX), None);
     }
 
     /// Collapse must actually reclaim the plate AND take its body with it, and
@@ -949,6 +967,8 @@ mod tests {
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
         state.show_spreadsheet = true;
+        // A plate in the right dock to tuck under: the params HUD is in none.
+        state.add_dock_tab(crate::app::Dock::Right, crate::slots::NETWORK_PANEL2_IDX);
         state.rebuild_positions();
         assert!(!state.spreadsheet_tucks_left() && !state.spreadsheet_tucks_right());
 
@@ -1378,75 +1398,73 @@ mod tests {
     #[test]
     fn test_plate_tabs_share_a_dock() {
         use crate::app::{Dock, NO_PANE};
-        use crate::slots::{PARAM_IDX, SPREADSHEET_IDX};
+        use crate::slots::{NETWORK_PANEL2_IDX, SPREADSHEET_IDX};
         let mut state = State::new(false);
+        assert_eq!(state.pane_in_dock(Dock::Right), NO_PANE, "the right dock starts empty");
 
-        // Pull the spreadsheet into the right dock: it fronts, the params
-        // wait as a tab, and the bottom dock empties.
+        // The second editor takes the right dock; pulling the spreadsheet in
+        // beside it fronts it, the editor waits as a tab, and the bottom
+        // dock empties.
+        state.add_dock_tab(Dock::Right, NETWORK_PANEL2_IDX);
         state.add_dock_tab(Dock::Right, SPREADSHEET_IDX);
         assert_eq!(state.pane_in_dock(Dock::Right), SPREADSHEET_IDX);
-        assert!(state.dock_tabs[Dock::Right as usize].contains(&PARAM_IDX));
+        assert!(state.dock_tabs[Dock::Right as usize].contains(&NETWORK_PANEL2_IDX));
         assert_eq!(state.pane_in_dock(Dock::Bottom), NO_PANE);
         // The waiting tab is laid out nowhere but keeps its home dock.
-        assert_eq!(state.dock_of_pane(PARAM_IDX), None);
-        assert_eq!(state.tab_dock_of_pane(PARAM_IDX), Some(Dock::Right));
+        assert_eq!(state.dock_of_pane(NETWORK_PANEL2_IDX), None);
+        assert_eq!(state.tab_dock_of_pane(NETWORK_PANEL2_IDX), Some(Dock::Right));
 
         // Switching fronts the waiting tab without evicting the other.
-        state.show_dock_tab(Dock::Right, PARAM_IDX);
-        assert_eq!(state.pane_in_dock(Dock::Right), PARAM_IDX);
+        state.show_dock_tab(Dock::Right, NETWORK_PANEL2_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Right), NETWORK_PANEL2_IDX);
         assert!(state.dock_tabs[Dock::Right as usize].contains(&SPREADSHEET_IDX));
 
         // The view state carries the groups, active first.
         let vs = state.project_view_state();
-        assert_eq!(vs.dock_tabs[1][0], "parameters");
+        let name = crate::plate_menu::pane_name_from_slot(NETWORK_PANEL2_IDX).unwrap();
+        assert_eq!(vs.dock_tabs[1][0], name);
         assert!(vs.dock_tabs[1].contains(&"spreadsheet".to_string()));
         assert!(vs.dock_tabs[2].is_empty());
 
         // Splitting moves the active pane to the empty dock; the tab left
         // behind fronts.
-        state.split_dock_tab(PARAM_IDX);
-        assert_eq!(state.pane_in_dock(Dock::Bottom), PARAM_IDX);
+        state.split_dock_tab(NETWORK_PANEL2_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Bottom), NETWORK_PANEL2_IDX);
         assert_eq!(state.pane_in_dock(Dock::Right), SPREADSHEET_IDX);
     }
 
     /// Move To Own Plate is offered only while a dock is free to take the
-    /// pane. Four tab candidates share three docks, so a dock can hold two
-    /// with none empty — and there the row used to show and do nothing.
+    /// pane. With the params pane out of the docks, three tab candidates
+    /// share three docks, so a dock holding two always leaves one empty —
+    /// the row is offered, and the split lands there.
     #[test]
     fn move_to_own_plate_needs_an_empty_dock() {
         use crate::app::Dock;
         use crate::plate_menu::PlateMenuAction;
-        use crate::slots::{NETWORK_PANEL2_IDX, NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX};
+        use crate::slots::{NETWORK_PANEL2_IDX, NETWORK_PANEL_IDX};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
 
-        // The second editor tabs in beside the first: left holds two, and
-        // params and spreadsheet keep the other two docks, so none is free.
-        state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
-        state.show_dock_tab(Dock::Left, NETWORK_PANEL_IDX);
+        // Every dock held, none shared: no split to offer.
+        state.add_dock_tab(Dock::Right, NETWORK_PANEL2_IDX);
         assert_eq!(state.first_empty_dock(), None);
         state.open_plate_menu(NETWORK_PANEL_IDX);
-        assert!(!state.plate_menu_actions.contains(&PlateMenuAction::SplitTab),
-            "no dock is free, so the row is left out");
-        assert!(state.plate_menu_actions.contains(&PlateMenuAction::ShowTab(NETWORK_PANEL2_IDX)),
-            "the tab list itself still shows");
+        assert!(!state.plate_menu_actions.contains(&PlateMenuAction::SplitTab), "its dock is its own");
         state.close_plate_menu();
 
-        // Pulling the spreadsheet in beside the params frees the bottom dock,
-        // and the row comes back — on both shared docks.
-        state.add_dock_tab(Dock::Right, SPREADSHEET_IDX);
-        state.show_dock_tab(Dock::Right, PARAM_IDX);
-        assert_eq!(state.first_empty_dock(), Some(Dock::Bottom));
-        for idx in [NETWORK_PANEL_IDX, PARAM_IDX] {
-            state.open_plate_menu(idx);
-            assert!(state.plate_menu_actions.contains(&PlateMenuAction::SplitTab),
-                "a shared dock with a free one offers the split");
-            state.close_plate_menu();
-        }
+        // The second editor tabs in beside the first: the right dock frees,
+        // and the row is offered on the shared dock.
+        state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
+        state.show_dock_tab(Dock::Left, NETWORK_PANEL_IDX);
+        assert_eq!(state.first_empty_dock(), Some(Dock::Right));
+        state.open_plate_menu(NETWORK_PANEL_IDX);
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::SplitTab));
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::ShowTab(NETWORK_PANEL2_IDX)));
+        state.close_plate_menu();
 
         // And the split lands there.
-        state.split_dock_tab(PARAM_IDX);
-        assert_eq!(state.pane_in_dock(Dock::Bottom), PARAM_IDX);
+        state.split_dock_tab(NETWORK_PANEL_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Right), NETWORK_PANEL_IDX);
     }
 
     /// The second network editor: joins a dock from nowhere through the tab
@@ -1566,7 +1584,9 @@ mod tests {
         assert!(a.show_viewport && !a.show_spreadsheet, "test assumes the default pane set");
         a.execute_menu_action("Show Viewport Pane");
         a.execute_menu_action("Show Spreadsheet Pane");
+        a.set_pane_collapsed(crate::slots::SPREADSHEET_IDX, true);
         a.set_pane_collapsed(PARAM_IDX, true);
+        assert!(!a.collapsed_panes[PARAM_IDX], "the params HUD does not collapse");
         a.splitter_layout.splitter1_x = 400.0;
         a.splitter_layout.splitter2_x = 1200.0;
         // Tab state: a second network editor tabbed beside the first (and
@@ -1586,7 +1606,8 @@ mod tests {
         b.load_from_file(&dir).expect("load");
         assert!(!b.show_viewport, "viewport hidden in the save must load hidden");
         assert!(b.show_spreadsheet, "spreadsheet shown in the save must load shown");
-        assert!(b.collapsed_panes[PARAM_IDX], "param pane collapse must round-trip");
+        assert!(b.collapsed_panes[crate::slots::SPREADSHEET_IDX], "a pane's collapse must round-trip");
+        assert!(!b.collapsed_panes[PARAM_IDX]);
         assert!((b.splitter_layout.splitter1_x - 200.0).abs() < 1.0,
             "splitters restore as fractions: 400/1600 of an 800-wide window = 200, got {}",
             b.splitter_layout.splitter1_x);
@@ -1626,8 +1647,10 @@ mod tests {
         a.resize(1600.0, 900.0, 1.0);
         a.execute_menu_action("Show Spreadsheet Pane");
         assert!(a.show_spreadsheet);
+        a.add_dock_tab(crate::app::Dock::Right, crate::slots::NETWORK_PANEL2_IDX);
         a.floating_network_layout.2 = 520.0;
         a.floating_param_width = 360.0;
+        a.params_hud_width = 420.0;
         a.floating_spreadsheet_height = 300.0;
         a.set_spreadsheet_full_width(true);
         a.rebuild_positions();
@@ -1644,6 +1667,7 @@ mod tests {
         b.load_from_file(&dir).expect("load");
         assert!((b.floating_network_layout.2 - 520.0).abs() < 0.5, "network width: {}", b.floating_network_layout.2);
         assert!((b.floating_param_width - 360.0).abs() < 0.5, "param width: {}", b.floating_param_width);
+        assert!((b.params_hud_width - 420.0).abs() < 0.5, "HUD width: {}", b.params_hud_width);
         assert!((b.floating_spreadsheet_height - 300.0).abs() < 0.5, "spreadsheet height: {}", b.floating_spreadsheet_height);
         assert!((b.floating_spreadsheet_inset_left - insets.0).abs() < 0.5 && (b.floating_spreadsheet_inset_right - insets.1).abs() < 0.5,
             "tucks: {:?} vs {:?}", (b.floating_spreadsheet_inset_left, b.floating_spreadsheet_inset_right), insets);
@@ -1655,6 +1679,7 @@ mod tests {
         c.load_from_file(&dir).expect("load half-size");
         assert!((c.floating_network_layout.2 - 260.0).abs() < 0.5, "scaled network width: {}", c.floating_network_layout.2);
         assert!((c.floating_param_width - 180.0).abs() < 0.5, "scaled param width: {}", c.floating_param_width);
+        assert!((c.params_hud_width - 210.0).abs() < 0.5, "scaled HUD width: {}", c.params_hud_width);
 
         // A detached pane window keeps its own plates.
         let mut d = State::new(true);
@@ -1728,7 +1753,7 @@ mod tests {
         // What was unsaved before the configure still is after it.
         let mut c = State::new(false);
         c.load_from_file(&dir).expect("load");
-        c.set_pane_collapsed(crate::slots::PARAM_IDX, true);
+        c.set_pane_collapsed(crate::slots::NETWORK_PANEL_IDX, true);
         c.resize(1400.0, 1080.0, 1.0);
         assert!(c.has_unsaved_changes(), "the configure must not hide an edit");
 
@@ -1746,6 +1771,7 @@ mod tests {
         s.execute_menu_action("Show Spreadsheet Pane");
         s.floating_network_layout.2 = 700.0;
         s.floating_param_width = 650.0;
+        s.params_hud_width = 650.0;
         s.floating_spreadsheet_height = 600.0;
         s.rebuild_positions();
 
@@ -1761,7 +1787,7 @@ mod tests {
         assert!((s.left_dock_width() - 700.0).abs() < 0.5, "network width: {}", s.left_dock_width());
         assert!((s.right_dock_width() - 650.0).abs() < 0.5, "param width: {}", s.right_dock_width());
         assert!((s.floating_spreadsheet_rect().3 - 600.0).abs() < 0.5, "spreadsheet height: {:?}", s.floating_spreadsheet_rect());
-        assert!((s.positions[crate::slots::PARAM_IDX].2 - 650.0).abs() < 0.5, "drawn param width: {:?}", s.positions[crate::slots::PARAM_IDX]);
+        assert!((s.positions[crate::slots::PARAM_IDX].2 - 650.0).abs() < 0.5, "drawn HUD width: {:?}", s.positions[crate::slots::PARAM_IDX]);
     }
 
     /// The main window's sync reload takes a detached window's TREE edit and
@@ -1790,7 +1816,7 @@ mod tests {
         child.fs_root.children[0].name = "synced_edit".to_string();
         child.save_to_file(&channel).expect("child writes the channel");
 
-        main.app_drag = Some(crate::app::AppDrag::ParamResize { start_w: 650.0, start_mouse_x: 0.0 });
+        main.app_drag = Some(crate::app::AppDrag::HudResize { start_w: 650.0, start_mouse_x: 0.0 });
         main.load_sync_channel(&channel, true).expect("main reloads");
 
         assert!(main.fs_root.children.iter().any(|c| c.name == "synced_edit"), "the tree edit must sync");
@@ -1875,8 +1901,11 @@ mod tests {
         state.update_window_title(); // the event loop's refresh, after the save event
         assert!(!state.title.ends_with('*'), "title: {}", state.title);
 
-        state.set_pane_collapsed(crate::slots::PARAM_IDX, true);
+        state.set_pane_collapsed(crate::slots::NETWORK_PANEL_IDX, true);
         assert!(state.has_unsaved_changes(), "a collapse is saved state too");
+        state.save_to_file(&dir).expect("save");
+        state.params_hud_width += 40.0;
+        assert!(state.has_unsaved_changes(), "a HUD resize is saved state too");
 
         let _ = fs::remove_dir_all(&dir);
     }
@@ -2044,6 +2073,86 @@ mod tests {
         assert_eq!(state.point_number_labels().len(), all.len());
     }
 
+    /// The params HUD lives on the scene: laid out from the viewport alone,
+    /// so no plate moves or sizes it, and under every plate, so where one
+    /// covers it the plate takes the pointer and the HUD draws nothing.
+    #[test]
+    fn the_params_hud_is_under_the_plates_and_sized_by_none() {
+        use crate::app::{Dock, NO_PANE};
+        use crate::slots::{NETWORK_PANEL2_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        let hud = state.positions[PARAM_IDX];
+        assert_eq!(hud, state.params_hud_rect());
+        assert_eq!(state.dock_of_pane(PARAM_IDX), None, "the HUD is in no dock");
+        assert_eq!(state.pane_in_dock(Dock::Right), NO_PANE);
+
+        // Plates coming, going and growing leave it where it is.
+        state.execute_menu_action("Show Spreadsheet Pane");
+        state.execute_menu_action("Show Playbar Pane");
+        state.floating_spreadsheet_height = 500.0;
+        state.set_spreadsheet_full_width(true);
+        state.add_dock_tab(Dock::Right, NETWORK_PANEL2_IDX);
+        state.floating_param_width = 700.0;
+        state.rebuild_positions();
+        state.apply_layout();
+        assert_eq!(state.positions[PARAM_IDX], hud, "no plate sizes the HUD");
+        assert!(state.slots.get_dyn(PARAM_IDX).visible());
+        // Its own width does.
+        state.params_hud_width = 420.0;
+        state.rebuild_positions();
+        assert_eq!(state.positions[PARAM_IDX].2, 420.0);
+        assert_eq!(state.positions[PARAM_IDX].0 + 420.0, hud.0 + hud.2, "it keeps its right edge");
+
+        // Under every plate: where one covers the HUD, the HUD claims
+        // nothing — not with its own plate on either.
+        state.params_plate = true;
+        let covers = state.plates_over_params();
+        for idx in [SPREADSHEET_IDX, PLAYBAR_IDX, NETWORK_PANEL2_IDX] {
+            assert!(covers.contains(&state.positions[idx]), "the {idx} plate is over the HUD");
+        }
+        let (hx, hy, hw, hh) = state.positions[PARAM_IDX];
+        let (sx, sy, sw, sh) = state.positions[SPREADSHEET_IDX];
+        let over = (hx + hw * 0.5, (sy + sh * 0.5).min(hy + hh - 1.0));
+        assert!(over.0 >= sx && over.0 < sx + sw && over.1 >= hy, "the spreadsheet runs under the HUD");
+        assert!(!state.params_claims(over.0, over.1), "a plate over the HUD takes the pointer");
+        assert!(state.params_claims(hx + hw * 0.5, hy + 30.0) || state.plate_over_params_at(hx + hw * 0.5, hy + 30.0));
+        assert_eq!(state.plate_at(over.0, over.1), Some(SPREADSHEET_IDX));
+
+        // An older save docked the params pane; it loads out of the dock.
+        let dir = std::env::temp_dir().join(format!("cce-designer-hud-dock-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        let mut a = State::new(false);
+        a.save_to_file(&dir).expect("save");
+        let file = dir.join("state.json");
+        let mut json: serde_json::Value = serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap();
+        json["view_state"]["dock_tabs"] = serde_json::json!([["parameters"], ["network"], ["spreadsheet"]]);
+        fs::write(&file, serde_json::to_string(&json).unwrap()).unwrap();
+        let mut b = State::new(false);
+        b.load_from_file(&dir).expect("load");
+        assert_eq!(b.pane_in_dock(Dock::Left), NO_PANE, "the dock the params fronted is empty");
+        assert_eq!(b.pane_in_dock(Dock::Right), crate::slots::NETWORK_PANEL_IDX, "the rest of the arrangement loads");
+        assert_eq!(b.tab_dock_of_pane(PARAM_IDX), None);
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    /// What the plates leave of the HUD, as rects that do not overlap.
+    #[test]
+    fn uncovered_takes_the_covers_out_of_a_rect() {
+        use crate::render::uncovered;
+        let r = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
+        let area = |v: &[cce_ui::scene::layout::Rect]| v.iter().map(|p| p.width * p.height).sum::<f32>();
+        assert_eq!(uncovered(r, &[]).len(), 1);
+        assert_eq!(area(&uncovered(r, &[(0.0, 60.0, 100.0, 40.0)])), 6000.0, "a strip across the bottom");
+        assert!(uncovered(r, &[(-10.0, -10.0, 200.0, 200.0)]).is_empty(), "covered whole");
+        let hole = uncovered(r, &[(40.0, 40.0, 20.0, 20.0)]);
+        assert_eq!(area(&hole), 10000.0 - 400.0, "a hole in the middle");
+        assert_eq!(area(&uncovered(r, &[(0.0, 60.0, 100.0, 40.0), (80.0, 0.0, 50.0, 100.0)])), 10000.0 - 4000.0 - 20.0 * 60.0);
+        assert_eq!(area(&uncovered(r, &[(200.0, 0.0, 10.0, 10.0)])), 10000.0, "a cover elsewhere");
+    }
+
     /// The params pane can drop its plate, and does by default: the rows
     /// stand on the scene, and the pane claims only the band they cover —
     /// a press or the wheel under the last row is the viewport's.
@@ -15353,7 +15462,7 @@ mod tests {
         assert!(hovered(&state), "the release re-hovers without a motion");
 
         // An app drag (a pane edge) captures it the same way.
-        state.app_drag = Some(crate::app::AppDrag::ParamResize { start_w: 100.0, start_mouse_x: cx });
+        state.app_drag = Some(crate::app::AppDrag::HudResize { start_w: 300.0, start_mouse_x: cx });
         moved(&mut state, cx + 2.0, cy);
         assert!(!hovered(&state));
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
diff --git a/src/plate_menu.rs b/src/plate_menu.rs
index 932ac70..aedcdaf 100644
--- a/src/plate_menu.rs
+++ b/src/plate_menu.rs
@@ -28,9 +28,11 @@ pub const PLATE_SLOTS: [usize; 5] =
 
 /// The panes the tab rows offer — the dockable set. The playbar's strip is
 /// not a dock, and the second network editor joins as the first CLOSABLE
-/// pane: unplaced it simply does not exist.
-pub const TAB_CANDIDATES: [usize; 4] =
-    [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, NETWORK_PANEL2_IDX];
+/// pane: unplaced it simply does not exist. The params pane is not one
+/// (since 2026-10-06): it is a HUD on the scene, under the plates, laid out
+/// from the viewport alone (`State::params_hud_rect`).
+pub const TAB_CANDIDATES: [usize; 3] =
+    [NETWORK_PANEL_IDX, SPREADSHEET_IDX, NETWORK_PANEL2_IDX];
 
 /// What the plate menu can do to its plate.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -101,6 +103,11 @@ impl State {
             if !w.visible() {
                 return false;
             }
+            // The params HUD is under every plate and, without its own,
+            // only its rows: it is the plate there only where it claims.
+            if idx == PARAM_IDX && !self.pane_is_stubbed(idx) {
+                return self.params_claims(px, py);
+            }
             let (x, y, ww, h) = w.rect();
             ww > 0.0 && h > 0.0 && px >= x && px < x + ww && py >= y && py < y + h
         })
@@ -121,6 +128,11 @@ impl State {
         let mut options: Vec<String> = Vec::new();
         let mut actions: Vec<PlateMenuAction> = Vec::new();
         for (label, action) in plate_dock::standard_menu(state, self.plate_can_detach(idx)) {
+            // The HUD does not collapse: it is not a plate with a stub to
+            // shrink to, and hiding it is Show Parameters Pane.
+            if idx == PARAM_IDX && matches!(action, PlateDockAction::Collapse | PlateDockAction::Expand) {
+                continue;
+            }
             options.push(label);
             actions.push(match action {
                 PlateDockAction::Collapse => PlateMenuAction::Collapse,
@@ -386,6 +398,8 @@ impl State {
     }
 
     pub fn set_pane_collapsed(&mut self, idx: usize, collapsed: bool) {
+        // The params HUD never collapses (see `plate_menu_rows`).
+        let collapsed = collapsed && idx != PARAM_IDX;
         if !PLATE_SLOTS.contains(&idx) || self.collapsed_panes[idx] == collapsed {
             return;
         }
diff --git a/src/project.rs b/src/project.rs
index cedfa32..b669464 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -143,6 +143,7 @@ impl State {
                 spreadsheet_height: (self.floating_spreadsheet_height / self.height).min(1.0),
                 spreadsheet_inset_left: self.floating_spreadsheet_inset_left / self.width,
                 spreadsheet_inset_right: self.floating_spreadsheet_inset_right / self.width,
+                hud_width: Some((self.params_hud_width / self.width).min(1.0)),
             })
         } else {
             None
@@ -373,11 +374,19 @@ impl State {
             let all: Vec<usize> = resolved.iter().flatten().copied().collect();
             let n2 = crate::slots::NETWORK_PANEL2_IDX;
             let n2_count = all.iter().filter(|&&s| s == n2).count();
+            // The params pane is not docked since 2026-10-06 (it is a HUD
+            // on the scene): an older save lists it in a dock, and it is
+            // taken out — the dock it fronted fronts its next tab, or is
+            // empty.
+            let resolved: Vec<Vec<usize>> = resolved
+                .into_iter()
+                .map(|tabs| tabs.into_iter().filter(|&s| s != crate::slots::PARAM_IDX).collect())
+                .collect();
+            let all: Vec<usize> = all.into_iter().filter(|&s| s != crate::slots::PARAM_IDX).collect();
             let mut core: Vec<usize> = all.iter().copied().filter(|&s| s != n2).collect();
             core.sort_unstable();
             let mut expected = vec![
                 crate::slots::NETWORK_PANEL_IDX,
-                crate::slots::PARAM_IDX,
                 crate::slots::SPREADSHEET_IDX,
             ];
             expected.sort_unstable();
@@ -423,10 +432,12 @@ impl State {
     /// than loading half of one.
     pub(crate) fn apply_plate_geometry(&mut self, pg: crate::app::PlateGeometry) {
         let sane = |f: f32| f.is_finite() && (0.0..=1.0).contains(&f);
-        let all = [pg.network_width, pg.params_width, pg.spreadsheet_height, pg.spreadsheet_inset_left, pg.spreadsheet_inset_right];
+        let hud = pg.hud_width.unwrap_or(pg.params_width);
+        let all = [pg.network_width, pg.params_width, pg.spreadsheet_height, pg.spreadsheet_inset_left, pg.spreadsheet_inset_right, hud];
         if self.width > 1.0 && self.height > 1.0 && all.iter().all(|&f| sane(f)) {
             self.floating_network_layout.2 = pg.network_width * self.width;
             self.floating_param_width = pg.params_width * self.width;
+            self.params_hud_width = hud * self.width;
             self.floating_spreadsheet_height = pg.spreadsheet_height * self.height;
             self.floating_spreadsheet_inset_left = pg.spreadsheet_inset_left * self.width;
             self.floating_spreadsheet_inset_right = pg.spreadsheet_inset_right * self.width;
diff --git a/src/render.rs b/src/render.rs
index 5fac112..f7594b8 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -143,12 +143,15 @@ impl State {
 
         let mut draw_order: Vec<usize> = (0..WIDGET_COUNT).collect();
         draw_order.sort_by_key(|&i| {
-            let base_key = if i == VIEWPORT_IDX
-                || i == NETWORK_PANEL_IDX
-                || i == crate::slots::NETWORK_PANEL2_IDX
-            {
+            // The params HUD lives on the scene: right after the viewport
+            // and before every plate, so each plate is drawn over it.
+            let base_key = if i == VIEWPORT_IDX {
+                -7
+            } else if i == PARAM_IDX {
+                -6
+            } else if i == NETWORK_PANEL_IDX || i == crate::slots::NETWORK_PANEL2_IDX {
                 -5
-            } else if i == CONTENT_IDX || i == crate::slots::CONTENT2_IDX || i == PARAM_IDX {
+            } else if i == CONTENT_IDX || i == crate::slots::CONTENT2_IDX {
                 -4
             } else if i == HEADER_IDX
                 || i == LEFT_MENUBAR_IDX
@@ -685,9 +688,18 @@ impl State {
 
             let (px, py, pw, ph) = self.positions[PARAM_IDX];
             let view = rect(px, py + 4.0, pw, (ph - 8.0).max(0.0));
-            pc.clip(view, |pc| {
-                w.paint_self(&self.ui_context, pc);
-            });
+            // The HUD is under every plate, and its geometry is drawn
+            // before theirs, so they cover it — but the engine lays ALL
+            // text out after all geometry, and a label under a plate would
+            // stand on top of it. So the HUD is painted into what the
+            // plates leave of it, a rect at a time (`uncovered`); one, most
+            // of the time, and a handful at most.
+            let pieces = uncovered(view, &self.plates_over_params());
+            for &piece in &pieces {
+                pc.clip(piece, |pc| {
+                    w.paint_self(&self.ui_context, pc);
+                });
+            }
 
             // Expression rows carry Houdini's tint: a translucent green over
             // the row, so a driven parameter reads as driven before its text
@@ -703,13 +715,15 @@ impl State {
                 };
                 if rows.iter().any(|r| is_expr(&r.0)) {
                     let rects = self.param_row_rects();
-                    pc.clip(view, |pc| {
-                        for (row, &(rx, ry, rw, rh)) in rows.iter().zip(rects.iter()) {
-                            if rh > 0.0 && is_expr(&row.0) {
-                                pc.rounded_rect(rect(rx, ry, rw, rh), 6.0, (true, true, true, true), [0.35, 0.8, 0.45, 0.16]);
+                    for &piece in &pieces {
+                        pc.clip(piece, |pc| {
+                            for (row, &(rx, ry, rw, rh)) in rows.iter().zip(rects.iter()) {
+                                if rh > 0.0 && is_expr(&row.0) {
+                                    pc.rounded_rect(rect(rx, ry, rw, rh), 6.0, (true, true, true, true), [0.35, 0.8, 0.45, 0.16]);
+                                }
                             }
-                        }
-                    });
+                        });
+                    }
                 }
             }
 
@@ -1768,3 +1782,39 @@ fn trim_number(v: f32) -> String {
     let s = format!("{v:.2}");
     s.trim_end_matches('0').trim_end_matches('.').to_string()
 }
+
+/// What is left of `r` once `covers` are taken out of it, as rects that do
+/// not overlap: each cover splits every piece it meets into the bands
+/// above and below it and the strips beside it. Covers are taken as their
+/// bounding rects, so a plate's rounded corner hides the sliver it would
+/// have shown.
+pub(crate) fn uncovered(r: Rect, covers: &[(f32, f32, f32, f32)]) -> Vec<Rect> {
+    let mut pieces = vec![r];
+    for &(cx, cy, cw, ch) in covers {
+        let (cx1, cy1) = (cx + cw, cy + ch);
+        let mut next = Vec::with_capacity(pieces.len() + 3);
+        for p in pieces {
+            let (px1, py1) = (p.x + p.width, p.y + p.height);
+            let (ix0, iy0, ix1, iy1) = (p.x.max(cx), p.y.max(cy), px1.min(cx1), py1.min(cy1));
+            if ix0 >= ix1 || iy0 >= iy1 {
+                next.push(p);
+                continue;
+            }
+            if iy0 > p.y {
+                next.push(rect(p.x, p.y, p.width, iy0 - p.y));
+            }
+            if iy1 < py1 {
+                next.push(rect(p.x, iy1, p.width, py1 - iy1));
+            }
+            if ix0 > p.x {
+                next.push(rect(p.x, iy0, ix0 - p.x, iy1 - iy0));
+            }
+            if ix1 < px1 {
+                next.push(rect(ix1, iy0, px1 - ix1, iy1 - iy0));
+            }
+        }
+        pieces = next;
+    }
+    pieces.retain(|p| p.width > 0.5 && p.height > 0.5);
+    pieces
+}