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

commit506649445d657c575549f98dfec1ab7db2b5eb21
parent753f5ef495
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 18:11
feat(visualizers): edit the attribute visualizers in the params HUD

Attribute Visualizers open in the params HUD instead of the dialog: a
Visualizer dropdown picks the one edited, Add and Delete beside it, its
settings as ordinary rows (the Visualize node's names and show_when
conditions), and Done back to the node. The rows are a pseudo-node's
parameters, so the HUD's own controls serve them; picking another node
also hands the HUD back. The viewport menu's row is a plain row now.

Removed with the dialog's visualizer modes: Mode::Visualizers /
VisualizerEdit, State::vis_editing, the visualizer row builders, and the
dialog's two-slider Float2 control, which only the Manual Range used.

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

 CLAUDE.md         |  83 ++++++++---------
 src/app.rs        |  30 ++++++-
 src/dialog.rs     | 253 +++-------------------------------------------------
 src/main.rs       | 242 +++++++++++++++----------------------------------
 src/menu_page.rs  |   7 +-
 src/visualizer.rs | 262 +++++++++++++++++++++++++++++++-----------------------
 6 files changed, 296 insertions(+), 581 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0f3d504..7237529 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2849,16 +2849,16 @@ 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 page rows: the viewport menu's **Style** and **Markers** (pages of rows;
+its **Attribute Visualizers** row was one too, into the dialog, until
+2026-10-06 — it is a plain row now, opening them in the params HUD); the
+network menu's **Add Node**
 (the dialog); the plate rows' **Add Tab** (a page, wherever the plate rows
 are); the node menu's **Rename** (the dialog). Inside the dialog the rows
 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. The
-mark rides the row's chord TEXT (cce-ui's `PAGE_MARK`), and a label that
-begins with `BACK_MARK` (the visualizer's Back to Visualizers) wears the
-other; the dialog's row painter draws them as the `chevron-right` /
+Markers. The mark rides the row's chord TEXT (cce-ui's `PAGE_MARK`), and a
+label that begins with `BACK_MARK` wears the other; the dialog's row painter draws them as the `chevron-right` /
 `chevron-left` glyphs, as the toolkit menu does, never as the characters
 (since 2026-10-05, the cce-icons rule: every symbol the app draws is a
 glyph). The menus' `● ` / `○ ` switch marks are cce-ui's `MARK_ON` /
@@ -2882,16 +2882,15 @@ glyph). The menus' `● ` / `○ ` switch marks are cce-ui's `MARK_ON` /
   `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
+  swipe back) takes it off. The palette's Group Markers row runs with the
+  palette still up, so it is on the trail. Opened afresh, the dialog
   has neither. A swipe back with neither does nothing.
 
 - **Every turn is animated** (cce-ui's `TURN_MS`, 180 ms): a page of rows
   by cce-ui itself; the dialog by `dialog::DialogTurn` the same way — the
   plate grows from the menu just put down (`open_dialog_from` reads its
   size off the hidden context menu), and a mode turned to while it is up
-  (Group Markers, a visualizer and back) slides its rows in from the side
+  (Group Markers and back) slides its rows in from the side
   it came from at the plate's own size. The render arm paints a turning
   dialog's content aside and replays it moved, clipped and faded
   (cce-ui's `Prim::faded`, geometry and text alike), its text bounded by
@@ -3511,25 +3510,31 @@ the later over the earlier, as a chain of Visualize nodes composites.
   "not in the scene" in the list; it is kept, since the scene it was made
   for may come back. Not in `--thumbnail` or `--export`, which have no
   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 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
-  that visualizer's settings as rows — the Visualize node's `show_when`
-  applied, so Ramp's rows or Vector's; Attribute and Group are choices
-  over the scene's attributes and groups; a Manual range is one
-  `Control::Float2` row (since 2026-10-06; two slider rows, From and To,
-  until then) over the attribute's range in the scene with a quarter to
-  spare — then Delete Visualizer and Back. Escape goes back to the list. Rows
-  re-read IN PLACE (`refresh_dialog_rows_in_place`, selection and scroll
-  kept), since switching the mode swaps rows under the one selected.
-  A new visualizer starts on the scene's first attribute that is not
-  `P`, `Cd` or `N`.
-
-`attribute_visualizers_are_edited_in_the_dialog_and_shown_on_the_scene`
-drives the dialog end to end.
+- **They are edited in the params HUD** (since 2026-10-06; until then in
+  the dialog, as its Visualizers and VisualizerEdit modes, which are gone
+  with the dialog's two-slider `Float2` control that only the Manual Range
+  used). The `attribute_visualizers` command — the palette's row, which
+  closes the palette, and a plain row of the viewport menu — sets
+  `State::vis_hud`, and the HUD shows, in place of the selected node's
+  parameters: a **Visualizer** dropdown picking the one edited (`#1 uv`),
+  **Add Visualizer** and **Delete Visualizer**, its settings — Enabled,
+  Attribute and Group as dropdowns over the scene's attributes and
+  groups, Mode, then Ramp, Range, a Manual Range `float2`, Blend and
+  Opacity, or Vector's Scale — and **Done**. The rows are a PSEUDO-NODE's
+  parameters (`visualizer_hud_params`), so `param_display`, the
+  `show_when` conditions, the separators and the controls are the HUD's
+  own; the write-back (`sync_visualizer_hud_back`, ahead of the node path
+  in `sync_parameters_to_project`) turns each changed row into the edit it
+  names, re-reading the rows when they change shape (another visualizer,
+  Mode, Range, Attribute, Add, Delete) and not during a slider drag, which
+  would drop the slider held. state.kdl is written at the frame
+  (`settings_save_pending`). Done hands the HUD back, and so does picking
+  another node (`vis_hud_from`, the node the HUD would have shown when it
+  opened). A new visualizer starts on the scene's first attribute that is
+  not `P`, `Cd` or `N`.
+
+`attribute_visualizers_are_edited_in_the_params_hud` drives the rows end
+to end.
 
 ### The Normal node writes point or vertex normals
 
@@ -3873,9 +3878,6 @@ rather than two:
   A marked name the scene has no group for marks nothing and is kept, so
   a group that comes and goes with a frame does not lose its switch.
   `the_group_markers_dialog_marks_a_groups_points` is the test.
-- `Mode::Visualizers` / `Mode::VisualizerEdit` (the
-  `attribute_visualizers` command) — the attribute visualizers and one
-  visualizer's settings; see "Attribute visualizers".
 - `Mode::AddNode` (**Tab**, in the network pane) — one list of node
   templates, and a pick that instantiates at the grid cursor. Tab is what
   opened it, so Tab closes it again. The query hint names the mode; there
@@ -3930,21 +3932,8 @@ selection stays where it was:
   pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
   range the pitch limits), landing through `set_zoom_percent`, which zooms
   about the cursor cell and re-reads the row, since `zoom` clamps.
-- `Float2` — two numbers over one range, `lo:hi`: the params pane's
-  float2, as two sliders SIDE BY SIDE in the row (the rows are one
-  height, so not stacked as the pane stacks them), each with its readout
-  ahead of it. The control begins `FLOAT2_W` (two `SLIDER_W`) in from the
-  row's right end and the label is clipped short of it. Each end is worked
-  as a slider is — a press on its band jumps and drags it, the wheel over
-  it turns it — and the arrows nudge the FIRST end, shift and the arrows
-  the second. One `Slider` stamp paints both. A change comes out of
-  `take_float2_change` and lands through `land_dialog_float2`. Only the
-  visualizer's Manual Range is one; state.kdl keeps it as `manual_range=lo:hi`,
-  and a file from before, with `from=` and `to=`, reads as the range.
-  `a_visualizers_manual_range_is_one_float2_row` drives it by pointer,
-  wheel and keys.
-- `Choice` — a fixed set (World Unit, GPU, Node Wire Style, the
-  visualizer settings): the PARAMS PANE'S DROPDOWN (since 2026-10-01,
+- `Choice` — a fixed set (World Unit, GPU, Node Wire Style): the PARAMS
+  PANE'S DROPDOWN (since 2026-10-01,
   the toolkit `Dropdown`), in the control band the sliders and colour
   wells use. Closed, a row draws `Dialog::dropdown_stamp` — one
   `Dropdown` handed each row's options and selection as it is painted,
diff --git a/src/app.rs b/src/app.rs
index 2c0229a..1f679a5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3430,8 +3430,13 @@ pub struct State {
     /// The attribute visualizers, applied in order to the displayed scene
     /// (`crate::visualizer`). Persisted in the viewport block.
     pub visualizers: Vec<crate::visualizer::Visualizer>,
-    /// The visualizer the dialog's VisualizerEdit page is editing.
-    pub vis_editing: Option<usize>,
+    /// The params HUD shows the attribute visualizers, editing visualizer
+    /// `i` (0 with none yet), in place of the selected node's parameters —
+    /// `visualizer::State::open_visualizers_hud`. `None` is the node.
+    pub vis_hud: Option<usize>,
+    /// What the HUD would have shown when the visualizers took it: picking
+    /// another node hands the HUD back to that node's parameters.
+    pub(crate) vis_hud_from: Option<ParamPaneTarget>,
     /// The half-span the Attribute node's Value row runs over, with the
     /// node it is for (`value_row_span`): kept between pane syncs so the row
     /// re-scales only when its value leaves it.
@@ -4962,6 +4967,11 @@ impl State {
     /// rows that were not loaded from the selected node are stale, and
     /// writing nothing is the only right thing to do with them.
     pub fn sync_parameters_to_project(&mut self) {
+        // The HUD showing the visualizers writes back to them, not a node.
+        if self.vis_hud.is_some() {
+            self.sync_visualizer_hud_back();
+            return;
+        }
         let mut file_to_open = None;
         if !self.is_detached_network && self.param_pane_source == self.param_pane_target() {
             if let Some(slot_idx) = self.param_editor_selected() {
@@ -5316,7 +5326,7 @@ impl State {
                 self.reset_parameters();
             }
             "Group Markers" => self.open_group_markers_dialog(),
-            "Attribute Visualizers" => self.open_visualizers_dialog(),
+            "Attribute Visualizers" => self.open_visualizers_hud(),
             "Rename Node" => match self.selected_slots().first().copied() {
                 Some(slot) => self.open_rename_dialog(slot),
                 None => self.update_status_text("Select a node to rename."),
@@ -5562,6 +5572,17 @@ impl State {
     }
 
     pub fn sync_parameters_pane(&mut self) {
+        // The attribute visualizers, when the HUD shows them — until another
+        // node is picked, which takes the HUD back.
+        if self.vis_hud.is_some() && self.param_pane_target() != self.vis_hud_from {
+            self.vis_hud = None;
+        }
+        if self.vis_hud.is_some() && !self.is_detached_network {
+            let rows = param_display(&self.visualizer_hud_params());
+            self.param_mut().set_display_params(&rows);
+            self.param_pane_source = None;
+            return;
+        }
         // Selection reads through the param-editor accessors: whichever
         // network editor took the last node click feeds the pane, at ITS
         // level — no matter the tab.
@@ -8734,7 +8755,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             show_vertex_normals: settings.viewport.show_vertex_normals,
             marked_groups: Self::marked_groups_of(&settings.viewport.marked_groups),
             visualizers: crate::visualizer::decode(&settings.viewport.visualizers),
-            vis_editing: None,
+            vis_hud: None,
+            vis_hud_from: None,
             value_row_span: None,
             float2_spans: None,
             scene_attributes: Vec::new(),
diff --git a/src/dialog.rs b/src/dialog.rs
index 4c3210e..bd8ee62 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -54,14 +54,6 @@ pub enum Mode {
     /// The palette TURNS INTO this list, the way it turns into the node
     /// list — one plate, one filter.
     Groups,
-    /// The `attribute_visualizers` command: the viewport's attribute
-    /// visualizers (`crate::visualizer`), a switch each, and Add
-    /// Visualizer. Picking a visualizer's row — off its switch — opens its
-    /// settings.
-    Visualizers,
-    /// One visualizer's settings (`State::vis_editing`) as rows: its
-    /// controls, Delete, and Back to the list, which Escape is too.
-    VisualizerEdit,
 }
 
 /// The control a row carries, drawn over its right end and worked in place —
@@ -77,11 +69,6 @@ pub enum Control {
     /// over the band turns it, a press on the band jumps to the pointer.
     /// A `dec` of zero snaps to whole numbers — the spinbox shape.
     Slider { value: f32, min: f32, max: f32, dec: usize, step: f32, suffix: &'static str },
-    /// Two numbers over one range, `lo:hi` — the params pane's `float2`:
-    /// two sliders side by side, each with its readout ahead of it, worked
-    /// as a [`Control::Slider`] is, an end at a time. The arrows nudge the
-    /// first end, shift and the arrows the second.
-    Float2 { values: [f32; 2], min: f32, max: f32, dec: usize, step: f32 },
     /// One of a fixed set: picking the row opens a dropdown of the options
     /// under its value ([`State::open_dialog_dropdown`]); the arrows step
     /// either way without it.
@@ -98,7 +85,6 @@ impl Control {
         match self {
             Control::Toggle(on) => if *on { "true" } else { "false" }.to_string(),
             Control::Slider { value, dec, .. } => format!("{:.*}", dec, value),
-            Control::Float2 { values, dec, .. } => format!("{:.*}:{:.*}", dec, values[0], dec, values[1]),
             Control::Choice { options, index } => options.get(*index).cloned().unwrap_or_default(),
             Control::Color { hex, .. } => hex.clone(),
         }
@@ -108,7 +94,7 @@ impl Control {
     /// readout shows none.
     fn quantize(&self, v: f32) -> f32 {
         match self {
-            Control::Slider { min, max, dec, .. } | Control::Float2 { min, max, dec, .. } => {
+            Control::Slider { min, max, dec, .. } => {
                 let v = v.clamp(min.min(*max), max.max(*min));
                 if *dec == 0 { v.round() } else { v }
             }
@@ -121,7 +107,6 @@ impl Control {
     fn slider_part(&self, k: usize) -> Option<(f32, f32, f32)> {
         match self {
             Control::Slider { value, min, max, .. } if k == 0 => Some((*value, *min, *max)),
-            Control::Float2 { values, min, max, .. } if k < 2 => Some((values[k], *min, *max)),
             _ => None,
         }
     }
@@ -131,7 +116,6 @@ impl Control {
         let q = self.quantize(v);
         match self {
             Control::Slider { value, .. } if k == 0 => *value = q,
-            Control::Float2 { values, .. } if k < 2 => values[k] = q,
             _ => {}
         }
     }
@@ -186,7 +170,7 @@ impl Row {
     /// Whether the row's control is made of sliders: a slider, or a
     /// float2's two.
     fn is_slider(&self) -> bool {
-        matches!(self.control, Some(Control::Slider { .. } | Control::Float2 { .. }))
+        matches!(self.control, Some(Control::Slider { .. }))
     }
 
     fn is_color(&self) -> bool {
@@ -234,11 +218,6 @@ pub const SLIDER_W: f32 = 180.0;
 /// would paint and never show.
 const READOUT_W: f32 = 60.0;
 const READOUT_GAP: f32 = 8.0;
-/// How far in from the row's right end a float2 row's control begins —
-/// twice a slider's, since it is two sliders, each with its readout.
-const FLOAT2_W: f32 = 2.0 * SLIDER_W;
-/// The gap between a float2's two halves.
-const FLOAT2_GAP: f32 = 12.0;
 /// Gap between the query line and the list.
 const GAP: f32 = 8.0;
 
@@ -355,9 +334,6 @@ pub struct Dialog {
     hover_ctl: Option<usize>,
     /// A row the pointer activated, drained by the app.
     activated: Option<String>,
-    /// Whether the press that set `activated` was on the row's control
-    /// (its switch) rather than on the rest of the row.
-    activated_control: bool,
     /// The choice rows' closed dropdowns: one toolkit `Dropdown`, handed
     /// each row's options and selection as the row is painted, as the
     /// toggle and slider stamps are.
@@ -411,8 +387,6 @@ pub struct Dialog {
     slider_track: (f32, f32),
     /// The row and value the pointer moved a slider to, drained by the app.
     slider_change: Option<(String, f32)>,
-    /// The row and values the pointer moved a float2 to, drained by the app.
-    float2_change: Option<(String, [f32; 2])>,
     /// One toolkit colour selector per colour row, by row id — real widgets,
     /// not stamps, because each carries state of its own: a hex edit in
     /// progress, a picker process streaming values. Kept across
@@ -446,7 +420,6 @@ impl Dialog {
             hover_row: None,
             hover_ctl: None,
             activated: None,
-            activated_control: false,
             dropdown_stamp: RefCell::new(Dropdown::new(Vec::new(), 0)),
             dropdown: Box::new(Dropdown::new(Vec::new(), 0)),
             dropdown_row: None,
@@ -459,7 +432,6 @@ impl Dialog {
             hover_part: 0,
             slider_track: (0.0, 1.0),
             slider_change: None,
-            float2_change: None,
             colors: Vec::new(),
             color_changes: Vec::new(),
         });
@@ -664,12 +636,6 @@ impl Dialog {
         self.activated.take()
     }
 
-    /// Whether the last activation was a press on the row's control —
-    /// read before [`Self::take_activated`] or after, it is the same press.
-    pub fn activated_on_control(&self) -> bool {
-        self.activated_control
-    }
-
     /// Put the scroll back where it was after a re-rank, clamped to the
     /// list the rows now make.
     pub fn restore_scroll_px(&mut self, px: f32) {
@@ -702,16 +668,11 @@ impl Dialog {
         self.slider_change.take()
     }
 
-    pub fn take_float2_change(&mut self) -> Option<(String, [f32; 2])> {
-        self.float2_change.take()
-    }
-
     /// Say what row `i`'s sliders now hold, for the app to land.
     fn note_slider_change(&mut self, i: usize) {
         let Some(row) = self.rows.get(i) else { return };
         match row.control {
             Some(Control::Slider { value, .. }) => self.slider_change = Some((row.id.clone(), value)),
-            Some(Control::Float2 { values, .. }) => self.float2_change = Some((row.id.clone(), values)),
             _ => {}
         }
     }
@@ -817,11 +778,6 @@ impl Dialog {
                 height: TOGGLE_H,
             }),
             Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
-            Some(Control::Float2 { .. }) => {
-                let parts = self.float2_parts(r);
-                let (a, b) = (parts[0].1, parts[1].1);
-                Some(Rect { width: b.x + b.width - a.x, ..a })
-            }
             Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
             Some(Control::Choice { .. }) => Some(self.slider_band_rect(r)),
             None => None,
@@ -841,28 +797,11 @@ impl Dialog {
         Rect { x: b.x - READOUT_W - READOUT_GAP, width: b.width + READOUT_W + READOUT_GAP, ..b }
     }
 
-    /// A float2 row's two sliders, each as (band, the band with its
-    /// readout lane ahead of it): the slider control's whole span — from
-    /// [`FLOAT2_W`] in from the row's right end out to the band's right
-    /// edge — halved, with [`FLOAT2_GAP`] between the halves.
-    fn float2_parts(&self, r: Rect) -> [(Rect, Rect); 2] {
-        let band = self.slider_band_rect(r);
-        let x0 = r.x + r.width - 8.0 - FLOAT2_W;
-        let half = ((band.x + band.width - x0 - FLOAT2_GAP) * 0.5).max(READOUT_W + READOUT_GAP + 10.0);
-        let part = |k: usize| {
-            let whole = Rect { x: x0 + k as f32 * (half + FLOAT2_GAP), width: half, ..band };
-            let lane = READOUT_W + READOUT_GAP;
-            (Rect { x: whole.x + lane, width: half - lane, ..whole }, whole)
-        };
-        [part(0), part(1)]
-    }
-
     /// A slider row's sliders, each as (band, the whole control): one for a
     /// slider, two for a float2.
     fn slider_parts(&self, r: Rect, row: &Row) -> Vec<(Rect, Rect)> {
         match row.control {
             Some(Control::Slider { .. }) => vec![(self.slider_band_rect(r), self.slider_rect(r))],
-            Some(Control::Float2 { .. }) => self.float2_parts(r).to_vec(),
             _ => Vec::new(),
         }
     }
@@ -1102,8 +1041,6 @@ impl Paint for Dialog {
                     Mode::AddNode => "Add Node: type to filter nodes",
                     Mode::Rename => "Rename: type the node's name",
                     Mode::Groups => "Group Markers: type to filter groups",
-                    Mode::Visualizers => "Attribute Visualizers: type to filter",
-                    Mode::VisualizerEdit => "Visualizer: type to filter its settings",
                 },
                 q_w,
             );
@@ -1145,7 +1082,6 @@ impl Paint for Dialog {
                 Mode::AddNode => "No matching node",
                 Mode::Rename => "No node to rename",
                 Mode::Groups => "No point group in the scene",
-                Mode::Visualizers | Mode::VisualizerEdit => "No matching row",
             };
             ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
             return;
@@ -1207,7 +1143,6 @@ impl Paint for Dialog {
             let chord_right = r.x + r.width - 8.0 - ctl_col;
             let label_right = match &row.control {
                 Some(Control::Choice { .. }) => self.slider_band_rect(r).x - 12.0,
-                Some(Control::Float2 { .. }) => self.float2_parts(r)[0].1.x - 12.0,
                 _ => chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
             };
             let glyph_y = r.y + (r.height - chev) * 0.5;
@@ -1273,21 +1208,6 @@ impl Paint for Dialog {
                         own,
                     );
                 }
-                Some(Control::Float2 { values, min, max, dec, .. }) => {
-                    // The Slider arm twice, an end each.
-                    for (k, (band, _)) in self.float2_parts(r).into_iter().enumerate() {
-                        {
-                            let mut stamp = self.slider_stamp.borrow_mut();
-                            stamp.set_range(*min, *max);
-                            stamp.set_scaled_value(values[k]);
-                            stamp.set_hovered(self.hover_ctl == Some(i) && self.hover_part == k);
-                            Paint::paint(&**stamp, band, ctx);
-                        }
-                        let readout = format!("{:.*}", *dec, values[k]);
-                        let rw = display::measure_text_width(&readout, &family, font_size);
-                        ctx.text_with(readout, band.x - READOUT_GAP - rw, ty, font_size, label_color, Some(family.clone()), own);
-                    }
-                }
                 Some(Control::Color { .. }) => {
                     if let Some(sel) = self.color_selector(&row.id) {
                         let band = self.slider_band_rect(r);
@@ -1446,9 +1366,6 @@ impl Input for Dialog {
                         return true;
                     }
                     self.activated = self.rows.get(i).map(|r| r.id.clone());
-                    self.activated_control = r
-                        .and_then(|r| self.control_rect(r, &self.rows[i]))
-                        .is_some_and(|c| *x >= c.x && *x < c.x + c.width && *y >= c.y && *y < c.y + c.height);
                     return true;
                 }
                 // Inside the plate but on no control: consumed anyway, so the
@@ -1561,7 +1478,6 @@ impl Input for Dialog {
 
 use crate::app::State;
 use crate::command::Context;
-use crate::visualizer::{VIS_ADD_ROW_ID, VIS_FIELD_PREFIX, VIS_ROW_PREFIX};
 use crate::slots::DIALOG_IDX;
 
 /// The list's zoom row: not a registry command but a control — a slider
@@ -1737,13 +1653,13 @@ pub use cce_ui::widget::context_menu::{BACK_MARK, PAGE_MARK};
 const GLYPH_GAP: f32 = 6.0;
 
 /// 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.
+/// palette's Group Markers. A press runs it, as before; a side swipe forward
+/// with the pointer on it does too. (Attribute Visualizers led into the
+/// dialog's visualizer list until 2026-10-06; they are edited in the params
+/// HUD now, and the row closes the palette and opens them there.)
 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),
+        Mode::Commands => matches!(id, "group_markers"),
         _ => false,
     }
 }
@@ -1811,14 +1727,7 @@ impl State {
     /// 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),
-        }
+        self.open_dialog_in(mode);
     }
 
     /// Wire the node just added (the level's last) into the wire from the
@@ -1867,35 +1776,6 @@ impl State {
         self.open_dialog_in(Mode::Groups);
     }
 
-    /// The Attribute Visualizers list: the `attribute_visualizers` command.
-    pub fn open_visualizers_dialog(&mut self) {
-        self.vis_editing = None;
-        self.open_dialog_in(Mode::Visualizers);
-    }
-
-    /// Visualizer `i`'s settings, in the same plate — the list turned into
-    /// the one visualizer, as the palette turns into Group Markers.
-    pub fn open_visualizer_editor(&mut self, i: usize) {
-        if i >= self.visualizers.len() {
-            return;
-        }
-        self.vis_editing = Some(i);
-        self.open_dialog_in(Mode::VisualizerEdit);
-    }
-
-    /// Rebuild the rows for a change that adds or drops some — Mode flipping
-    /// Ramp's rows for Vector's — keeping the selected row and the scroll,
-    /// as a control edited in place does.
-    fn refresh_dialog_rows_in_place(&mut self) {
-        let selected = self.slots.dialog.selected_id().map(str::to_string);
-        let scroll = self.slots.dialog.scroll_px;
-        self.refresh_dialog_rows();
-        if let Some(i) = selected.and_then(|id| self.slots.dialog.rows.iter().position(|r| r.id == id)) {
-            self.slots.dialog.selected = i;
-        }
-        self.slots.dialog.restore_scroll_px(scroll);
-    }
-
     /// What renaming the dialog's node to `typed` would do: the name it
     /// has and the one it would get, or why not. None when the node is
     /// gone.
@@ -1954,8 +1834,6 @@ impl State {
             Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
             Mode::Rename => "Rename: type the name, Enter renames, Escape closes.",
             Mode::Groups => "Group Markers: Enter or a click marks a group's points in the scene, Escape closes.",
-            Mode::Visualizers => "Attribute Visualizers: a switch turns one on or off, Enter or a click edits it, Escape closes.",
-            Mode::VisualizerEdit => "Visualizer: change a setting in place; Escape goes back to the list.",
         });
     }
 
@@ -2116,18 +1994,6 @@ impl State {
                 }
                 rows
             }
-            // The visualizers, then one visualizer's settings, ranked by
-            // label as every list is.
-            Mode::Visualizers | Mode::VisualizerEdit => {
-                let all = if self.slots.dialog.mode == Mode::Visualizers {
-                    self.visualizer_rows()
-                } else {
-                    self.vis_editing.map(|i| self.visualizer_edit_rows(i)).unwrap_or_default()
-                };
-                let labels: Vec<&str> = all.iter().map(|r| r.label.as_str()).collect();
-                let order = crate::command::fuzzy_rank(&query, &labels);
-                order.into_iter().map(|i| all[i].clone()).collect()
-            }
             // The scene's point groups, ranked by name, a switch each and the
             // member count in the chord column.
             Mode::Groups => {
@@ -2312,12 +2178,6 @@ impl State {
     /// moved, and re-ranking would throw the selection back to the top of a
     /// list the user is still working down.
     pub(crate) fn refresh_dialog_controls(&mut self) {
-        if matches!(self.slots.dialog.mode, Mode::Visualizers | Mode::VisualizerEdit) {
-            // Labels follow the settings ("mass — Ramp, Heat"), and the edit
-            // page's rows follow its Mode and Range, so these re-read whole.
-            self.refresh_dialog_rows_in_place();
-            return;
-        }
         if self.slots.dialog.mode == Mode::Groups {
             let ids: Vec<String> = self.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
             for id in ids {
@@ -2661,13 +2521,7 @@ impl State {
         }
         match &event.logical_key {
             Key::Named(NamedKey::Escape) => {
-                // A visualizer's settings are a page of the list: Escape
-                // goes back to it, and from there closes.
-                if self.slots.dialog.mode == Mode::VisualizerEdit {
-                    self.open_visualizers_dialog();
-                } else {
-                    self.close_dialog();
-                }
+                self.close_dialog();
                 return true;
             }
             Key::Named(NamedKey::Tab) => {
@@ -2751,13 +2605,6 @@ impl State {
                 let Some(v) = self.slots.dialog.rows[i].slider_value() else { return };
                 self.land_dialog_slider(&id, v);
             }
-            Control::Float2 { values, step, .. } => {
-                let i = self.slots.dialog.selected;
-                let k = self.modifiers.shift_key() as usize;
-                self.slots.dialog.set_slider_part(i, k, values[k] + dir as f32 * step);
-                let Some(Control::Float2 { values, .. }) = self.slots.dialog.selected_control().cloned() else { return };
-                self.land_dialog_float2(&id, values);
-            }
             Control::Choice { options, index } => {
                 if options.is_empty() {
                     return;
@@ -2771,15 +2618,11 @@ impl State {
     }
 
     /// A choice row's new option, from its dropdown or an arrow key: a
-    /// setting row writes its setting, a visualizer's row its field.
+    /// setting row writes its setting.
     pub(crate) fn land_dialog_choice(&mut self, id: &str, value: &str) {
         if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
             let label = label.to_string();
             self.apply_setting(&label, value);
-        } else if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
-            let field = field.to_string();
-            self.set_visualizer_field(i, &field, value, true);
-            self.refresh_dialog_controls();
         }
     }
 
@@ -2930,32 +2773,7 @@ impl State {
     /// arrow key is a single landing and saves at once, as the menu's wheel
     /// does. A spin row lands its whole number over the row's unit. A row the landing
     /// does not know falls through to `apply_setting`.
-    /// A float2 row's new values, from the pointer or an arrow key. Only a
-    /// visualizer's Manual Range is one; it lands as its sliders do.
-    pub(crate) fn land_dialog_float2(&mut self, id: &str, v: [f32; 2]) {
-        let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) else { return };
-        let field = field.to_string();
-        let save = !self.slots.dialog.slider_dragging();
-        self.set_visualizer_field(i, &field, &format!("{}:{}", v[0], v[1]), save);
-        if let Some(row) = self.visualizer_edit_rows(i).into_iter().find(|r| r.id == id) {
-            self.slots.dialog.set_control(id, row.control);
-        }
-    }
-
     pub(crate) fn land_dialog_slider(&mut self, id: &str, v: f32) {
-        if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
-            // In place: the rows do not change under a drag, and a rebuild
-            // would drop the scroll it is being dragged at. The scene is
-            // re-presented, not re-evaluated; state.kdl is written on the
-            // release, or now for a wheel notch or an arrow.
-            let field = field.to_string();
-            let save = !self.slots.dialog.slider_dragging();
-            self.set_visualizer_field(i, &field, &v.to_string(), save);
-            if let Some(row) = self.visualizer_edit_rows(i).into_iter().find(|r| r.id == id) {
-                self.slots.dialog.set_control(id, row.control);
-            }
-            return;
-        }
         if id == ZOOM_ROW_ID {
             self.set_zoom_percent(v);
         } else if let Some(s) = setting_of_row(id) {
@@ -3000,52 +2818,10 @@ impl State {
     /// does nothing; the controls re-read, and the selection stays where it
     /// was — by Enter or by a click, since both arrive here.
     pub(crate) fn take_dialog_pick(&mut self, id: String) {
-        self.take_dialog_pick_at(id, false);
-    }
-
-    /// [`Self::take_dialog_pick`], told whether the press was on the row's
-    /// CONTROL: in the visualizer list the switch turns a visualizer on or
-    /// off, and the rest of the row opens it.
-    pub(crate) fn take_dialog_pick_at(&mut self, id: String, on_control: bool) {
         let mode = self.slots.dialog.mode;
         if mode == Mode::Commands && id == ZOOM_ROW_ID {
             return;
         }
-        if mode == Mode::Visualizers {
-            if id == VIS_ADD_ROW_ID {
-                let i = self.add_visualizer();
-                self.open_visualizer_editor(i);
-            } else if let Some(i) = id.strip_prefix(VIS_ROW_PREFIX).and_then(|i| i.parse::<usize>().ok()) {
-                if on_control {
-                    let on = !self.visualizers.get(i).is_some_and(|v| v.enabled);
-                    self.set_visualizer_enabled(i, on);
-                    self.refresh_dialog_controls();
-                } else {
-                    self.open_visualizer_editor(i);
-                }
-            }
-            return;
-        }
-        if mode == Mode::VisualizerEdit {
-            let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) else { return };
-            match field {
-                "back" => self.open_visualizers_dialog(),
-                "delete" => {
-                    self.delete_visualizer(i);
-                    self.open_visualizers_dialog();
-                }
-                _ => match self.slots.dialog.rows.iter().find(|r| r.id == id).and_then(|r| r.control.clone()) {
-                    Some(Control::Toggle(on)) => {
-                        let field = field.to_string();
-                        self.set_visualizer_field(i, &field, if on { "false" } else { "true" }, true);
-                        self.refresh_dialog_controls();
-                    }
-                    Some(Control::Choice { .. }) => self.open_dialog_dropdown(&id),
-                    _ => {}
-                },
-            }
-            return;
-        }
         // A group's row is its switch: flipped in place, the list stays up.
         if mode == Mode::Groups {
             if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
@@ -3130,7 +2906,7 @@ impl State {
                     Ok(said) | Err(said) => self.update_status_text(&said),
                 }
             }
-            Mode::Groups | Mode::Visualizers | Mode::VisualizerEdit => {}
+            Mode::Groups => {}
             Mode::AddNode => {
                 // A free cursor cell a wire runs through is a place in that
                 // chain: the new node is spliced into the wire, as a node
@@ -3167,18 +2943,13 @@ impl State {
     pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
         let mut changed = false;
         if let Some(id) = self.slots.dialog.take_activated() {
-            let on_control = self.slots.dialog.activated_on_control();
-            self.take_dialog_pick_at(id, on_control);
+            self.take_dialog_pick(id);
             changed = true;
         }
         if let Some((id, v)) = self.slots.dialog.take_slider_change() {
             self.land_dialog_slider(&id, v);
             changed = true;
         }
-        if let Some((id, v)) = self.slots.dialog.take_float2_change() {
-            self.land_dialog_float2(&id, v);
-            changed = true;
-        }
         for (id, hex) in self.slots.dialog.take_color_changes() {
             if let Some(s) = setting_of_row(&id) {
                 self.apply_setting(s.label, &hex);
diff --git a/src/main.rs b/src/main.rs
index 4a9a8ec..adea491 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2669,8 +2669,8 @@ mod tests {
     /// 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.
+    /// row is a row of the menu, not a page: it closes the menu and opens
+    /// them in the params HUD. A row of the menu itself still closes it.
     #[test]
     fn the_viewport_menu_turns_into_its_pages_and_back() {
         use crate::app::{ViewportMenuAction as A, ViewportMenuPage as P};
@@ -2702,7 +2702,8 @@ mod tests {
         };
         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!([style, markers].iter().all(|&i| context_menu::leads_to_page(i)), "the two pages are page rows");
+        assert!(!context_menu::leads_to_page(vis), "the visualizers are the HUD's, not a page");
         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);
@@ -2734,24 +2735,21 @@ mod tests {
         press(&mut state);
         assert_eq!(state.viewport_menu_page, None, "a press on the back band turns back");
 
-        // 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);
         press(&mut state);
         assert!(!state.viewport_menu_open(), "a row of the menu closes it");
+
+        // The visualizers' row too, and the HUD shows them.
+        state.cursor_x = 300.0;
+        state.cursor_y = 200.0;
+        state.open_viewport_context_menu();
+        let vis = row_of(&state, A::Command("attribute_visualizers"));
+        move_to(&mut state, context_menu::x() + 20.0, context_menu::row_y(vis) + 12.0);
+        press(&mut state);
+        assert!(!state.viewport_menu_open() && !state.dialog_visible());
+        assert!(state.vis_hud.is_some(), "the visualizers are in the params HUD");
     }
 
     /// The primitive and vertex overlays read off the scene as the points'
@@ -8359,12 +8357,11 @@ mod tests {
     /// following the geometry through a rebuild and persisted with the
     /// display settings.
     /// Attribute visualizers: added, edited, switched and deleted in the
-    /// dialog, applied to the displayed scene with no node in the graph,
-    /// and kept with the display settings.
+    /// params HUD — the pane's own rows, worked as a node's are — applied
+    /// to the displayed scene with no node in the graph, kept with the
+    /// display settings, and handed back to the node by Done or a pick.
     #[test]
-    fn attribute_visualizers_are_edited_in_the_dialog_and_shown_on_the_scene() {
-        use crate::dialog::Mode;
-        use crate::visualizer::{VIS_ADD_ROW_ID, VIS_FIELD_PREFIX, VIS_ROW_PREFIX};
+    fn attribute_visualizers_are_edited_in_the_params_hud() {
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
         state.rebuild_positions();
@@ -8374,174 +8371,79 @@ mod tests {
         let colours = |state: &State| state.rt_sphere_verts.iter().map(|v| v.color).collect::<Vec<_>>();
         let plain = colours(&state);
         let nodes_before = serde_json::to_string(&state.fs_root).unwrap();
+        let keys = |state: &State| state.param().node_params().into_iter().map(|r| r.0).filter(|k| !k.is_empty()).collect::<Vec<_>>();
+        // The pane reporting a row at a value, as a press or a drag does.
+        let pane = |state: &mut State, key: &str, value: &str| {
+            let rows: Vec<(String, String, String)> = state
+                .param()
+                .node_params()
+                .into_iter()
+                .map(|(k, v, t)| if k == key { (k, value.to_string(), t) } else { (k, v, t) })
+                .collect();
+            assert!(rows.iter().any(|r| r.0 == key), "no {key} row in {:?}", rows.iter().map(|r| &r.0).collect::<Vec<_>>());
+            state.param_mut().set_display_params(&rows);
+            state.sync_parameters_to_project();
+        };
 
-        // The list, empty but for Add Visualizer.
+        // In the HUD, not the dialog: Add and Done, nothing to edit yet.
         assert!(state.run_command("attribute_visualizers"));
-        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
-        assert_eq!(state.slots.dialog.rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), vec![VIS_ADD_ROW_ID]);
+        assert!(!state.dialog_visible());
+        assert_eq!(state.vis_hud, Some(0));
+        assert_eq!(keys(&state), ["Add Visualizer", "Done"]);
 
-        // Add one: the list turns into its settings.
-        state.take_dialog_pick(VIS_ADD_ROW_ID.to_string());
+        // Add one: it is edited, on the first attribute worth showing.
+        pane(&mut state, "Add Visualizer", "clicked");
         assert_eq!(state.visualizers.len(), 1);
-        assert_eq!(state.slots.dialog.mode, Mode::VisualizerEdit);
-        assert_eq!(state.vis_editing, Some(0));
-        let field = |f: &str| format!("{VIS_FIELD_PREFIX}{f}");
-        let has = |state: &State, f: &str| state.slots.dialog.rows.iter().any(|r| r.id == field(f));
-        assert!(has(&state, "ramp") && has(&state, "opacity") && !has(&state, "scale"), "Ramp's rows");
+        let k = keys(&state);
+        assert!(k.contains(&"Visualizer".to_string()) && k.contains(&"Ramp".to_string()) && !k.contains(&"Scale".to_string()), "Ramp's rows: {k:?}");
 
         // On N, a Ramp recolours the scene, which gained no node.
-        state.set_visualizer_field(0, "attribute", "N", true);
+        pane(&mut state, "Attribute", "N");
+        assert_eq!(state.visualizers[0].attribute, "N");
         assert_ne!(colours(&state), plain, "the ramp is on the scene");
         assert_eq!(serde_json::to_string(&state.fs_root).unwrap(), nodes_before, "no node in the graph");
 
-        // Mode's dropdown, Down, Enter: Vector's rows, and lines drawn.
-        let mode_row = state.slots.dialog.rows.iter().position(|r| r.id == field("mode")).unwrap();
-        state.slots.dialog.selected = mode_row;
-        let lines_before = state.overlay_normal_verts.len();
-        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
-        assert!(state.dialog_dropdown_open(), "a choice opens its dropdown");
-        assert_eq!(state.slots.dialog.dropdown.options, vec!["Ramp".to_string(), "Vector".to_string()]);
-        assert_eq!(state.slots.dialog.dropdown.selected, 0);
-        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowDown)));
-        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+        // Manual range: its float2 row appears, and takes two ends.
+        pane(&mut state, "Range", "Manual");
+        assert!(keys(&state).contains(&"Manual Range".to_string()));
+        pane(&mut state, "Manual Range", "-0.5:0.5");
+        assert_eq!(state.visualizers[0].manual_range, [-0.5, 0.5]);
+
+        // Vector: Scale's row in place of Ramp's.
+        pane(&mut state, "Mode", "Vector");
         assert!(state.visualizers[0].is_vector());
-        assert!(has(&state, "scale") && !has(&state, "ramp"), "Vector's rows");
-        assert_eq!(state.slots.dialog.selected_id(), Some(field("mode").as_str()), "the selection stays");
-        assert!(state.overlay_normal_verts.len() > lines_before, "the vectors are drawn");
+        let k = keys(&state);
+        assert!(k.contains(&"Scale".to_string()) && !k.contains(&"Ramp".to_string()), "Vector's rows: {k:?}");
         assert_eq!(colours(&state), plain, "a Vector leaves the colours");
 
-        // Escape goes back to the list, which names it.
-        state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
-        assert!(state.dialog_visible());
-        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
-        let id = format!("{VIS_ROW_PREFIX}0");
-        let row = state.slots.dialog.rows.iter().find(|r| r.id == id).expect("its row").clone();
-        assert!(row.label.starts_with("N — Vector"), "{}", row.label);
-        assert_eq!(row.toggle(), Some(true));
+        // Its switch turns it off; a second one is picked from the list.
+        pane(&mut state, "Enabled", "false");
+        assert!(!state.visualizers[0].enabled);
+        pane(&mut state, "Add Visualizer", "clicked");
+        assert_eq!((state.visualizers.len(), state.vis_hud), (2, Some(1)));
+        pane(&mut state, "Visualizer", "#1 N");
+        assert_eq!(state.vis_hud, Some(0));
 
-        // Kept with the display settings.
+        // Kept with the display settings, at the frame.
+        state.save_settings();
         let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
         let kept = crate::visualizer::decode(&crate::app::DesignSettings::from_kdl_str(&kdl).viewport.visualizers);
         assert_eq!(kept, state.visualizers);
 
-        // Its switch turns it off; the rest of the row opens it.
-        state.take_dialog_pick_at(id.clone(), true);
-        assert!(!state.visualizers[0].enabled);
-        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
-        let lines_off = state.overlay_normal_verts.len();
-        assert!(lines_off < lines_before + 1, "off draws nothing");
-        state.take_dialog_pick_at(id, false);
-        assert_eq!(state.slots.dialog.mode, Mode::VisualizerEdit);
-
-        // Delete, and the list is empty again.
-        state.take_dialog_pick(field("delete"));
-        assert!(state.visualizers.is_empty());
-        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
-        assert_eq!(colours(&state), plain);
-        state.close_dialog();
-    }
+        // Delete, then Done: the HUD is the node's again.
+        pane(&mut state, "Delete Visualizer", "clicked");
+        assert_eq!(state.visualizers.len(), 1);
+        pane(&mut state, "Done", "clicked");
+        assert_eq!(state.vis_hud, None);
 
-    /// A visualizer's Manual Range is the node's float2, and the dialog
-    /// edits it as one: a row of two sliders side by side, each end worked
-    /// on its own — pressed and dragged, turned by the wheel, nudged by the
-    /// arrows (shift for the second). A drag lands live and saves on the
-    /// release. A settings file from before, which kept From and To, reads
-    /// as the range.
-    #[test]
-    fn a_visualizers_manual_range_is_one_float2_row() {
-        use crate::dialog::Control;
-        use crate::slots::DIALOG_IDX;
-        use crate::visualizer::{decode, VIS_ADD_ROW_ID, VIS_FIELD_PREFIX};
-        use crate::window::{LocalPosition, WindowEvent};
-        use cce_ui::widget::{ElementState, MouseButton, MouseScrollDelta};
-        let mut state = State::new(false);
-        state.resize(1600.0, 900.0, 1.0);
-        state.rebuild_positions();
-        state.apply_layout();
-        state.rebuild_scene_geometry();
+        // Picking a node also hands the HUD back.
         assert!(state.run_command("attribute_visualizers"));
-        state.take_dialog_pick(VIS_ADD_ROW_ID.to_string());
-        state.set_visualizer_field(0, "attribute", "N", true);
-        state.set_visualizer_field(0, "range", "Manual", true);
-        state.refresh_dialog_controls();
-        let (lo, hi) = {
-            let a = state.scene_attributes.iter().find(|a| a.name == "N").unwrap();
-            (a.min, a.max)
-        };
-        assert_eq!(state.visualizers[0].manual_range, [lo, hi], "Manual starts at what Auto showed");
-
-        let id = format!("{VIS_FIELD_PREFIX}manual_range");
-        let i = state.slots.dialog.rows.iter().position(|r| r.id == id).expect("a Manual Range row");
-        let ids: Vec<&str> = state.slots.dialog.rows.iter().map(|r| r.id.as_str()).collect();
-        assert!(!ids.iter().any(|r| r.ends_with(":from") || r.ends_with(":to")), "no From and To rows: {ids:?}");
-        let control = |state: &State| match state.slots.dialog.rows[i].control.clone() {
-            Some(Control::Float2 { values, min, max, .. }) => (values, min, max),
-            other => panic!("not a float2: {other:?}"),
-        };
-        let (values, min, max) = control(&state);
-        assert_eq!(values, [lo, hi]);
-        let at = |t: f32| min + t * (max - min);
-
-        let bands = state.slots.dialog.slider_bands(state.positions[DIALOG_IDX], i);
-        assert_eq!(bands.len(), 2, "two sliders");
-        assert!(bands[0].x + bands[0].width < bands[1].x, "side by side");
-        let point = |state: &mut State, k: usize, t: f32| {
-            let b = bands[k];
-            let (px, py) = (b.x + b.width * t, b.y + b.height * 0.5);
-            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: px as f64, y: py as f64 } });
-        };
-        let saved = || {
-            let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
-            decode(&crate::app::DesignSettings::from_kdl_str(&kdl).viewport.visualizers)[0].manual_range
-        };
-        let close = |a: f32, b: f32| (a - b).abs() < (max - min) * 0.02;
-
-        // The first end, pressed and dragged: live, saved on the release.
-        point(&mut state, 0, 0.25);
-        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
-        assert!(state.slots.dialog.slider_dragging());
-        point(&mut state, 0, 0.1);
-        let [a, b] = state.visualizers[0].manual_range;
-        assert!(close(a, at(0.1)) && b == hi, "{a} {b}");
-        assert_eq!(control(&state).0, state.visualizers[0].manual_range, "the row shows what the visualizer holds");
-        assert_eq!(saved(), [lo, hi], "not written mid-drag");
-        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
-        assert_eq!(saved(), state.visualizers[0].manual_range, "written on the release");
-
-        // The second end, on its own band; the first stays.
-        point(&mut state, 1, 0.9);
-        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
-        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
-        let [a2, b2] = state.visualizers[0].manual_range;
-        assert!(a2 == a && close(b2, at(0.9)), "{a2} {b2}");
-
-        // The wheel over an end turns that end, up being more.
-        point(&mut state, 0, 0.5);
-        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 1.0) });
-        let [a3, b3] = state.visualizers[0].manual_range;
-        assert!(a3 > a2 && b3 == b2, "{a3} {b3}");
-
-        // The arrows nudge the first end; with shift, the second.
-        state.slots.dialog.selected = i;
-        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowRight)));
-        let [a4, b4] = state.visualizers[0].manual_range;
-        assert!(a4 > a3 && b4 == b3, "{a4} {b4}");
-        state.modifiers = ModifiersState { shift: true, ..Default::default() };
-        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowLeft)));
-        state.modifiers = ModifiersState::default();
-        let [a5, b5] = state.visualizers[0].manual_range;
-        assert!(a5 == a4 && b5 < b4, "{a5} {b5}");
-        assert_eq!(saved(), [a5, b5], "a key's landing saves at once");
-
-        // What it draws is the node's Manual Range.
-        let node = state.visualizers[0].as_node();
-        assert_eq!(crate::geometry::node_param_vec2(&node, "manual_range", [0.0, 0.0]), [a5, b5]);
-
-        // A settings file from before kept the two ends apart.
-        let old = decode("attribute=N|mode=Ramp|range=Manual|from=2|to=5");
-        assert_eq!(old[0].manual_range, [2.0, 5.0]);
-        assert_eq!(decode(&crate::visualizer::encode(&old)), old);
-        state.close_dialog();
+        let mut redraw = false;
+        let slot = geo(&state.fs_root).children.iter().position(|c| c.node_type == "sphere").unwrap();
+        state.apply_action(McpAction::Select { slot }, &mut redraw).unwrap();
+        state.sync_parameters_pane();
+        assert_eq!(state.vis_hud, None);
+        assert!(keys(&state).contains(&"Radius".to_string()), "the sphere's rows");
     }
 
     /// A visualizer is the Visualize node's reading: the same settings give
diff --git a/src/menu_page.rs b/src/menu_page.rs
index fd32600..b12487a 100644
--- a/src/menu_page.rs
+++ b/src/menu_page.rs
@@ -67,7 +67,7 @@ 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"))
+        matches!(self, ViewportMenuAction::Page(_))
     }
 }
 
@@ -195,11 +195,6 @@ impl State {
             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() {
diff --git a/src/visualizer.rs b/src/visualizer.rs
index a4811f5..1692db8 100644
--- a/src/visualizer.rs
+++ b/src/visualizer.rs
@@ -14,13 +14,15 @@
 //! `attribute_visualizers` command). Several apply in order, the later over
 //! the earlier, as a chain of Visualize nodes composites.
 //!
-//! They are edited in the dialog: [`crate::dialog::Mode::Visualizers`] lists
-//! them with a switch each, and [`crate::dialog::Mode::VisualizerEdit`] is
-//! one visualizer's settings as rows.
+//! They are edited in the params HUD (`State::vis_hud`, since 2026-10-06;
+//! they were the dialog's Visualizers / VisualizerEdit modes): a Visualizer
+//! dropdown picks the one edited, Add and Delete beside it, its settings as
+//! ordinary rows under it, and Done back to the selected node. The rows are
+//! a pseudo-node's parameters (`visualizer_hud_params`), so the HUD's own
+//! row building, `show_when` and controls serve them unchanged.
 
 use crate::app::{FsNode, ParamDef, State};
 use crate::detail::Detail;
-use crate::dialog::{Control, Row};
 
 pub const MODES: [&str; 2] = ["Ramp", "Vector"];
 pub const RAMPS: [&str; 4] = ["Grayscale", "Heat", "Spectrum", "Viridis"];
@@ -58,7 +60,7 @@ pub struct Visualizer {
     /// `Manual` over Manual Range.
     pub range: String,
     /// The ramp's two ends under a Manual range: the node's float2, edited
-    /// in the dialog as one (`Control::Float2`).
+    /// in the params HUD as one `float2` row.
     pub manual_range: [f32; 2],
     pub blend: String,
     pub opacity: f32,
@@ -290,110 +292,11 @@ pub fn scene_attributes(geom: &Detail) -> Vec<SceneAttribute> {
     out
 }
 
-/// A visualizer in [`crate::dialog::Mode::Visualizers`]: the prefix, then
-/// its index.
-pub const VIS_ROW_PREFIX: &str = "vis:";
-/// The list's Add Visualizer row.
-pub const VIS_ADD_ROW_ID: &str = "vis:add";
-/// A setting of the visualizer being edited, in
-/// [`crate::dialog::Mode::VisualizerEdit`]: the prefix, then the field.
-pub const VIS_FIELD_PREFIX: &str = "visfield:";
-
 /// The Attribute and Group choices' word for "nothing chosen".
 const NO_ATTRIBUTE: &str = "(none)";
 const ALL_POINTS: &str = "(all points)";
 
-fn choice(options: &[&str], current: &str) -> Control {
-    let options: Vec<String> = options.iter().map(|s| s.to_string()).collect();
-    let index = options.iter().position(|o| o.eq_ignore_ascii_case(current)).unwrap_or(0);
-    Control::Choice { options, index }
-}
-
-fn row(field: &str, label: &str, control: Option<Control>) -> Row {
-    Row { id: format!("{VIS_FIELD_PREFIX}{field}"), label: label.to_string(), chord: String::new(), control, truncate_head: false }
-}
-
 impl State {
-    /// The list's rows: each visualizer, a switch each, and Add Visualizer.
-    /// A visualizer whose attribute the scene does not have says so in the
-    /// chord column, since it draws nothing.
-    pub(crate) fn visualizer_rows(&self) -> Vec<Row> {
-        let mut rows: Vec<Row> = self
-            .visualizers
-            .iter()
-            .enumerate()
-            .map(|(i, v)| Row {
-                id: format!("{VIS_ROW_PREFIX}{i}"),
-                label: v.label(),
-                chord: if self.scene_attributes.iter().any(|a| a.name == v.attribute) { String::new() } else { "not in the scene".to_string() },
-                control: Some(Control::Toggle(v.enabled)),
-                truncate_head: false,
-            })
-            .collect();
-        rows.push(Row::plain(VIS_ADD_ROW_ID, "Add Visualizer", ""));
-        rows
-    }
-
-    /// The rows of visualizer `i`'s settings, those that apply to its mode:
-    /// Ramp's ramp, range and blend, Vector's scale — as the Visualize
-    /// node's `show_when` conditions have them.
-    pub(crate) fn visualizer_edit_rows(&self, i: usize) -> Vec<Row> {
-        let Some(v) = self.visualizers.get(i) else { return Vec::new() };
-        let mut rows = vec![row("enabled", "Enabled", Some(Control::Toggle(v.enabled)))];
-
-        let mut attrs: Vec<String> = self.scene_attributes.iter().map(|a| a.name.clone()).collect();
-        if !v.attribute.is_empty() && !attrs.contains(&v.attribute) {
-            attrs.push(v.attribute.clone());
-        }
-        if attrs.is_empty() {
-            attrs.push(NO_ATTRIBUTE.to_string());
-        }
-        let index = attrs.iter().position(|a| *a == v.attribute).unwrap_or(0);
-        rows.push(row("attribute", "Attribute", Some(Control::Choice { options: attrs, index })));
-        rows.push(row("mode", "Mode", Some(choice(&MODES, &v.mode))));
-        if v.is_vector() {
-            rows.push(row("scale", "Scale", Some(Control::Slider { value: v.scale, min: 0.0, max: 10.0f32.max(v.scale), dec: 2, step: 0.05, suffix: "" })));
-        } else {
-            rows.push(row("ramp", "Ramp", Some(choice(&RAMPS, &v.ramp))));
-            rows.push(row("range", "Range", Some(choice(&RANGES, &v.range))));
-            if v.is_manual() {
-                let (lo, hi) = self.visualizer_value_range(v);
-                let step = ((hi - lo) / 100.0).max(1e-4);
-                rows.push(row("manual_range", "Manual Range", Some(Control::Float2 { values: v.manual_range, min: lo, max: hi, dec: 3, step })));
-            }
-            rows.push(row("blend", "Blend", Some(choice(&BLENDS, &v.blend))));
-            rows.push(row("opacity", "Opacity", Some(Control::Slider { value: v.opacity, min: 0.0, max: 1.0, dec: 2, step: 0.05, suffix: "" })));
-        }
-
-        let mut groups: Vec<String> = vec![ALL_POINTS.to_string()];
-        groups.extend(self.scene_groups.iter().map(|(g, _)| g.clone()));
-        if !v.group.is_empty() && !groups.contains(&v.group) {
-            groups.push(v.group.clone());
-        }
-        let index = if v.group.is_empty() { 0 } else { groups.iter().position(|g| *g == v.group).unwrap_or(0) };
-        rows.push(row("group", "Group", Some(Control::Choice { options: groups, index })));
-        rows.push(row("delete", "Delete Visualizer", None));
-        rows.push(row("back", &format!("{} Back to Visualizers", crate::dialog::BACK_MARK), None));
-        rows
-    }
-
-    /// The span a Manual range's sliders cover: the attribute's range in the
-    /// scene with a quarter of it to spare each side, and the values in hand
-    /// whatever they are.
-    fn visualizer_value_range(&self, v: &Visualizer) -> (f32, f32) {
-        let (mut lo, mut hi) = match self.scene_attributes.iter().find(|a| a.name == v.attribute) {
-            Some(a) => {
-                let pad = ((a.max - a.min) * 0.25).max(if a.max > a.min { 0.0 } else { 1.0 });
-                (a.min - pad, a.max + pad)
-            }
-            None => (0.0, 1.0),
-        };
-        let [a, b] = v.manual_range;
-        lo = lo.min(a).min(b);
-        hi = hi.max(a).max(b);
-        (lo, hi)
-    }
-
     /// Add a visualizer, on, on the first attribute of the scene that is
     /// not its colour or its position — what is worth looking at — and
     /// return its index.
@@ -418,15 +321,6 @@ impl State {
         }
     }
 
-    pub(crate) fn set_visualizer_enabled(&mut self, i: usize, on: bool) {
-        if let Some(v) = self.visualizers.get_mut(i) {
-            if v.enabled != on {
-                v.enabled = on;
-                self.visualizers_changed(true);
-            }
-        }
-    }
-
     /// Write one of visualizer `i`'s settings from the text its row holds —
     /// a choice's option, a toggle's `true`/`false`, a number.
     pub(crate) fn set_visualizer_field(&mut self, i: usize, field: &str, value: &str, save: bool) {
@@ -463,3 +357,145 @@ impl State {
         }
     }
 }
+
+/// The HUD's visualizer view: its rows' parameter names.
+const HUD_PICK: &str = "visualizer";
+const HUD_ADD: &str = "add_visualizer";
+const HUD_DELETE: &str = "delete_visualizer";
+const HUD_DONE: &str = "done";
+
+impl State {
+    /// Show the attribute visualizers in the params HUD, editing the first
+    /// (or `i`), in place of the selected node's parameters.
+    pub(crate) fn open_visualizers_hud(&mut self) {
+        self.vis_hud = Some(self.vis_hud.unwrap_or(0).min(self.visualizers.len().saturating_sub(1)));
+        self.vis_hud_from = self.param_pane_target();
+        if !self.show_parameters {
+            self.execute_menu_action("Show Parameters Pane");
+        }
+        self.sync_parameters_pane();
+        self.update_status_text("Attribute Visualizers: in the parameters; Done goes back to the node.");
+    }
+
+    /// Back to the selected node's parameters.
+    pub(crate) fn close_visualizers_hud(&mut self) {
+        if self.vis_hud.take().is_some() {
+            self.sync_parameters_pane();
+        }
+    }
+
+    /// What the picker calls visualizer `i`.
+    fn visualizer_pick_label(&self, i: usize) -> String {
+        let v = &self.visualizers[i];
+        let attr = if v.attribute.is_empty() { NO_ATTRIBUTE } else { &v.attribute };
+        format!("#{} {attr}", i + 1)
+    }
+
+    /// The HUD's visualizer view as a pseudo-node's parameters: the picker,
+    /// Add and Delete, the edited visualizer's settings under the Visualize
+    /// node's names and `show_when` conditions, and Done. With none yet,
+    /// Add and Done alone.
+    pub(crate) fn visualizer_hud_params(&self) -> Vec<ParamDef> {
+        let p = |name: &str, ty: String, text: String, label: &str| ParamDef::new(name.to_string(), ty, text).with_label(label);
+        let grouped = |mut d: ParamDef, g: &str| {
+            d.group = g.to_string();
+            d
+        };
+        let mut out = Vec::new();
+        let editing = self.vis_hud.filter(|&i| i < self.visualizers.len());
+        if let Some(i) = editing {
+            let labels: Vec<String> = (0..self.visualizers.len()).map(|k| self.visualizer_pick_label(k)).collect();
+            out.push(grouped(p(HUD_PICK, format!("choice:{}", labels.join(",")), labels[i].clone(), "Visualizer"), "pick"));
+        }
+        out.push(grouped(p(HUD_ADD, "button".into(), String::new(), "Add Visualizer"), "pick"));
+        if let Some(i) = editing {
+            out.push(grouped(p(HUD_DELETE, "button".into(), String::new(), "Delete Visualizer"), "pick"));
+            let v = &self.visualizers[i];
+            out.push(grouped(p("enabled", "toggle".into(), v.enabled.to_string(), "Enabled"), "settings"));
+            let mut attrs: Vec<String> = self.scene_attributes.iter().map(|a| a.name.clone()).collect();
+            if !v.attribute.is_empty() && !attrs.contains(&v.attribute) {
+                attrs.push(v.attribute.clone());
+            }
+            if attrs.is_empty() {
+                attrs.push(NO_ATTRIBUTE.to_string());
+            }
+            let attr = if v.attribute.is_empty() { attrs[0].clone() } else { v.attribute.clone() };
+            out.push(grouped(p("attribute", format!("choice:{}", attrs.join(",")), attr, "Attribute"), "settings"));
+            out.push(grouped(p("mode", format!("choice:{}", MODES.join(",")), v.mode.clone(), "Mode"), "settings"));
+            out.push(grouped(p("ramp", format!("choice:{}", RAMPS.join(",")), v.ramp.clone(), "Ramp").with_show_when("mode == Ramp"), "settings"));
+            out.push(grouped(p("range", format!("choice:{}", RANGES.join(",")), v.range.clone(), "Range").with_show_when("mode == Ramp"), "settings"));
+            out.push(grouped(
+                p("manual_range", "float2".into(), format!("{}:{}", v.manual_range[0], v.manual_range[1]), "Manual Range").with_show_when("mode == Ramp && range == Manual"),
+                "settings",
+            ));
+            out.push(grouped(p("blend", format!("choice:{}", BLENDS.join(",")), v.blend.clone(), "Blend").with_show_when("mode == Ramp"), "settings"));
+            out.push(grouped(p("opacity", "slider:0:1".into(), format!("{:.2}", v.opacity), "Opacity").with_show_when("mode == Ramp"), "settings"));
+            out.push(grouped(p("scale", format!("slider:0:{}", 10.0f32.max(v.scale)), format!("{:.2}", v.scale), "Scale").with_show_when("mode == Vector"), "settings"));
+            let mut groups: Vec<String> = vec![ALL_POINTS.to_string()];
+            groups.extend(self.scene_groups.iter().map(|(g, _)| g.clone()));
+            if !v.group.is_empty() && !groups.contains(&v.group) {
+                groups.push(v.group.clone());
+            }
+            let group = if v.group.is_empty() { ALL_POINTS.to_string() } else { v.group.clone() };
+            out.push(grouped(p("group", format!("choice:{}", groups.join(",")), group, "Group"), "where"));
+        }
+        out.push(grouped(p(HUD_DONE, "button".into(), String::new(), "Done"), "done"));
+        out
+    }
+
+    /// The HUD's rows written back into the visualizers: each row whose
+    /// value differs from what the view shows becomes the edit it names.
+    /// A change that adds or drops rows (another visualizer, Mode, Range,
+    /// Add, Delete) re-reads the view; a slider being dragged does not,
+    /// which would drop the slider held.
+    pub(crate) fn sync_visualizer_hud_back(&mut self) {
+        let shown = self.visualizer_hud_params();
+        let rows = self.param().node_params();
+        let mut reread = false;
+        let mut changed = false;
+        for (key, value, _) in rows {
+            let Some(def) = shown.iter().find(|d| d.shown_name() == key) else { continue };
+            if def.text() == value {
+                continue;
+            }
+            let i = self.vis_hud.unwrap_or(0);
+            match def.name.as_str() {
+                HUD_PICK => {
+                    if let Some(k) = (0..self.visualizers.len()).find(|&k| self.visualizer_pick_label(k) == value) {
+                        self.vis_hud = Some(k);
+                    }
+                    reread = true;
+                }
+                HUD_ADD if value == "clicked" => {
+                    let k = self.add_visualizer();
+                    self.vis_hud = Some(k);
+                    reread = true;
+                }
+                HUD_DELETE if value == "clicked" => {
+                    self.delete_visualizer(i);
+                    self.vis_hud = Some(i.min(self.visualizers.len().saturating_sub(1)));
+                    reread = true;
+                }
+                HUD_DONE if value == "clicked" => {
+                    self.close_visualizers_hud();
+                    return;
+                }
+                HUD_ADD | HUD_DELETE | HUD_DONE => {}
+                field => {
+                    // Saved at the frame, not per motion of a drag.
+                    self.set_visualizer_field(i, field, &value, false);
+                    changed = true;
+                    if matches!(field, "mode" | "range" | "attribute") {
+                        reread = true;
+                    }
+                }
+            }
+        }
+        if changed {
+            self.settings_save_pending = true;
+        }
+        if reread {
+            self.sync_parameters_pane();
+        }
+    }
+}