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

commit075582326503d21f65fa07c44d6a596a44ea9151
parent3b62372bf7
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 09:49
refactor(docks): remove the dock tab system

With the network, the params HUD and the second network editor out of the
docks, the spreadsheet is the one pane a dock can hold, so tabs had
nothing to share a dock with. Gone: State::dock_tabs, add_dock_tab,
show_dock_tab, split_dock_tab, first_empty_dock, tab_dock_of_pane,
TAB_CANDIDATES, the tab radio rows, Add Tab and its page, Move To Own
Plate, plate_page_root and MenuOrigin::of_plate. dock_panes is the
arrangement, still changed by Move To.

The save keeps its dock_tabs field and list shape, one name to a dock,
and an older save's tab group loads as its first dockable name.

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

 CLAUDE.md         |  26 +++++-----
 src/app.rs        | 113 +++++++------------------------------------
 src/main.rs       |  12 ++---
 src/menu_page.rs  |  38 ++++-----------
 src/plate_menu.rs | 140 ++++++------------------------------------------------
 src/project.rs    |  35 +++++---------
 6 files changed, 70 insertions(+), 294 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f9de9eb..03f6085 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -247,8 +247,12 @@ gone from cce-ui with the wgpu path).
 - `src/plate_menu.rs` — the plate menu: what can be done to a pane's PLATE
   (`PLATE_SLOTS` — network, params, spreadsheet, playbar; NOT the viewport,
   whose plate is the window-spanning lip) — Collapse/Expand, Detach/Reattach,
-  Full Width (only with a plate in a side dock to tuck under), the dock's tabs,
-  Add Tab, Move To Own Plate and **Move To Left/Right/Bottom**. The network and
+  Full Width (only with a plate in a side dock to tuck under) and **Move To
+  Left/Right/Bottom**. (Docks held TABS until 2026-10-07 — several panes to a
+  dock, with tab rows, Add Tab and Move To Own Plate here — retired once the
+  spreadsheet was the one pane a dock could hold; a save keeps the
+  `dock_tabs` field and its list shape, one name to a dock, and an older
+  save's tab group loads as its first dockable name.) The network and
   the params HUD are in no dock, so theirs are Detach alone, and the
   network's are in no menu: its Plate page went on 2026-10-07, Detach being the
   `detach_circular_window` command in the palette. `plate_menu_rows(idx)` is the
@@ -263,11 +267,8 @@ gone from cce-ui with the wgpu path).
   inline there), and the whole menu where a pane has none of its own — the
   params pane off a row, the spreadsheet (`open_plate_menu`, at the
   pointer). A row from any of them runs through
-  `run_plate_menu_action`. Add Tab is a page row too: the menu turns into
-  the list of panes where it stood, under a back band to whichever menu it
-  turned from (`State::plate_page_from`) — from a Plate page, back to that
-  page, whose band goes back to the playbar's menu
-  (`State::plate_page_root`). Collapse shrinks a plate to its title stub via
+  `run_plate_menu_action`; a Plate page's band goes back to the menu it was
+  turned from (`State::plate_page_from`). 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`
@@ -2779,8 +2780,7 @@ An older save's `"network2"` in its dock lists names no pane and is dropped
 wherever it stood, its `current_path2` and pins ignored
 (`an_older_saves_second_network_editor_is_dropped`). With the network and
 the params HUD out of the docks too, the spreadsheet is the one dockable
-pane: Move To still moves it between the three docks, the tab rows have
-nothing to offer, and its Full Width row is shown only while a side dock
+pane: Move To still moves it between the three docks, and its Full Width row is shown only while a side dock
 holds a plate to tuck under — which, with nothing else dockable, is never.
 
 ### The network has no plate (since 2026-10-06)
@@ -2809,7 +2809,7 @@ navigation.
 none. The left dock starts EMPTY (`dock_panes` `[NO_PANE, NO_PANE,
 spreadsheet]`), so the spreadsheet runs flush to the window's left, where
 it stopped short of the network's invisible dock until then;
-`TAB_CANDIDATES` is the spreadsheet alone; the network's
+the network's
 plate menu is Detach alone (no tab, Move To or Collapse rows) — and is in
 no menu: the network menu's Plate page went the next day, Detach being the
 palette's `detach_circular_window` — and
@@ -2943,7 +2943,7 @@ bottom raised by a spreadsheet tucked under it, tabbable and movable.
   `AppDrag::RightDockResize`, asked first, its plate being on top.
 - **Out of the docks.** The right dock starts EMPTY (`dock_panes`
   `[NO_PANE, NO_PANE, spreadsheet]` — the network left the left dock the
-  next day); `TAB_CANDIDATES` no longer lists params, its plate menu has no tab, Move To or Collapse rows (Detach
+  next day); the tab candidates (tabs went 2026-10-07) no longer listed params, its plate menu has no tab, Move To or Collapse rows (Detach
   stays), `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
@@ -3022,8 +3022,8 @@ and **Network**, the network menu, while the network overlays the scene
 its **Attribute Visualizers** row was one too, into the dialog, until
 2026-10-06 — it is a plain row now, opening them in the params HUD); the
 network menu's **Add Node**
-(the dialog); the plate rows' **Add Tab** (a page, wherever the plate rows
-are); the node menu's **Rename** (the dialog). Inside the dialog the rows
+(the dialog); the playbar menu's **Plate** (its plate rows); the node
+menu's **Rename** (the dialog). Inside the dialog the rows
 that turn it into another list are marked `›` in the chord column and take
 the forward swipe too (`dialog::dialog_row_leads`): the palette's Group
 Markers. The mark rides the row's chord TEXT (cce-ui's `PAGE_MARK`), and a
diff --git a/src/app.rs b/src/app.rs
index 0a9a7ec..d1ccee1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -284,11 +284,14 @@ pub struct ProjectViewState {
     /// window size. None in older saves keeps the live positions.
     #[serde(default)]
     pub splitters: Option<(f32, f32)>,
-    /// The docks' tab groups, Left/Right/Bottom order, pane names with the
-    /// ACTIVE tab first. Empty (older saves) keeps the default one-pane-per-
-    /// dock arrangement; a list that does not name each core docked pane
-    /// exactly once is ignored the same way. ("network2", the second network
-    /// editor, removed on 2026-10-07, is dropped from an older save's.)
+    /// What each dock holds, Left/Right/Bottom order: a list of pane names,
+    /// at most one since docks held no tabs (2026-10-07) — the name and the
+    /// list shape kept, so an older save, whose lists were tab groups with
+    /// the active tab first, still loads (its first dockable name is the
+    /// dock's). Empty (older saves) keeps the default arrangement; a set
+    /// that does not name each dockable pane exactly once is ignored the
+    /// same way. ("network2", the second network editor, removed the same
+    /// day, is dropped from an older save's.)
     #[serde(default)]
     pub dock_tabs: Vec<Vec<String>>,
     // `current_path2` and the viewport / params / spreadsheet pins rode
@@ -2833,8 +2836,7 @@ pub enum Dock {
     Bottom,
 }
 
-/// `dock_panes` entry for a dock whose tabs were all pulled elsewhere: no
-/// slot index, so `pane_shown` reads it as hidden and the dock lays out
+/// `dock_panes` entry for a dock that holds nothing: no slot index, so `pane_shown` reads it as hidden and the dock lays out
 /// nothing. Never a valid `positions[..]` index.
 pub const NO_PANE: usize = usize::MAX;
 
@@ -3015,12 +3017,9 @@ pub struct State {
     /// up, nearest last: Commands under Group Markers, the visualizer list
     /// under one visualizer.
     pub dialog_trail: Vec<crate::dialog::Mode>,
-    /// The menu the plate menu's Add Tab page was turned to from.
+    /// The menu a plate PAGE was turned to from (the playbar's Plate row),
+    /// which its back band returns to (`open_plate_page`).
     pub plate_page_from: Option<crate::menu_page::MenuOrigin>,
-    /// The plate PAGE shown from another menu's Plate row: the plate's slot
-    /// and that menu. Add Tab turned to from the page goes back to the page,
-    /// and the page back to the menu (`open_plate_page`).
-    pub plate_page_root: Option<(usize, crate::menu_page::MenuOrigin)>,
     /// A parameter row's right-click menu — the same thread-local; the
     /// target is (node id, parameter name) rather than a slot and a row, so
     /// it holds across a re-layout of the pane.
@@ -3130,17 +3129,10 @@ pub struct State {
     /// Panes shrunk to their title stub, indexed by slot. Only the
     /// `plate_menu::PLATE_SLOTS` entries are ever set.
     pub collapsed_panes: [bool; WIDGET_COUNT],
-    /// 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.
+    /// Dock occupancy, indexed Left/Right/Bottom: the pane each dock holds,
+    /// or [`NO_PANE`]. Swapped by the plate menu's Move To rows. One pane
+    /// to a dock: the tabs that let several share one went on 2026-10-07.
     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
-    /// menus. Every docked pane lives in exactly ONE dock's list.
-    pub dock_tabs: [Vec<usize>; 3],
 
     pub drag_widget: Option<usize>,
     /// Where the pointer pressed when `drag_widget` armed — the drag
@@ -4728,19 +4720,16 @@ impl State {
 
     /// 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
-    /// plate and every tab riding it, exactly what the drag shows moving.
     pub fn move_pane_to_dock(&mut self, slot: usize, dock: Dock) {
         let Some(from) = self.dock_of_pane(slot) else { return };
         if from == dock {
             return;
         }
         self.dock_panes.swap(from as usize, dock as usize);
-        self.dock_tabs.swap(from as usize, dock as usize);
         self.after_dock_change();
     }
 
-    /// The full re-sync a dock/tab change needs: layout, panel offsets, the
+    /// The full re-sync a dock change needs: layout, panel offsets, the
     /// graphs' grid origins (they follow their pane rects), and the node
     /// lists — a fronted pane must not wait for the next unrelated event to
     /// fill in.
@@ -4752,65 +4741,6 @@ impl State {
         self.sync_nodes();
     }
 
-    /// The dock whose TAB LIST holds `slot` — its home whether or not it is
-    /// the active tab there ([`Self::dock_of_pane`] finds only actives).
-    pub fn tab_dock_of_pane(&self, slot: usize) -> Option<Dock> {
-        [Dock::Left, Dock::Right, Dock::Bottom]
-            .into_iter()
-            .find(|&d| self.dock_tabs[d as usize].contains(&slot))
-    }
-
-    /// Bring one of a dock's tabs to the front (the corner menu's tab switch).
-    pub fn show_dock_tab(&mut self, dock: Dock, slot: usize) {
-        if !self.dock_tabs[dock as usize].contains(&slot) || self.dock_panes[dock as usize] == slot {
-            return;
-        }
-        self.dock_panes[dock as usize] = slot;
-        self.after_dock_change();
-    }
-
-    /// Pull `slot` out of its current dock (if it has one — an unplaced pane
-    /// simply joins) and tab it into
-    /// `dock`, active. The dock it leaves fronts its next remaining tab, or
-    /// empties ([`NO_PANE`]) — its rect stays reserved by the dock-owned
-    /// dimensions either way, ready for a tab to move back.
-    pub fn add_dock_tab(&mut self, dock: Dock, slot: usize) {
-        if let Some(from) = self.tab_dock_of_pane(slot) {
-            if from == dock {
-                self.show_dock_tab(dock, slot);
-                return;
-            }
-            let f = from as usize;
-            self.dock_tabs[f].retain(|&s| s != slot);
-            if self.dock_panes[f] == slot {
-                self.dock_panes[f] = self.dock_tabs[f].first().copied().unwrap_or(NO_PANE);
-            }
-        }
-        self.dock_tabs[dock as usize].push(slot);
-        self.dock_panes[dock as usize] = slot;
-        self.after_dock_change();
-    }
-
-    /// The dock a split would move a tab to: the first EMPTY one, Left,
-    /// Right, Bottom. There are four tab candidates for three docks, so two
-    /// docks can each hold two with none left free — the corner menu reads
-    /// this to leave Move To Own Plate out rather than offer a dead row.
-    pub fn first_empty_dock(&self) -> Option<Dock> {
-        [Dock::Left, Dock::Right, Dock::Bottom]
-            .into_iter()
-            .find(|&d| self.dock_tabs[d as usize].is_empty())
-    }
-
-    /// Move `slot` out of its shared dock to the first EMPTY dock — the
-    /// corner menu's inverse of Add Tab. No empty dock, no move.
-    pub fn split_dock_tab(&mut self, slot: usize) {
-        if self.tab_dock_of_pane(slot).is_none() {
-            return;
-        }
-        let Some(empty) = self.first_empty_dock() else { return };
-        self.add_dock_tab(empty, slot);
-    }
-
     pub fn floating_spreadsheet_rect(&self) -> (f32, f32, f32, f32) {
         let gap = 18.0_f32;
         let fx = gap;
@@ -8549,7 +8479,6 @@ pub(crate) fn geometry_to_spreadsheet_columns(geom: &Detail) -> (Vec<String>, Ve
             dialog_from: None,
             dialog_trail: Vec::new(),
             plate_page_from: None,
-            plate_page_root: None,
             param_menu_active: false,
             param_menu_actions: Vec::new(),
             playbar_menu_active: false,
@@ -8585,11 +8514,6 @@ pub(crate) fn geometry_to_spreadsheet_columns(geom: &Detail) -> (Vec<String>, Ve
             // the params HUD is a dock pane (both live on the scene), and a
             // plate moved into either is drawn over them.
             dock_panes: [NO_PANE, NO_PANE, SPREADSHEET_IDX],
-            dock_tabs: [
-                Vec::new(),
-                Vec::new(),
-                vec![SPREADSHEET_IDX],
-            ],
             drag_widget: None,
             drag_press_cursor: None,
             focused_widget: None,
@@ -9626,11 +9550,6 @@ pub(crate) fn geometry_to_spreadsheet_columns(geom: &Detail) -> (Vec<String>, Ve
                 };
                 self.slots.playbar.inner_mut().frame = cce_ui::layout::bevel_width();
 
-                // Tab-aware visibility: a pane WAITING in a dock's tab list
-                // is hidden regardless of its View flag — a zero rect alone
-                // does not stop the text pass, so a waiting editor's labels
-                // would paint over whichever pane fronted.
-                let ss_active = self.dock_of_pane(SPREADSHEET_IDX).is_some();
                 self.slots.header.set_visible(false);
                 self.slots.status.set_visible(false);
                 self.slots.playbar.set_visible(self.show_playbar);
@@ -9644,7 +9563,7 @@ pub(crate) fn geometry_to_spreadsheet_columns(geom: &Detail) -> (Vec<String>, Ve
                 self.slots.right_menubar.set_visible(false);
                 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.set_visible(spreadsheet_visible);
                 self.slots.spreadsheet_menubar.set_visible(false);
             }
         }
diff --git a/src/main.rs b/src/main.rs
index b94a6c7..e27f8c6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -486,7 +486,7 @@ mod tests {
         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(SPREADSHEET_IDX, PlateMenuAction::MoveTo(Dock::Left), (0.0, 0.0));
+        state.run_plate_menu_action(SPREADSHEET_IDX, PlateMenuAction::MoveTo(Dock::Left));
         assert_eq!(state.dock_of_pane(SPREADSHEET_IDX), Some(Dock::Left));
         assert_eq!(state.pane_in_dock(Dock::Bottom), NO_PANE, "it swapped with the empty dock");
         let (x, _, w, _) = state.positions[SPREADSHEET_IDX];
@@ -1514,7 +1514,7 @@ mod tests {
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
         state.execute_menu_action("Show Spreadsheet Pane");
-        assert_eq!(state.tab_dock_of_pane(NETWORK_PANEL_IDX), None);
+        assert_eq!(state.dock_of_pane(NETWORK_PANEL_IDX), None);
         assert_eq!(state.pane_in_dock(Dock::Left), NO_PANE);
         assert_eq!(state.positions[NETWORK_PANEL_IDX], (0.0, HEADER_H, 1600.0, state.body_h()));
         assert!(state.slots.get_dyn(crate::slots::CONTENT_IDX).visible(), "shown though it fronts no dock");
@@ -1523,7 +1523,7 @@ mod tests {
         state.set_pane_collapsed(NETWORK_PANEL_IDX, true);
         assert!(!state.pane_is_collapsed(NETWORK_PANEL_IDX));
         state.move_pane_to_dock(NETWORK_PANEL_IDX, Dock::Bottom);
-        assert_eq!(state.tab_dock_of_pane(NETWORK_PANEL_IDX), None);
+        assert_eq!(state.dock_of_pane(NETWORK_PANEL_IDX), None);
         state.open_plate_menu(NETWORK_PANEL_IDX);
         assert_eq!(state.plate_menu_actions, vec![PlateMenuAction::Detach]);
         state.close_plate_menu();
@@ -1643,7 +1643,7 @@ mod tests {
             b.splitter_layout.splitter1_x);
         // The dock arrangement rides the file; the network is in none.
         assert_eq!(b.pane_in_dock(crate::app::Dock::Left), crate::slots::SPREADSHEET_IDX, "the spreadsheet loads where it was moved");
-        assert_eq!(b.tab_dock_of_pane(crate::slots::NETWORK_PANEL_IDX), None, "the network loads in no dock");
+        assert_eq!(b.dock_of_pane(crate::slots::NETWORK_PANEL_IDX), None, "the network loads in no dock");
 
         // A detached pane window must ignore the same file's pane state.
         let mut d = State::new(true);
@@ -2224,8 +2224,8 @@ mod tests {
         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), NO_PANE, "the dock the network fronted is empty");
         assert_eq!(b.pane_in_dock(Dock::Bottom), SPREADSHEET_IDX, "the rest of the arrangement loads");
-        assert_eq!(b.tab_dock_of_pane(PARAM_IDX), None);
-        assert_eq!(b.tab_dock_of_pane(crate::slots::NETWORK_PANEL_IDX), None);
+        assert_eq!(b.dock_of_pane(PARAM_IDX), None);
+        assert_eq!(b.dock_of_pane(crate::slots::NETWORK_PANEL_IDX), None);
         let _ = fs::remove_dir_all(&dir);
     }
 
diff --git a/src/menu_page.rs b/src/menu_page.rs
index e2b6399..6faa7c4 100644
--- a/src/menu_page.rs
+++ b/src/menu_page.rs
@@ -19,9 +19,9 @@
 //! content, as a horizontal list does: see `cce_ui::widget::side_swipe`.
 //!
 //! - A page of rows is shown by the menu that owns it (`put_up_menu` with
-//!   an `at`): the viewport menu's Style and Markers, the plate rows' Add
-//!   Tab — whose back band returns to whichever menu it was turned from,
-//!   the playbar menu or a stub's plate menu.
+//!   an `at`): the viewport menu's Style, Markers and Network, the
+//!   playbar menu's Plate. (The plate rows' Add Tab was one until docks
+//!   lost their tabs, 2026-10-07.)
 //! - The dialog turned to from a row (`State::dialog_from`) goes back to
 //!   that menu, shown again at the dialog's corner; inside the dialog, a
 //!   mode turned to from another (Group Markers from the palette, one
@@ -33,7 +33,7 @@
 
 use crate::app::{NetworkMenuAction, NodeMenuAction, PlaybarMenuAction, State, ViewportMenuAction};
 use crate::dialog::Mode;
-use crate::plate_menu::{plate_title, PlateMenuAction};
+use crate::plate_menu::plate_title;
 use crate::slots::{DIALOG_IDX, PLAYBAR_IDX};
 use cce_ui::widget::context_menu::{self, PageTurn};
 use cce_ui::widget::WidgetId;
@@ -51,16 +51,6 @@ pub enum MenuOrigin {
     Node(usize),
 }
 
-impl MenuOrigin {
-    /// The menu a plate's rows are part of: the playbar menu carries its
-    /// plate's rows, every other plate's are a menu alone.
-    pub fn of_plate(idx: usize) -> MenuOrigin {
-        match idx {
-            PLAYBAR_IDX => MenuOrigin::Playbar,
-            other => MenuOrigin::Plate(other),
-        }
-    }
-}
 
 impl ViewportMenuAction {
     /// Whether the row turns the menu: its pages, and the visualizers'
@@ -149,11 +139,7 @@ impl State {
             MenuOrigin::Viewport => self.show_viewport_menu_page(None, at),
             MenuOrigin::Network => self.open_network_context_menu_at(at),
             MenuOrigin::Playbar => self.open_playbar_context_menu_at(at),
-            // A plate PAGE of another menu goes back to being that page.
-            MenuOrigin::Plate(idx) => match self.plate_page_root.filter(|(i, _)| *i == idx) {
-                Some((_, root)) => self.open_plate_page(idx, (x, y), root),
-                None => self.open_plate_menu_at(idx, at),
-            },
+            MenuOrigin::Plate(idx) => self.open_plate_menu_at(idx, at),
             MenuOrigin::Node(slot) => self.open_node_context_menu_at(slot, at),
         }
     }
@@ -197,10 +183,6 @@ impl State {
                 }
                 MenuOrigin::Plate(_) if self.plate_page_from.is_some() => {
                     let from = self.plate_page_from.take().unwrap();
-                    // Back out of a plate page to its menu: the page is done.
-                    if !matches!(from, MenuOrigin::Plate(_)) {
-                        self.plate_page_root = None;
-                    }
                     self.close_plate_menu();
                     self.reopen_menu(from, at.0, at.1);
                 }
@@ -229,13 +211,9 @@ impl State {
                     }
                     _ => return false,
                 },
-                MenuOrigin::Plate(idx) => match self.plate_menu_actions.get(n).copied() {
-                    Some(PlateMenuAction::AddTabMenu) => {
-                        self.close_plate_menu();
-                        self.open_plate_add_tab_menu(idx, at, origin);
-                    }
-                    _ => return false,
-                },
+                // A plate menu has no page rows (Add Tab was one, until
+                // docks lost their tabs).
+                MenuOrigin::Plate(_) => return false,
                 MenuOrigin::Node(slot) => match self.node_menu_actions.get(n).copied() {
                     Some(NodeMenuAction::Rename) => {
                         self.close_node_menu();
diff --git a/src/plate_menu.rs b/src/plate_menu.rs
index 8ef947c..2beb45c 100644
--- a/src/plate_menu.rs
+++ b/src/plate_menu.rs
@@ -1,6 +1,5 @@
 //! 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.
+//! where it is docked — as rows of that pane's right-click menu.
 //!
 //! 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,
@@ -26,14 +25,6 @@ use cce_ui::widget::plate_dock::{self, PlateDockAction, PlateDockState};
 /// its "plate" is the window-spanning lip, not a pane.
 pub const PLATE_SLOTS: [usize; 4] = [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX, PLAYBAR_IDX];
 
-/// The panes the tab rows offer — the dockable set. The playbar's strip is
-/// not a dock. 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`); nor is the network (since 2026-10-07), which
-/// spans the window. The second network editor, the one closable pane, was
-/// removed the same day.
-pub const TAB_CANDIDATES: [usize; 1] = [SPREADSHEET_IDX];
-
 /// What the plate menu can do to its plate.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum PlateMenuAction {
@@ -49,18 +40,8 @@ pub enum PlateMenuAction {
     FullWidth,
     /// Spreadsheet: back to the strip between the network and params panes.
     BetweenPanes,
-    /// Bring this dock's named tab to the front.
-    ShowTab(usize),
-    /// Pull the named pane out of its dock and tab it into this one, active.
-    AddTab(usize),
-    /// Turn the menu into the Add Tab page — the list of panes that can be
-    /// pulled in ([`State::open_plate_add_tab_menu`]). A page row: its back
-    /// band, or a swipe back, returns to the menu it was turned from.
-    AddTabMenu,
-    /// 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.
+    /// Move this pane to another dock, swapping with what is there — what
+    /// dragging the plate's corner used to do.
     MoveTo(Dock),
     /// A "-" row: engraved, inert — keeps the action list aligned with the
     /// option rows so a click on the line dispatches nothing.
@@ -146,8 +127,7 @@ impl State {
         }
 
         // Group boundaries are engraved separators ("-" rows — the toolkit
-        // convention): window actions | layout spans | tab switching | tab
-        // management. Pushed lazily so a group that contributes nothing
+        // convention): window actions | layout spans | Move To. Pushed lazily so a group that contributes nothing
         // leaves no orphaned line.
         let separate = |options: &mut Vec<String>, actions: &mut Vec<PlateMenuAction>| {
             if !options.is_empty() && options.last().map(String::as_str) != Some("-") {
@@ -177,47 +157,16 @@ impl State {
             }
         }
 
-        // Tabs — only on docked plates (the playbar's strip is not a dock).
-        // The dock's other tabs switch to the front; panes docked elsewhere
-        // can be pulled in as tabs; a pane sharing its dock can move back
-        // out to the empty dock its arrival left behind.
+        // Move To — only on docked plates (the playbar's strip is not a
+        // dock): every other dock, swapping with what is there, the drag the
+        // corner trigger used to start, as rows. (Docks held TABS until
+        // 2026-10-07 — several panes to a dock, switched, added and split
+        // from here — retired when the spreadsheet became the one pane a
+        // dock could hold.)
         if let Some(d) = self.dock_of_pane(idx) {
-            // The dock's tabs as a RADIO group: every tab listed, the front
-            // one marked. Clicking the marked row is a no-op (show_dock_tab
-            // declines the already-active slot), so the list reads as state,
-            // not just as actions.
             separate(&mut options, &mut actions);
-            for &t in &self.dock_tabs[d as usize] {
-                let mark = if t == idx { cce_ui::widget::context_menu::MARK_ON } else { cce_ui::widget::context_menu::MARK_OFF };
-                options.push(format!("{mark}{}", plate_title(t)));
-                actions.push(PlateMenuAction::ShowTab(t));
-            }
-            let mut managed = false;
-            let mut manage_row = |options: &mut Vec<String>, actions: &mut Vec<PlateMenuAction>| {
-                if !managed {
-                    separate(options, actions);
-                    managed = true;
-                }
-            };
-            // ONE "Add Tab" row: clicking it swaps the menu for the page of
-            // addable panes, instead of one row per candidate here.
-            if !self.plate_add_tab_candidates(idx, d).is_empty() {
-                manage_row(&mut options, &mut actions);
-                options.push("Add Tab".to_string());
-                actions.push(PlateMenuAction::AddTabMenu);
-            }
-            // Only while a dock is free to take it: with none, the split
-            // has nowhere to go and the row would do nothing.
-            if self.dock_tabs[d as usize].len() > 1 && self.first_empty_dock().is_some() {
-                manage_row(&mut options, &mut actions);
-                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));
                 }
@@ -239,55 +188,15 @@ impl State {
         if options.is_empty() {
             return;
         }
-        // A menu of its own, not another menu's page.
-        self.plate_page_root = None;
         let target = self.slots.get_dyn(idx).base().id();
         self.put_up_menu(at, None, options, 0, target);
-        crate::menu_page::mark_page_rows(&actions, |a| a == PlateMenuAction::AddTabMenu);
         self.plate_menu_slot = Some(idx);
         self.plate_menu_actions = actions;
         self.plate_page_from = None;
     }
 
-    /// The panes a plate's Add Tab page can offer: docked (or dockable)
-    /// elsewhere, not already in this dock's list, not detached.
-    fn plate_add_tab_candidates(&self, idx: usize, d: crate::app::Dock) -> Vec<usize> {
-        TAB_CANDIDATES
-            .into_iter()
-            .filter(|&other| {
-                other != idx
-                    && !self.dock_tabs[d as usize].contains(&other)
-                    && !self.pane_is_detached(other)
-            })
-            .collect()
-    }
-
-    /// The Add Tab page: the menu `from` turned in place into the list of
-    /// addable panes, under a dimmed header row, its top-left at `at` —
-    /// where that menu stood — and a back band to it across the top.
-    pub fn open_plate_add_tab_menu(&mut self, idx: usize, at: (f32, f32), from: crate::menu_page::MenuOrigin) {
-        let Some(d) = self.dock_of_pane(idx) else { return };
-        let candidates = self.plate_add_tab_candidates(idx, d);
-        if candidates.is_empty() {
-            return;
-        }
-        let mut options = vec!["Add Tab".to_string()];
-        let mut actions = vec![PlateMenuAction::Separator];
-        for other in candidates {
-            options.push(plate_title(other).to_string());
-            actions.push(PlateMenuAction::AddTab(other));
-        }
-        let target = self.slots.get_dyn(idx).base().id();
-        self.put_up_menu(Some(at), Some(from), options, 1, target);
-        self.plate_menu_slot = Some(idx);
-        self.plate_menu_actions = actions;
-        self.plate_page_from = Some(from);
-    }
-
-    /// `idx`'s plate rows as a PAGE of another menu — the network's or the
-    /// playbar's Plate row turned into them — at `at`, under a back band to
-    /// `from`. Add Tab turned to from here comes back here, and the band
-    /// here goes back to `from` (`State::plate_page_root`).
+    /// `idx`'s plate rows as a PAGE of another menu — the playbar's Plate
+    /// row turned into them — at `at`, under a back band to `from`.
     pub fn open_plate_page(&mut self, idx: usize, at: (f32, f32), from: crate::menu_page::MenuOrigin) {
         let (options, actions) = self.plate_menu_rows(idx);
         if options.is_empty() {
@@ -295,11 +204,9 @@ impl State {
         }
         let target = self.slots.get_dyn(idx).base().id();
         self.put_up_menu(Some(at), Some(from), options, 0, target);
-        crate::menu_page::mark_page_rows(&actions, |a| a == PlateMenuAction::AddTabMenu);
         self.plate_menu_slot = Some(idx);
         self.plate_menu_actions = actions;
         self.plate_page_from = Some(from);
-        self.plate_page_root = Some((idx, from));
     }
 
     pub fn plate_menu_open(&self) -> bool {
@@ -322,10 +229,9 @@ 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.run_plate_menu_action(idx, action, at);
+                self.run_plate_menu_action(idx, action);
             }
             return true;
         }
@@ -333,10 +239,8 @@ impl State {
         false
     }
 
-    /// Run a plate row for `idx`, picked from a menu whose top-left was `at`
-    /// — where the Add Tab page goes, turned from the menu the plate's rows
-    /// are part of.
-    pub fn run_plate_menu_action(&mut self, idx: usize, action: PlateMenuAction, at: (f32, f32)) {
+    /// Run a plate row for `idx`.
+    pub fn run_plate_menu_action(&mut self, idx: usize, action: PlateMenuAction) {
         match action {
             PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
             PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
@@ -344,20 +248,6 @@ impl State {
             PlateMenuAction::Reattach => self.reattach_plate(idx),
             PlateMenuAction::FullWidth => self.set_spreadsheet_full_width(true),
             PlateMenuAction::BetweenPanes => self.set_spreadsheet_full_width(false),
-            PlateMenuAction::ShowTab(t) => {
-                if let Some(d) = self.dock_of_pane(idx) {
-                    self.show_dock_tab(d, t);
-                }
-            }
-            PlateMenuAction::AddTab(o) => {
-                if let Some(d) = self.dock_of_pane(idx) {
-                    self.add_dock_tab(d, o);
-                }
-            }
-            PlateMenuAction::AddTabMenu => {
-                self.open_plate_add_tab_menu(idx, at, crate::menu_page::MenuOrigin::of_plate(idx))
-            }
-            PlateMenuAction::SplitTab => self.split_dock_tab(idx),
             PlateMenuAction::MoveTo(d) => self.move_pane_to_dock(idx, d),
             PlateMenuAction::Separator => {}
         }
diff --git a/src/project.rs b/src/project.rs
index 086baf7..f929438 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -114,23 +114,13 @@ impl State {
         } else {
             None
         };
-        // Each dock's tabs by name, active first — the order the loader
-        // reads back (first = front).
+        // What each dock holds, by name: a list of at most one, the shape
+        // the save has kept since docks held tabs.
         let dock_tabs = (0..3)
             .map(|d| {
-                let active = self.dock_panes[d];
-                let mut names: Vec<String> = Vec::new();
-                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_menu::pane_name_from_slot(t) {
-                            names.push(n.to_string());
-                        }
-                    }
-                }
-                names
+                crate::plate_menu::pane_name_from_slot(self.dock_panes[d])
+                    .map(|n| vec![n.to_string()])
+                    .unwrap_or_default()
             })
             .collect();
         // A plate's stored size is what was asked for and may exceed a window
@@ -328,10 +318,11 @@ impl State {
                 .map_or(false, |n| vs.collapsed_panes.iter().any(|c| c == n));
             self.set_pane_collapsed(idx, desired);
         }
-        // Dock tab groups: accepted only whole — three lists whose names
-        // resolve and cover each CORE docked pane exactly once. Anything
-        // else (older saves' empty list included) keeps the current layout
-        // rather than loading half of one.
+        // What the docks hold: accepted only whole — three lists whose names
+        // resolve and cover each dockable pane exactly once. Anything else
+        // (older saves' empty list included) keeps the current layout rather
+        // than loading half of one. A list of an older save is a tab group,
+        // active first; its first dockable name is what the dock holds.
         if vs.dock_tabs.len() == 3 {
             let resolved: Vec<Vec<usize>> = vs
                 .dock_tabs
@@ -349,7 +340,7 @@ impl State {
             // Neither the params pane nor the network is docked since
             // 2026-10-06 (the one a HUD on the scene, the other an overlay
             // spanning it): an older save lists them in docks, and they are
-            // taken out — a dock one fronted fronts its next tab, or is
+            // taken out — a dock one fronted holds its next tab, or is
             // empty.
             let undocked = |s: usize| s == crate::slots::PARAM_IDX || s == crate::slots::NETWORK_PANEL_IDX;
             let resolved: Vec<Vec<usize>> = resolved
@@ -360,9 +351,7 @@ impl State {
             let expected = vec![crate::slots::SPREADSHEET_IDX];
             if all == expected {
                 for d in 0..3 {
-                    self.dock_tabs[d] = resolved[d].clone();
-                    self.dock_panes[d] =
-                        resolved[d].first().copied().unwrap_or(crate::app::NO_PANE);
+                    self.dock_panes[d] = resolved[d].first().copied().unwrap_or(crate::app::NO_PANE);
                 }
                 self.rebuild_positions();
                 self.apply_layout();