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

commit60a45b8a0065e80ffa46a966ba8940e60f70b75a
parentad2e21149d
authorLucas Galante <[email protected]>
date2026-09-19 09:13
feat: an Alt+D dialog that runs commands and edits the display settings

Two halves behind one chord: the command registry, fuzzy-filtered in
place, and the viewport/graph display state DesignSettings persists.

The settings controls are a SECOND ParametersBg laid out inside the
dialog's plate, not new widgets — a slider in the dialog should be the
same slider as one in the params pane. What the dialog adds is the row
table (SETTINGS), which says who OWNS each value: the meta node's
utility subnets own most of them, because apply_settings_from_menubar_
subnets copies those over the live state on every param change, so a
write to State::grid_thickness survives exactly until the next one.

Alt+D rather than Super+D: the compositor claims every Super chord
before a client sees one (input.kdl binds super+d to the app launcher),
and Super held is the DE's window-adjust modifier.

Three of the four hard parts are paint order and occlusion:

- The dialog paints AFTER the overlay passes, not in the widget walk. A
  high z_order is not enough — append_frame_text and the viewport
  readouts run after the whole walk and drew the graph's node labels
  over a dialog that had already covered them.
- Text is not painted in display-list order at all: the engine lays
  every Text prim out at the end, so a plate over a label hides nothing
  at any depth. Dialog::popover claims the rect for the engine's
  popover-occlusion clamp, and every label inside carries that same
  rect (the clamp's exemption) and truncates itself.
- That one claim then made the Settings half inert, because
  is_coordinate_covered reads popover_rect off every registered widget
  to reject a covered press — so each control was covered by the plate
  it is drawn on. Dialog::occluding lowers the claim for the length of
  a dispatch into the dialog.

Input is intercepted whole at the top of handle_event's keyboard and
mouse branches; dialog_key_input is total rather than layered, since
the network pane's bare-letter family is ungated and typing "frame"
into the filter would otherwise step the grid cursor and flip a node's
geometry toggle on the way past.

Verified in a headless shadow at output scale 2: opens on Alt+D, Tab
switches halves, a Settings toggle reaches the Guides subnet, the live
viewport and state.kdl, and a press outside dismisses.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md       |   72 ++++
 src/app.rs      |   50 ++-
 src/command.rs  |    6 +
 src/dialog.rs   | 1180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs     |  229 +++++++++++
 src/render.rs   |  130 +++++-
 src/shortcut.rs |    3 +
 src/slots.rs    |   24 ++
 8 files changed, 1692 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 32bcea2..6b82934 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -114,6 +114,11 @@ gone from cce-ui with the wgpu path).
   slot's concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too, and
   `State` keeps one-line forwarders. `PassivePlate` and `Canvas`, the two app-owned
   slot-only widgets, are also here.
+- `src/dialog.rs` — the Alt+D dialog: the `Dialog` widget (a third app-owned
+  slot-only widget) plus the `State` half that fills it, routes its input and
+  writes its settings back. See "The dialog (Alt+D)" below — three of its four
+  hard parts are about paint order and occlusion, none of which is guessable
+  from the widget.
 - `src/plate_corner.rs` — the plate corner control: a circular menu trigger on the
   top-right of each pane that draws its own plate (`PLATE_SLOTS` — network, params,
   spreadsheet, playbar; NOT the viewport, whose plate is the window-spanning lip).
@@ -742,6 +747,73 @@ the alternative is calling every command to see whether it is handled, and
 argues for: a label kept in two places drifts, and a renamed one fails silently
 — the dispatch falls through its match and the command does nothing.
 
+### The dialog (Alt+D)
+
+`src/dialog.rs` is a modal overlay with two halves: **Commands**, the registry
+fuzzy-filtered in place, and **Settings**, the viewport/graph display state
+`DesignSettings` persists. It is two roster slots — `DIALOG_IDX`, an app-owned
+`Dialog` that paints the plate, the tab strip, the query line and the command
+list, and `DIALOG_PARAMS_IDX`, a **second `ParametersBg`** laid out inside it.
+The settings controls are that second params pane rather than new widgets,
+because a slider in the dialog should be the same slider as a slider in the
+params pane; what the dialog adds is the row TABLE, not the rows.
+
+**Alt+D, not Super+D.** Every Super chord is the compositor's before any client
+sees one (`input.kdl`'s `cce-window-manager` domain has `super+d` on the app
+launcher), and Super held is the DE's window-adjust modifier besides. Alt is the
+app's own — the `move_*` family already lives there.
+
+**A Settings row edits the meta node, never the live field.** The values behind
+those rows have exactly one owner, and it is not `State` and not
+`DesignSettings`: `apply_settings_from_menubar_subnets` copies the utility
+subnets (`Main`, `View`, `Guides`) onto the live state on EVERY param change, so
+a write straight to `State::grid_thickness` survives until the next one and no
+longer. `SETTINGS` is the table of which row belongs to which owner, and `Owner`
+has three arms for the three kinds there turn out to be: a subnet param, a
+registry command (Square Aspect and Show Camera Pivot are per-CAMERA, with no
+node at all behind the Default Camera — their commands are the only code that
+gets both cases right), and an active-camera param with the live field as its
+fallback. Writeback is `sync_parameters_to_project`'s shape, polled rather than
+pushed for the same reason: a `ParametersBg` reports its values, it does not
+emit events. `dialog_settings_rows_name_owners_that_exist` is the backstop,
+because the failure is silent — `dialog_settings_params` SKIPS a row whose param
+it cannot find, so a rename quietly shortens the Settings half.
+
+**The dialog is painted after the overlay passes, not in the widget walk.** A
+high `z_order` is not enough: `append_frame_text`, `append_scale_readout` and the
+meta-point overlays all run AFTER the whole walk, so the graph's node labels drew
+straight over a dialog that had already covered them. `append_dialog` runs
+between the plate corners and the context menu instead.
+
+**And even that is not enough, because text is not painted in display-list
+order.** The engine collects every `Prim::Text` and lays them all out at the end,
+so a plate over a label does not hide it at any depth. What hides it is the
+engine's popover-occlusion clamp, which reads `UiContext::active_popovers` — so
+`Dialog::popover` claims the dialog's whole rect, and the designer's
+registration loop picks it up. The clamp exempts text whose own bounds COINCIDE
+with the occluder, so every label inside the dialog carries the dialog's rect and
+truncates itself; the settings body is painted twice for this (once for its
+controls, once to re-emit its text retagged), since a `PaintCtx` can be handed
+text back but not geometry.
+
+**`Dialog::occluding` exists because that one claim serves two mechanisms that
+want opposite answers.** `UiContext::is_coordinate_covered` reads the same
+`popover_rect` — off every REGISTERED widget, not just the ones in
+`active_popovers` — to decide a press landed under something else. With the claim
+standing, every control in the Settings half is covered by the plate it is drawn
+on and nothing can be clicked; the toggles looked laid out, painted, and
+completely inert. `State::dispatch_uncovered` lowers the flag for the length of a
+dispatch into the dialog and puts it back, invalidating the coverage memo on both
+edges (the engine queries it on every left press, so lowering the flag alone
+leaves a stale cached answer).
+
+Input is intercepted whole, at the top of `handle_event`'s keyboard and mouse
+branches: `dialog_key_input` is TOTAL rather than a layer, because the network
+pane's bare-letter family is ungated and typing "frame" into the filter would
+otherwise step the grid cursor four times and flip a node's geometry toggle on
+the way past. A press outside the plate dismisses and is swallowed, the way the
+node and plate-corner menus behave.
+
 ### Runtime paths point into the source tree
 
 Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/src/app.rs b/src/app.rs
index dff68d3..8564a9e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1095,6 +1095,12 @@ pub struct State {
 
     pub slots: Box<WidgetSlots>,
     pub positions: Vec<(f32, f32, f32, f32)>,
+    /// The Alt+D dialog's Settings rows as last handed to its `ParametersBg` —
+    /// the baseline `sync_dialog_settings_to_project` diffs the controls
+    /// against. The params pane gets away without one because it can compare a
+    /// reported value to the `ParamDef` it came from; the dialog's rows are
+    /// assembled from several owners, so what was shown is its own fact.
+    pub dialog_settings_shown: Vec<(String, String, String)>,
     pub splitter_layout: cce_ui::layout::SplitterLayout,
     /// The add-node palette's cce-cloud popup (single active popup, toggle
     /// semantics — the status bar's tracker pattern).
@@ -1171,7 +1177,7 @@ pub struct State {
     /// slide the graph under the armed node drag, or the commit re-derives the
     /// node's cell against the panned origin and it teleports. Cleared (latch
     /// open) once the pointer strays a real-drag distance from the press.
-    drag_press_cursor: Option<(f32, f32)>,
+    pub(crate) drag_press_cursor: Option<(f32, f32)>,
     pub focused_widget: Option<usize>,
 
     pub cursor_x: f32,
@@ -4082,6 +4088,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 v.set_visible(false);
                 v
             },
+            dialog: crate::dialog::Dialog::new(),
+            dialog_params: {
+                // Shown only while the dialog's Settings tab is up; laid out
+                // inside the dialog's plate, so it draws no plate of its own.
+                let mut p = ParametersBg::new();
+                p.set_visible(false);
+                p
+            },
         });
 
         if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
@@ -4172,6 +4186,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             event_sender: None,
             slots,
             positions,
+            dialog_settings_shown: Vec::new(),
             splitter_layout,
             cloud_popups: cce_ui::process::CloudPopupTracker::new(),
             node_menu_slot: None,
@@ -5134,6 +5149,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
 
         self.apply_collapsed_panes();
+        // Last of all: the dialog floats over whatever the branches above
+        // produced, so its rect depends on the window and nothing else.
+        self.layout_dialog();
     }
 
 
@@ -5559,6 +5577,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let mut settings_changed = false;
         match action {
             Action::CommandPalette => self.open_command_palette(),
+            Action::ToggleDialog => self.toggle_dialog(),
             // The network navigation families. Each returns false when the
             // network pane does not have focus, which is how one gate covers
             // all fourteen of them.
@@ -5911,6 +5930,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     pub fn handle_event(&mut self, event: &WindowEvent) -> bool {
         match event {
             WindowEvent::MouseWheel { delta } => {
+                // 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.
+                if self.dialog_visible() {
+                    return self.dialog_mouse_wheel(*delta);
+                }
                 let in_network_pane = self.in_network_pane();
                 // eprintln!("DEBUG MOUSEWHEEL: delta={:?}, phase={:?}, cursor=({}, {}), in_network_pane={}", delta, phase, self.cursor_x, self.cursor_y, in_network_pane);
                 let node_area_y = self.positions[CONTENT_IDX].1;
@@ -6235,6 +6260,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             changed = true;
                             if idx == PARAM_IDX {
                                 self.sync_parameters_to_project();
+                            } else if idx == crate::slots::DIALOG_PARAMS_IDX {
+                                self.sync_dialog_settings_to_project();
                             }
                         }
                     }
@@ -6286,6 +6313,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                     self.viewport_mut().reset_velocity();
                 }
+                // The dialog is modal over what it covers, so it takes the
+                // press before the pan trigger and before the whole
+                // hit-target cascade. A press OUTSIDE it dismisses — the
+                // convention every other floating surface in this app follows
+                // (the node menu, the plate corner menu) — and is swallowed
+                // rather than also acting on the pane it landed on.
+                if self.dialog_visible() {
+                    if let Some(handled) = self.dialog_mouse_input(*button, *btn_state) {
+                        return handled;
+                    }
+                }
                 let in_network_pane = self.in_network_pane();
                 let node_area_x = self.positions[CONTENT_IDX].0;
                 let node_area_y = self.positions[CONTENT_IDX].1;
@@ -7092,6 +7130,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     self.space_pressed = event.state == ElementState::Pressed;
                 }
 
+                // The dialog owns the keyboard outright while it is open —
+                // ahead of the context chords, ahead of the params pane, ahead
+                // of everything. It has a text field in it, and a modal whose
+                // typing leaks into the pane behind it is worse than no modal:
+                // typing "frame" into the filter would step the grid cursor
+                // four times and toggle a node's geometry on the way past.
+                if self.dialog_visible() {
+                    return self.dialog_key_input(event);
+                }
+
                 // Context switching dispatches ahead of the widget key paths
                 // (param pane, node palette) so the chord works from any pane.
                 if event.state == ElementState::Pressed {
diff --git a/src/command.rs b/src/command.rs
index d924c5d..bed43f4 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -113,6 +113,12 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "next_context", label: "Focus Next Pane", context: Context::Always, run: Run::Key(Action::NextContext), default_chord: Some("Ctrl+Tab") },
     Command { id: "previous_context", label: "Focus Previous Pane", context: Context::Always, run: Run::Key(Action::PrevContext), default_chord: Some("Ctrl+Shift+Tab") },
     Command { id: "command_palette", label: "Command Palette", context: Context::Always, run: Run::Key(Action::CommandPalette), default_chord: Some("Ctrl+p") },
+    // Alt+D, not Super+D: the compositor claims every Super chord before any
+    // client sees one (`input.kdl`'s `cce-window-manager` domain binds
+    // super+d to the app launcher), and Super held is also the DE's
+    // window-adjust modifier. Alt is the app's own — the network move family
+    // already lives there.
+    Command { id: "toggle_dialog", label: "Dialog", context: Context::Always, run: Run::Key(Action::ToggleDialog), default_chord: Some("Alt+d") },
     Command { id: "toggle_configure", label: "Configure", context: Context::Always, run: Run::Key(Action::ToggleConfigure), default_chord: Some("Ctrl+,") },
 
     // Escape already does this, handled inline with the rest of Escape's
diff --git a/src/dialog.rs b/src/dialog.rs
new file mode 100644
index 0000000..472f1ae
--- /dev/null
+++ b/src/dialog.rs
@@ -0,0 +1,1180 @@
+//! The dialog (`Alt+D`): run a command, or change a setting, without leaving
+//! the keyboard.
+//!
+//! Two halves behind one chord because they answer the same question — "make
+//! the app do the thing". The Commands half is the registry
+//! ([`crate::command`]) fuzzy-filtered in place; the Settings half is the
+//! viewport/graph display state that `DesignSettings` persists.
+//!
+//! This widget owns the FRAME — plate, tab strip, query line, command list —
+//! and not the settings controls. Those are a second roster slot
+//! (`DIALOG_PARAMS_IDX`, a `ParametersBg`) laid out inside this one's body, so
+//! a slider in the dialog is the same slider as a slider in the params pane
+//! rather than a second implementation that drifts from it. The values behind
+//! those rows stay owned by the meta node's utility subnets, which is where
+//! `apply_settings_from_menubar_subnets` reads them from — see
+//! `State::dialog_settings_rows`.
+//!
+//! App-owned on the narrow traits wrapped in `Adapted<Dialog>`, like
+//! [`crate::playbar::Playbar`], and a subtree painter for the same reason:
+//! `paint` authors geometry AND text, so `append_frame_text` skips the slot.
+//! Geometry helpers take the laid-out `rect` rather than caching one, again
+//! like the playbar — the designer computes the same rects from
+//! `positions[DIALOG_IDX]` when it needs them outside a paint.
+
+use cce_ui::colors;
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::PaintCtx;
+use cce_ui::widget::*;
+
+/// Which half of the dialog is showing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Tab {
+    Commands,
+    Settings,
+}
+
+impl Tab {
+    pub const ALL: [Tab; 2] = [Tab::Commands, Tab::Settings];
+
+    pub fn label(self) -> &'static str {
+        match self {
+            Tab::Commands => "Commands",
+            Tab::Settings => "Settings",
+        }
+    }
+}
+
+/// One command row, resolved: the registry id to run, plus what to draw.
+#[derive(Debug, Clone)]
+pub struct Row {
+    pub id: &'static str,
+    pub label: String,
+    /// The chord as a human reads it, or empty for an unbound command. Drawn
+    /// in its own right-hand column so the dialog teaches the keyboard rather
+    /// than replacing it — the same argument the `cce-cloud` palette's padded
+    /// rows make, except that here the column can be a real column.
+    pub chord: String,
+}
+
+/// The dialog's outer size. Fixed rather than proportional: it is a focused
+/// list, and a list that grows with the window turns into a wall of rows with
+/// the one you want somewhere in it.
+const DIALOG_W: f32 = 520.0;
+const DIALOG_H: f32 = 420.0;
+
+const PAD: f32 = 12.0;
+/// Tab strip height, and the query line's.
+const TAB_H: f32 = 30.0;
+const QUERY_H: f32 = 30.0;
+pub const ROW_H: f32 = 24.0;
+/// Gap between the tab strip, the query line and the list.
+const GAP: f32 = 8.0;
+
+/// The dialog's rect inside a `width` x `height` window: centered
+/// horizontally, and a little above centre vertically so the list grows into
+/// the window's roomier half rather than down over the status bar.
+pub fn layout_in(width: f32, height: f32) -> (f32, f32, f32, f32) {
+    let w = DIALOG_W.min((width - 2.0 * PAD).max(200.0));
+    let h = DIALOG_H.min((height - 2.0 * PAD).max(160.0));
+    let x = ((width - w) * 0.5).max(0.0).round();
+    let y = ((height - h) * 0.4).max(0.0).round();
+    (x, y, w, h)
+}
+
+fn tab_strip(rect: Rect) -> Rect {
+    Rect { x: rect.x + PAD, y: rect.y + PAD, width: (rect.width - 2.0 * PAD).max(0.0), height: TAB_H }
+}
+
+/// One tab's segment of the strip — the strip split evenly, which is what
+/// makes the pair read as one segmented control rather than two buttons.
+fn tab_rect(rect: Rect, tab: Tab) -> Rect {
+    let strip = tab_strip(rect);
+    let n = Tab::ALL.len() as f32;
+    let w = strip.width / n;
+    let i = Tab::ALL.iter().position(|t| *t == tab).unwrap_or(0) as f32;
+    Rect { x: strip.x + i * w, y: strip.y, width: w, height: strip.height }
+}
+
+/// The query line. Commands only — the Settings half has no filter, since its
+/// rows are a fixed handful and a filter over them would hide more than it
+/// found.
+fn query_rect(rect: Rect) -> Rect {
+    let strip = tab_strip(rect);
+    Rect { x: strip.x, y: strip.y + strip.height + GAP, width: strip.width, height: QUERY_H }
+}
+
+/// The command list's viewport.
+fn list_rect(rect: Rect) -> Rect {
+    let q = query_rect(rect);
+    let top = q.y + q.height + GAP;
+    Rect { x: q.x, y: top, width: q.width, height: (rect.y + rect.height - PAD - top).max(0.0) }
+}
+
+/// The Settings half's body — where the dialog's `ParametersBg` goes. It
+/// starts where the query line would, there being no query line.
+pub fn settings_rect(x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) {
+    let strip = tab_strip(Rect { x, y, width: w, height: h });
+    let top = strip.y + strip.height + GAP;
+    (strip.x, top, strip.width, (y + h - PAD - top).max(0.0))
+}
+
+/// How many rows the command list can show at once, for a dialog of this size.
+pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
+    (list_rect(Rect { x, y, width: w, height: h }).height / ROW_H).floor().max(0.0) as usize
+}
+
+pub struct Dialog {
+    pub tab: Tab,
+    /// What has been typed into the Commands half's filter.
+    pub query: String,
+    /// The filtered, ranked rows — rebuilt by the app whenever `query`
+    /// changes, never here: ranking needs the registry AND the focused pane,
+    /// and this widget knows neither.
+    pub rows: Vec<Row>,
+    /// Which row Enter would run. Kept in range by [`Dialog::set_rows`].
+    pub selected: usize,
+    /// First visible row, scrolled to keep `selected` in view.
+    pub scroll: usize,
+    /// How many rows fit — pushed in from the layout, since `on_event` and the
+    /// app's key handling both need it and neither has the rect to hand.
+    page: usize,
+    hover_row: Option<usize>,
+    hover_tab: Option<Tab>,
+    /// A row the pointer activated, drained by the app.
+    activated: Option<&'static str>,
+    /// A tab the pointer chose, drained by the app.
+    tab_click: Option<Tab>,
+    /// Whether the dialog is currently claiming its rect as an occluder — see
+    /// [`Paint::popover`]. Lowered for the length of an event dispatch into
+    /// the dialog, because the one claim serves two mechanisms that want
+    /// opposite answers.
+    occluding: bool,
+}
+
+impl Dialog {
+    pub fn new() -> Adapted<Dialog> {
+        let mut d = Adapted::new(Dialog {
+            tab: Tab::Commands,
+            query: String::new(),
+            rows: Vec::new(),
+            selected: 0,
+            scroll: 0,
+            page: 1,
+            hover_row: None,
+            hover_tab: None,
+            activated: None,
+            tab_click: None,
+            occluding: true,
+        });
+        d.set_visible(false);
+        d
+    }
+
+    /// Record how many rows fit, from the laid-out rect.
+    pub fn set_page(&mut self, page: usize) {
+        self.page = page.max(1);
+        self.scroll_to_selected();
+    }
+
+    /// How many rows fit — what PageUp/PageDown step by.
+    pub fn page_len(&self) -> usize {
+        self.page.max(1)
+    }
+
+    /// Claim, or stop claiming, the dialog's rect as an occluder.
+    pub fn set_occluding(&mut self, on: bool) {
+        self.occluding = on;
+    }
+
+    fn row_rect(&self, rect: Rect, i: usize) -> Option<Rect> {
+        let list = list_rect(rect);
+        if i < self.scroll {
+            return None;
+        }
+        let offset = (i - self.scroll) as f32 * ROW_H;
+        if offset + ROW_H > list.height {
+            return None;
+        }
+        Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
+    }
+
+    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 {
+            return None;
+        }
+        let i = self.scroll + ((y - list.y) / ROW_H).floor() as usize;
+        (i < self.rows.len()).then_some(i)
+    }
+
+    fn tab_at(&self, rect: Rect, x: f32, y: f32) -> Option<Tab> {
+        Tab::ALL.into_iter().find(|t| {
+            let r = tab_rect(rect, *t);
+            x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
+        })
+    }
+
+    /// Keep `selected` inside the scrolled window.
+    pub fn scroll_to_selected(&mut self) {
+        let per_page = self.page.max(1);
+        if self.selected < self.scroll {
+            self.scroll = self.selected;
+        } else if self.selected >= self.scroll + per_page {
+            self.scroll = self.selected + 1 - per_page;
+        }
+    }
+
+    pub fn move_selection(&mut self, delta: i32) {
+        if self.rows.is_empty() {
+            self.selected = 0;
+            self.scroll = 0;
+            return;
+        }
+        let n = self.rows.len() as i32;
+        // Wrapping, not clamping: a list you can fall off the bottom of makes
+        // the last row harder to reach than the first, and this one is short.
+        self.selected = (self.selected as i32 + delta).rem_euclid(n) as usize;
+        self.scroll_to_selected();
+    }
+
+    /// The command Enter would run.
+    pub fn selected_id(&self) -> Option<&'static str> {
+        self.rows.get(self.selected).map(|r| r.id)
+    }
+
+    pub fn take_activated(&mut self) -> Option<&'static str> {
+        self.activated.take()
+    }
+
+    pub fn take_tab_click(&mut self) -> Option<Tab> {
+        self.tab_click.take()
+    }
+
+    /// Swap in a freshly ranked row list, keeping the selection in range.
+    ///
+    /// The selection goes back to the top rather than trying to follow the
+    /// command it was on: the rows are re-ranked by the query, so "the same
+    /// row" after a keystroke is a different command, and the best match
+    /// being preselected is the whole point of ranking them.
+    pub fn set_rows(&mut self, rows: Vec<Row>) {
+        self.rows = rows;
+        self.selected = 0;
+        self.scroll = 0;
+    }
+}
+
+impl Layout for Dialog {
+    /// Above every pane, and above the second network editor's plates: the
+    /// dialog is modal in practice — a press inside it never reaches what it
+    /// covers — so it has to be drawn that way too. The dialog's params body
+    /// sits one above this (see `DIALOG_PARAMS_IDX`).
+    fn z_order(&self) -> i32 {
+        900
+    }
+}
+
+impl Paint for Dialog {
+    /// `paint` authors geometry AND text, so the Text prims pass through
+    /// `paint_self` verbatim instead of the single-font own-labels bridge —
+    /// the chord column needs its own family and its own clip bounds.
+    fn paints_own_subtree(&self) -> bool {
+        true
+    }
+
+    /// The dialog IS its own plate, the contract every floating surface in
+    /// this app wears: the parameter plate's fill, so it tracks the configured
+    /// tint, opacity and blur-behind marker with the panes.
+    fn color(&self) -> [f32; 4] {
+        colors::param_plate_fill()
+    }
+
+    fn solid_border(&self) -> Option<([f32; 4], f32)> {
+        colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
+    }
+
+    fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+        let r = cce_ui::layout::plate_corner_radius();
+        (r > 0.0).then_some((r, (true, true, true, true)))
+    }
+
+    /// The dialog's whole rect, as an occluder.
+    ///
+    /// Text is not painted in display-list order — the engine collects every
+    /// Text prim and lays them all out at the end — so a plate drawn over a
+    /// label does not hide it, whatever the z. What hides it is the
+    /// popover-occlusion clamp, which reads `UiContext::active_popovers`; the
+    /// designer registers every visible widget whose `popover_rect` is `Some`,
+    /// so claiming one here is how the graph's node labels and the viewport's
+    /// readouts stop bleeding through the plate.
+    ///
+    /// The clamp exempts text whose OWN bounds coincide with the occluder, so
+    /// everything drawn inside the dialog — this widget's labels and, via
+    /// `append_dialog`, the settings body's — carries these exact bounds and
+    /// does its own truncating.
+    /// **`occluding` exists because one claim serves two mechanisms that want
+    /// opposite answers.** `UiContext::is_coordinate_covered` reads the same
+    /// `popover_rect` — off every REGISTERED widget, not only the ones in
+    /// `active_popovers` — to decide that a press has landed under something
+    /// else. With the claim standing, every control inside the dialog is
+    /// covered by the plate it is drawn on and nothing in the Settings half
+    /// can be clicked. `State::dispatch_uncovered` lowers the flag for the
+    /// length of a dispatch into the dialog and puts it straight back.
+    fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
+        self.occluding.then_some((rect.x, rect.y, rect.width, rect.height))
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        if rect.width <= 0.0 || rect.height <= 0.0 {
+            return;
+        }
+        let (family, font_size) = cce_ui::layout::control_label_font_parsed();
+        let accent = colors::highlight_primary_color();
+        let tint = [accent[0], accent[1], accent[2]];
+        let depth = colors::plate_bevel_width();
+        let ctrl_r = cce_ui::layout::control_corner_radius();
+        let radii = (ctrl_r, ctrl_r, ctrl_r, ctrl_r);
+        // The occlusion-clamp exemption (see `popover`): every label in here
+        // carries the dialog's own rect, so none of them is clipped away by
+        // the occluder the dialog itself registers. The cost is that bounds
+        // no longer trim an overlong label, so the rows truncate by hand.
+        let own = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
+        let fit = |text: &str, width: f32| -> String {
+            if width <= 0.0 {
+                return String::new();
+            }
+            let cols = (width / display::measure_text_width("M", &family, font_size).max(1.0))
+                .floor() as usize;
+            display::truncate_tail(text, cols)
+        };
+
+        // --- The tab strip: one segmented control, so the seam between the
+        // two halves reads as a seam and not as a gap.
+        for tab in Tab::ALL {
+            let r = tab_rect(rect, tab);
+            let active = tab == self.tab;
+            // Active wears the focus language the rest of the app uses for
+            // "this is the live one": the tinted bevel, a glint on the
+            // control's own silhouette.
+            if active {
+                ctx.bevel_tinted(r, radii, colors::param_plate_fill(), depth, tint);
+            } else if self.hover_tab == Some(tab) {
+                ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+            }
+            let label = tab.label();
+            let tw = display::measure_text_width(label, &family, font_size);
+            let tx = r.x + (r.width - tw) * 0.5;
+            let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
+            let color = if active { [0xf0, 0xf0, 0xf6] } else { [0x9a, 0x9a, 0xa6] };
+            ctx.text_with(
+                label,
+                tx,
+                ty,
+                font_size,
+                color,
+                Some(family.clone()),
+                own,
+            );
+        }
+
+        // The Settings half's body is a separate roster slot, painted by the
+        // designer's own walk — nothing more to draw here.
+        if self.tab == Tab::Settings {
+            return;
+        }
+
+        // --- The query line: a well, like the text rows in the params pane.
+        // The caret is a plain rule and does not blink: the dialog owns the
+        // keyboard outright while it is open, so there is no focus to signal.
+        let q = query_rect(rect);
+        ctx.rounded_rect(q, ctrl_r, (true, true, true, true), [0.0, 0.0, 0.0, 0.22]);
+        let qty = cce_ui::layout::align_text_y(q.y, q.height, font_size, 0.0);
+        let qtx = q.x + 8.0;
+        let q_w = q.width - 16.0;
+        if self.query.is_empty() {
+            let hint = fit("Type to filter commands", q_w);
+            ctx.text_with(hint, qtx, qty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
+        } else {
+            // Head-truncated: what matters while typing is the end of the
+            // query, which is where the caret is.
+            let shown = display::truncate_head(&self.query, (q_w / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize);
+            ctx.text_with(shown, qtx, qty, font_size, [0xe6, 0xe6, 0xee], Some(family.clone()), own);
+        }
+        let caret_x = qtx + display::measure_text_width(&self.query, &family, font_size) + 1.0;
+        if caret_x < q.x + q.width - 4.0 {
+            ctx.quad(
+                Rect { x: caret_x, y: q.y + 6.0, width: 1.0, height: q.height - 12.0 },
+                [accent[0], accent[1], accent[2], 0.9],
+            );
+        }
+
+        // --- The rows. The chord column is right-aligned against the list's
+        // right edge rather than padded out to a fixed width: the label is
+        // what gets read, so it is the label that keeps the stable left edge.
+        let list = list_rect(rect);
+        if self.rows.is_empty() {
+            let ty = cce_ui::layout::align_text_y(list.y, ROW_H, font_size, 0.0);
+            ctx.text_with("No matching command", list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
+            return;
+        }
+        for i in self.scroll..self.rows.len() {
+            let Some(r) = self.row_rect(rect, i) else { break };
+            let row = &self.rows[i];
+            if i == self.selected {
+                ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [accent[0], accent[1], accent[2], 0.16]);
+                ctx.bevel_tinted(r, radii, [0.0; 4], depth, tint);
+            } else if self.hover_row == Some(i) {
+                ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+            }
+            let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
+            let chord_w = if row.chord.is_empty() {
+                0.0
+            } else {
+                display::measure_text_width(&row.chord, &family, font_size)
+            };
+            // The label's clip stops short of the chord column so a long
+            // label is cut by it rather than running under it.
+            let label_right = r.x + r.width - 8.0 - if chord_w > 0.0 { chord_w + 12.0 } else { 0.0 };
+            let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
+            ctx.text_with(
+                fit(&row.label, label_right - (r.x + 8.0)),
+                r.x + 8.0,
+                ty,
+                font_size,
+                label_color,
+                Some(family.clone()),
+                own,
+            );
+            if chord_w > 0.0 {
+                ctx.text_with(
+                    row.chord.clone(),
+                    r.x + r.width - 8.0 - chord_w,
+                    ty,
+                    font_size,
+                    [0x85, 0x85, 0x92],
+                    Some(family.clone()),
+                    own,
+                );
+            }
+        }
+    }
+}
+
+impl Input for Dialog {
+    /// The whole rect, always — this is what makes the dialog modal over what
+    /// it covers: the designer's press cascade asks the dialog first and a hit
+    /// never falls through to the pane underneath.
+    fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+        x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
+    }
+
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        let rect = ectx.rect;
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+                if let Some(tab) = self.tab_at(rect, *x, *y) {
+                    self.tab_click = Some(tab);
+                    return true;
+                }
+                if self.tab == Tab::Commands {
+                    if let Some(i) = self.row_at(rect, *x, *y) {
+                        self.selected = i;
+                        self.activated = self.rows.get(i).map(|r| r.id);
+                        return true;
+                    }
+                }
+                // Inside the plate but on no control: consumed anyway, so the
+                // press cannot reach the pane the dialog is covering.
+                true
+            }
+            Event::PointerMove { x, y, .. } => {
+                let row = self.row_at(rect, *x, *y);
+                let tab = self.tab_at(rect, *x, *y);
+                let changed = row != self.hover_row || tab != self.hover_tab;
+                self.hover_row = row;
+                self.hover_tab = tab;
+                changed
+            }
+            Event::MouseWheel { delta, .. } => {
+                if self.tab != Tab::Commands || self.rows.is_empty() {
+                    return false;
+                }
+                let lines = match delta {
+                    MouseScrollDelta::LineDelta(_, y) => -*y,
+                    MouseScrollDelta::PixelDelta(p) => -(p.y as f32) / ROW_H,
+                };
+                let max_scroll = self.rows.len().saturating_sub(self.page.max(1));
+                let next = (self.scroll as f32 + lines).round().clamp(0.0, max_scroll as f32) as usize;
+                let changed = next != self.scroll;
+                self.scroll = next;
+                changed
+            }
+            _ => false,
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// The designer's half: what the dialog shows, and what choosing a row does.
+// ---------------------------------------------------------------------------
+
+use crate::app::{param_display, ParamDef, State};
+use crate::slots::{DIALOG_IDX, DIALOG_PARAMS_IDX};
+
+/// Where a Settings row's value actually lives.
+///
+/// Not the live `State` fields, and not `DesignSettings`: both are DOWNSTREAM
+/// of the meta node. `apply_settings_from_menubar_subnets` copies the utility
+/// subnets onto the live state on every param change, so a write straight to
+/// `State::grid_thickness` would survive exactly until the next one. The
+/// subnet param is the value's owner; this enum says which owner each row has,
+/// so the dialog edits values in the one place that keeps them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Owner {
+    /// A param on a utility subnet under the root meta node (`Main`, `View`,
+    /// `Guides`), named here by subnet and param name.
+    Subnet(&'static str, &'static str),
+    /// A toggle the command registry already owns end to end: the command does
+    /// the live flip, the menu checkmark AND the per-camera writeback in one
+    /// place. Square Aspect and Show Camera Pivot are per-CAMERA settings with
+    /// no node at all behind the Default Camera, and their commands are the
+    /// only code that gets both cases right — so the row dispatches instead of
+    /// writing, and reads its displayed value off the live state.
+    Command(&'static str),
+    /// A param on the ACTIVE camera node, with the live field as the fallback:
+    /// the Default Camera has no node, so there is nothing to write but the
+    /// field, and `apply_settings_from_menubar_subnets` leaves it alone.
+    ActiveCamera(&'static str),
+}
+
+/// One row of the Settings half.
+pub struct Setting {
+    /// What the dialog calls it — and, because `param_display` keys a row by
+    /// its label, the identity the writeback resolves back to this row. Unique
+    /// across the table, section titles included.
+    ///
+    /// Spelled out rather than borrowed from the owning param's own label: the
+    /// subnets' labels are tuned for the section they sit in ("Plate", under
+    /// the View node's Network section) and stop making sense anywhere else.
+    pub label: &'static str,
+    pub owner: Option<Owner>,
+}
+
+impl Setting {
+    const fn section(label: &'static str) -> Self {
+        Setting { label, owner: None }
+    }
+
+    const fn row(label: &'static str, owner: Owner) -> Self {
+        Setting { label, owner: Some(owner) }
+    }
+}
+
+/// The Settings half, in order.
+///
+/// Scope is exactly what `DesignSettings` persists — the viewport and graph
+/// DISPLAY state, which is the part of the app's configuration that is a
+/// preference rather than part of a project. What is deliberately NOT here:
+/// the render subnet (per-project look), the pane-visibility toggles (the
+/// View menu and the plate corners already own those, and a settings dialog
+/// is a strange place to hide a pane from), and keybindings, which this DE
+/// edits as `input.kdl` on purpose.
+pub const SETTINGS: &[Setting] = &[
+    Setting::section("Viewport"),
+    Setting::row("Background Color", Owner::Subnet("Main", "Background Color")),
+    Setting::row("Square Aspect", Owner::Command("toggle_square_viewport")),
+    Setting::section("Grid"),
+    Setting::row("Show Grid", Owner::Subnet("Guides", "Show Grid Guide")),
+    Setting::row("Grid Color", Owner::Subnet("Guides", "Grid Color")),
+    Setting::row("Grid Thickness", Owner::Subnet("Guides", "Grid Thickness")),
+    Setting::section("Guides"),
+    Setting::row("Show Origin Axes", Owner::Subnet("Guides", "Show Origin Axes")),
+    Setting::row("Origin Size", Owner::Subnet("Guides", "Origin Guide Size")),
+    Setting::row("Show Reference Cube", Owner::Subnet("Guides", "Show Reference Cube")),
+    Setting::section("Camera"),
+    Setting::row("Show Camera Pivot", Owner::Command("toggle_camera_pivot")),
+    Setting::row("Camera Pivot Size", Owner::ActiveCamera("Camera Pivot Size")),
+    Setting::section("Network"),
+    Setting::row("Show Network Plate", Owner::Subnet("View", "Show Network Plate")),
+];
+
+fn setting_by_label(label: &str) -> Option<&'static Setting> {
+    SETTINGS.iter().find(|s| s.label == label)
+}
+
+/// A synthetic `ParamDef` carrying `label` and the shape of `src`, so the row
+/// renders as whatever control its owner already uses.
+fn relabel(src: &ParamDef, label: &'static str) -> ParamDef {
+    ParamDef { label: label.to_string(), ..src.clone() }
+}
+
+fn bool_param(label: &'static str, on: bool) -> ParamDef {
+    ParamDef {
+        name: label.to_string(),
+        label: label.to_string(),
+        param_type: "toggle".to_string(),
+        default: if on { "true" } else { "false" }.to_string(),
+        options: Vec::new(),
+        min: None,
+        max: None,
+        step: None,
+        show_when: String::new(),
+    }
+}
+
+impl State {
+    pub fn dialog_visible(&self) -> bool {
+        self.slots.dialog.visible()
+    }
+
+    /// The dialog's tab, or `Commands` when it is closed.
+    pub fn dialog_tab(&self) -> Tab {
+        self.slots.dialog.tab
+    }
+
+    pub fn toggle_dialog(&mut self) {
+        if self.dialog_visible() {
+            self.close_dialog();
+        } else {
+            self.open_dialog();
+        }
+    }
+
+    pub fn open_dialog(&mut self) {
+        // Always on Commands, and 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.tab = Tab::Commands;
+        self.slots.dialog.query.clear();
+        self.slots.dialog.set_visible(true);
+        self.refresh_dialog_rows();
+        self.refresh_dialog_settings();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_status_text("Dialog: type to filter, Tab switches halves, Escape closes.");
+    }
+
+    pub fn close_dialog(&mut self) {
+        if !self.dialog_visible() {
+            return;
+        }
+        // The settings body keeps whatever a half-finished text edit left in
+        // it; drop that focus so the next open starts clean.
+        self.slots.dialog_params.unfocus();
+        self.slots.dialog.set_visible(false);
+        self.slots.dialog_params.set_visible(false);
+        if self.focused_widget == Some(DIALOG_IDX) || self.focused_widget == Some(DIALOG_PARAMS_IDX) {
+            self.focused_widget = None;
+        }
+        self.rebuild_positions();
+        self.apply_layout();
+    }
+
+    pub fn set_dialog_tab(&mut self, tab: Tab) {
+        if self.slots.dialog.tab == tab {
+            return;
+        }
+        self.slots.dialog.tab = tab;
+        if tab == Tab::Settings {
+            // Re-read on every entry: a chord or a menu may have changed one
+            // of these while the Commands half was up.
+            self.refresh_dialog_settings();
+        } else {
+            self.refresh_dialog_rows();
+        }
+        self.rebuild_positions();
+        self.apply_layout();
+    }
+
+    /// Re-rank the Commands half against the current query.
+    ///
+    /// Ranking is [`crate::command::palette_entries`] — the same fuzzy rank
+    /// and the same focused-pane-first partition as the `cce-cloud` palette,
+    /// so the two agree on what typing "sg" means.
+    pub fn refresh_dialog_rows(&mut self) {
+        let entries = {
+            let query = self.slots.dialog.query.clone();
+            crate::command::palette_entries(&query, self.focused_context())
+        };
+        let rows: Vec<Row> = entries
+            .iter()
+            .map(|c| Row {
+                id: c.id,
+                label: c.label.to_string(),
+                chord: self.shortcut_manager.chord_for(c.id).map(|s| s.describe()).unwrap_or_default(),
+            })
+            .collect();
+        self.slots.dialog.set_rows(rows);
+    }
+
+    /// The Settings half's rows, each read from whatever owns its value.
+    ///
+    /// Returns `ParamDef`s rather than display triples so the encoding
+    /// (`spinbox:min:max:step`, `choice:a,b`) stays in `param_display` — one
+    /// place, shared with the params pane, instead of a second copy here that
+    /// could disagree about what a spinbox is.
+    fn dialog_settings_params(&self) -> Vec<ParamDef> {
+        let subnet_param = |subnet: &str, name: &str| -> Option<&ParamDef> {
+            self.session_node()?
+                .children
+                .iter()
+                .find(|c| c.name == subnet)?
+                .params
+                .iter()
+                .find(|p| p.name == name)
+        };
+        let camera_param = |name: &str| -> Option<&ParamDef> {
+            if self.active_camera == "Default Camera" {
+                return None;
+            }
+            self.current_dir()
+                .children
+                .iter()
+                .find(|c| c.node_type == "camera" && c.name == self.active_camera)?
+                .params
+                .iter()
+                .find(|p| p.name == name)
+        };
+
+        let mut out = Vec::with_capacity(SETTINGS.len());
+        for s in SETTINGS {
+            match s.owner {
+                None => out.push(ParamDef {
+                    name: s.label.to_string(),
+                    label: s.label.to_string(),
+                    param_type: "section".to_string(),
+                    default: String::new(),
+                    options: Vec::new(),
+                    min: None,
+                    max: None,
+                    step: None,
+                    show_when: String::new(),
+                }),
+                Some(Owner::Subnet(subnet, name)) => {
+                    // A subnet param that does not exist is a row that cannot
+                    // work, so it is not offered — the subnets are recreated
+                    // on every load, but a detached window has no meta node at
+                    // all.
+                    if let Some(p) = subnet_param(subnet, name) {
+                        out.push(relabel(p, s.label));
+                    }
+                }
+                Some(Owner::Command(id)) => {
+                    let on = match id {
+                        "toggle_square_viewport" => self.square_viewport,
+                        "toggle_camera_pivot" => self.viewport().show_camera_pivot,
+                        _ => false,
+                    };
+                    out.push(bool_param(s.label, on));
+                }
+                Some(Owner::ActiveCamera(name)) => match camera_param(name) {
+                    Some(p) => out.push(relabel(p, s.label)),
+                    // No camera node behind the Default Camera: the live field
+                    // is the value, in the same tenths the camera param uses.
+                    None => out.push(ParamDef {
+                        name: s.label.to_string(),
+                        label: s.label.to_string(),
+                        param_type: "spinbox".to_string(),
+                        default: ((self.camera_pivot_size * 10.0).round() as i32).to_string(),
+                        options: Vec::new(),
+                        min: Some(1.0),
+                        max: Some(50.0),
+                        step: Some(1.0),
+                        show_when: String::new(),
+                    }),
+                },
+            }
+        }
+        // A section with nothing under it is a header for an empty list.
+        let mut trimmed: Vec<ParamDef> = Vec::with_capacity(out.len());
+        for (i, p) in out.iter().enumerate() {
+            let empty_section = p.param_type == "section"
+                && out.get(i + 1).is_none_or(|n| n.param_type == "section");
+            if !empty_section {
+                trimmed.push(p.clone());
+            }
+        }
+        trimmed
+    }
+
+    /// Push the Settings rows into the dialog's params body, and remember them
+    /// as the baseline the writeback diffs against.
+    pub fn refresh_dialog_settings(&mut self) {
+        let rows = param_display(&self.dialog_settings_params());
+        self.slots.dialog_params_mut().set_display_params(&rows);
+        self.dialog_settings_shown = rows;
+    }
+
+    /// Apply whatever the Settings half's controls changed.
+    ///
+    /// The params pane's writeback (`sync_parameters_to_project`), pointed at
+    /// [`SETTINGS`] instead of the selected node: read the controls, diff
+    /// against what was put into them, write each changed row to its owner,
+    /// and then run the one apply-and-persist pass. Polled rather than pushed
+    /// for the same reason the params pane is — a `ParametersBg` reports its
+    /// values, it does not emit events.
+    pub fn sync_dialog_settings_to_project(&mut self) {
+        if !self.dialog_visible() || self.slots.dialog.tab != Tab::Settings {
+            return;
+        }
+        let updated = self.slots.dialog_params().node_params();
+        let mut commands: Vec<&'static str> = Vec::new();
+        let mut changed = false;
+        for (key, value, _) in &updated {
+            let was = self.dialog_settings_shown.iter().find(|(k, _, _)| k == key);
+            if was.is_none_or(|(_, v, _)| v == value) {
+                continue;
+            }
+            let Some(setting) = setting_by_label(key) else { continue };
+            let Some(owner) = setting.owner else { continue };
+            changed = true;
+            match owner {
+                Owner::Subnet(subnet, name) => {
+                    if let Some(session) = self.session_node_mut() {
+                        if let Some(node) = session.children.iter_mut().find(|c| c.name == subnet) {
+                            if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+                                p.default = value.clone();
+                            }
+                        }
+                    }
+                }
+                Owner::Command(id) => commands.push(id),
+                Owner::ActiveCamera(name) => {
+                    let active = self.active_camera.clone();
+                    let wrote = {
+                        let dir = self.current_dir_mut();
+                        match dir
+                            .children
+                            .iter_mut()
+                            .find(|c| c.node_type == "camera" && c.name == active)
+                            .and_then(|c| c.params.iter_mut().find(|p| p.name == name))
+                        {
+                            Some(p) => {
+                                p.default = value.clone();
+                                true
+                            }
+                            None => false,
+                        }
+                    };
+                    if !wrote {
+                        if let Ok(v) = value.parse::<f32>() {
+                            self.camera_pivot_size = v / 10.0;
+                        }
+                    }
+                }
+            }
+        }
+        if !changed {
+            return;
+        }
+
+        // The commands run FIRST and on their own: each is a toggle whose
+        // whole job is to flip live state, mark the menus and persist, and
+        // running them after the apply below would have them flip against
+        // values the apply had just settled.
+        for id in commands {
+            self.run_command(id);
+        }
+
+        self.apply_settings_from_menubar_subnets();
+        // The viewport meshes bake their sizes and colors in, so a changed
+        // thickness/size/tint is a re-generate, not a re-draw. This is the
+        // same set the settings-file reload in `tick_frame` regenerates.
+        self.update_grid_geometry();
+        self.update_origin_geometry();
+        self.update_pivot_geometry();
+        self.update_viewport_bg_geometry();
+        self.sync_grid_settings();
+        self.rebuild_scene_geometry();
+        self.sync_nodes();
+        self.save_settings();
+        // The params pane may be showing one of these very nodes.
+        self.sync_parameters_pane();
+        // Re-read rather than patching the baseline: an apply can normalize a
+        // value (a spinbox clamp), and the baseline has to be what the
+        // controls now hold or the next poll reports a phantom change.
+        self.refresh_dialog_settings();
+    }
+
+    /// Lay the dialog and its settings body out over the window.
+    ///
+    /// Called at the end of `rebuild_positions`, after every layout branch has
+    /// run — like the 2D page pane, the rect it wants never depends on which
+    /// branch produced the panes underneath it.
+    pub(crate) fn layout_dialog(&mut self) {
+        let open = self.dialog_visible();
+        if !open {
+            self.positions[DIALOG_IDX] = (0.0, 0.0, 0.0, 0.0);
+            self.positions[DIALOG_PARAMS_IDX] = (0.0, 0.0, 0.0, 0.0);
+            self.slots.dialog_params.set_visible(false);
+            return;
+        }
+        let (x, y, w, h) = layout_in(self.width, self.height);
+        self.positions[DIALOG_IDX] = (x, y, w, h);
+        self.slots.dialog.set_page(visible_rows(x, y, w, h));
+
+        let settings = self.slots.dialog.tab == Tab::Settings;
+        self.positions[DIALOG_PARAMS_IDX] =
+            if settings { settings_rect(x, y, w, h) } else { (0.0, 0.0, 0.0, 0.0) };
+        self.slots.dialog_params.set_visible(settings);
+    }
+
+    /// Every key, while the dialog is open.
+    ///
+    /// Total, not layered: the branch that calls this returns whatever it
+    /// returns, so nothing below reaches the panes. A modal that leaks its
+    /// typing is worse than no modal — typing "frame" into the filter would
+    /// otherwise step the grid cursor and flip a node's geometry flag on the
+    /// way past, since the network pane's bare-letter family is ungated.
+    pub(crate) fn dialog_key_input(&mut self, event: &KeyEvent) -> bool {
+        if event.state != ElementState::Pressed {
+            return true;
+        }
+        // The dialog's own chord closes it, wherever the user has bound it —
+        // asked for by id rather than hardcoded to Alt+D, so a rebind in
+        // `input.kdl` keeps working both ways.
+        if self.shortcut_manager.match_command(&self.modifiers, &event.logical_key)
+            == Some("toggle_dialog")
+        {
+            self.close_dialog();
+            return true;
+        }
+        match &event.logical_key {
+            Key::Named(NamedKey::Escape) => {
+                self.close_dialog();
+                return true;
+            }
+            Key::Named(NamedKey::Tab) => {
+                // One key for two halves, in both directions: there are only
+                // two, so Tab and Shift+Tab are the same move.
+                let next = if self.slots.dialog.tab == Tab::Commands { Tab::Settings } else { Tab::Commands };
+                self.set_dialog_tab(next);
+                return true;
+            }
+            _ => {}
+        }
+
+        if self.slots.dialog.tab == Tab::Settings {
+            // The settings body is a real `ParametersBg` with real text
+            // fields; hand it the key and poll what it did, exactly as the
+            // params pane's own key path does.
+            let taken = {
+                let ptr = &mut self.slots.dialog_params as *mut cce_ui::widget::Adapted<ParametersBg>;
+                unsafe { (*ptr).keyboard_input(event, &mut self.ui_context) }
+            };
+            if taken {
+                self.sync_dialog_settings_to_project();
+            }
+            // Consumed either way: an unhandled key inside a modal does
+            // nothing, it does not fall through to the network pane.
+            return true;
+        }
+
+        match &event.logical_key {
+            Key::Named(NamedKey::ArrowDown) => self.slots.dialog.move_selection(1),
+            Key::Named(NamedKey::ArrowUp) => self.slots.dialog.move_selection(-1),
+            Key::Named(NamedKey::PageDown) => {
+                let page = self.slots.dialog.page_len() as i32;
+                self.slots.dialog.move_selection(page);
+            }
+            Key::Named(NamedKey::PageUp) => {
+                let page = self.slots.dialog.page_len() as i32;
+                self.slots.dialog.move_selection(-page);
+            }
+            Key::Named(NamedKey::Home) => {
+                self.slots.dialog.selected = 0;
+                self.slots.dialog.scroll_to_selected();
+            }
+            Key::Named(NamedKey::End) => {
+                let last = self.slots.dialog.rows.len().saturating_sub(1);
+                self.slots.dialog.selected = last;
+                self.slots.dialog.scroll_to_selected();
+            }
+            Key::Named(NamedKey::Enter) => {
+                if let Some(id) = self.slots.dialog.selected_id() {
+                    self.run_dialog_command(id);
+                }
+            }
+            Key::Named(NamedKey::Backspace) => {
+                if self.slots.dialog.query.pop().is_some() {
+                    self.refresh_dialog_rows();
+                }
+            }
+            Key::Named(NamedKey::Space) => {
+                self.slots.dialog.query.push(' ');
+                self.refresh_dialog_rows();
+            }
+            Key::Character(c) => {
+                // Bare typing only: a modified key is a chord, and the ones
+                // this dialog answers to are handled above.
+                if !self.modifiers.control_key()
+                    && !self.modifiers.alt_key()
+                    && !self.modifiers.super_key()
+                {
+                    self.slots.dialog.query.push_str(c);
+                    self.refresh_dialog_rows();
+                }
+            }
+            _ => {}
+        }
+        true
+    }
+
+    /// Run a command the dialog chose, and close.
+    ///
+    /// Closing FIRST, so a command that opens something of its own (the
+    /// `cce-cloud` palette, a file chooser) does not come up behind the
+    /// dialog. The dialog's own row is the exception: toggling it here would
+    /// reopen what was just closed.
+    pub(crate) fn run_dialog_command(&mut self, id: &'static str) {
+        self.close_dialog();
+        if id != "toggle_dialog" {
+            self.run_command(id);
+        }
+    }
+
+    /// Drain what the pointer did inside the dialog, after an event reached
+    /// one of its two slots. Returns whether anything changed.
+    pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
+        let mut changed = false;
+        if let Some(tab) = self.slots.dialog.take_tab_click() {
+            self.set_dialog_tab(tab);
+            changed = true;
+        }
+        if let Some(id) = self.slots.dialog.take_activated() {
+            self.run_dialog_command(id);
+            changed = true;
+        }
+        changed
+    }
+
+    /// A mouse button, while the dialog is open.
+    ///
+    /// `None` hands the press back to the ordinary cascade — which happens
+    /// only for the buttons the dialog has no use for, so a right-click still
+    /// reaches whatever is under it outside the plate. `Some(handled)` means
+    /// the dialog dealt with it and nothing else should.
+    pub(crate) fn dialog_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+    ) -> Option<bool> {
+        if button != MouseButton::Left {
+            return None;
+        }
+        let (x, y) = (self.cursor_x, self.cursor_y);
+
+        // A slider drag started in the settings body ends wherever the pointer
+        // happens to be — including outside the plate. Ending it has to come
+        // before the dismiss test below, or dragging a value past the dialog's
+        // edge and letting go would close the dialog instead of committing.
+        if state == ElementState::Released && self.drag_widget == Some(DIALOG_PARAMS_IDX) {
+            let ptr = &mut self.slots.dialog_params as *mut cce_ui::widget::Adapted<ParametersBg>;
+            unsafe {
+                (*ptr).handle_event(&cce_ui::widget::Event::DragEnd, &mut self.ui_context);
+                (*ptr).handle_event(
+                    &cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y },
+                    &mut self.ui_context,
+                );
+            }
+            self.drag_widget = None;
+            self.drag_press_cursor = None;
+            self.sync_dialog_settings_to_project();
+            return Some(true);
+        }
+
+        let hits_dialog = self.in_dialog_slot(DIALOG_IDX, x, y);
+        let hits_settings = self.in_dialog_slot(DIALOG_PARAMS_IDX, x, y);
+
+        if !hits_dialog && !hits_settings {
+            // Outside: a press dismisses, a release is the tail of that press
+            // and is simply eaten.
+            if state == ElementState::Pressed {
+                self.close_dialog();
+            }
+            return Some(true);
+        }
+
+        // The settings body first — it sits INSIDE the dialog's rect, so the
+        // dialog's own (deliberately total) hit test would otherwise claim
+        // every press meant for a control.
+        if hits_settings {
+            let ev = cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
+            let taken = self.dispatch_uncovered(DIALOG_PARAMS_IDX, &ev);
+            if taken {
+                self.sync_dialog_settings_to_project();
+            }
+            // Sliders and ramps drag; arm the same drag the params pane arms.
+            if state == ElementState::Pressed && self.slots.draggable(DIALOG_PARAMS_IDX) {
+                let ev = cce_ui::widget::Event::DragStart { start_x: x, start_y: y };
+                let ptr = &mut self.slots.dialog_params
+                    as *mut cce_ui::widget::Adapted<ParametersBg>;
+                unsafe {
+                    (*ptr).handle_event(&ev, &mut self.ui_context);
+                }
+                self.drag_widget = Some(DIALOG_PARAMS_IDX);
+                self.drag_press_cursor = Some((x, y));
+            }
+            return Some(true);
+        }
+
+        let ev = cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
+        self.dispatch_uncovered(DIALOG_IDX, &ev);
+        self.drain_dialog_clicks();
+        Some(true)
+    }
+
+    /// The wheel, while the dialog is open: to whichever half is showing, and
+    /// no further. Both halves scroll their own list, so there is nothing to
+    /// fall through to.
+    pub(crate) fn dialog_mouse_wheel(&mut self, delta: MouseScrollDelta) -> bool {
+        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_PARAMS_IDX, x, y) {
+            return self.dispatch_uncovered(DIALOG_PARAMS_IDX, &ev);
+        }
+        if self.in_dialog_slot(DIALOG_IDX, x, y) {
+            return self.dispatch_uncovered(DIALOG_IDX, &ev);
+        }
+        false
+    }
+
+    /// Is `(x, y)` inside a dialog slot's laid-out rect?
+    ///
+    /// The rect, not `hit_test`: the dialog registers itself as a text
+    /// occluder (see `Dialog::popover`) and `Adapted::hit_test` reads that
+    /// same list as COVERAGE, so every point inside the dialog reports as
+    /// covered — by the dialog's own plate. Inside a modal the geometry IS
+    /// the answer.
+    pub(crate) fn in_dialog_slot(&self, idx: usize, x: f32, y: f32) -> bool {
+        if !self.slots.get_dyn(idx).visible() {
+            return false;
+        }
+        let (rx, ry, rw, rh) = self.positions[idx];
+        rw > 0.0 && rh > 0.0 && x >= rx && x < rx + rw && y >= ry && y < ry + rh
+    }
+
+    /// Deliver `ev` to a dialog slot with the dialog's occluder claim lowered.
+    ///
+    /// `Adapted::handle_event` hit-gates presses and wheels through
+    /// `is_coordinate_covered`, which asks every REGISTERED widget for its
+    /// `popover_rect` — including the dialog's, which covers the whole plate
+    /// (see [`Dialog::popover`]). So while the claim stands, every press
+    /// aimed at a settings control is rejected as covered by the surface the
+    /// control is drawn on, and the Settings half is inert. The dialog's own
+    /// routing has already decided who gets this event, so the claim comes
+    /// down for the dispatch and goes straight back up.
+    pub(crate) fn dispatch_uncovered(&mut self, idx: usize, ev: &cce_ui::widget::Event) -> bool {
+        self.slots.dialog.set_occluding(false);
+        // The coverage answer is memoized per point, so lowering the claim is
+        // not enough — a query from earlier this frame is served from the
+        // cache, and the engine makes one on every left press
+        // (`close_popovers_missed_by_press`).
+        self.ui_context.invalidate_coverage_cache();
+        let ptr = self.slots.get_dyn_mut(idx) as *mut (dyn cce_ui::widget::WidgetHost + 'static);
+        let taken = unsafe { (*ptr).handle_event(ev, &mut self.ui_context) };
+        self.slots.dialog.set_occluding(true);
+        self.ui_context.invalidate_coverage_cache();
+        taken
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 101cbfc..e34e258 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,7 @@ pub mod render;
 pub mod shortcut;
 pub mod slots;
 pub mod command;
+pub mod dialog;
 pub mod layout;
 pub mod mold;
 pub mod page;
@@ -641,6 +642,7 @@ mod tests {
             RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, STATUS_IDX, BREADCRUMB_IDX,
             SPREADSHEET_IDX, SPREADSHEET_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
             NETWORK_PANEL2_IDX, CONTENT2_IDX, BREADCRUMB2_IDX, PAGE_IDX,
+            DIALOG_IDX, DIALOG_PARAMS_IDX,
         ];
         assert_eq!(roster.len(), WIDGET_COUNT, "roster length vs WIDGET_COUNT");
         for (i, idx) in roster.iter().enumerate() {
@@ -7123,4 +7125,231 @@ mod tests {
         assert_eq!(copy.num_prims(), 4);
         assert_eq!(d.num_prims(), 4, "the original is untouched");
     }
+
+    // ----- The Alt+D dialog (src/dialog.rs) -----
+
+    /// A key, as the dialog's handler expects one.
+    fn key_press(key: Key) -> cce_ui::widget::KeyEvent {
+        cce_ui::widget::KeyEvent {
+            state: cce_ui::widget::ElementState::Pressed,
+            logical_key: key,
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+            alt: false,
+        }
+    }
+
+    fn typed(c: &str) -> cce_ui::widget::KeyEvent {
+        key_press(Key::Character(c.to_string()))
+    }
+
+    /// Every Settings row still names something that exists.
+    ///
+    /// The failure this catches is silent and the reason the table is a table:
+    /// `dialog_settings_params` SKIPS a row whose owning param it cannot find,
+    /// so renaming a subnet param quietly shortens the Settings half and
+    /// nothing says why. Same argument as
+    /// `test_every_menu_command_names_a_label_that_is_dispatched`.
+    #[test]
+    fn dialog_settings_rows_name_owners_that_exist() {
+        use crate::dialog::Owner;
+        let state = State::new(false);
+        let session = state.session_node().expect("the root meta node");
+        for s in crate::dialog::SETTINGS {
+            match s.owner {
+                None => {}
+                Some(Owner::Subnet(subnet, name)) => {
+                    let node = session
+                        .children
+                        .iter()
+                        .find(|c| c.name == subnet)
+                        .unwrap_or_else(|| panic!("no '{subnet}' subnet for row '{}'", s.label));
+                    assert!(
+                        node.params.iter().any(|p| p.name == name),
+                        "'{subnet}' has no param '{name}' — row '{}' would vanish",
+                        s.label
+                    );
+                }
+                Some(Owner::Command(id)) => assert!(
+                    crate::command::by_id(id).is_some(),
+                    "row '{}' names no command '{id}'",
+                    s.label
+                ),
+                // The active camera's params exist only once a camera node
+                // does; the Default Camera branch is exercised below.
+                Some(Owner::ActiveCamera(_)) => {}
+            }
+        }
+    }
+
+    /// Row labels are the writeback's identity — `param_display` keys a row by
+    /// its label and `sync_dialog_settings_to_project` resolves it back the
+    /// same way — so two rows sharing one would write each other's values.
+    #[test]
+    fn dialog_settings_labels_are_unique() {
+        let mut seen: Vec<&str> = Vec::new();
+        for s in crate::dialog::SETTINGS {
+            assert!(!seen.contains(&s.label), "two Settings rows are called '{}'", s.label);
+            seen.push(s.label);
+        }
+    }
+
+    /// The dialog opens on its registry command, lists every command, and
+    /// closes on Escape.
+    #[test]
+    fn dialog_opens_on_its_command_and_escape_closes_it() {
+        let mut state = State::new(false);
+        assert!(!state.dialog_visible(), "closed until asked for");
+
+        assert!(state.run_command("toggle_dialog"));
+        assert!(state.dialog_visible());
+        assert_eq!(
+            state.slots.dialog.rows.len(),
+            crate::command::COMMANDS.len(),
+            "an empty query lists everything"
+        );
+
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
+        assert!(!state.dialog_visible());
+    }
+
+    /// Typing filters, and Enter runs the row it landed on — then closes,
+    /// because a modal that stays up after acting hides what it just did.
+    #[test]
+    fn dialog_filters_as_you_type_and_enter_runs_the_selection() {
+        let mut state = State::new(false);
+        state.run_command("toggle_dialog");
+
+        for c in ["s", "q", "u", "a"] {
+            state.dialog_key_input(&typed(c));
+        }
+        assert_eq!(state.slots.dialog.query, "squa");
+        assert_eq!(
+            state.slots.dialog.selected_id(),
+            Some("toggle_square_viewport"),
+            "rows: {:?}",
+            state.slots.dialog.rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>()
+        );
+
+        let before = state.square_viewport;
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+        assert_eq!(state.square_viewport, !before, "Enter ran the command");
+        assert!(!state.dialog_visible(), "and closed behind it");
+    }
+
+    /// Backspace walks the query back, and the ranking follows it.
+    #[test]
+    fn dialog_backspace_widens_the_filter() {
+        let mut state = State::new(false);
+        state.run_command("toggle_dialog");
+        for c in ["z", "z", "z"] {
+            state.dialog_key_input(&typed(c));
+        }
+        assert!(state.slots.dialog.rows.is_empty(), "nothing matches 'zzz'");
+        for _ in 0..3 {
+            state.dialog_key_input(&key_press(Key::Named(NamedKey::Backspace)));
+        }
+        assert_eq!(state.slots.dialog.query, "");
+        assert_eq!(state.slots.dialog.rows.len(), crate::command::COMMANDS.len());
+    }
+
+    /// The dialog owns the keyboard outright while it is open.
+    ///
+    /// The network pane's bare-letter family is ungated by design, so typing
+    /// "e" into an unguarded filter would flip the selected node's geometry
+    /// toggle on the way past. The guard is the whole reason
+    /// `dialog_key_input` is total rather than a layer.
+    #[test]
+    fn dialog_keys_never_reach_the_pane_underneath() {
+        use crate::window::WindowEvent;
+        let mut state = State::new(false);
+        state.focused_pane = crate::slots::LEFT_MENUBAR_IDX;
+        let col = state.grid_cursor_col;
+        state.run_command("toggle_dialog");
+
+        // "l" is Cursor Right in the network pane and a plain letter here.
+        state.handle_event(&WindowEvent::KeyboardInput { event: typed("l") });
+        assert_eq!(state.grid_cursor_col, col, "the grid cursor must not move");
+        assert_eq!(state.slots.dialog.query, "l");
+    }
+
+    /// Tab moves between the halves, and entering Settings lays its body out
+    /// and fills it.
+    #[test]
+    fn dialog_tab_switches_halves_and_settings_has_rows() {
+        use cce_ui::widget::WidgetHost as _;
+        use crate::dialog::Tab;
+        let mut state = State::new(false);
+        state.run_command("toggle_dialog");
+        assert_eq!(state.dialog_tab(), Tab::Commands);
+
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Tab)));
+        assert_eq!(state.dialog_tab(), Tab::Settings);
+        assert!(state.slots.dialog_params.visible(), "the settings body shows");
+        assert!(
+            state.positions[crate::slots::DIALOG_PARAMS_IDX].2 > 0.0,
+            "and is laid out inside the dialog"
+        );
+        // Every non-section row of the table, minus the ones whose owner is
+        // missing in a fresh project — there are none, per the test above.
+        let rows = state.dialog_settings_shown.clone();
+        assert_eq!(rows.len(), crate::dialog::SETTINGS.len());
+        assert!(rows.iter().any(|(k, _, t)| k == "Show Grid" && t == "toggle"));
+        assert!(rows.iter().any(|(k, _, t)| k == "Grid Color" && t == "color"));
+        assert!(rows.iter().any(|(k, _, t)| k == "Grid Thickness" && t.starts_with("spinbox")));
+
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Tab)));
+        assert_eq!(state.dialog_tab(), Tab::Commands);
+        assert!(!state.slots.dialog_params.visible());
+    }
+
+    /// A Settings row writes to whatever OWNS its value, not to the live field
+    /// — which is the only write that survives, since
+    /// `apply_settings_from_menubar_subnets` copies the subnets over the live
+    /// state on every param change.
+    #[test]
+    fn dialog_settings_write_reaches_the_owning_subnet() {
+        use crate::dialog::Tab;
+        let mut state = State::new(false);
+        state.run_command("toggle_dialog");
+        state.set_dialog_tab(Tab::Settings);
+
+        let was = state.viewport().show_grid;
+        // What a click on the toggle leaves behind: the control reports the
+        // flipped value, and the poll picks it up.
+        let mut rows = state.dialog_settings_shown.clone();
+        let row = rows.iter_mut().find(|(k, _, _)| k == "Show Grid").expect("the Show Grid row");
+        row.1 = if was { "false" } else { "true" }.to_string();
+        state.slots.dialog_params_mut().set_display_params(&rows);
+        state.sync_dialog_settings_to_project();
+
+        assert_eq!(state.viewport().show_grid, !was, "the live state followed");
+        let guides = state
+            .session_node()
+            .expect("meta")
+            .children
+            .iter()
+            .find(|c| c.name == "Guides")
+            .expect("Guides");
+        let p = guides.params.iter().find(|p| p.name == "Show Grid Guide").expect("the param");
+        assert_eq!(p.default == "true", !was, "and so did its owner");
+    }
+
+    /// Reopening starts clean: on Commands, with an empty query.
+    #[test]
+    fn dialog_reopens_without_the_last_search() {
+        use crate::dialog::Tab;
+        let mut state = State::new(false);
+        state.run_command("toggle_dialog");
+        state.dialog_key_input(&typed("g"));
+        state.set_dialog_tab(Tab::Settings);
+        state.run_command("toggle_dialog");
+        assert!(!state.dialog_visible());
+
+        state.run_command("toggle_dialog");
+        assert_eq!(state.slots.dialog.query, "");
+        assert_eq!(state.dialog_tab(), Tab::Commands);
+    }
 }
diff --git a/src/render.rs b/src/render.rs
index 5866dc8..7aa04e1 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -9,6 +9,7 @@ use crate::slots::{
     BREADCRUMB_IDX, HEADER_IDX, RIGHT_MENUBAR_IDX,
     SPREADSHEET_MENUBAR_IDX, SPREADSHEET_IDX,
     LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
+    DIALOG_IDX, DIALOG_PARAMS_IDX,
 };
 use crate::geometry::network_sphere_vertices_with_errors;
 use cce_ui::scene::layout::Rect;
@@ -169,6 +170,14 @@ impl State {
             if !self.slots.get_dyn(i).visible() {
                 continue;
             }
+            // The dialog is painted after the overlay passes below, not in the
+            // walk. A high z_index is not enough: `append_frame_text` and the
+            // viewport overlays run AFTER the whole walk, so the graph's node
+            // labels and the scale readout drew straight over a dialog that
+            // had already covered them.
+            if i == DIALOG_IDX || i == DIALOG_PARAMS_IDX {
+                continue;
+            }
             unsafe {
                 self.paint_element(&*widget_ptrs[i], &mut pc, show_cursor, &mut visited, clip, clip_circle);
             }
@@ -182,6 +191,9 @@ impl State {
         self.append_popovers(&mut pc);
         self.append_dock_drag_overlay(&mut pc);
         self.append_plate_corners(&mut pc);
+        // Above every pane AND every overlay text pass, below only the context
+        // menu — which can be opened from inside it.
+        self.append_dialog(&mut pc, show_cursor, &mut visited, clip);
 
         // The context menu (node/viewport right-click AND the plate corner
         // menus — one shared state) floats above everything, drawn last as
@@ -264,6 +276,45 @@ impl State {
             fill[3] = fill[3].abs();
             pc.circle(cx, cy, r, fill);
             pc.arc(cx, cy, r, 3.0, 0.0, TAU, [0.35, 0.65, 0.95, 0.80 * self.network_opacity]);
+        } else if idx == DIALOG_IDX {
+            // Modern-paint surface, the playbar's contract: the designer
+            // authors the plate (the dialog floats, so the pane radii and the
+            // focus tint do not apply — it is never a pane and never the
+            // focused one), then Dialog::paint emits the tab strip, the query
+            // line and the rows. A subtree painter, so append_frame_text skips
+            // the slot and the chord column keeps its own font and bounds.
+            append_widget_plate(w, pc);
+            w.paint_self(&self.ui_context, pc);
+        } else if idx == DIALOG_PARAMS_IDX {
+            // The dialog's settings body: PARAM_IDX's arm without the plate,
+            // because it is laid out INSIDE the dialog's plate and a second
+            // one would draw a panel on a panel. The scrollbar straddle goes
+            // with it — over a plate it is not straddling, it is just on top.
+            let (px, py, pw, ph) = self.positions[DIALOG_PARAMS_IDX];
+            let view = rect(px, py, pw, ph);
+            pc.clip(view, |pc| {
+                w.paint_self(&self.ui_context, pc);
+            });
+            let scrollbar = self
+                .slots
+                .dialog_params
+                .as_any()
+                .downcast_ref::<cce_ui::widget::ParametersBg>()
+                .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+                .scrollbar_visible()
+                .then(|| {
+                    self.slots
+                        .dialog_params
+                        .as_any()
+                        .downcast_ref::<cce_ui::widget::ParametersBg>()
+                        .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+                        .scrollbar_quads()
+                });
+            if let Some(quads) = scrollbar {
+                for &(qx, qy, qw, qh, qc) in &quads {
+                    pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
+                }
+            }
         } else if idx == PLAYBAR_IDX {
             // Modern-paint pane: the plate from the legacy views like the other
             // panes, then paint_self emits the transport controls — geometry AND
@@ -720,7 +771,13 @@ impl State {
             // pass already (see paint_widget: subtree text for the playbar
             // and spreadsheet, the own-labels bridge for the params pane) —
             // drawing them here again would double it.
-            if is_menubar || i == PLAYBAR_IDX || i == PARAM_IDX || i == SPREADSHEET_IDX {
+            if is_menubar
+                || i == PLAYBAR_IDX
+                || i == PARAM_IDX
+                || i == SPREADSHEET_IDX
+                || i == DIALOG_IDX
+                || i == DIALOG_PARAMS_IDX
+            {
                 continue;
             }
             let is_node = i == CONTENT_IDX;
@@ -810,6 +867,72 @@ impl State {
         }
     }
 
+    /// The Alt+D dialog, last of the pane content: its plate and command list,
+    /// its settings body, and that body's popovers.
+    ///
+    /// Out of the widget walk entirely, because the walk is not the end of the
+    /// frame — `append_frame_text` and the viewport overlays follow it, and
+    /// they drew the graph's node labels and the scale readout straight over
+    /// a dialog whose z_index had already put it on top of the same panes.
+    /// Only the context menu goes above this, and it can be opened from inside
+    /// the dialog.
+    fn append_dialog(
+        &self,
+        pc: &mut PaintCtx,
+        show_cursor: bool,
+        visited: &mut [bool],
+        clip: Rect,
+    ) {
+        if !self.slots.dialog.visible() {
+            return;
+        }
+        self.paint_widget(DIALOG_IDX, pc, show_cursor, visited, clip, None);
+        if !self.slots.dialog_params.visible() {
+            return;
+        }
+        // The settings body is painted twice, on purpose.
+        //
+        // Its labels have to carry the DIALOG's bounds or the occluder the
+        // dialog registers (see `Dialog::popover`) clamps them away: the
+        // clamp's exemption is bounds that COINCIDE with the occluder, and a
+        // params row's bounds are its row's. The first pass lays down the
+        // controls — its labels land inside the occluder and are clamped to
+        // nothing, which is exactly what should happen to a row-bounded label
+        // under this plate. The second pass re-emits only the text, retagged
+        // with the dialog's bounds, which is what is actually read.
+        //
+        // Two passes rather than one because a `PaintCtx` cannot be handed a
+        // prim back: text can be re-emitted through `text_with`, geometry
+        // cannot, so the geometry has to come from a pass that writes
+        // straight into `pc`. It costs a dozen labels' shaping while the
+        // Settings half is open.
+        let (dx, dy, dw, dh) = self.positions[DIALOG_IDX];
+        let own = Some([dx, dy, dx + dw, dy + dh]);
+        self.paint_widget(DIALOG_PARAMS_IDX, pc, show_cursor, visited, clip, None);
+        let mut scratch = PaintCtx::new();
+        visited[DIALOG_PARAMS_IDX] = false;
+        self.paint_widget(DIALOG_PARAMS_IDX, &mut scratch, show_cursor, visited, clip, None);
+        for item in scratch.finish().items {
+            if let Prim::Text { text, x, y, font_size, color, font, .. } = item.prim {
+                pc.text_with(text, x, y, font_size, color, font, own);
+            }
+        }
+
+        let mut popover_pc = cce_ui::layout::PopoverCollector::new();
+        self.slots.dialog_params.render_popover(&mut popover_pc);
+        for (color, px, py, pw, ph) in popover_pc.rects {
+            pc.quad(rect(px, py, pw, ph), color);
+        }
+        for (t, size, x, y, tc, font_opt, _) in popover_pc.texts {
+            let color = [
+                (tc[0] * 255.0).round().clamp(0.0, 255.0) as u8,
+                (tc[1] * 255.0).round().clamp(0.0, 255.0) as u8,
+                (tc[2] * 255.0).round().clamp(0.0, 255.0) as u8,
+            ];
+            pc.text_with(t, x, y, size, color, font_opt, own);
+        }
+    }
+
     fn append_popovers(&self, pc: &mut PaintCtx) {
         for i in 0..WIDGET_COUNT {
             let w = self.slots.get_dyn(i);
@@ -820,6 +943,11 @@ impl State {
             if is_menubar {
                 continue;
             }
+            if i == DIALOG_PARAMS_IDX {
+                // Drawn by `append_dialog`, after this pass: a popover of the
+                // dialog's belongs above the dialog, not under it.
+                continue;
+            }
             if self.focused_widget == Some(i) || i == PARAM_IDX {
                 let mut popover_pc = cce_ui::layout::PopoverCollector::new();
                 w.render_popover(&mut popover_pc);
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 30e451f..0f31e1d 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -25,6 +25,9 @@ pub enum Action {
     /// Open the command palette — a command like any other, so it is
     /// rebindable and lists itself.
     CommandPalette,
+    /// Open or close the in-app dialog (`src/dialog.rs`): the same commands,
+    /// plus the viewport/graph settings, without leaving the window.
+    ToggleDialog,
     /// Snap dragged handles in the active viewer state to a world increment.
     ToggleSnap,
     /// Enter or leave the selected node's viewer state.
diff --git a/src/slots.rs b/src/slots.rs
index 94b46e2..eec8f86 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -117,6 +117,12 @@ widget_roster! {
     // its place when the displayed level holds a page. Appended, like the
     // second network editor, so established slot indexes stay stable.
     PAGE_IDX:                page_view:           ImageView,
+    // The Alt+D dialog: the frame (plate, tabs, query line, command list) and
+    // its settings body, a second ParametersBg so a control in the dialog is
+    // the same control as one in the params pane. Appended, like every slot
+    // since the second network editor, so established indexes stay stable.
+    DIALOG_IDX:              dialog:              crate::dialog::Dialog,
+    DIALOG_PARAMS_IDX:       dialog_params:       ParametersBg,
 }
 
 impl WidgetSlots {
@@ -183,6 +189,24 @@ impl WidgetSlots {
         self.spreadsheet.as_any_mut().downcast_mut::<Spreadsheet>().expect("SPREADSHEET_IDX must be a Spreadsheet")
     }
 
+    /// The dialog's settings body. A `ParametersBg` like `PARAM_IDX`, reached
+    /// through the same controller trait — the writeback in
+    /// `sync_dialog_settings_to_project` is the params pane's, pointed at the
+    /// dialog's row table instead of the selected node's params.
+    pub fn dialog_params(&self) -> &dyn cce_ui::widget::ParamController {
+        self.dialog_params
+            .as_any()
+            .downcast_ref::<ParametersBg>()
+            .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+    }
+
+    pub fn dialog_params_mut(&mut self) -> &mut dyn cce_ui::widget::ParamController {
+        self.dialog_params
+            .as_any_mut()
+            .downcast_mut::<ParametersBg>()
+            .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+    }
+
     pub fn path_mut(&mut self) -> &mut dyn cce_ui::widget::PathController {
         self.breadcrumb.as_any_mut().downcast_mut::<Breadcrumb>().expect("BREADCRUMB_IDX must be a Breadcrumb")
     }