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

commit36c0ff522f3fd551f7552b1dc250339ed2ceb438
parent993ca82378
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 15:50
feat: menu rows that lead somewhere turn the menu, by a press or a side swipe

One kind of row for what was two: the viewport menu's Style and Markers
flew a submenu out on hover, while Add Node, Add Tab, Attribute
Visualizers and Rename swapped the menu for another plate on a click,
mostly with no way back. Every such row is now a page row (cce-ui's
set_row_page): it wears ›, and a press on it or a two-finger swipe to the
side over it turns the menu into what it names, with the new plate's
top-left where the menu's was. A swipe the other way turns back; a page of
rows also has a ‹ back band.

src/menu_page.rs holds the one dispatch (run_menu_turn), MenuOrigin and
reopen_menu. The dialog remembers the menu it was turned from
(dialog_from) and the modes it turned through (dialog_trail), so a swipe
back from one visualizer goes to the list, and from there to the viewport
menu. The palette's Group Markers / Attribute Visualizers and the
visualizer rows are page rows of the dialog too.

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

 CLAUDE.md         |  95 ++++++++++++++------
 src/app.rs        | 213 +++++++++++++++++++++++++-------------------
 src/dialog.rs     | 116 +++++++++++++++++++++++-
 src/main.rs       | 155 +++++++++++++++++++-------------
 src/menu_page.rs  | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/plate_menu.rs |  43 ++++-----
 6 files changed, 680 insertions(+), 202 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index eb7483b..ab5cd2c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -259,8 +259,10 @@ gone from cce-ui with the wgpu path).
   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
+  through `run_plate_menu_action`. Add Tab is a PAGE row (see "Page rows"
+  below): the menu turns into the list of panes where it stood, under a back
+  band to whichever menu it turned from — the network's, the playbar's, or the
+  plate menu alone (`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`
@@ -2618,15 +2620,63 @@ ON a node still opens that node's menu, which is the more specific thing under
 the pointer. Until 2026-09-22 the empty-space press opened the **add-node
 palette** outright, which left the network the one pane whose right-click was
 not a context menu, and left every other graph-wide command reachable only by
-chord or through the palette. **Add Node is the first row** instead, and picking
-it opens the same palette — ON THE MENU'S CORNER (since 2026-10-01): the menu
-transforms into the list, as the palette transforms into Group Markers, where
-it used to vanish for a plate centred across the window. `Dialog::anchor` holds
+chord or through the palette. **Add Node is the first row** instead, a PAGE
+row (see "Page rows" below): a press, or a side swipe forward over it, turns
+the menu into the same palette ON THE MENU'S CORNER (since 2026-10-01), and a
+swipe back turns the palette back into the menu. `Dialog::anchor` holds
 the corner and `dialog::layout_at` places the plate there, giving up height
 (down to `ANCHORED_MIN_H`) before it moves up and pulling in from the right
 edge; every other opening clears the anchor and centres, Tab's Add Node
 included.
 
+### Page rows: a menu turns into what a row names (since 2026-10-02)
+
+`src/menu_page.rs`. A row of a context menu that leads to another plate is a
+PAGE row (cce-ui's `context_menu::set_row_page`; see its CLAUDE.md, "A row
+can lead to a page"): it wears `›`, and a press on it, or a two-finger swipe
+to the side with the pointer on it, TURNS the menu into what it names with
+the new plate's top-left where the menu's was. A swipe the other way, from
+anywhere on the new plate, turns back; a page of rows also has a back band
+(`‹ Viewport`) for a press. Under natural scrolling forward is the fingers
+going LEFT, as the content goes (cce-ui's `side_swipe`). Until this the
+viewport menu's Style and Markers flew a second menu out on hover while the
+other rows below swapped the plate on a click — some with a Back row, most
+with no way back — two gestures for one idea.
+
+The page rows: the viewport menu's **Style** and **Markers** (pages of rows)
+and **Attribute Visualizers** (the dialog); 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
+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 and Attribute Visualizers, a visualizer and Add Visualizer.
+
+- **`State::run_menu_turn` is the one dispatch**, reached by a left press
+  (`press_menu_turn`, ahead of every menu's own click handler) and by a swipe
+  (`take_menu_turn`, from the wheel arm, which now routes the wheel to ANY
+  open menu, not only the slider menus). `MenuOrigin` names the menu a turn
+  came from; `reopen_menu` shows it again at a corner (each menu's opener
+  takes an `at`, through `put_up_menu`). The wheel arm first asks cce-ui's
+  `side_swipe::swallow`: what is left of a swipe that turned is dropped,
+  so the end of a swipe back from the wide Add Node list does not orbit
+  the scene the narrower menu uncovers.
+- **A page of the viewport menu stays up while its rows run**: a switch
+  flips and is re-marked in place (`refill_viewport_menu`, cce-ui's
+  `refill`), a slider is worked; a row of the menu itself runs and closes
+  it, as before.
+- **The dialog remembers where it was turned from**: `State::dialog_from`,
+  the menu (shown again at the dialog's corner by a swipe back), and
+  `State::dialog_trail`, the modes it turned through while up — a mode
+  opened while the dialog is up keeps the plate where it stands and puts
+  the mode it leaves on the trail, and turning to the trail's last (by a
+  swipe back, Escape out of a visualizer or its Back row) takes it off.
+  The palette's Group Markers and Attribute Visualizers rows now run with
+  the palette still up, so it is on the trail. Opened afresh, the dialog
+  has neither. A swipe back with neither does nothing.
+
+`the_viewport_menu_turns_into_its_pages_and_back` drives the viewport
+menu's pages, the back band and both swipes, into the dialog and back.
+
 Rows are `NETWORK_MENU_COMMANDS` — a list of COMMAND IDS, `None` for a
 separator — resolved through `command::by_id`, so a label is the registry's
 label and `NetworkMenuAction::Command(id)` dispatches through `run_command`.
@@ -2959,7 +3009,8 @@ as `step_buttons` in `get_state`'s playbar block.
 
 ### Display mode: the viewport menu, and smooth shading
 
-The viewport's right-click menu has two SUBMENUS (since 2026-09-29;
+The viewport's right-click menu has two PAGES (since 2026-09-29, as
+flyout submenus until 2026-10-02 — see "Page rows";
 until then it was one list of some twenty rows). The menu itself holds
 what is done — Frame All, View 1:1 — the guides (Show Grid, Show Origin,
 and since 2026-09-29 Show Camera Pivot with a Camera Pivot Size slider
@@ -2971,7 +3022,7 @@ the reference CUBE guide was removed on 2026-09-25 — its command, mesh,
 RT-scene copy, settings field and menubar item, with the Guides menubar
 addressed through `GUIDES_MENU` / `GUIDE_*` so no item slid onto another's
 action, while old files carrying `show_cube_enabled` still load), and a
-row for each submenu: **Style** (how the geometry is drawn: the
+row for each page: **Style** (how the geometry is drawn: the
 wireframe's switch, thickness and opacity, then the surface's shading,
 opacity and Show Occluded) and **Markers** (what is drawn on it: Group
 Marker Size and Pull Arrow Scale; then the
@@ -2979,25 +3030,15 @@ overlays a class at a time — Show Point Markers and its size, Show Point
 Numbers, Show Point Normals; Show Primitive Numbers, Show Primitive
 Normals; Show Vertex Markers, Show Vertex Numbers, Show Vertex Normals).
 
-**The submenus are cce-ui's** (`context_menu::set_row_submenu`, see its
-CLAUDE.md, "A row can open a submenu"): a second menu in its own popup
-that flies out beside the row under the pointer, which the toolkit opens,
-closes and places. What this app does is fill and dispatch.
+**The pages are turned to in place** (see "Page rows"):
 `viewport_menu_rows_of(page)` is the rows of the menu (`None`) or of a
-submenu; `fill_viewport_submenus` hands each `ViewportMenuAction::Submenu`
-row its rows and sliders, read from the live state, at the open and again
-after anything a submenu row did — the toolkit changes an open submenu
-where it stands, which is how a mark follows its switch — and
-`open_viewport_submenu_actions` is the open submenu's actions, by
-`submenu::parent_row`. **A row of a submenu keeps both menus up**: a
-submenu is a panel of settings, opened to set several, so they close on a
-press outside or Escape; a row of the menu itself closes it as before.
-The slider hooks are unchanged, the toolkit's pointer calls answering for
-both menus; `drain_viewport_menu_slider` drains the menu's sliders and
-then the submenu's. For one day the two were PAGES of the one popup,
-entered by a row and left by a Back row, because the toolkit had no
-submenu. `the_viewport_menu_flies_its_settings_out_beside_it` drives it
-by pointer.
+page, `show_viewport_menu_page` puts either up — at the pointer, or at the
+corner of the plate it replaces with a back band to the menu — and
+`State::viewport_menu_page` says which is up. `viewport_menu_actions` is
+always the shown rows' actions, so the slider hooks and
+`drain_viewport_menu_slider` need nothing per page. History: on
+2026-09-29 the two were pages of the one popup with a Back row, then the
+same day flyout submenus (cce-ui's, retired with this), then pages again.
 
 **The primitive and vertex overlays** (`toggle_prim_numbers`,
 `toggle_prim_normals`, `toggle_vertex_numbers`, the same day) are
@@ -3236,7 +3277,7 @@ the later over the earlier, as a chain of Visualize nodes composites.
   display settings.
 - **They are edited in the dialog**, two modes: `Mode::Visualizers` (the
   `attribute_visualizers` command — the palette, and a row of the viewport
-  menu under its submenus) lists them, a switch each, and Add Visualizer;
+  menu under its pages, a page row turning the menu into the list) lists them, a switch each, and Add Visualizer;
   a press on a row's SWITCH turns it on or off and a press on the rest of
   the row, or Enter, opens it (`Dialog::activated_on_control`,
   `take_dialog_pick_at`). `Mode::VisualizerEdit` (`State::vis_editing`) is
diff --git a/src/app.rs b/src/app.rs
index 19d15e4..7af2480 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -442,8 +442,8 @@ pub const GUIDE_GRID: usize = 0;
 pub const GUIDE_ORIGIN: usize = 1;
 pub const GUIDE_CAMERA_PIVOT: usize = 2;
 
-/// A submenu of the viewport menu: a second menu that flies out beside its
-/// row (cce-ui's `context_menu::SubmenuSpec`).
+/// A page of the viewport menu: a row of the menu that the menu turns into
+/// (see `crate::menu_page`), with a way back to the menu at its top.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ViewportMenuPage {
     /// How the geometry itself is drawn: the wireframe and the surface.
@@ -464,8 +464,8 @@ impl ViewportMenuPage {
 
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub enum ViewportMenuAction {
-    /// A row that opens a submenu beside it. The menu stays open.
-    Submenu(ViewportMenuPage),
+    /// A row that turns the menu into one of its pages.
+    Page(ViewportMenuPage),
     /// Move the active camera so the visible node geometry fills the view.
     FrameAll,
     /// Put the pivot plane at true size: one world unit (the Guides "World
@@ -2613,6 +2613,18 @@ pub struct State {
     /// machinery as the node menu; this flag says the open menu is OURS).
     pub viewport_menu_active: bool,
     pub viewport_menu_actions: Vec<ViewportMenuAction>,
+    /// Which page of the viewport menu is up: `None` for the menu itself.
+    pub viewport_menu_page: Option<ViewportMenuPage>,
+    /// The menu the dialog was turned to from by a page row (Add Node,
+    /// Rename, Attribute Visualizers), which a swipe back from it shows
+    /// again — see `crate::menu_page`.
+    pub dialog_from: Option<crate::menu_page::MenuOrigin>,
+    /// The dialog's own modes it was turned through on the way to the one
+    /// 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.
+    pub plate_page_from: Option<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.
@@ -5399,12 +5411,18 @@ impl State {
     }
 
     fn open_node_context_menu(&mut self, slot: usize) {
+        self.open_node_context_menu_at(slot, None);
+    }
+
+    /// A node's menu at the pointer, or with its top-left at `at`.
+    pub(crate) fn open_node_context_menu_at(&mut self, slot: usize, at: Option<(f32, f32)>) {
         let (options, actions) = self.node_menu_rows(slot);
         if options.is_empty() {
             return;
         }
         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);
+        self.put_up_menu(at, None, options, 0, target);
+        crate::menu_page::mark_page_rows(&actions, |a| a == NodeMenuAction::Rename);
         self.node_menu_slot = Some(slot);
         self.node_menu_actions = actions;
     }
@@ -5413,7 +5431,7 @@ impl State {
         cce_ui::widget::context_menu::is_visible() && self.node_menu_slot.is_some()
     }
 
-    fn close_node_menu(&mut self) {
+    pub(crate) fn close_node_menu(&mut self) {
         cce_ui::widget::context_menu::hide();
         self.node_menu_slot = None;
         self.node_menu_actions.clear();
@@ -5929,9 +5947,15 @@ impl State {
     }
 
     pub(crate) fn open_playbar_context_menu(&mut self) {
+        self.open_playbar_context_menu_at(None);
+    }
+
+    /// The playbar's menu at the pointer, or with its top-left at `at`.
+    pub(crate) fn open_playbar_context_menu_at(&mut self, at: Option<(f32, f32)>) {
         let (options, actions) = self.playbar_menu_rows();
         let target = self.slots.get_dyn(PLAYBAR_IDX).base().id();
-        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+        self.put_up_menu(at, None, options, 0, target);
+        crate::menu_page::mark_page_rows(&actions, |a| a == PlaybarMenuAction::Plate(crate::plate_menu::PlateMenuAction::AddTabMenu));
         for (i, a) in actions.iter().enumerate() {
             if let Some(slider) = self.playbar_menu_slider(*a) {
                 cce_ui::widget::context_menu::set_row_slider(i, slider);
@@ -5945,7 +5969,7 @@ impl State {
         cce_ui::widget::context_menu::is_visible() && self.playbar_menu_active
     }
 
-    fn close_playbar_menu(&mut self) {
+    pub(crate) fn close_playbar_menu(&mut self) {
         cce_ui::widget::context_menu::hide();
         self.playbar_menu_active = false;
         self.playbar_menu_actions.clear();
@@ -6146,39 +6170,39 @@ impl State {
 
     /// Open the viewport right-click context menu at the cursor.
     pub(crate) fn open_viewport_context_menu(&mut self) {
-        let (options, actions) = self.viewport_menu_rows();
+        self.show_viewport_menu_page(None, None);
+    }
+
+    /// Put up the viewport menu (`None`) or one of its pages: at the
+    /// pointer, or with its top-left at `at` in place of the plate that
+    /// stood there — a page under a back band to the menu.
+    pub(crate) fn show_viewport_menu_page(&mut self, page: Option<ViewportMenuPage>, at: Option<(f32, f32)>) {
+        let (options, actions) = self.viewport_menu_rows_of(page);
         let target = self.slots.viewport.id();
-        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+        let back = page.map(|_| crate::menu_page::MenuOrigin::Viewport);
+        self.put_up_menu(at, back, options, 0, target);
         for (i, a) in actions.iter().enumerate() {
             if let Some(slider) = self.viewport_menu_slider(*a) {
                 cce_ui::widget::context_menu::set_row_slider(i, slider);
             }
         }
+        crate::menu_page::mark_page_rows(&actions, |a| a.leads_to_page());
         self.viewport_menu_actions = actions;
-        self.fill_viewport_submenus();
+        self.viewport_menu_page = page;
         self.viewport_menu_active = true;
     }
 
-    /// Give each submenu row of the open viewport menu its submenu, read
-    /// from the live state: at the open, and again after anything a row of
-    /// one did, which is how its marks and readouts follow. The menu changes
-    /// an open submenu where it stands.
-    pub(crate) fn fill_viewport_submenus(&self) {
-        use cce_ui::widget::context_menu::{self, SubmenuSpec};
-        for (i, a) in self.viewport_menu_actions.iter().enumerate() {
-            let ViewportMenuAction::Submenu(page) = *a else { continue };
-            let (options, actions) = self.viewport_menu_rows_of(Some(page));
-            let sliders = actions.iter().map(|a| self.viewport_menu_slider(*a)).collect();
-            context_menu::set_row_submenu(i, SubmenuSpec { options, header_count: 0, sliders });
-        }
-    }
-
-    /// The rows' actions of the submenu that is open, if one is.
-    pub(crate) fn open_viewport_submenu_actions(&self) -> Option<Vec<ViewportMenuAction>> {
-        let row = cce_ui::widget::context_menu::submenu::parent_row()?;
-        match self.viewport_menu_actions.get(row)? {
-            ViewportMenuAction::Submenu(page) => Some(self.viewport_menu_rows_of(Some(*page)).1),
-            _ => None,
+    /// Re-read the shown viewport menu page's labels and slider values in
+    /// place, after a row of it ran and it stays up: how a switch's mark
+    /// follows the switch.
+    pub(crate) fn refill_viewport_menu(&mut self) {
+        let (options, actions) = self.viewport_menu_rows_of(self.viewport_menu_page);
+        let sliders: Vec<_> = actions.iter().map(|a| self.viewport_menu_slider(*a)).collect();
+        if cce_ui::widget::context_menu::refill(options, &sliders) {
+            self.viewport_menu_actions = actions;
+        } else {
+            let at = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
+            self.show_viewport_menu_page(self.viewport_menu_page, Some(at));
         }
     }
 
@@ -6348,13 +6372,8 @@ impl State {
     /// drag does once, on the release.
     pub(crate) fn drain_viewport_menu_slider(&mut self, persist: bool) -> bool {
         use cce_ui::widget::context_menu;
-        // The menu's own sliders, then those of its open submenu.
         let changed = context_menu::take_slider_change()
-            .and_then(|(idx, v)| Some((self.viewport_menu_actions.get(idx).copied()?, v)))
-            .or_else(|| {
-                let (idx, v) = context_menu::submenu::take_slider_change()?;
-                Some((self.open_viewport_submenu_actions()?.get(idx).copied()?, v))
-            });
+            .and_then(|(idx, v)| Some((self.viewport_menu_actions.get(idx).copied()?, v)));
         let Some((action, v)) = changed else {
             if persist {
                 self.save_settings();
@@ -6364,8 +6383,6 @@ impl State {
         {
             if self.viewport_menu_slider(action).is_some() {
                 self.land_viewport_menu_slider(action, v);
-                // What a submenu is opened with next time is what is so now.
-                self.fill_viewport_submenus();
                 if persist {
                     self.save_settings();
                 }
@@ -6390,13 +6407,14 @@ impl State {
         self.viewport_menu_rows_of(None)
     }
 
-    /// Open the submenu of the open viewport menu that holds `action`, as a
-    /// press on its row does, and hand back that submenu's actions.
+    /// Turn the open viewport menu to the page that holds `action`, as a
+    /// press on its row does, and hand back that page's actions.
     #[cfg(test)]
-    pub(crate) fn open_viewport_submenu_with(&mut self, action: ViewportMenuAction) -> Vec<ViewportMenuAction> {
-        let page = self.viewport_menu_page_of(action).expect("a submenu holds the row");
-        self.run_viewport_menu_action(ViewportMenuAction::Submenu(page));
-        self.open_viewport_submenu_actions().expect("the submenu opened")
+    pub(crate) fn open_viewport_page_with(&mut self, action: ViewportMenuAction) -> Vec<ViewportMenuAction> {
+        let page = self.viewport_menu_page_of(action).expect("a page holds the row");
+        self.run_viewport_menu_action(ViewportMenuAction::Page(page));
+        assert_eq!(self.viewport_menu_page, Some(page), "the menu turned");
+        self.viewport_menu_actions.clone()
     }
 
     /// The page of the viewport menu that holds `action`, `None` for the
@@ -6409,10 +6427,10 @@ impl State {
             .find(|page| self.viewport_menu_rows_of(Some(*page)).1.contains(&action))
     }
 
-    /// The rows of the viewport menu (`None`) or of one of its submenus.
+    /// The rows of the viewport menu (`None`) or of one of its pages.
     /// The menu holds what is DONE (framing), the guides, and a row for each
-    /// submenu; the STYLE submenu holds how the geometry is drawn
-    /// (wireframe, then surface) and the MARKERS submenu what is drawn on
+    /// page; the STYLE page holds how the geometry is drawn
+    /// (wireframe, then surface) and the MARKERS page what is drawn on
     /// it (the points, then the overlays of each element class).
     pub(crate) fn viewport_menu_rows_of(&self, page: Option<ViewportMenuPage>) -> (Vec<String>, Vec<ViewportMenuAction>) {
         let mut options: Vec<String> = Vec::new();
@@ -6486,13 +6504,13 @@ impl State {
         toggle(&mut options, &mut actions, "toggle_camera_pivot");
         row(&mut options, &mut actions, "Camera Pivot Size".into(), ViewportMenuAction::CameraPivotSizeSlider);
 
-        // The display settings, a submenu each; the menu marks the rows.
+        // The display settings, a page each, and the visualizers' editor,
+        // which is the dialog: three rows the menu turns into what they
+        // name.
         row(&mut options, &mut actions, "-".into(), sep);
         for page in [ViewportMenuPage::Style, ViewportMenuPage::Markers] {
-            row(&mut options, &mut actions, page.label().into(), ViewportMenuAction::Submenu(page));
+            row(&mut options, &mut actions, page.label().into(), ViewportMenuAction::Page(page));
         }
-        // The visualizers' editor, which is the dialog — a list is not a
-        // submenu's shape.
         row(
             &mut options,
             &mut actions,
@@ -6532,12 +6550,10 @@ impl State {
             ViewportMenuAction::OneToOne => {
                 self.view_one_to_one();
             }
-            // The menu opens a submenu under the pointer by itself; a press
-            // on the row opens it for a pointer that has not moved.
-            ViewportMenuAction::Submenu(_) => {
-                if let Some(i) = self.viewport_menu_actions.iter().position(|a| *a == action) {
-                    cce_ui::widget::context_menu::open_submenu(i);
-                }
+            // The menu turns into the page where it stands.
+            ViewportMenuAction::Page(page) => {
+                let at = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
+                self.show_viewport_menu_page(Some(page), Some(at));
             }
             ViewportMenuAction::PinFollow => {
                 self.viewport_pin = None;
@@ -6571,10 +6587,11 @@ impl State {
         cce_ui::widget::context_menu::is_visible() && self.viewport_menu_active
     }
 
-    fn close_viewport_menu(&mut self) {
+    pub(crate) fn close_viewport_menu(&mut self) {
         cce_ui::widget::context_menu::hide();
         self.viewport_menu_active = false;
         self.viewport_menu_actions.clear();
+        self.viewport_menu_page = None;
     }
 
     /// Route a left press while the viewport menu is open — same contract as
@@ -6590,30 +6607,25 @@ impl State {
             return true;
         }
         use cce_ui::widget::context_menu;
-        // A row of the open submenu runs and the menu stays up: a submenu
-        // is a panel of settings, opened to set several, and a switch that
-        // closed the menu would cost a right-click and a row per setting.
-        // It is filled again for its marks.
-        if context_menu::submenu::hit_test(self.cursor_x, self.cursor_y) {
-            let picked = context_menu::submenu::row_at(self.cursor_x, self.cursor_y)
-                .and_then(|i| self.open_viewport_submenu_actions()?.get(i).copied());
-            if let Some(action) = picked {
-                self.run_viewport_menu_action(action);
-                self.fill_viewport_submenus();
-            }
-            return true;
-        }
         if context_menu::hit_test(self.cursor_x, self.cursor_y) {
             let idx = context_menu::row_at(self.cursor_x, self.cursor_y);
             let picked = idx.and_then(|i| self.viewport_menu_actions.get(i).copied());
-            match picked {
-                Some(a @ ViewportMenuAction::Submenu(_)) => self.run_viewport_menu_action(a),
-                picked => {
-                    self.close_viewport_menu();
-                    if let Some(action) = picked {
-                        self.run_viewport_menu_action(action);
+            // A row of a PAGE runs and the page stays up, re-marked: a page
+            // is a panel of settings, opened to set several, and a switch
+            // that closed it would cost a right-click and a turn per
+            // setting. A row of the menu itself runs and closes it.
+            if self.viewport_menu_page.is_some() {
+                if let Some(action) = picked {
+                    self.run_viewport_menu_action(action);
+                    if self.viewport_menu_open() {
+                        self.refill_viewport_menu();
                     }
                 }
+                return true;
+            }
+            self.close_viewport_menu();
+            if let Some(action) = picked {
+                self.run_viewport_menu_action(action);
             }
             return true;
         }
@@ -6636,6 +6648,12 @@ impl State {
     /// the viewport menu's radio rows use, read through
     /// `command_toggle_state` — the one table the dialog's switches read too.
     fn open_network_context_menu(&mut self) {
+        self.open_network_context_menu_at(None);
+    }
+
+    /// The network menu at the pointer, or with its top-left at `at` in
+    /// place of a page it is turned back to from.
+    pub(crate) fn open_network_context_menu_at(&mut self, at: Option<(f32, f32)>) {
         let mut options: Vec<String> = Vec::new();
         let mut actions: Vec<NetworkMenuAction> = Vec::new();
         for entry in NETWORK_MENU_COMMANDS {
@@ -6671,7 +6689,8 @@ impl State {
         }
 
         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);
+        self.put_up_menu(at, None, options, 0, target);
+        crate::menu_page::mark_page_rows(&actions, |a| a.leads_to_page());
         self.network_menu_active = true;
         self.network_menu_actions = actions;
     }
@@ -6680,7 +6699,7 @@ impl State {
         cce_ui::widget::context_menu::is_visible() && self.network_menu_active
     }
 
-    fn close_network_menu(&mut self) {
+    pub(crate) fn close_network_menu(&mut self) {
         cce_ui::widget::context_menu::hide();
         self.network_menu_active = false;
         self.network_menu_actions.clear();
@@ -6697,13 +6716,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.network_menu_actions.get(i).copied());
-            // Where the menu stands, read before it is hidden: Add Node
-            // opens its list on the menu's corner, so the menu turns into
-            // the list rather than vanishing for one across the window.
+            // Where the menu stands, read before it is hidden. The rows
+            // that turn the menu (Add Node, Add Tab) were taken by the
+            // press's page turn ahead of this.
             let corner = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
             self.close_network_menu();
             match picked {
-                Some(NetworkMenuAction::Command("add_node")) => self.open_node_palette_at(corner.0, corner.1),
                 Some(NetworkMenuAction::Command(id)) => {
                     self.run_command(id);
                 }
@@ -7820,6 +7838,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             node_menu_actions: Vec::new(),
             viewport_menu_active: false,
             viewport_menu_actions: Vec::new(),
+            viewport_menu_page: None,
+            dialog_from: None,
+            dialog_trail: Vec::new(),
+            plate_page_from: None,
             param_menu_active: false,
             param_menu_actions: Vec::new(),
             playbar_menu_active: false,
@@ -10087,6 +10109,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
         match event {
             WindowEvent::MouseWheel { delta } => {
+                // What is left of a swipe that turned a menu or the dialog is
+                // the turn's, not the scene's or a list's under the new plate.
+                if cce_ui::widget::side_swipe::swallow(delta) {
+                    return true;
+                }
                 // The dialog is modal: a wheel over it scrolls it, and a wheel
                 // anywhere else does nothing rather than scrolling — and
                 // focusing — the pane it is covering.
@@ -10098,13 +10125,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 if self.dialog_visible() {
                     return self.dialog_mouse_wheel(*delta);
                 }
-                // Over an open viewport menu the wheel is the menu's: a
-                // slider row steps, and nothing scrolls or orbits beneath.
-                if self.slider_menu_open()
+                // Over an open menu the wheel is the menu's: a side swipe
+                // turns it (`crate::menu_page`), a slider row steps, and
+                // nothing scrolls or orbits beneath.
+                if self.open_menu_origin().is_some()
                     && cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y)
                 {
                     if cce_ui::widget::context_menu::mouse_wheel(delta, self.cursor_x, self.cursor_y) {
-                        self.drain_menu_slider(true);
+                        if !self.take_menu_turn() && self.slider_menu_open() {
+                            self.drain_menu_slider(true);
+                        }
                     }
                     return true;
                 }
@@ -10307,7 +10337,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                 // Track hover on the node/viewport/network context menus so
                 // the highlight follows.
-                if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open() || self.playbar_menu_open())
+                if self.open_menu_origin().is_some()
                     && cce_ui::widget::context_menu::cursor_moved(self.cursor_x, self.cursor_y)
                 {
                     changed = true;
@@ -10636,6 +10666,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                 match btn_state {
                     ElementState::Pressed => {
+                        // A left press on a page row or a back band of any
+                        // open menu turns it, ahead of the menus' own rows.
+                        if *button == MouseButton::Left && self.press_menu_turn() {
+                            return true;
+                        }
                         // The node context menu takes the first shot at a
                         // press: a left click on it runs the item; any other
                         // press (or a left click outside) dismisses it and
diff --git a/src/dialog.rs b/src/dialog.rs
index 707db07..7eb40d4 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -524,6 +524,11 @@ impl Dialog {
         Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
     }
 
+    /// The row under `(x, y)` in a dialog laid out on `rect`.
+    pub fn row_index_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
+        self.row_at(rect, x, y)
+    }
+
     fn row_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
         let list = list_rect(rect);
         if x < list.x || x >= list.x + list.width || y < list.y || y >= list.y + list.height {
@@ -1538,6 +1543,21 @@ pub const SETTINGS: &[Setting] = &[
     Setting::field("Origin Size", "origin_size", Ctl::Spin { min: 1.0, max: 50.0, unit: 10.0 }),
 ];
 
+/// The page mark, as a menu's page row wears it.
+use cce_ui::widget::context_menu::PAGE_MARK;
+
+/// Whether a row of the dialog in `mode` turns it into another list: the
+/// palette's Group Markers and Attribute Visualizers, a visualizer of the
+/// list and Add Visualizer. A press runs it, as before; a side swipe forward
+/// with the pointer on it does too.
+pub fn dialog_row_leads(mode: Mode, id: &str) -> bool {
+    match mode {
+        Mode::Commands => matches!(id, "group_markers" | "attribute_visualizers"),
+        Mode::Visualizers => id.starts_with(VIS_ROW_PREFIX),
+        _ => false,
+    }
+}
+
 impl State {
     pub fn dialog_visible(&self) -> bool {
         self.slots.dialog.visible()
@@ -1580,7 +1600,29 @@ impl State {
     /// is what makes it read as the same plate grown, not a second one
     /// arriving across the window.
     pub fn open_node_palette_at(&mut self, x: f32, y: f32) {
-        self.open_dialog_anchored(Mode::AddNode, Some((x, y)));
+        self.open_dialog_from(Mode::AddNode, crate::menu_page::MenuOrigin::Network, (x, y));
+    }
+
+    /// The dialog in `mode`, turned to from the menu `origin` by one of its
+    /// page rows: its top-left where the menu's was, and a swipe back shows
+    /// the menu again (see `crate::menu_page`).
+    pub fn open_dialog_from(&mut self, mode: Mode, origin: crate::menu_page::MenuOrigin, at: (f32, f32)) {
+        self.close_dialog();
+        self.open_dialog_anchored(mode, Some(at));
+        self.dialog_from = Some(origin);
+    }
+
+    /// Turn the open dialog to `mode`, as the row or key that leads there
+    /// does.
+    pub(crate) fn open_dialog_mode(&mut self, mode: Mode) {
+        match mode {
+            Mode::Visualizers => self.open_visualizers_dialog(),
+            Mode::VisualizerEdit => match self.vis_editing {
+                Some(i) => self.open_visualizer_editor(i),
+                None => self.open_visualizers_dialog(),
+            },
+            other => self.open_dialog_in(other),
+        }
     }
 
     /// Wire the node just added (the level's last) into the wire from the
@@ -1603,12 +1645,21 @@ impl State {
     /// The query line is the name: it opens holding the one the node has,
     /// so a rename that changes a letter is a letter typed.
     pub fn open_rename_dialog(&mut self, slot: usize) {
+        self.open_rename_dialog_from(slot, None);
+    }
+
+    /// [`Self::open_rename_dialog`], turned to from a menu's Rename row
+    /// standing at a corner, when `from` says so.
+    pub fn open_rename_dialog_from(&mut self, slot: usize, from: Option<(crate::menu_page::MenuOrigin, (f32, f32))>) {
         let Some((id, name)) = self.current_dir().children.get(slot).map(|n| (n.id.clone(), n.name.clone())) else {
             self.update_status_text("Select a node to rename.");
             return;
         };
         self.rename_target = Some(id);
-        self.open_dialog_in(Mode::Rename);
+        match from {
+            Some((origin, at)) => self.open_dialog_from(Mode::Rename, origin, at),
+            None => self.open_dialog_in(Mode::Rename),
+        }
         self.slots.dialog.query = name;
         self.refresh_dialog_rows();
     }
@@ -1663,11 +1714,30 @@ impl State {
     }
 
     fn open_dialog_anchored(&mut self, mode: Mode, anchor: Option<(f32, f32)>) {
+        // A dialog that is up and turns to another mode stays where it
+        // stands, turned to from where it was: the mode it leaves goes on
+        // the trail a swipe back follows — or, when the mode it turns to IS
+        // the trail's last, it is going back, and comes off. Opened afresh
+        // it has neither, and whoever opened it from a menu says so after.
+        let was = self.dialog_visible().then_some(self.slots.dialog.mode);
+        match was {
+            Some(prev) if anchor.is_none() => {
+                if self.dialog_trail.last() == Some(&mode) {
+                    self.dialog_trail.pop();
+                } else if prev != mode {
+                    self.dialog_trail.push(prev);
+                }
+            }
+            _ => {
+                self.slots.dialog.anchor = anchor;
+                self.dialog_trail.clear();
+                self.dialog_from = None;
+            }
+        }
         // Always with an empty query: a dialog that reopens holding the last
         // search has to be cleared before it can be used, which is a step
         // every single time to save one occasionally.
         self.slots.dialog.mode = mode;
-        self.slots.dialog.anchor = anchor;
         self.slots.dialog.query.clear();
         self.slots.dialog.set_visible(true);
         self.refresh_dialog_rows();
@@ -1696,6 +1766,8 @@ impl State {
         self.slots.dialog.dropdown_row = None;
         self.slots.dialog.dropdown_armed = false;
         self.slots.dialog.set_visible(false);
+        self.dialog_from = None;
+        self.dialog_trail.clear();
         if self.focused_widget == Some(DIALOG_IDX) {
             self.focused_widget = None;
         }
@@ -1889,6 +1961,18 @@ impl State {
                     .collect()
             }
         };
+        // A row that turns the dialog into another list says so where a
+        // menu's page row does, at its right end.
+        let mode = self.slots.dialog.mode;
+        let rows = rows
+            .into_iter()
+            .map(|mut r| {
+                if dialog_row_leads(mode, &r.id) {
+                    r.chord = if r.chord.is_empty() { PAGE_MARK.to_string() } else { format!("{}  {PAGE_MARK}", r.chord) };
+                }
+                r
+            })
+            .collect();
         self.slots.dialog.set_rows(rows);
     }
 
@@ -2784,6 +2868,12 @@ impl State {
                 return;
             }
         }
+        // A row that turns the palette into another list does so in place,
+        // the palette left on the trail for a swipe back.
+        if mode == Mode::Commands && dialog_row_leads(mode, &id) {
+            self.run_command(&id);
+            return;
+        }
         if mode == Mode::Commands && self.command_toggle_state(&id).is_some() {
             self.run_command(&id);
             self.refresh_dialog_controls();
@@ -2944,6 +3034,26 @@ impl State {
         let (x, y) = (self.cursor_x, self.cursor_y);
         let ev = cce_ui::widget::Event::MouseWheel { delta, x, y, local_x: x, local_y: y };
         if self.in_dialog_slot(DIALOG_IDX, x, y) {
+            // A side swipe turns the dialog: into what the row under the
+            // pointer leads to, or back to where it was turned from.
+            match cce_ui::widget::side_swipe::feed(&delta) {
+                Some(cce_ui::widget::SwipeDir::Forward) => {
+                    let (rx, ry, rw, rh) = self.positions[DIALOG_IDX];
+                    let rect = Rect { x: rx, y: ry, width: rw, height: rh };
+                    let mode = self.slots.dialog.mode;
+                    let row = self.slots.dialog.row_index_at(rect, x, y).and_then(|i| self.slots.dialog.rows.get(i)).map(|r| r.id.clone());
+                    if let Some(id) = row.filter(|id| dialog_row_leads(mode, id)) {
+                        self.take_dialog_pick(id);
+                        return true;
+                    }
+                }
+                Some(cce_ui::widget::SwipeDir::Back) => {
+                    if self.dialog_back() {
+                        return true;
+                    }
+                }
+                None => {}
+            }
             let taken = self.dispatch_uncovered(DIALOG_IDX, &ev);
             // A wheel over a slider row moved it: land the value.
             self.drain_dialog_clicks();
diff --git a/src/main.rs b/src/main.rs
index 3db5b9f..dbdf75d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,6 +23,7 @@ pub mod collide;
 #[allow(unused_imports)]
 use app::{CustomEvent, McpAction, ModifiersState};
 pub mod plate_menu;
+pub mod menu_page;
 pub mod visualizer;
 pub mod playbar;
 pub mod viewport_3d;
@@ -2220,15 +2221,15 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let sub_actions = state.open_viewport_submenu_with(A::OpacitySlider);
+        let sub_actions = state.open_viewport_page_with(A::OpacitySlider);
         assert!(state.viewport_menu_open());
         let i = sub_actions.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
-        let s = context_menu::submenu::slider(i).expect("the row is a slider");
+        let s = context_menu::slider(i).expect("the row is a slider");
         assert_eq!((s.value, s.min, s.max, s.step), (50.0, 0.0, 100.0, 5.0));
 
         // Wheel over the row: one notch up is 5% more, saved, menu still up.
-        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
-        state.cursor_x = context_menu::submenu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::x() + 20.0;
         let wheel = |state: &mut State, notches: f32| {
             state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, notches) })
         };
@@ -2241,7 +2242,7 @@ mod tests {
         assert!((state.geo_opacity - 0.40).abs() < 1e-6);
 
         // Press on the band's right end: 100%, live; drag back; release.
-        let band = context_menu::SUBMENU.with(|m| m.borrow().slider_band(i));
+        let band = context_menu::CONTEXT_MENU.with(|m| m.borrow().slider_band(i));
         state.cursor_x = band.x + band.width - 1.0;
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
         assert!((state.geo_opacity - 1.0).abs() < 1e-6, "{}", state.geo_opacity);
@@ -2311,20 +2312,20 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let sub_actions = state.open_viewport_submenu_with(A::WireOpacitySlider);
+        let sub_actions = state.open_viewport_page_with(A::WireOpacitySlider);
         let acts = sub_actions.clone();
         let i = acts.iter().position(|a| *a == A::WireOpacitySlider).expect("a Wire Opacity row");
-        let sl = context_menu::submenu::slider(i).expect("the row is a slider");
+        let sl = context_menu::slider(i).expect("the row is a slider");
         assert_eq!((sl.value, sl.min, sl.max, sl.step), (50.0, 0.0, 100.0, 5.0));
 
-        state.cursor_x = context_menu::submenu::x() + 20.0;
-        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, -2.0) });
         assert!((state.wire_opacity - 0.40).abs() < 1e-6, "{}", state.wire_opacity);
         assert!((state.geo_opacity - 0.5).abs() < 1e-6, "the polygon opacity moved with the wires'");
 
         let j = acts.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
-        state.cursor_y = context_menu::submenu::row_y(j) + context_menu::ROW_H * 0.5;
+        state.cursor_y = context_menu::row_y(j) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
         assert!((state.geo_opacity - 0.60).abs() < 1e-6, "{}", state.geo_opacity);
         assert!((state.wire_opacity - 0.40).abs() < 1e-6, "the wires' opacity moved with the polygons'");
@@ -2374,7 +2375,7 @@ mod tests {
     }
 
     /// The viewport menu holds framing, the guides and a row for each
-    /// submenu; the display rows are in the submenus, in groups a separator
+    /// page; the display rows are in the pages, in groups a separator
     /// apart — Style the wireframe then the surface, Markers the points then
     /// each element class's overlays — every one in exactly one.
     #[test]
@@ -2392,7 +2393,7 @@ mod tests {
             vec![
                 vec![A::FrameAll, A::OneToOne],
                 vec![A::Command("toggle_grid"), A::Command("toggle_origin"), A::Command("toggle_camera_pivot"), A::CameraPivotSizeSlider],
-                vec![A::Submenu(P::Style), A::Submenu(P::Markers), A::Command("attribute_visualizers")],
+                vec![A::Page(P::Style), A::Page(P::Markers), A::Command("attribute_visualizers")],
             ]
         );
         assert_eq!(
@@ -2422,21 +2423,24 @@ mod tests {
         );
     }
 
-    /// The display settings fly out beside their row: the pointer on the
-    /// row opens its submenu, beside the menu and level with the row; a
-    /// switch in it flips, is re-marked where it stands and leaves both
-    /// menus up; the pointer on the other row swaps the submenu; a row of
-    /// the menu itself closes everything, as it always did.
+    /// The display settings are PAGES of the viewport menu: pointing at
+    /// their row turns nothing; a press on it, or a side swipe forward over
+    /// it, turns the menu into the page where it stands, under a back band;
+    /// a switch on the page flips, is re-marked in place and leaves the page
+    /// up; a press on the band or a swipe back turns back. The visualizers'
+    /// row turns the menu into the dialog, which a swipe back turns back
+    /// into the menu at its corner. A row of the menu itself still closes it.
     #[test]
-    fn the_viewport_menu_flies_its_settings_out_beside_it() {
+    fn the_viewport_menu_turns_into_its_pages_and_back() {
         use crate::app::{ViewportMenuAction as A, ViewportMenuPage as P};
         use crate::window::{LocalPosition, WindowEvent};
-        use cce_ui::widget::{context_menu, context_menu::submenu, ElementState, MouseButton};
+        use cce_ui::widget::{context_menu, ElementState, MouseButton, MouseScrollDelta, Position};
         let mut state = State::new(false);
         state.show_prim_numbers = false;
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
+        let corner = (context_menu::x(), context_menu::y());
         let row_of = |state: &State, a: A| state.viewport_menu_actions.iter().position(|x| *x == a).unwrap_or_else(|| panic!("no {a:?} row"));
         let move_to = |state: &mut State, x: f32, y: f32| {
             state.cursor_x = x;
@@ -2447,37 +2451,64 @@ mod tests {
             state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
             state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
         };
-        let (style, markers) = (row_of(&state, A::Submenu(P::Style)), row_of(&state, A::Submenu(P::Markers)));
-        assert!(context_menu::has_submenu(style) && context_menu::has_submenu(markers));
-        assert!(!submenu::is_visible(), "nothing flies out until a row is pointed at");
-
-        move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(markers) + 12.0);
-        assert_eq!(submenu::parent_row(), Some(markers));
-        assert_eq!(submenu::x(), context_menu::x() + context_menu::w(), "beside the menu");
-        assert_eq!(submenu::row_y(0), context_menu::row_y(markers), "level with its row");
-        let actions = state.open_viewport_submenu_actions().unwrap();
-        let prims = actions.iter().position(|a| *a == A::Command("toggle_prim_numbers")).unwrap();
-        assert!(submenu::options()[prims].starts_with('○'));
-
-        // Into the submenu: its row hovers, and the menu's row stays lit.
-        move_to(&mut state, submenu::x() + 20.0, submenu::row_y(prims) + 12.0);
-        assert_eq!(submenu::hovered_item(), Some(prims));
-        assert_eq!(context_menu::hovered_item(), Some(markers));
-        assert_eq!(context_menu::row_at(state.cursor_x, state.cursor_y), None, "a row of the submenu is not a row of the menu");
+        // Two fingers to the side, a few events long: negative x shows what
+        // is to the right, which is forward.
+        let swipe = |state: &mut State, dx: f64| {
+            cce_ui::widget::side_swipe::end_gesture();
+            for _ in 0..4 {
+                state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::PixelDelta(Position { x: dx / 4.0, y: 0.0 }) });
+            }
+        };
+        let (style, markers) = (row_of(&state, A::Page(P::Style)), row_of(&state, A::Page(P::Markers)));
+        let vis = row_of(&state, A::Command("attribute_visualizers"));
+        assert!([style, markers, vis].iter().all(|&i| context_menu::leads_to_page(i)), "the three rows are page rows");
+        assert!(!context_menu::leads_to_page(row_of(&state, A::FrameAll)));
+
+        move_to(&mut state, corner.0 + 20.0, context_menu::row_y(markers) + 12.0);
+        assert_eq!(state.viewport_menu_page, None, "pointing turns nothing");
+        press(&mut state);
+        assert_eq!(state.viewport_menu_page, Some(P::Markers), "a press turns the menu");
+        assert_eq!((context_menu::x(), context_menu::y()), corner, "where the menu stood");
+        assert_eq!(context_menu::back_title().as_deref(), Some("Viewport"));
+        let prims = row_of(&state, A::Command("toggle_prim_numbers"));
+        assert!(context_menu::options()[prims].starts_with('○'));
+
+        move_to(&mut state, corner.0 + 20.0, context_menu::row_y(prims) + 12.0);
         press(&mut state);
         assert!(state.show_prim_numbers, "the switch flipped");
-        assert!(state.viewport_menu_open() && submenu::is_visible(), "and both are still up");
-        assert!(submenu::options()[prims].starts_with('●'), "re-marked: {}", submenu::options()[prims]);
+        assert!(state.viewport_menu_open() && state.viewport_menu_page == Some(P::Markers), "and the page is still up");
+        assert!(context_menu::options()[prims].starts_with('●'), "re-marked: {}", context_menu::options()[prims]);
 
-        // Straight back onto the other row: its submenu takes the place.
-        move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(style) + 12.0);
-        assert_eq!(submenu::parent_row(), Some(style));
-        assert_eq!(state.open_viewport_submenu_actions().unwrap()[0], A::Command("toggle_wireframe"));
+        swipe(&mut state, 80.0);
+        assert_eq!(state.viewport_menu_page, None, "a swipe back turned back to the menu");
+        assert!(state.viewport_menu_open());
+        assert_eq!((context_menu::x(), context_menu::y()), corner);
+        assert_eq!(context_menu::back_title(), None, "the menu goes back nowhere");
+
+        move_to(&mut state, corner.0 + 20.0, context_menu::row_y(style) + 12.0);
+        swipe(&mut state, -80.0);
+        assert_eq!(state.viewport_menu_page, Some(P::Style), "a swipe forward over the row turns into its page");
+        assert_eq!(state.viewport_menu_actions[0], A::Command("toggle_wireframe"));
+        move_to(&mut state, corner.0 + 20.0, corner.1 + context_menu::PAD + context_menu::ROW_H * 0.5);
+        press(&mut state);
+        assert_eq!(state.viewport_menu_page, None, "a press on the back band turns back");
 
-        // A row with no submenu closes it; picking that row closes the menu.
+        // Into the dialog, and back.
+        move_to(&mut state, corner.0 + 20.0, context_menu::row_y(vis) + 12.0);
+        press(&mut state);
+        assert!(!state.viewport_menu_open());
+        assert!(state.dialog_visible() && state.slots.dialog.mode == crate::dialog::Mode::Visualizers);
+        assert_eq!(state.slots.dialog.anchor, Some(corner), "the dialog took the menu's corner");
+        let (dx, dy, _, _) = state.positions[crate::slots::DIALOG_IDX];
+        move_to(&mut state, dx + 30.0, dy + 30.0);
+        swipe(&mut state, 80.0);
+        assert!(!state.dialog_visible(), "a swipe back closes the dialog");
+        assert!(state.viewport_menu_open() && state.viewport_menu_page.is_none(), "and turns it back into the menu");
+        assert_eq!((context_menu::x(), context_menu::y()), (dx, dy), "at the dialog's corner");
+
+        // A row of the menu itself runs and closes it.
         let grid = row_of(&state, A::Command("toggle_grid"));
         move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(grid) + 12.0);
-        assert!(!submenu::is_visible());
         press(&mut state);
         assert!(!state.viewport_menu_open(), "a row of the menu closes it");
     }
@@ -2932,26 +2963,26 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let sub_actions = state.open_viewport_submenu_with(A::WireThicknessSlider);
+        let sub_actions = state.open_viewport_page_with(A::WireThicknessSlider);
         let acts = sub_actions.clone();
         let i = acts.iter().position(|a| *a == A::WireThicknessSlider).expect("a Wire Thickness row");
         assert_eq!(acts[i - 1], A::Command("toggle_wireframe"), "it sits under Show Wireframe");
-        let sl = context_menu::submenu::slider(i).expect("the row is a slider");
+        let sl = context_menu::slider(i).expect("the row is a slider");
         assert_eq!((sl.value, sl.min, sl.max, sl.step), (2.0, 1.0, 8.0, 0.5));
         // Every other slider the menu carries answers the same table.
         for (k, a) in acts.iter().enumerate() {
-            assert_eq!(context_menu::submenu::slider(k).is_some(), state.viewport_menu_slider(*a).is_some(), "row {k} {a:?}");
+            assert_eq!(context_menu::slider(k).is_some(), state.viewport_menu_slider(*a).is_some(), "row {k} {a:?}");
         }
 
-        state.cursor_x = context_menu::submenu::x() + 20.0;
-        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
         assert!((state.wire_width - 3.0).abs() < 1e-6, "{}", state.wire_width);
         let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
         assert!(kdl.contains("wire_width (f64)3") || kdl.contains("wire_width 3"), "persisted: {kdl}");
         assert!(state.viewport_menu_open());
 
-        let band = context_menu::SUBMENU.with(|m| m.borrow().slider_band(i));
+        let band = context_menu::CONTEXT_MENU.with(|m| m.borrow().slider_band(i));
         state.cursor_x = band.x + 1.0;
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
@@ -3020,15 +3051,15 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let sub_actions = state.open_viewport_submenu_with(A::PointMarkerSizeSlider);
+        let sub_actions = state.open_viewport_page_with(A::PointMarkerSizeSlider);
         let i = sub_actions.iter().position(|a| *a == A::PointMarkerSizeSlider).expect("a Point Marker Size row");
         assert_eq!(sub_actions[i - 1], A::Command("toggle_point_markers"), "it sits under its switch");
-        let sl = context_menu::submenu::slider(i).expect("a slider");
+        let sl = context_menu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step), (0.005, 0.1, 0.005));
         assert!((sl.value - 0.02).abs() < 1e-6);
 
-        state.cursor_x = context_menu::submenu::x() + 20.0;
-        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 4.0) });
         assert!((state.point_marker_size - 0.04).abs() < 1e-5, "{}", state.point_marker_size);
         assert_eq!(state.rt_geometry_version, version, "no rebuild ran");
@@ -3070,14 +3101,14 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let sub_actions = state.open_viewport_submenu_with(A::PullArrowScaleSlider);
+        let sub_actions = state.open_viewport_page_with(A::PullArrowScaleSlider);
         let i = sub_actions.iter().position(|a| *a == A::PullArrowScaleSlider).expect("a Pull Arrow Scale row");
-        let sl = context_menu::submenu::slider(i).expect("a slider");
+        let sl = context_menu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step, sl.suffix), (0.25, 10.0, 0.25, "x"));
         assert_eq!(sl.readout(), "1.00x");
 
-        state.cursor_x = context_menu::submenu::x() + 20.0;
-        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 15.0) });
         let k = state.pull_arrow_scale;
         assert!(k > 1.0, "the wheel raised the scale: {k}");
@@ -3108,14 +3139,14 @@ mod tests {
         state.cursor_x = 300.0;
         state.cursor_y = 200.0;
         state.open_viewport_context_menu();
-        let sub_actions = state.open_viewport_submenu_with(A::GroupMarkerSizeSlider);
+        let sub_actions = state.open_viewport_page_with(A::GroupMarkerSizeSlider);
         let i = sub_actions.iter().position(|a| *a == A::GroupMarkerSizeSlider).expect("a Group Marker Size row");
-        let sl = context_menu::submenu::slider(i).expect("a slider");
+        let sl = context_menu::slider(i).expect("a slider");
         assert_eq!((sl.min, sl.max, sl.step, sl.decimals), (0.0, 0.2, 0.005, 3));
         assert!((sl.value - 0.025).abs() < 1e-6);
 
-        state.cursor_x = context_menu::submenu::x() + 20.0;
-        state.cursor_y = context_menu::submenu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
         state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 3.0) });
         assert!((state.group_marker_size - 0.04).abs() < 1e-5, "{}", state.group_marker_size);
         assert!((radius(&state) - 0.04).abs() < 1e-5, "the markers re-sized: {}", radius(&state));
diff --git a/src/menu_page.rs b/src/menu_page.rs
new file mode 100644
index 0000000..2cc42f5
--- /dev/null
+++ b/src/menu_page.rs
@@ -0,0 +1,260 @@
+//! Page rows: a row of a context menu that TURNS the menu into another
+//! plate where it stands — a page of more rows, or the dialog — with a way
+//! back.
+//!
+//! Until 2026-10-02 there were two kinds of such row. The viewport menu's
+//! Style and Markers flew a second menu out beside themselves on hover
+//! (cce-ui's submenu), while the network menu's Add Node, the plate menu's
+//! Add Tab, the viewport menu's Attribute Visualizers and the node menu's
+//! Rename replaced the menu with another plate on a click — one with a Back
+//! row, the others with no way back at all. One idea, two gestures, and the
+//! user could not tell from a row which it was.
+//!
+//! Now every such row is a PAGE row (cce-ui's `context_menu::set_row_page`):
+//! it wears `›`, and a press on it, or a two-finger swipe to the side with
+//! the pointer on it, turns the menu into what it names, the new plate's
+//! top-left where the menu's was. A swipe the other way goes back, from
+//! anywhere on the page; a page that is a menu also has a back band across
+//! its top (`‹ Viewport`) for a press. Which way is forward follows the
+//! 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 network or playbar menu or a stub's plate menu.
+//! - 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
+//!   visualizer from the list) goes back to it first (`State::dialog_trail`).
+//!
+//! What runs a turn is [`State::run_menu_turn`], which a press
+//! ([`State::press_menu_turn`]) and a swipe ([`State::take_menu_turn`]) both
+//! reach, so the two cannot disagree about where a row leads.
+
+use crate::app::{NetworkMenuAction, NodeMenuAction, PlaybarMenuAction, State, ViewportMenuAction};
+use crate::dialog::Mode;
+use crate::plate_menu::{plate_title, PlateMenuAction};
+use crate::slots::{DIALOG_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX};
+use cce_ui::widget::context_menu::{self, PageTurn};
+use cce_ui::widget::WidgetId;
+
+/// A context menu a page or the dialog was turned to from: where a turn back
+/// goes, and what the back band names.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MenuOrigin {
+    Viewport,
+    Network,
+    Playbar,
+    /// A pane's plate menu shown alone (a stub's, the params pane's…).
+    Plate(usize),
+    /// A node's menu, by slot on the current level.
+    Node(usize),
+}
+
+impl MenuOrigin {
+    /// The menu a plate's rows are part of: the network and playbar menus
+    /// carry their plates' rows, every other plate's are a menu alone.
+    pub fn of_plate(idx: usize) -> MenuOrigin {
+        match idx {
+            NETWORK_PANEL_IDX => MenuOrigin::Network,
+            PLAYBAR_IDX => MenuOrigin::Playbar,
+            other => MenuOrigin::Plate(other),
+        }
+    }
+}
+
+impl ViewportMenuAction {
+    /// Whether the row turns the menu: its pages, and the visualizers'
+    /// editor, which is the dialog.
+    pub fn leads_to_page(self) -> bool {
+        matches!(self, ViewportMenuAction::Page(_) | ViewportMenuAction::Command("attribute_visualizers"))
+    }
+}
+
+impl NetworkMenuAction {
+    /// Add Node turns the menu into the add-node list; Add Tab into its page.
+    pub fn leads_to_page(self) -> bool {
+        matches!(self, NetworkMenuAction::Command("add_node") | NetworkMenuAction::Plate(PlateMenuAction::AddTabMenu))
+    }
+}
+
+/// Mark the rows of the menu just shown that lead to a page.
+pub fn mark_page_rows<A: Copy>(actions: &[A], leads: impl Fn(A) -> bool) {
+    for (i, a) in actions.iter().enumerate() {
+        if leads(*a) {
+            context_menu::set_row_page(i);
+        }
+    }
+}
+
+impl State {
+    /// Show a menu: at the pointer (`at` None), as a menu opens, or with its
+    /// top-left at `at` in place of the plate that stood there — under a
+    /// back band to `back` when there is somewhere to go back to.
+    pub(crate) fn put_up_menu(&self, at: Option<(f32, f32)>, back: Option<MenuOrigin>, options: Vec<String>, header_count: usize, target: WidgetId) {
+        match at {
+            None => context_menu::show(self.cursor_x, self.cursor_y, options, header_count, target),
+            Some((x, y)) => {
+                let title = back.map(|o| self.menu_title(o));
+                context_menu::show_page(x, y, title.as_deref(), options, header_count, target);
+            }
+        }
+    }
+
+    /// What a back band to `origin` reads.
+    pub fn menu_title(&self, origin: MenuOrigin) -> String {
+        match origin {
+            MenuOrigin::Viewport => "Viewport".to_string(),
+            MenuOrigin::Network => "Network".to_string(),
+            MenuOrigin::Playbar => "Playbar".to_string(),
+            MenuOrigin::Plate(idx) => plate_title(idx).to_string(),
+            MenuOrigin::Node(slot) => self.current_dir().children.get(slot).map(|n| n.name.clone()).unwrap_or_else(|| "Node".to_string()),
+        }
+    }
+
+    /// Which of the app's menus is up, if one is.
+    pub fn open_menu_origin(&self) -> Option<MenuOrigin> {
+        if !context_menu::is_visible() {
+            return None;
+        }
+        if self.viewport_menu_active {
+            Some(MenuOrigin::Viewport)
+        } else if self.network_menu_active {
+            Some(MenuOrigin::Network)
+        } else if self.playbar_menu_active {
+            Some(MenuOrigin::Playbar)
+        } else if let Some(idx) = self.plate_menu_slot {
+            Some(MenuOrigin::Plate(idx))
+        } else {
+            self.node_menu_slot.map(MenuOrigin::Node)
+        }
+    }
+
+    /// Close whichever of the app's menus is up.
+    fn close_open_menu(&mut self) {
+        match self.open_menu_origin() {
+            Some(MenuOrigin::Viewport) => self.close_viewport_menu(),
+            Some(MenuOrigin::Network) => self.close_network_menu(),
+            Some(MenuOrigin::Playbar) => self.close_playbar_menu(),
+            Some(MenuOrigin::Plate(_)) => self.close_plate_menu(),
+            Some(MenuOrigin::Node(_)) => self.close_node_menu(),
+            None => context_menu::hide(),
+        }
+    }
+
+    /// Show `origin`'s menu again with its top-left at `(x, y)`: a turn back
+    /// to it from a page or the dialog.
+    pub fn reopen_menu(&mut self, origin: MenuOrigin, x: f32, y: f32) {
+        let at = Some((x, y));
+        match origin {
+            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),
+            MenuOrigin::Plate(idx) => self.open_plate_menu_at(idx, at),
+            MenuOrigin::Node(slot) => self.open_node_context_menu_at(slot, at),
+        }
+    }
+
+    /// A left press on a page row or a back band of the open menu turns it.
+    /// `true` when it did, and the press is spent.
+    pub fn press_menu_turn(&mut self) -> bool {
+        if self.open_menu_origin().is_none() {
+            return false;
+        }
+        match context_menu::turn_at(self.cursor_x, self.cursor_y) {
+            Some(turn) => self.run_menu_turn(turn),
+            None => false,
+        }
+    }
+
+    /// The turn a side swipe over the open menu asked for, run.
+    pub fn take_menu_turn(&mut self) -> bool {
+        match context_menu::take_turn() {
+            Some(turn) => self.run_menu_turn(turn),
+            None => false,
+        }
+    }
+
+    /// Turn the open menu: into what row `n` leads to, or back to the menu
+    /// this page was turned from. `false` when the menu has no such turn.
+    pub fn run_menu_turn(&mut self, turn: PageTurn) -> bool {
+        let Some(origin) = self.open_menu_origin() else { return false };
+        let at = (context_menu::x(), context_menu::y());
+        match turn {
+            PageTurn::Back => match origin {
+                MenuOrigin::Viewport if self.viewport_menu_page.is_some() => {
+                    self.show_viewport_menu_page(None, Some(at));
+                }
+                MenuOrigin::Plate(_) if self.plate_page_from.is_some() => {
+                    let from = self.plate_page_from.take().unwrap();
+                    self.close_plate_menu();
+                    self.reopen_menu(from, at.0, at.1);
+                }
+                _ => return false,
+            },
+            PageTurn::Into(n) => match origin {
+                MenuOrigin::Viewport => match self.viewport_menu_actions.get(n).copied() {
+                    Some(ViewportMenuAction::Page(page)) => self.show_viewport_menu_page(Some(page), Some(at)),
+                    Some(ViewportMenuAction::Command("attribute_visualizers")) => {
+                        self.close_viewport_menu();
+                        self.vis_editing = None;
+                        self.open_dialog_from(Mode::Visualizers, origin, at);
+                    }
+                    _ => return false,
+                },
+                MenuOrigin::Network => match self.network_menu_actions.get(n).copied() {
+                    Some(NetworkMenuAction::Command("add_node")) => {
+                        self.close_network_menu();
+                        self.open_dialog_from(Mode::AddNode, origin, at);
+                    }
+                    Some(NetworkMenuAction::Plate(PlateMenuAction::AddTabMenu)) => {
+                        self.close_network_menu();
+                        self.open_plate_add_tab_menu(NETWORK_PANEL_IDX, at, origin);
+                    }
+                    _ => return false,
+                },
+                MenuOrigin::Playbar => match self.playbar_menu_actions.get(n).copied() {
+                    Some(PlaybarMenuAction::Plate(PlateMenuAction::AddTabMenu)) => {
+                        self.close_playbar_menu();
+                        self.open_plate_add_tab_menu(PLAYBAR_IDX, at, origin);
+                    }
+                    _ => 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,
+                },
+                MenuOrigin::Node(slot) => match self.node_menu_actions.get(n).copied() {
+                    Some(NodeMenuAction::Rename) => {
+                        self.close_node_menu();
+                        self.open_rename_dialog_from(slot, Some((origin, at)));
+                    }
+                    _ => return false,
+                },
+            },
+        }
+        true
+    }
+
+    /// Turn the dialog back: to the mode it was turned to from, else to the
+    /// menu it was turned to from, shown at the dialog's corner. `false`
+    /// when it came from neither.
+    pub fn dialog_back(&mut self) -> bool {
+        if !self.dialog_visible() {
+            return false;
+        }
+        if let Some(&prev) = self.dialog_trail.last() {
+            self.open_dialog_mode(prev);
+            return true;
+        }
+        let Some(origin) = self.dialog_from else { return false };
+        let (x, y, _, _) = self.positions[DIALOG_IDX];
+        self.close_dialog();
+        self.close_open_menu();
+        self.reopen_menu(origin, x, y);
+        true
+    }
+}
diff --git a/src/plate_menu.rs b/src/plate_menu.rs
index a7606c9..fe99f0b 100644
--- a/src/plate_menu.rs
+++ b/src/plate_menu.rs
@@ -51,11 +51,10 @@ pub enum PlateMenuAction {
     ShowTab(usize),
     /// Pull the named pane out of its dock and tab it into this one, active.
     AddTab(usize),
-    /// Swap the menu for the Add Tab page — the list of panes that can be
-    /// pulled in ([`State::open_plate_add_tab_menu`]).
+    /// 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,
-    /// 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
@@ -240,19 +239,22 @@ impl State {
     /// 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);
+        self.open_plate_menu_at(idx, None);
     }
 
-    /// 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) {
+    /// Show `idx`'s plate menu alone: at the pointer, or with its top-left
+    /// at `at` in place of a page it is turned back to from.
+    pub fn open_plate_menu_at(&mut self, idx: usize, at: Option<(f32, 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(x, y, options, 0, target);
+        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)
@@ -268,10 +270,10 @@ impl State {
             .collect()
     }
 
-    /// 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) {
+    /// 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() {
@@ -283,14 +285,11 @@ impl State {
             options.push(plate_title(other).to_string());
             actions.push(PlateMenuAction::AddTab(other));
         }
-        options.push("-".to_string());
-        actions.push(PlateMenuAction::Separator);
-        options.push("‹ Back".to_string());
-        actions.push(PlateMenuAction::BackToMain);
         let target = self.slots.get_dyn(idx).base().id();
-        cce_ui::widget::context_menu::show(x, y, options, 1, target);
+        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);
     }
 
     pub fn plate_menu_open(&self) -> bool {
@@ -301,6 +300,7 @@ impl State {
         cce_ui::widget::context_menu::hide();
         self.plate_menu_slot = None;
         self.plate_menu_actions.clear();
+        self.plate_page_from = None;
     }
 
     /// Route a left press while the plate menu is open — same contract as
@@ -324,8 +324,8 @@ impl State {
     }
 
     /// 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.
+    /// — 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)) {
         match action {
             PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
@@ -344,8 +344,9 @@ impl State {
                     self.add_dock_tab(d, o);
                 }
             }
-            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::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::CloseTab => self.close_dock_tab(idx),