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

commit7a0ef4eaedc8040603798fe88c0f271cb3136f1c
parentb88f847b55
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:38
refactor: the plate corner circles are gone; their rows are in each plate's right-click menu

The plate menu's rows (Collapse, Detach, tabs, Add Tab, pins, Full Width)
are appended to the network editor's and the playbar's context menus, and
make up the whole menu on the params pane off a row, the spreadsheet, the
second network editor and a stub. A left press on a collapsed stub expands
it. Dragging the circle to move a pane between docks is replaced by Move To
Left/Right/Bottom rows; the drag, its drop highlight and AppDrag::DockDrag
are removed. plate_corner.rs is plate_menu.rs.

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

 CLAUDE.md                              |  42 +++++---
 src/app.rs                             | 174 ++++++++++++--------------------
 src/application.rs                     |   4 +-
 src/main.rs                            | 151 ++++++++++++++++++----------
 src/{plate_corner.rs => plate_menu.rs} | 176 +++++++++++++++++----------------
 src/project.rs                         |  14 +--
 src/render.rs                          |  56 +++--------
 src/window.rs                          |   4 +-
 8 files changed, 299 insertions(+), 322 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6cc5b4e..a7aa967 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,13 +52,13 @@ gone.)
   with the main window by autosaving/polling `default_project.json` mtime (see the
   main loop in `src/main.rs`) — there is no socket between the two.
 - `cce-designer --detached-params` / `--detached-spreadsheet` / `--detached-playbar`
-  — the same idea for the other plates (`plate_corner::pane_detach_flag`), spawned by
-  the plate corner menu's Detach. These windows are plain rectangles with standard CSD
+  — the same idea for the other plates (`plate_menu::pane_detach_flag`), spawned by
+  the plate menu's Detach. These windows are plain rectangles with standard CSD
   and no 3D canvas; `--detached-network` stays its own flag because that window is
   CIRCULAR, with a radial border resize no rectangular pane wants. All of them share
   the one `default_project.json` sync channel, and only the main window runs the MCP
-  server. The parent keeps a stub for each pane it handed out — that stub's corner
-  control is the only way to Reattach — and reaps its children with `try_wait` from
+  server. The parent keeps a stub for each pane it handed out — a right press on
+  that stub is the only way to Reattach — and reaps its children with `try_wait` from
   the frame tick, so a window the user closes hands its pane back. NOT `kill(pid, 0)`:
   an unreaped exited child is a zombie, which that probe calls alive forever.
   The main window's reload of that channel takes the TREE and navigation only
@@ -245,16 +245,26 @@ gone from cce-ui with the wgpu path).
   writes its settings back. See "The dialog (Alt+D)" below — three of its four
   hard parts are about paint order and occlusion, none of which is guessable
   from the widget.
-- `src/plate_corner.rs` — the plate corner control: a circular menu trigger on the
-  top-right of each pane that draws its own plate (`PLATE_SLOTS` — network, params,
-  spreadsheet, playbar; NOT the viewport, whose plate is the window-spanning lip).
-  Geometry is derived from the slot's live rect, so it holds across all three
-  `rebuild_positions` branches; the circular network pane is special-cased onto its
-  arc. The menu is a fourth `cce_ui::widget::context_menu` consumer alongside the node,
-  viewport and network right-click menus, with the same `*_menu_actions` +
-  `handle_*_menu_click` contract. Collapse shrinks a plate to its title stub via `apply_collapsed_panes`, a
-  post-pass over `positions[..]` (one place, all three branches); the stub is exempt
-  from the minimum-span guard or it would lose the control that expands it again.
+- `src/plate_menu.rs` — the plate menu: what can be done to a pane's PLATE
+  (`PLATE_SLOTS` — network, params, spreadsheet, playbar, network 2; NOT the
+  viewport, whose plate is the window-spanning lip) — Collapse/Expand, Detach/
+  Reattach, Full Width, the selection pins, the dock's tabs, Add Tab, Move To Own
+  Plate, **Move To Left/Right/Bottom** and Close Tab. `plate_menu_rows(idx)` is the
+  one list. **There is no corner trigger** (since 2026-10-01; it was a small
+  circle on each plate's top-right, which opened these rows as a menu of their
+  own and, DRAGGED, moved the pane to another dock — the drag, its drop
+  highlight and `AppDrag::DockDrag` went with it, and Move To is the rows'
+  replacement). The rows are in each plate's RIGHT-CLICK menu: appended under
+  the network editor's empty-space menu (`NetworkMenuAction::Plate`) and the
+  playbar's (`PlaybarMenuAction::Plate`), and the whole menu where a pane has
+  none of its own — the params pane off a row, the spreadsheet, the second
+  network editor (`open_plate_menu`, at the pointer). A row from any of them runs
+  through `run_plate_menu_action`, whose Add Tab / Back page swaps open where the
+  menu stood. Collapse shrinks a plate to its title stub via
+  `apply_collapsed_panes`, a post-pass over `positions[..]` (one place, all three
+  branches); a LEFT press on a collapsed stub expands it, and a right press on any
+  stub (collapsed or detached) opens its plate menu. `a_plates_rows_are_in_its_right_click_menu`
+  is the test.
 - `src/application.rs` — the `Application` impl: translates engine hooks into
   `WindowEvent`s, detached-window CSD, HTTP-server startup, exit autosave.
 - `src/window.rs` — `WindowEvent` plus the post-event side-effect pass
@@ -3397,7 +3407,7 @@ compresses as configured.
 high `z_order` is not enough: `append_frame_text`, `append_scale_readout` and the
 point-number overlay all run AFTER the whole walk, so the graph's node labels drew
 straight over a dialog that had already covered them. `append_dialog` runs
-between the plate corners and the context menu instead.
+after the popovers, before the context menu, instead.
 
 **And even that is not enough, because text is not painted in display-list
 order.** The engine collects every `Prim::Text` and lays them all out at the end,
@@ -3452,7 +3462,7 @@ branches: `dialog_key_input` is TOTAL rather than a layer, because the network
 pane's bare-letter family is ungated and typing "frame" into the filter would
 otherwise step the grid cursor four times and flip a node's geometry toggle on
 the way past. A press outside the plate dismisses and is swallowed, the way the
-node and plate-corner menus behave.
+node and plate menus behave.
 
 **Nothing in this crate shells out to `cce-cloud` any more**, and
 `nothing_shells_out_to_cce_cloud_any_more` scans the source to keep it that
diff --git a/src/app.rs b/src/app.rs
index b1d51c4..e5a937b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -511,6 +511,8 @@ pub enum PlaybarMenuAction {
     StartFrameSlider,
     /// The frame range's far end, 2–1000 by one; kept above the near end.
     EndFrameSlider,
+    /// A row of the playbar's plate menu (Collapse, Detach).
+    Plate(crate::plate_menu::PlateMenuAction),
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -523,6 +525,9 @@ pub enum PlaybarMenuAction {
 pub enum NetworkMenuAction {
     /// Run `command::by_id(id)` — the row's label came from the same row.
     Command(&'static str),
+    /// A row of the network pane's plate menu: collapse, detach, its dock's
+    /// tabs, Move To.
+    Plate(crate::plate_menu::PlateMenuAction),
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -2155,12 +2160,6 @@ pub enum AppDrag {
     SpreadsheetResizeLeft { start_inset: f32, start_mouse_x: f32 },
     /// The spreadsheet's right edge, symmetrically, tucking under the parameter pane.
     SpreadsheetResizeRight { start_inset: f32, start_mouse_x: f32 },
-    /// Dragging a plate's corner dot repositions the plate: the dock region
-    /// under the cursor highlights, and release snaps the plate there,
-    /// swapping with whatever pane held that dock. Armed from a press on the
-    /// dot once motion exceeds the click threshold; a clean click still opens
-    /// the menu. (Sizing stays on the pane edge hotspots.)
-    DockDrag { idx: usize },
 }
 
 #[repr(C)]
@@ -2374,25 +2373,21 @@ pub struct State {
     /// Frame the scene was last built at, so the timeline moving can invalidate it.
     pub last_sim_frame: i32,
     pub plate_menu_slot: Option<usize>,
-    pub plate_menu_actions: Vec<crate::plate_corner::PlateMenuAction>,
+    pub plate_menu_actions: Vec<crate::plate_menu::PlateMenuAction>,
     /// Panes shrunk to their title stub, indexed by slot. Only the
-    /// `plate_corner::PLATE_SLOTS` entries are ever set.
+    /// `plate_menu::PLATE_SLOTS` entries are ever set.
     pub collapsed_panes: [bool; WIDGET_COUNT],
-    /// A press on a plate corner dot, not yet resolved into click-opens-menu
-    /// or drag-repositions-plate: `(slot, press_x, press_y)`.
-    pub corner_press: Option<(usize, f32, f32)>,
-    /// Dock occupancy, indexed Left/Right/Bottom. Swapped by dot drags.
+    /// Dock occupancy, indexed Left/Right/Bottom. Swapped by the plate
+    /// menu's Move To rows.
     /// With tabs this names each dock's ACTIVE pane — always a member of the
     /// dock's `dock_tabs` list — or [`NO_PANE`] for a dock whose tabs were
     /// all pulled elsewhere.
     pub dock_panes: [usize; 3],
     /// The panes tabbed into each dock, indexed Left/Right/Bottom. One dock
     /// rect, several panes: only the active one (`dock_panes`) is laid out;
-    /// the rest wait as tabs, switched and moved through the plate corner
+    /// the rest wait as tabs, switched and moved through the plate
     /// menus. Every docked pane lives in exactly ONE dock's list.
     pub dock_tabs: [Vec<usize>; 3],
-    /// The dock a live DockDrag would drop into — the render pass highlights it.
-    pub dock_drag_target: Option<Dock>,
 
     pub drag_widget: Option<usize>,
     /// Where the pointer pressed when `drag_widget` armed — the drag
@@ -3678,37 +3673,6 @@ impl State {
         self.pane_shown(self.pane_in_dock(dock))
     }
 
-    /// The dock a cursor position drops into: the lower band is the bottom
-    /// strip, the rest splits into left and right halves.
-    pub fn dock_region_at(&self, cx: f32, cy: f32) -> Dock {
-        if cy > self.height * 0.62 {
-            Dock::Bottom
-        } else if cx < self.width * 0.5 {
-            Dock::Left
-        } else {
-            Dock::Right
-        }
-    }
-
-    /// A dock's current rect from the DOCK-owned dimensions — independent of
-    /// whether its pane is shown, so the drag overlay can highlight it.
-    pub fn dock_rect(&self, dock: Dock) -> (f32, f32, f32, f32) {
-        let gap = 18.0_f32;
-        let pb_off = if self.show_playbar { PLAYBAR_H + gap } else { 0.0 };
-        match dock {
-            Dock::Left => {
-                let (fx, fy, _, fh) = self.floating_network_layout;
-                (fx.max(gap), fy.max(gap), self.left_dock_width(), fh)
-            }
-            Dock::Right => {
-                let param_w = self.right_dock_width();
-                let h = (self.height - STATUS_H - pb_off - 2.0 * gap).max(100.0);
-                (self.width - gap - param_w, gap, param_w, h)
-            }
-            Dock::Bottom => self.floating_spreadsheet_rect(),
-        }
-    }
-
     /// Move a plate to a dock, swapping with the pane that held it. The
     /// dock-owned dimensions stay put, so the geometry survives the swap.
     /// With tabs, the whole GROUPS trade places — a dot drag moves the
@@ -3902,7 +3866,6 @@ impl State {
             return Some(match drag {
                 AppDrag::NetworkResize { dir, .. } => dir_cursor(dir),
                 AppDrag::ParamResize { .. } => CursorIcon::EwResize,
-                AppDrag::DockDrag { .. } => CursorIcon::Grabbing,
                 AppDrag::SpreadsheetResize { .. } => CursorIcon::NsResize,
                 AppDrag::SpreadsheetResizeLeft { .. } | AppDrag::SpreadsheetResizeRight { .. } => {
                     CursorIcon::EwResize
@@ -5557,6 +5520,12 @@ impl State {
         row(&mut options, &mut actions, "Playback Rate", PlaybarMenuAction::FpsSlider);
         row(&mut options, &mut actions, "Start Frame", PlaybarMenuAction::StartFrameSlider);
         row(&mut options, &mut actions, "End Frame", PlaybarMenuAction::EndFrameSlider);
+        let (plate, plate_actions) = self.plate_menu_rows(PLAYBAR_IDX);
+        if !plate.is_empty() {
+            row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
+            options.extend(plate);
+            actions.extend(plate_actions.into_iter().map(PlaybarMenuAction::Plate));
+        }
         (options, actions)
     }
 
@@ -5638,9 +5607,12 @@ impl State {
         if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
             let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
             let picked = idx.and_then(|i| self.playbar_menu_actions.get(i).copied());
+            let at = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
             self.close_playbar_menu();
-            if let Some(action) = picked {
-                self.run_playbar_menu_action(action);
+            match picked {
+                Some(PlaybarMenuAction::Plate(a)) => self.run_plate_menu_action(PLAYBAR_IDX, a, at),
+                Some(action) => self.run_playbar_menu_action(action),
+                None => {}
             }
             return true;
         }
@@ -5649,8 +5621,12 @@ impl State {
     }
 
     pub(crate) fn run_playbar_menu_action(&mut self, action: PlaybarMenuAction) {
-        if let PlaybarMenuAction::Command(id) = action {
-            self.run_command(id);
+        match action {
+            PlaybarMenuAction::Command(id) => {
+                self.run_command(id);
+            }
+            PlaybarMenuAction::Plate(a) => self.run_plate_menu_action(PLAYBAR_IDX, a, (self.cursor_x, self.cursor_y)),
+            _ => {}
         }
     }
 
@@ -6313,6 +6289,14 @@ impl State {
             options.pop();
             actions.pop();
         }
+        // The network pane's plate rows, below the graph's own.
+        let (plate, plate_actions) = self.plate_menu_rows(NETWORK_PANEL_IDX);
+        if !plate.is_empty() {
+            options.push("-".to_string());
+            actions.push(NetworkMenuAction::Separator);
+            options.extend(plate);
+            actions.extend(plate_actions.into_iter().map(NetworkMenuAction::Plate));
+        }
 
         let target = self.slots.get_dyn(CONTENT_IDX).base().id();
         cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
@@ -6351,6 +6335,7 @@ impl State {
                 Some(NetworkMenuAction::Command(id)) => {
                     self.run_command(id);
                 }
+                Some(NetworkMenuAction::Plate(a)) => self.run_plate_menu_action(NETWORK_PANEL_IDX, a, corner),
                 _ => {}
             }
             return true;
@@ -7486,14 +7471,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],
-            corner_press: None,
             dock_panes: [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX],
             dock_tabs: [
                 vec![NETWORK_PANEL_IDX],
                 vec![PARAM_IDX],
                 vec![SPREADSHEET_IDX],
             ],
-            dock_drag_target: None,
             drag_widget: None,
             drag_press_cursor: None,
             focused_widget: None,
@@ -9971,24 +9954,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     return self.grid_cursor_drag_motion();
                 }
 
-                // An armed corner-dot press becomes a layout drag once it
-                // moves; stubbed (collapsed/detached) panes stay click-only.
-                if let Some((idx, px, py)) = self.corner_press {
-                    if cce_ui::widget::plate_dock::press_becomes_drag(
-                        (px, py),
-                        (self.cursor_x, self.cursor_y),
-                    ) {
-                        self.corner_press = None;
-                        if !self.pane_is_stubbed(idx) && idx != PLAYBAR_IDX {
-                            self.app_drag = Some(AppDrag::DockDrag { idx });
-                            self.dock_drag_target = Some(self.dock_region_at(self.cursor_x, self.cursor_y));
-                            if std::env::var("CCE_DOCK_DEBUG").is_ok() {
-                                eprintln!("[dock] drag start slot={idx} target={:?}", self.dock_drag_target);
-                            }
-                        }
-                    }
-                }
-
                 if self.is_panning {
                     let dx = self.cursor_x - self.pan_start_cx;
                     let dy = self.cursor_y - self.pan_start_cy;
@@ -10073,17 +10038,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 self.sync_grid_settings();
                                 changed = true;
                             }
-                            AppDrag::DockDrag { idx } => {
-                                let _ = idx;
-                                let target = self.dock_region_at(self.cursor_x, self.cursor_y);
-                                if std::env::var("CCE_DOCK_DEBUG").is_ok() {
-                                    eprintln!("[dock] motion ({:.0},{:.0}) -> {target:?}", self.cursor_x, self.cursor_y);
-                                }
-                                if self.dock_drag_target != Some(target) {
-                                    self.dock_drag_target = Some(target);
-                                    changed = true;
-                                }
-                            }
                             AppDrag::SpreadsheetResizeRight { start_inset, start_mouse_x } => {
                                 // The right edge tucks under the parameter pane, symmetrically.
                                 let dx = self.cursor_x - start_mouse_x;
@@ -10292,16 +10246,21 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 return true;
                             }
                         }
-                        // A press ON a corner control arms click-vs-drag: a
-                        // clean release opens the menu, motion past the
-                        // threshold becomes a layout drag. Either way the
-                        // press never reaches the pane underneath.
-                        if *button == MouseButton::Left {
-                            if let Some(idx) = self.plate_corner_at(self.cursor_x, self.cursor_y) {
-                                if std::env::var("CCE_DOCK_DEBUG").is_ok() {
-                                    eprintln!("[dock] armed corner press slot={idx} at ({:.0},{:.0})", self.cursor_x, self.cursor_y);
-                                }
-                                self.corner_press = Some((idx, self.cursor_x, self.cursor_y));
+                        // A plate drawn as its stub has nothing in it but its
+                        // title: a left press on a collapsed one expands it,
+                        // and a right press on either kind opens its plate
+                        // menu (Expand, or a detached pane's Reattach).
+                        if let Some(idx) = self.plate_at(self.cursor_x, self.cursor_y).filter(|&i| self.pane_is_stubbed(i)) {
+                            if *button == MouseButton::Left && !self.pane_is_detached(idx) {
+                                self.set_pane_collapsed(idx, false);
+                                return true;
+                            }
+                            if *button == MouseButton::Right {
+                                self.close_node_menu();
+                                self.close_viewport_menu();
+                                self.close_network_menu();
+                                self.close_param_menu();
+                                self.open_plate_menu(idx);
                                 return true;
                             }
                         }
@@ -10369,6 +10328,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 self.open_playbar_context_menu();
                                 return true;
                             }
+                            // A plate with no context menu of its own — the
+                            // params pane off a row, the spreadsheet, the
+                            // second network editor — has its plate menu.
+                            if let Some(idx) = self
+                                .plate_at(self.cursor_x, self.cursor_y)
+                                .filter(|&i| matches!(i, PARAM_IDX | SPREADSHEET_IDX | crate::slots::NETWORK_PANEL2_IDX))
+                            {
+                                self.close_node_menu();
+                                self.close_viewport_menu();
+                                self.close_network_menu();
+                                self.open_plate_menu(idx);
+                                return true;
+                            }
                         }
 
                         if *button == MouseButton::Left && self.circular_network_pane {
@@ -10824,18 +10796,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 AppDrag::SpreadsheetResize { .. }
                                 | AppDrag::SpreadsheetResizeLeft { .. }
                                 | AppDrag::SpreadsheetResizeRight { .. } => SPREADSHEET_IDX,
-                                AppDrag::DockDrag { idx } => idx,
                             };
-                            // Dock drop: snap the dragged plate into the
-                            // highlighted region, swapping occupants.
-                            if let AppDrag::DockDrag { idx } = drag {
-                                if let Some(target) = self.dock_drag_target.take() {
-                                    if std::env::var("CCE_DOCK_DEBUG").is_ok() {
-                                        eprintln!("[dock] drop slot={idx} into {target:?} (was {:?})", self.dock_of_pane(idx));
-                                    }
-                                    self.move_pane_to_dock(idx, target);
-                                }
-                            }
                             {
                                 let ptr = self.slots.get_dyn_mut(idx) as *mut (dyn WidgetHost + 'static);
                                 unsafe { (*ptr).handle_event(&cce_ui::widget::Event::DragEnd, &mut self.ui_context); }
@@ -10847,18 +10808,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 drag,
                                 AppDrag::NetworkResize { .. }
                                     | AppDrag::SpreadsheetResizeLeft { .. }
-                                    | AppDrag::DockDrag { .. }
                             ) {
                                 self.read_panel_offsets();
                             }
                             changed = true;
                         }
-                        // A corner-dot press that never became a drag is a
-                        // click: open that plate's menu now, on release.
-                        if let Some((idx, _, _)) = self.corner_press.take() {
-                            self.open_plate_menu(idx);
-                            changed = true;
-                        }
                         if self.drag_widget.is_some() {
                             let idx = self.drag_widget.unwrap();
                             if idx == PARAM_IDX {
diff --git a/src/application.rs b/src/application.rs
index 59f1d81..a938b27 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -150,7 +150,7 @@ impl Application for State {
     ) -> Self {
         let is_detached_network = std::env::args().any(|arg| arg == "--detached-network");
         let detached_pane = std::env::args()
-            .find_map(|arg| crate::plate_corner::pane_from_detach_flag(&arg));
+            .find_map(|arg| crate::plate_menu::pane_from_detach_flag(&arg));
         let mut state = State::new(is_detached_network);
         if let Some(idx) = detached_pane {
             // Set after construction, so the layout that `State::new` already
@@ -179,7 +179,7 @@ impl Application for State {
         let (app_id, min_size) = if self.is_detached_network {
             ("circular-network-pane", (200, 200))
         } else if let Some(idx) = self.detached_pane {
-            (crate::plate_corner::pane_app_id(idx), (240, 160))
+            (crate::plate_menu::pane_app_id(idx), (240, 160))
         } else {
             ("cce-designer", (480, 320))
         };
diff --git a/src/main.rs b/src/main.rs
index f064c20..15f6966 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,7 +22,7 @@ pub mod collide;
 // Root-level aliases some modules import via `crate::` paths.
 #[allow(unused_imports)]
 use app::{CustomEvent, McpAction, ModifiersState};
-pub mod plate_corner;
+pub mod plate_menu;
 pub mod playbar;
 pub mod viewport_3d;
 pub mod api;
@@ -368,37 +368,84 @@ mod tests {
         assert_eq!(state.default_project_setting, before);
     }
 
-    /// The corner control has to land ON its plate: derived from the slot's live
-    /// rect, an off-by-one in the inset would put the trigger outside the pane
-    /// (unclickable, and painted over the neighbour) with nothing to catch it —
-    /// the render pass draws wherever it is told.
+    /// Press `button` at (x, y), as the pointer would.
+    fn press_at(state: &mut State, x: f32, y: f32, button: cce_ui::widget::MouseButton) {
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::ElementState;
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button });
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button });
+    }
+
+    /// There are no corner triggers: a plate's rows are in its right-click
+    /// menu. The panes with a menu of their own carry them under it (the
+    /// network's empty space, the playbar); the rest open them alone.
     #[test]
-    fn test_plate_corner_sits_inside_its_plate() {
-        use crate::plate_corner::{CORNER_R, PLATE_SLOTS};
+    fn a_plates_rows_are_in_its_right_click_menu() {
+        use crate::plate_menu::PlateMenuAction;
+        use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX};
+        use cce_ui::widget::MouseButton;
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
+        state.show_spreadsheet = true;
+        state.show_playbar = true;
+        state.rebuild_positions();
+        state.apply_layout();
+        let collapse = "Collapse".to_string();
 
-        let mut checked = 0;
-        for idx in PLATE_SLOTS {
-            let Some((cx, cy)) = state.plate_corner_center(idx) else { continue };
+        // 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();
-            checked += 1;
+            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 network: its own rows, then the plate's.
+        let (cx, cy, cw, ch) = state.positions[crate::slots::CONTENT_IDX];
+        let (px, py) = (cx + cw * 0.85, cy + ch * 0.2);
+        assert!(state.graph().node_at(px, py).is_none());
+        press_at(&mut state, px, py, MouseButton::Right);
+        let options = cce_ui::widget::context_menu::options();
+        assert_eq!(options.first().map(String::as_str), Some("Add Node"));
+        assert!(options.contains(&collapse), "{options:?}");
+        assert!(options.iter().any(|o| o.starts_with("Move To")), "{options:?}");
+        // Picking Collapse there collapses the network plate.
+        let row = options.iter().position(|o| *o == collapse).unwrap();
+        let rx = cce_ui::widget::context_menu::x() + 8.0;
+        let ry = cce_ui::widget::context_menu::row_y(row) + 4.0;
+        press_at(&mut state, rx, ry, MouseButton::Left);
+        assert!(state.pane_is_collapsed(NETWORK_PANEL_IDX));
+        state.set_pane_collapsed(NETWORK_PANEL_IDX, false);
 
-            // Inside the plate, with the whole disc clear of every edge.
-            assert!(cx - CORNER_R >= x && cx + CORNER_R <= x + w,
-                "slot {idx}: corner x {cx} escapes plate {x}..{}", x + w);
-            assert!(cy - CORNER_R >= y && cy + CORNER_R <= y + h,
-                "slot {idx}: corner y {cy} escapes plate {y}..{}", y + h);
-            // ...and in the TOP-RIGHT quadrant of it, not merely somewhere inside.
-            assert!(cx > x + w / 2.0, "slot {idx}: corner is not on the right");
-            assert!(cy < y + h / 2.0, "slot {idx}: corner is not at the top");
+        // The playbar: its transport, then the plate's.
+        let (x, y, w, h) = state.positions[PLAYBAR_IDX];
+        press_at(&mut state, x + w * 0.5, y + h * 0.5, MouseButton::Right);
+        let options = cce_ui::widget::context_menu::options();
+        assert!(options.contains(&collapse), "{options:?}");
+        assert!(state.playbar_menu_actions.contains(&crate::app::PlaybarMenuAction::Plate(PlateMenuAction::Collapse)));
+        press_at(&mut state, 2.0, 2.0, MouseButton::Left);
+    }
 
-            // The hit test must agree with where it is painted.
-            assert_eq!(state.plate_corner_at(cx, cy), Some(idx), "slot {idx}: centre misses");
-            assert_eq!(state.plate_corner_at(cx + CORNER_R * 2.0, cy), None,
-                "slot {idx}: hit radius reaches past the control");
-        }
-        assert!(checked >= 2, "expected at least the network and params plates, checked {checked}");
+    /// Move To swaps the pane, and the tabs riding it, with what holds the
+    /// other dock — what dragging the corner used to do.
+    #[test]
+    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};
+        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.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));
     }
 
     /// Collapse must actually reclaim the plate AND take its body with it, and
@@ -406,7 +453,7 @@ mod tests {
     /// graph would paint the pane over the viewport it just freed.
     #[test]
     fn test_collapse_shrinks_the_plate_and_restores_it() {
-        use crate::plate_corner::STUB_H;
+        use crate::plate_menu::STUB_H;
         use crate::slots::{CONTENT_IDX, NETWORK_PANEL_IDX};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
@@ -419,11 +466,12 @@ mod tests {
         let (_, _, _, stub_h) = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
         assert_eq!(stub_h, STUB_H, "collapsed plate is not the stub height");
         assert!(!state.slots.get_dyn(CONTENT_IDX).visible(), "graph survived the collapse");
-        // The control that expands it again must still be there.
-        assert!(state.plate_corner_center(NETWORK_PANEL_IDX).is_some(),
-            "collapsed plate lost its corner control — nothing can expand it");
-
-        state.set_pane_collapsed(NETWORK_PANEL_IDX, false);
+        // A right press on the stub offers Expand; a left press expands it.
+        let (x, y, w, h) = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
+        press_at(&mut state, x + w * 0.5, y + h * 0.5, cce_ui::widget::MouseButton::Right);
+        assert!(state.plate_menu_actions.contains(&crate::plate_menu::PlateMenuAction::Expand));
+        state.close_plate_menu();
+        press_at(&mut state, x + w * 0.5, y + h * 0.5, cce_ui::widget::MouseButton::Left);
         let (_, _, _, back_h) = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
         assert_eq!(back_h, full_h, "expanding did not restore the plate height");
         assert!(state.slots.get_dyn(CONTENT_IDX).visible(), "graph did not come back");
@@ -434,7 +482,7 @@ mod tests {
     /// laying the pane out, or the space it held is never released.
     #[test]
     fn test_detached_pane_claims_its_window_and_leaves_the_parent() {
-        use crate::plate_corner::DETACHED_MARGIN;
+        use crate::plate_menu::DETACHED_MARGIN;
         use crate::slots::{PARAM_IDX, VIEWPORT_IDX, WIDGET_COUNT};
 
         // Child: the detached window.
@@ -451,8 +499,8 @@ mod tests {
         assert_eq!(h, 400.0 - 2.0 * DETACHED_MARGIN);
 
         // Parent: the window that handed the pane out keeps a STUB, because the
-        // stub carries the corner control that is the only way to reattach.
-        use crate::plate_corner::STUB_H;
+        // stub's plate menu is the only way to reattach.
+        use crate::plate_menu::STUB_H;
         let mut parent = State::new(false);
         parent.resize(1600.0, 900.0, 1.0);
         assert!(parent.slots.get_dyn(PARAM_IDX).visible(), "params starts in the parent");
@@ -465,15 +513,18 @@ mod tests {
         let (_, _, _, stub_h) = parent.slots.get_dyn(PARAM_IDX).rect();
         assert!(full_h > stub_h, "detaching did not shrink the pane in the parent");
         assert_eq!(stub_h, STUB_H, "the parent's leftover is not a stub");
-        assert!(parent.plate_corner_center(PARAM_IDX).is_some(),
-            "the stub has no corner control — nothing can reattach the pane");
+        let (sx, sy, sw, sh) = parent.slots.get_dyn(PARAM_IDX).rect();
+        press_at(&mut parent, sx + sw * 0.5, sy + sh * 0.5, cce_ui::widget::MouseButton::Right);
+        assert_eq!(parent.plate_menu_actions, vec![crate::plate_menu::PlateMenuAction::Reattach],
+            "the stub's right press offers Reattach — nothing else can reattach the pane");
+        parent.close_plate_menu();
         // Collapsed and detached stubs must not read the same.
         let label = parent.pane_stub_label(PARAM_IDX).expect("a detached pane is stubbed");
         assert!(label.contains("detached"), "stub does not say the pane is detached: {label}");
         assert!(parent.slots.get_dyn(VIEWPORT_IDX).visible(), "the rest of the parent survived");
     }
 
-    /// Dock swap: dragging a plate's dot to another region swaps occupants,
+    /// Dock swap: Move To another dock swaps occupants,
     /// and the dock-owned dimensions stay put — the network lands in the
     /// bottom strip's rect, the spreadsheet in the left column's.
     /// A left press on the spreadsheet's column header reaches the widget,
@@ -667,18 +718,6 @@ mod tests {
         assert_eq!(state.dock_of_pane(SPREADSHEET_IDX), Some(Dock::Bottom));
     }
 
-    /// The drop-region mapping: lower band is the bottom dock, the rest
-    /// splits into halves.
-    #[test]
-    fn test_dock_region_mapping() {
-        use crate::app::Dock;
-        let mut state = State::new(false);
-        state.resize(1000.0, 1000.0, 1.0);
-        assert_eq!(state.dock_region_at(100.0, 100.0), Dock::Left);
-        assert_eq!(state.dock_region_at(900.0, 100.0), Dock::Right);
-        assert_eq!(state.dock_region_at(500.0, 900.0), Dock::Bottom);
-    }
-
     /// Full width tucks the spreadsheet under BOTH neighbors (their bottoms
     /// rise via the tuck interlock); between-panes clears both tucks.
     #[test]
@@ -703,7 +742,7 @@ mod tests {
     /// and reattaching restores the pane in full.
     #[test]
     fn test_reattach_brings_a_detached_pane_back() {
-        use crate::plate_corner::PlateMenuAction;
+        use crate::plate_menu::PlateMenuAction;
         use crate::slots::PARAM_IDX;
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
@@ -782,13 +821,13 @@ mod tests {
     /// restores it is unreachable.
     #[test]
     fn test_plate_corner_menu_is_contextual() {
-        use crate::plate_corner::{PlateMenuAction, PLATE_SLOTS};
+        use crate::plate_menu::{PlateMenuAction, PLATE_SLOTS};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
 
         let idx = PLATE_SLOTS.iter().copied()
-            .find(|&i| state.plate_corner_center(i).is_some())
-            .expect("some plate carries a corner control at this size");
+            .find(|&i| state.slots.get_dyn(i).visible())
+            .expect("some plate is shown at this size");
 
         state.open_plate_menu(idx);
         assert!(state.plate_menu_actions.contains(&PlateMenuAction::Collapse));
@@ -1150,7 +1189,7 @@ mod tests {
     #[test]
     fn move_to_own_plate_needs_an_empty_dock() {
         use crate::app::Dock;
-        use crate::plate_corner::PlateMenuAction;
+        use crate::plate_menu::PlateMenuAction;
         use crate::slots::{NETWORK_PANEL2_IDX, NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
@@ -2500,6 +2539,10 @@ mod tests {
                 vec![A::Command("play_pause"), A::Command("play_pause_reverse"), A::Command("frame_start")],
                 vec![A::Command("toggle_playbar_repeat")],
                 vec![A::FpsSlider, A::StartFrameSlider, A::EndFrameSlider],
+                vec![
+                    A::Plate(crate::plate_menu::PlateMenuAction::Collapse),
+                    A::Plate(crate::plate_menu::PlateMenuAction::Detach),
+                ],
             ]
         );
         let repeat = actions.iter().position(|a| *a == A::Command("toggle_playbar_repeat")).unwrap();
diff --git a/src/plate_corner.rs b/src/plate_menu.rs
similarity index 84%
rename from src/plate_corner.rs
rename to src/plate_menu.rs
index 00e8211..a7606c9 100644
--- a/src/plate_corner.rs
+++ b/src/plate_menu.rs
@@ -1,32 +1,28 @@
-//! The plate corner control: a small circular menu trigger riding the top-right
-//! of every pane that draws a plate of its own, and the menu it opens.
+//! The plate menu: what can be done to a pane's PLATE — collapse, detach,
+//! its dock's tabs, where it is docked — as rows of that pane's right-click
+//! menu.
 //!
-//! Geometry is derived from the slot's LIVE rect rather than computed alongside
-//! `positions[..]`, because `rebuild_positions` lays the panes out in three
-//! different branches (normal, circular network, detached window) and a corner
-//! computed per-branch would be three things to keep in step. The circular
-//! network pane is the one shape whose "top-right" is not a rect corner, so it
-//! is special-cased onto the arc.
+//! Until 2026-10-01 these rows were a menu of their own, opened by a small
+//! circular trigger on the top-right of every plate (and that trigger, dragged,
+//! moved the pane to another dock). The trigger is gone: the rows are appended
+//! to the plate's own context menu where it has one (the network editor's,
+//! the playbar's) and make up the whole menu where it has none (the params
+//! pane off a row, the spreadsheet, the second network editor, and a
+//! collapsed or detached plate's stub). Moving a pane to another dock is a
+//! row too, `Move To …`, which swaps it with what is there as the drag did.
 //!
-//! The control follows the DE's closed-menu-trigger language (see
-//! `cce-ui`'s popover conventions): a transparent face over an inset trough,
-//! here on a fully-round radius so the trough reads as a ring.
+//! [`State::plate_menu_rows`] is the one list; [`State::open_plate_menu_at`]
+//! shows it alone, and a pane with a menu of its own appends it, dispatching
+//! a pick through [`State::run_plate_menu_action`].
 
-use crate::app::State;
+use crate::app::{Dock, State};
 use crate::slots::{
     NETWORK_PANEL2_IDX, NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX, WIDGET_COUNT,
 };
-
-/// Radius of the control itself.
-// The affordance's geometry and protocol are toolkit-owned since cce-ui RFC
-// Phase 7c generalized this file's machinery (`cce_ui::widget::plate_dock`);
-// the constants re-export so the designer's draw/hit code keeps its names.
-pub use cce_ui::widget::plate_dock::{CORNER_INSET, CORNER_R, MIN_PLATE_SPAN};
 use cce_ui::widget::plate_dock::{self, PlateDockAction, PlateDockState};
 
-/// The plates that carry a corner control. The viewport is deliberately absent:
-/// its "plate" is the window-spanning lip, so a top-right control would sit on
-/// the window corner rather than on a pane.
+/// The plates that carry a plate menu. The viewport is deliberately absent:
+/// its "plate" is the window-spanning lip, not a pane.
 pub const PLATE_SLOTS: [usize; 5] =
     [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, PLAYBAR_IDX, NETWORK_PANEL2_IDX];
 
@@ -36,7 +32,7 @@ pub const PLATE_SLOTS: [usize; 5] =
 pub const TAB_CANDIDATES: [usize; 4] =
     [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, NETWORK_PANEL2_IDX];
 
-/// What the corner menu can do to its plate.
+/// What the plate menu can do to its plate.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum PlateMenuAction {
     /// Shrink the plate to its title stub (or restore it).
@@ -58,60 +54,24 @@ pub enum PlateMenuAction {
     /// Swap the menu for the Add Tab page — the list of panes that can be
     /// pulled in ([`State::open_plate_add_tab_menu`]).
     AddTabMenu,
-    /// The Add Tab page's Back row: swap the main menu page back in.
+    /// The Add Tab page's Back row: swap the plate's rows back in.
     BackToMain,
     /// Move this pane out of its shared dock into the first empty one.
     SplitTab,
+    /// Move this pane, and the tabs riding it, to another dock, swapping
+    /// with what is there — what dragging the plate's corner used to do.
+    MoveTo(Dock),
     /// Remove a closable pane (the second network editor) from the docks.
     CloseTab,
     /// Bind this pane to whichever editor takes the last node click.
     PinFollow,
     /// Bind this pane to one editor (CONTENT_IDX / CONTENT2_IDX).
     PinTo(usize),
-    /// A "-" row: engraved, inert — keeps `plate_menu_actions` aligned with
-    /// the option rows so a click on the line dispatches nothing.
+    /// A "-" row: engraved, inert — keeps the action list aligned with the
+    /// option rows so a click on the line dispatches nothing.
     Separator,
 }
 
-impl State {
-    /// Centre of `idx`'s corner control, or `None` when the plate is hidden or
-    /// too small to carry one.
-    pub fn plate_corner_center(&self, idx: usize) -> Option<(f32, f32)> {
-        if !PLATE_SLOTS.contains(&idx) {
-            return None;
-        }
-        let w = self.slots.get_dyn(idx);
-        if !w.visible() {
-            return None;
-        }
-
-        // The circular network pane: put the control where the plate's own
-        // top-right actually is — on the arc, at 45°.
-        if idx == NETWORK_PANEL_IDX && self.circular_network_pane {
-            let c = &self.circular_network_layout;
-            if c.r < MIN_PLATE_SPAN {
-                return None;
-            }
-            let d = std::f32::consts::FRAC_1_SQRT_2 * (c.r - CORNER_INSET);
-            return Some((c.x + d, c.y - d));
-        }
-
-        // Rect-based placement is toolkit-owned (stub exemption included);
-        // only the circular-pane arc above stays designer policy.
-        plate_dock::corner_center(w.rect(), self.pane_is_stubbed(idx))
-    }
-
-    /// The plate whose corner control is under `(px, py)`, if any. Searched in
-    /// reverse draw order so an overlapping pane's control wins, matching what
-    /// the user sees on top.
-    pub fn plate_corner_at(&self, px: f32, py: f32) -> Option<usize> {
-        PLATE_SLOTS.iter().rev().copied().find(|&idx| {
-            self.plate_corner_center(idx)
-                .is_some_and(|c| plate_dock::corner_hit(c, px, py))
-        })
-    }
-}
-
 /// The pane's display name — the stub's label, and what the menu is "about".
 pub fn plate_title(idx: usize) -> &'static str {
     match idx {
@@ -124,13 +84,34 @@ pub fn plate_title(idx: usize) -> &'static str {
     }
 }
 
+/// A dock's name in a `Move To` row.
+fn dock_title(d: Dock) -> &'static str {
+    match d {
+        Dock::Left => "Left",
+        Dock::Right => "Right",
+        Dock::Bottom => "Bottom",
+    }
+}
+
 impl State {
-    /// Open the corner menu for `idx`, anchored under its control. Items are
-    /// contextual: a collapsed plate offers Expand instead of Collapse, and
-    /// Detach only appears where a detached window exists for that pane.
-    pub fn open_plate_menu(&mut self, idx: usize) {
-        let Some((cx, cy)) = self.plate_corner_center(idx) else { return };
+    /// The plate whose rect holds (px, py), topmost first — the plate a
+    /// right press there is about.
+    pub fn plate_at(&self, px: f32, py: f32) -> Option<usize> {
+        PLATE_SLOTS.iter().rev().copied().find(|&idx| {
+            let w = self.slots.get_dyn(idx);
+            if !w.visible() {
+                return false;
+            }
+            let (x, y, ww, h) = w.rect();
+            ww > 0.0 && h > 0.0 && px >= x && px < x + ww && py >= y && py < y + h
+        })
+    }
 
+    /// The rows of `idx`'s plate menu, labels and actions in step. Contextual:
+    /// a collapsed plate offers Expand instead of Collapse, Detach appears only
+    /// where a detached window exists for that pane, and a detached pane's
+    /// stub offers Reattach alone.
+    pub fn plate_menu_rows(&self, idx: usize) -> (Vec<String>, Vec<PlateMenuAction>) {
         // The standard rows come from the toolkit protocol (Reattach-only for
         // a detached pane's stub, Collapse/Expand, Detach when allowed); the
         // designer appends its app rows after.
@@ -150,11 +131,7 @@ impl State {
             });
         }
         if state.detached {
-            let target = self.slots.get_dyn(idx).base().id();
-            cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 0, target);
-            self.plate_menu_slot = Some(idx);
-            self.plate_menu_actions = actions;
-            return;
+            return (options, actions);
         }
 
         // Group boundaries are engraved separators ("-" rows — the toolkit
@@ -242,15 +219,38 @@ impl State {
                 options.push("Move To Own Plate".to_string());
                 actions.push(PlateMenuAction::SplitTab);
             }
+            // Every other dock, swapping with what is there — the drag the
+            // corner trigger used to start, as rows.
+            for other in [Dock::Left, Dock::Right, Dock::Bottom] {
+                if other != d {
+                    manage_row(&mut options, &mut actions);
+                    options.push(format!("Move To {}", dock_title(other)));
+                    actions.push(PlateMenuAction::MoveTo(other));
+                }
+            }
             if idx == NETWORK_PANEL2_IDX {
                 manage_row(&mut options, &mut actions);
                 options.push("Close Tab".to_string());
                 actions.push(PlateMenuAction::CloseTab);
             }
         }
+        (options, actions)
+    }
 
+    /// Show `idx`'s plate menu alone, at the pointer — a pane with no
+    /// context menu of its own, or a stub.
+    pub fn open_plate_menu(&mut self, idx: usize) {
+        self.open_plate_menu_at(idx, self.cursor_x, self.cursor_y);
+    }
+
+    /// Show `idx`'s plate menu alone, its top-left at (x, y).
+    pub fn open_plate_menu_at(&mut self, idx: usize, x: f32, y: f32) {
+        let (options, actions) = self.plate_menu_rows(idx);
+        if options.is_empty() {
+            return;
+        }
         let target = self.slots.get_dyn(idx).base().id();
-        cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 0, target);
+        cce_ui::widget::context_menu::show(x, y, options, 0, target);
         self.plate_menu_slot = Some(idx);
         self.plate_menu_actions = actions;
     }
@@ -268,11 +268,10 @@ impl State {
             .collect()
     }
 
-    /// The Add Tab page: swaps the corner menu in place for the list of
-    /// addable panes, under a dimmed header row. Same anchor, same click
-    /// contract — a second PAGE of the one menu, not a second menu.
-    pub fn open_plate_add_tab_menu(&mut self, idx: usize) {
-        let Some((cx, cy)) = self.plate_corner_center(idx) else { return };
+    /// The Add Tab page: swaps the menu in place for the list of addable
+    /// panes, under a dimmed header row, at (x, y) — where the menu that
+    /// asked for it stood. A second PAGE of the menu, not a second menu.
+    pub fn open_plate_add_tab_menu(&mut self, idx: usize, x: f32, y: f32) {
         let Some(d) = self.dock_of_pane(idx) else { return };
         let candidates = self.plate_add_tab_candidates(idx, d);
         if candidates.is_empty() {
@@ -289,7 +288,7 @@ impl State {
         options.push("‹ Back".to_string());
         actions.push(PlateMenuAction::BackToMain);
         let target = self.slots.get_dyn(idx).base().id();
-        cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 1, target);
+        cce_ui::widget::context_menu::show(x, y, options, 1, target);
         self.plate_menu_slot = Some(idx);
         self.plate_menu_actions = actions;
     }
@@ -304,7 +303,7 @@ impl State {
         self.plate_menu_actions.clear();
     }
 
-    /// Route a left press while the corner menu is open — same contract as
+    /// Route a left press while the plate menu is open — same contract as
     /// `handle_node_menu_click`.
     pub fn handle_plate_menu_click(&mut self) -> bool {
         if !self.plate_menu_open() {
@@ -313,9 +312,10 @@ impl State {
         if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
             let row = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
             let picked = self.plate_menu_slot.zip(row.and_then(|r| self.plate_menu_actions.get(r).copied()));
+            let at = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
             self.close_plate_menu();
             if let Some((idx, action)) = picked {
-                self.dispatch_plate_menu(idx, action);
+                self.run_plate_menu_action(idx, action, at);
             }
             return true;
         }
@@ -323,7 +323,10 @@ impl State {
         false
     }
 
-    fn dispatch_plate_menu(&mut self, idx: usize, action: PlateMenuAction) {
+    /// Run a plate row for `idx`, picked from a menu whose top-left was `at`
+    /// — where a page swap (Add Tab, Back) puts the next page, whichever
+    /// menu the row was part of.
+    pub fn run_plate_menu_action(&mut self, idx: usize, action: PlateMenuAction, at: (f32, f32)) {
         match action {
             PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
             PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
@@ -341,9 +344,10 @@ impl State {
                     self.add_dock_tab(d, o);
                 }
             }
-            PlateMenuAction::AddTabMenu => self.open_plate_add_tab_menu(idx),
-            PlateMenuAction::BackToMain => self.open_plate_menu(idx),
+            PlateMenuAction::AddTabMenu => self.open_plate_add_tab_menu(idx, at.0, at.1),
+            PlateMenuAction::BackToMain => self.open_plate_menu_at(idx, at.0, at.1),
             PlateMenuAction::SplitTab => self.split_dock_tab(idx),
+            PlateMenuAction::MoveTo(d) => self.move_pane_to_dock(idx, d),
             PlateMenuAction::CloseTab => self.close_dock_tab(idx),
             PlateMenuAction::PinFollow => self.set_pane_pin(idx, None),
             PlateMenuAction::PinTo(e) => self.set_pane_pin(idx, Some(e)),
diff --git a/src/project.rs b/src/project.rs
index b52d827..44e794a 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -100,10 +100,10 @@ impl State {
     /// camera/pan fields. Visibility rode the root meta node's View subnet
     /// params into the file until that node was retired.
     pub(crate) fn project_view_state(&self) -> ProjectViewState {
-        let collapsed_panes = crate::plate_corner::PLATE_SLOTS
+        let collapsed_panes = crate::plate_menu::PLATE_SLOTS
             .iter()
             .filter(|&&i| self.collapsed_panes[i])
-            .filter_map(|&i| crate::plate_corner::pane_name_from_slot(i))
+            .filter_map(|&i| crate::plate_menu::pane_name_from_slot(i))
             .map(str::to_string)
             .collect();
         let splitters = if self.width > 1.0 {
@@ -120,12 +120,12 @@ impl State {
             .map(|d| {
                 let active = self.dock_panes[d];
                 let mut names: Vec<String> = Vec::new();
-                if let Some(n) = crate::plate_corner::pane_name_from_slot(active) {
+                if let Some(n) = crate::plate_menu::pane_name_from_slot(active) {
                     names.push(n.to_string());
                 }
                 for &t in &self.dock_tabs[d] {
                     if t != active {
-                        if let Some(n) = crate::plate_corner::pane_name_from_slot(t) {
+                        if let Some(n) = crate::plate_menu::pane_name_from_slot(t) {
                             names.push(n.to_string());
                         }
                     }
@@ -347,8 +347,8 @@ impl State {
         }
         // Absent names expand: an older save (no collapse list) loads with
         // every pane open rather than inheriting this session's collapses.
-        for &idx in crate::plate_corner::PLATE_SLOTS.iter() {
-            let desired = crate::plate_corner::pane_name_from_slot(idx)
+        for &idx in crate::plate_menu::PLATE_SLOTS.iter() {
+            let desired = crate::plate_menu::pane_name_from_slot(idx)
                 .map_or(false, |n| vs.collapsed_panes.iter().any(|c| c == n));
             self.set_pane_collapsed(idx, desired);
         }
@@ -366,7 +366,7 @@ impl State {
                 .map(|names| {
                     names
                         .iter()
-                        .filter_map(|n| crate::plate_corner::pane_slot_from_name(n))
+                        .filter_map(|n| crate::plate_menu::pane_slot_from_name(n))
                         .collect()
                 })
                 .collect();
diff --git a/src/render.rs b/src/render.rs
index 4dee6ba..2c097b6 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -194,14 +194,12 @@ impl State {
         self.append_scale_readout(&mut pc);
         self.append_viewer_state_overlay(&mut pc);
         self.append_popovers(&mut pc);
-        self.append_dock_drag_overlay(&mut pc);
-        self.append_plate_corners(&mut pc);
         // Above every pane AND every overlay text pass, below only the context
         // menu — which can be opened from inside it.
         self.append_dialog(&mut pc, show_cursor, &mut visited, clip);
 
-        // The context menu (node/viewport right-click AND the plate corner
-        // menus — one shared state) floats above everything, drawn last as
+        // The context menu (every right-click menu, the plate menus
+        // included — one shared state) floats above everything, drawn last as
         // the toolkit's lit plate: rounded, translucent, frosted — the
         // material every other floating surface wears. NOT the legacy
         // extra_quads loop, which is the square opaque pre-frost look.
@@ -234,8 +232,8 @@ impl State {
             return;
         }
 
-        // A collapsed pane is its title stub and nothing else: the plate, the
-        // name, and (from the later corner pass) the control that restores it.
+        // A collapsed pane is its title stub and nothing else: the plate and
+        // the name. A press on it restores it; a right press, its plate menu.
         // Returning here is what suppresses the body — the params rows, the
         // spreadsheet grid, the transport controls — rather than relying on
         // each pane's own clip to hide content taller than the stub.
@@ -244,8 +242,8 @@ impl State {
             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(sx, sy, sw, sh));
             let font_size = 12.0;
             let ty = cce_ui::layout::align_text_y(sy, sh, font_size, 0.0);
-            // Bounds stop at the corner control so a long name cannot run under it.
-            let text_right = sx + sw - 2.0 * crate::plate_corner::CORNER_INSET;
+            // Bounds stop a margin short of the stub's end.
+            let text_right = sx + sw - 12.0;
             pc.text_with(
                 stub_label,
                 sx + 12.0,
@@ -880,43 +878,6 @@ impl State {
 
     }
 
-    /// Open popovers (the params pane's expanded dropdowns), background then
-    /// text per widget. Appended after `append_frame_text` so the popover
-    /// occludes the widget labels underneath it — the display list is drawn
-    /// strictly in order, so a popover background emitted in the geometry
-    /// pass would sit under every label.
-    /// The plates' corner menu triggers, drawn above pane content but below an
-    /// open context menu: a solid dot in the plate's border color — one color,
-    /// like the graph's port dots and geometry toggles — growing slightly on
-    /// hover (and while its menu is open) instead of changing tint.
-    /// The dock-drop highlight while a plate is being dragged by its dot: the
-    /// region the release would snap it into, tinted and outlined.
-    fn append_dock_drag_overlay(&self, pc: &mut PaintCtx) {
-        let (Some(crate::app::AppDrag::DockDrag { .. }), Some(target)) = (self.app_drag, self.dock_drag_target) else {
-            return;
-        };
-        let (x, y, w, h) = self.dock_rect(target);
-        if w <= 0.0 || h <= 0.0 {
-            return;
-        }
-        let hl = colors::highlight_primary_color();
-        let r = cce_ui::layout::plate_corner_radius();
-        pc.rounded_rect(rect(x, y, w, h), r, (true, true, true, true), [hl[0], hl[1], hl[2], 0.12]);
-        pc.border(rect(x, y, w, h), (r, r, r, r), [0.0; 4], [hl[0], hl[1], hl[2], 0.8], 2.0);
-    }
-
-    fn append_plate_corners(&self, pc: &mut PaintCtx) {
-        let hovered = self.plate_corner_at(self.cursor_x, self.cursor_y);
-        for idx in crate::plate_corner::PLATE_SLOTS {
-            let Some(c) = self.plate_corner_center(idx) else { continue };
-            cce_ui::widget::plate_dock::draw_corner_dot(
-                pc,
-                c,
-                hovered == Some(idx) || self.plate_menu_slot == Some(idx),
-            );
-        }
-    }
-
     /// The Alt+D dialog, last of the pane content: its plate and command list,
     /// its settings body, and that body's popovers.
     ///
@@ -939,6 +900,11 @@ impl State {
         self.paint_widget(DIALOG_IDX, pc, show_cursor, visited, clip, None);
     }
 
+    /// Open popovers (the params pane's expanded dropdowns), background then
+    /// text per widget. Appended after `append_frame_text` so the popover
+    /// occludes the widget labels underneath it — the display list is drawn
+    /// strictly in order, so a popover background emitted in the geometry
+    /// pass would sit under every label.
     fn append_popovers(&self, pc: &mut PaintCtx) {
         for i in 0..WIDGET_COUNT {
             let w = self.slots.get_dyn(i);
diff --git a/src/window.rs b/src/window.rs
index bfe0e1d..075abfe 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -572,14 +572,14 @@ impl State {
                 }
             }
             McpAction::SetPaneCollapsed { pane, collapsed } => {
-                let idx = crate::plate_corner::pane_slot_from_name(&pane)
+                let idx = crate::plate_menu::pane_slot_from_name(&pane)
                     .ok_or_else(|| format!("unknown pane: {pane}"))?;
                 state.set_pane_collapsed(idx, collapsed);
                 needs_redraw = true;
                 Ok(format!("{pane} collapsed={collapsed}"))
             }
             McpAction::SetPaneDetached { pane, detached } => {
-                let idx = crate::plate_corner::pane_slot_from_name(&pane)
+                let idx = crate::plate_menu::pane_slot_from_name(&pane)
                     .ok_or_else(|| format!("unknown pane: {pane}"))?;
                 state.set_pane_detached(idx, detached);
                 needs_redraw = true;