git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit36cbca1810d0a061e607f94b0818960db71fb546
parent402e088b19
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 14:32
feat: a kept renderer across an empty spell, accessible names; unused widgets gone

- wants_surface keeps the renderer. When a layer app has nothing to show,
  the runner detaches its renderer (swapchain and VkSurfaceKHR go; device,
  pipelines, atlases and image table stay) and drops the surface; when it has
  something again it moves the same renderer onto the new surface, as the
  menu popup's renderer moves between popups. It built a new renderer each
  time, ~45 ms before the notifier's first card. Image ids stay good, so
  renderer_init does not run and surface_hidden is gone. Checked on a
  private session bus: a card, its expiry, a second card with a thumbnail,
  identical to the pixel old and new.
- Widget::accessible_name (Adapted::set_accessible_name): what a screen
  reader calls a widget that draws no label of its own; the accessibility
  tree prefers it over the label, and nothing draws it.
  an_accessible_name_names_a_widget_and_draws_nothing.
- Deleted display::Panel, display::Node, serialize_widgets and
  widget::ColumnLayout / RowLayout, used by no app and nothing in the
  toolkit.

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

 CLAUDE.md                       |  32 +++--
 scripts/style-audit             |   2 +-
 src/a11y.rs                     |  30 ++++-
 src/backend/app.rs              |  16 +--
 src/backend/window_runner.rs    |  51 +++++--
 src/widget/core.rs              |   7 +
 src/widget/display/mod.rs       |   6 -
 src/widget/display/node.rs      | 292 ----------------------------------------
 src/widget/display/panel.rs     |  92 -------------
 src/widget/display/serialize.rs | 153 ---------------------
 src/widget/layout_helper.rs     | 106 ---------------
 src/widget/mod.rs               |   6 +-
 src/widget/model.rs             |   6 +
 13 files changed, 102 insertions(+), 697 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 684b2be..e8a7148 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -653,20 +653,30 @@ live data (uptime, temperatures, a different saved plan per shadow home).
 ### A layer app can have no surface while it is empty (`Application::wants_surface`, 2026-10-05)
 
 A layer-shell app that is usually empty — the notifier, between notifications — returns
-false from `wants_surface` while it has nothing to show. On that turn the runner drops the
-renderer and then the layer surface (SCTK destroys the role, then the `wl_surface`); on the
-turn it says true again it builds a fresh `wl_surface`, re-attaches the same layer role and
-a new renderer, and the first configure makes it presentable as at session start. The app
+false from `wants_surface` while it has nothing to show. On that turn the runner detaches
+its renderer (`VkRenderer::detach_surface`: the swapchain and `VkSurfaceKHR` go, the device,
+pipelines, atlases and image table stay) and drops the layer surface (SCTK destroys the
+role, then the `wl_surface`); on the turn it says true again it builds a fresh `wl_surface`,
+re-attaches the same layer role, moves the SAME renderer onto it (`attach_surface`, as the
+menu popup's renderer moves between popups), and the first configure makes it presentable
+as at session start. The app
 keeps running throughout — its calloop sources, D-Bus thread and state are untouched; only
 the surface goes. Why bother: an always-mapped transparent overlay still made the
 compositor blur behind it whenever anything under it changed, and it kept a fullscreen
 client off direct scanout (scenefx scans out only a one-entry render list).
 
-`surface_hidden` tells the app it happened. Image ids it uploads from then on are queued
-for the NEXT renderer, so that renderer's `renderer_init` must not re-upload them the way
-it would after a lost connection (the notifier keeps a flag for this). Rebuilding costs a
-renderer: a card after an empty spell appeared ~45 ms after `Notify` in a scale-2 shadow.
-Default true; xdg windows ignore it.
+Since the renderer is the same one, image ids stay good across the gap and
+`renderer_init` does not run; ids uploaded while hidden are drained into it at its next
+frame. Until 2026-10-08 the runner dropped the renderer and built a new one on show — a
+device and every pipeline, about 45 ms before a card after an empty spell appeared — and a
+`surface_hidden` hook told the app, whose `renderer_init` then had to skip re-uploading what
+was already queued (the notifier kept a flag for it). Both are gone. Only if attaching fails
+does the surface stay hidden with no renderer; the next show makes a new one and calls
+`renderer_init` as for a replacement. It is also what a launcher like cce-cloud, which keeps
+one renderer for its life and moves it between popups, would need from the runner. Checked
+in a scale-2 shadow on a private session bus: a card, its expiry, a second card with a
+thumbnail after the empty spell — drawn identically to the pixel by the old binary and the
+new. Default true; xdg windows ignore it.
 
 ### `renderer_init` — GPU handles do not survive a reconnect
 
@@ -1588,8 +1598,8 @@ when unset, the radius rule applied to spacing; do not add a new one.
 
 In the box model the ladder is presets — `Style::root_column()` /
 `root_row()`, `pane_column()` / `pane_row()`, `controls_column()` /
-`controls_row()` — and the legacy strategies' `Default`s and
-`ColumnLayout::pane` / `controls` read the same getters. An app picks the
+`controls_row()` — and the container layouts' `Default`s read the same
+getters. An app picks the
 rung; a literal padding or gap in an app (`const PAD`, `+ 12.0`) is a number
 the ladder should be supplying, and the audit counts them.
 
diff --git a/scripts/style-audit b/scripts/style-audit
index f658fbd..e9cc338 100755
--- a/scripts/style-audit
+++ b/scripts/style-audit
@@ -44,7 +44,7 @@ for n in "${apps[@]}"; do
   if grep -rqE 'PlateSpec::(window|root_at)\(|\.root_plate\(' "$src"; then root=PLATE
   elif grep -rqE '(quad|rounded_rect)\([^;]*(0\.0, *0\.0|x: *0\.0, *y: *0\.0)' "$src"; then root=FILL
   else root=NONE; fi
-  ladder=$(grep -rhoE 'root_plate_(inset|padding|gap)\(\)|plate_(padding|gap)\(\)|control_gap\(\)|Style::(root|pane|controls)_(column|row)\(\)|ColumnLayout::(pane|controls)\(' "$src" | wc -l)
+  ladder=$(grep -rhoE 'root_plate_(inset|padding|gap)\(\)|plate_(padding|gap)\(\)|control_gap\(\)|Style::(root|pane|controls)_(column|row)\(\)' "$src" | wc -l)
   literals=$(grep -rhE '^\s*(pub )?const [A-Z_]*(PAD|GAP|MARGIN|INSET)[A-Z_]*: *f32' "$src" | wc -l)
   note=""
   [ "$root" != PLATE ] && { note="base is not the standard root plate"; fail=1; }
diff --git a/src/a11y.rs b/src/a11y.rs
index 993026b..d702292 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -156,7 +156,7 @@ pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Ro
         "ImageView" => Role::Image,
         "Splitter" => Role::Splitter,
         "Graph" | "Trackpad" => Role::Canvas,
-        "Group" | "ParametersBg" | "Panel" => Role::Group,
+        "Group" | "ParametersBg" => Role::Group,
         _ => match focus {
             FocusRole::Plate => Role::Button,
             FocusRole::Well | FocusRole::None => Role::GenericContainer,
@@ -169,7 +169,8 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
     let focus = w.focus_role();
     let role = role_for(w.type_name(), focus, w.a11y_role());
     let mut node = Node::new(role);
-    if let Some(label) = w.label().filter(|l| !l.is_empty()) {
+    let name = w.base().accessible_name.clone().filter(|n| !n.is_empty());
+    if let Some(label) = name.or_else(|| w.label().filter(|l| !l.is_empty())) {
         node.set_label(label);
     }
     if let Some(value) = w.a11y_value() {
@@ -229,10 +230,11 @@ pub fn key_for(w: &dyn WidgetHost, action: Action) -> Option<NamedKey> {
     }
 }
 
-/// Whether a widget is on screen: visible, with a size, and not parked. Apps park a widget
-/// they are not showing far off the window (x or y below -9000, the toolkit's sentinel —
-/// cce-data-editor's per-type value editors) instead of hiding it; a reader must not see
-/// those either.
+/// Whether a widget is on screen: visible, with a size, and not parked far off the window
+/// (x or y below -9000). A widget an app is not showing should be HIDDEN
+/// (`WidgetHost::set_visible(false)`), which takes it out of the Tab walk too; the sentinel
+/// is a backstop for one only parked. cce-data-editor parked its per-type value editors at
+/// -1000, above the sentinel, so all of them were in the tree until it hid them (2026-10-08).
 fn shown_on_screen(w: &dyn WidgetHost) -> bool {
     let (x, y, width, height) = w.rect();
     w.visible() && width > 0.0 && height > 0.0 && x > -9000.0 && y > -9000.0
@@ -457,6 +459,22 @@ mod tests {
     /// A node offers the actions the Linux adapter can carry out, and each by the key a
     /// keyboard user presses: Space on a plate, Right / Left on a slider or a range,
     /// Up / Down on a spin button, nothing on a text box.
+    /// A control named without a label of its own (a value editor in a table row) is called
+    /// by its accessible name, which wins over a label and adds no label strip.
+    #[test]
+    fn an_accessible_name_names_a_widget_and_draws_nothing() {
+        let mut text = TextBox::new(String::new());
+        let strip = text.label_strip();
+        text.set_accessible_name(Some("Value of font_size"));
+        assert_eq!(widget_node(&text, Vec::new()).label(), Some("Value of font_size"));
+        assert_eq!(text.label_strip(), strip, "no label strip for a name nobody draws");
+        let mut save = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
+        save.set_accessible_name(Some("Save the file"));
+        assert_eq!(widget_node(&save, Vec::new()).label(), Some("Save the file"));
+        save.set_accessible_name(None);
+        assert_eq!(widget_node(&save, Vec::new()).label(), Some("Save"), "back to the label");
+    }
+
     #[test]
     fn a_node_offers_the_actions_its_keys_carry_out() {
         let button = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 1160f40..b3aa206 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -222,10 +222,10 @@ pub trait Application: Sized + 'static {
     }
     /// For a [`layer`](Self::layer) app that is usually empty — a
     /// notification stack — whether there is anything to show right now.
-    /// While this is `false` the Wayland runner destroys the layer surface
-    /// and its renderer, and it builds both again (a new renderer, so
-    /// [`renderer_init`](Self::renderer_init) runs) on the turn it turns
-    /// `true`. An always-mapped transparent overlay is not free: the
+    /// While this is `false` the Wayland runner destroys the layer surface,
+    /// keeping its renderer detached, and on the turn it turns `true` builds a
+    /// new surface and moves the same renderer onto it — so image ids stay
+    /// good and [`renderer_init`](Self::renderer_init) does not run. An always-mapped transparent overlay is not free: the
     /// compositor blurs behind it whenever anything under it changes, and it
     /// keeps a fullscreen client off direct scanout. Asked once a loop turn;
     /// ignored for xdg windows. Default `true`: always mapped.
@@ -233,14 +233,6 @@ pub trait Application: Sized + 'static {
     fn wants_surface(&self) -> bool {
         true
     }
-    /// The runner has just destroyed the surface and renderer because
-    /// [`wants_surface`](Self::wants_surface) said `false`. Image ids
-    /// uploaded from here on are queued for the NEXT renderer, so its
-    /// `renderer_init` has nothing of theirs to re-upload — unlike the
-    /// `renderer_init` after a lost connection, whose ids died with the old
-    /// renderer.
-    #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
-    fn surface_hidden(&mut self) {}
     /// Declare the window a UTILITY window: a tool whose shape is decided by
     /// its contents. The compositor then never dictates a size to it (every
     /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 65ebd79..9c0fc50 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -83,7 +83,7 @@ pub struct EngineState<A: Application> {
     /// which is what makes [`Application::wants_surface`] apply.
     pub is_layer_app: bool,
     /// The app said [`Application::wants_surface`] = false and the layer
-    /// surface and renderer are gone until it says true.
+    /// surface is gone until it says true (the renderer is kept, detached).
     pub layer_hidden: bool,
     pub surface: Option<wl_surface::WlSurface>,
     
@@ -766,10 +766,14 @@ impl<A: Application> EngineState<A> {
         }
     }
 
-    /// Destroy the renderer (its swapchain first, as at session end), then
-    /// the layer surface — SCTK destroys the role and then the `wl_surface`.
+    /// Let the renderer go of the surface (its swapchain and `VkSurfaceKHR`;
+    /// the device, pipelines, atlases and image table stay), then destroy the
+    /// layer surface — SCTK destroys the role and then the `wl_surface`, which
+    /// must not happen while a swapchain still presents to it.
     fn hide_layer_surface(&mut self) {
-        self.renderer = None;
+        if let Some(renderer) = self.renderer.as_mut() {
+            renderer.detach_surface();
+        }
         self.layer_surface = None;
         self.surface = None;
         self.layer_hidden = true;
@@ -780,12 +784,15 @@ impl<A: Application> EngineState<A> {
         self.entered_outputs.clear();
         self.applied_input_regions = None;
         log::info!("[window_runner] nothing to show; layer surface unmapped");
-        self.inner.as_mut().unwrap().surface_hidden();
     }
 
-    /// A fresh `wl_surface` with the app's layer role and a renderer on it.
-    /// The first configure then makes it presentable, exactly as at session
-    /// start. A renderer that cannot be made leaves the surface hidden.
+    /// A fresh `wl_surface` with the app's layer role, and the renderer moved
+    /// onto it (`attach_surface`: one swapchain, where a new renderer costs a
+    /// device and every pipeline). The first configure then makes it
+    /// presentable, exactly as at session start. The renderer is the same one,
+    /// so its image ids are still good and `renderer_init` is not called; only
+    /// where there is none (an attach that failed before) is one made, and
+    /// that one is announced. A surface nothing can draw to stays hidden.
     fn show_layer_surface(&mut self) {
         let app = self.inner.as_ref().unwrap();
         let settings = app.settings();
@@ -801,21 +808,37 @@ impl<A: Application> EngineState<A> {
         let display_ptr = self.display_ptr as *mut std::ffi::c_void;
         let surface_ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
         self.surface = Some(surface);
-        match unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) } {
-            Ok(renderer) => self.renderer = Some(renderer),
+        let made = match self.renderer.as_mut() {
+            Some(renderer) => match unsafe { renderer.attach_surface(display_ptr, surface_ptr, pw, ph) } {
+                Ok(()) => Ok(false),
+                Err(lost) => Err(lost),
+            },
+            None => unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }.map(|renderer| {
+                self.renderer = Some(renderer);
+                true
+            }),
+        };
+        let made = match made {
+            Ok(made) => made,
             Err(lost) => {
-                log::error!("[window_runner] cannot rebuild the renderer, staying unmapped: {lost}");
+                log::error!("[window_runner] cannot draw to the new surface, staying unmapped: {lost}");
+                self.renderer = None;
                 self.layer_surface = None;
                 self.surface = None;
                 return;
             }
-        }
+        };
         self.logical_width = settings.width as f32;
         self.logical_height = settings.height as f32;
         self.layer_hidden = false;
         self.redraw = true;
-        log::info!("[window_runner] layer surface mapped again");
-        self.inner.as_mut().unwrap().renderer_init(self.renderer.as_mut().unwrap());
+        log::info!(
+            "[window_runner] layer surface mapped again ({})",
+            if made { "a new renderer" } else { "the renderer moved onto it" }
+        );
+        if made {
+            self.inner.as_mut().unwrap().renderer_init(self.renderer.as_mut().unwrap());
+        }
     }
 
     /// One warm-down step: a frame callback and a commit with no buffer, so
diff --git a/src/widget/core.rs b/src/widget/core.rs
index aee8db3..df70c8b 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -1929,6 +1929,11 @@ pub struct Widget {
     pub w: f32,
     pub h: f32,
     pub label: Option<String>,
+    /// What a screen reader calls the widget when it draws no label of its own
+    /// (an `aria-label`): the accessibility tree prefers it over [`label`](Self::label),
+    /// and nothing draws it. For a control whose label stands beside it in the host's
+    /// own text — a value editor in a table row, an icon button.
+    pub accessible_name: Option<String>,
     pub hovered: bool,
     pub row_x: f32,
     pub row_w: f32,
@@ -1990,6 +1995,7 @@ impl Widget {
             w: 0.0,
             h: 0.0,
             label: None,
+            accessible_name: None,
             hovered: false,
             row_x: 0.0,
             row_w: 0.0,
@@ -2009,6 +2015,7 @@ impl Widget {
             w,
             h,
             label: None,
+            accessible_name: None,
             hovered: false,
             row_x: 0.0,
             row_w: 0.0,
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index 908abfa..aa6d261 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -1,6 +1,4 @@
 pub mod text_label;
-pub mod panel;
-pub mod node;
 pub mod label;
 pub mod float3;
 pub mod progress_bar;
@@ -8,7 +6,6 @@ pub mod status_bar;
 pub mod splitter;
 pub mod list_item;
 pub mod separator;
-pub mod serialize;
 pub mod graph;
 pub mod usage_bar;
 pub mod info_box;
@@ -18,8 +15,6 @@ pub mod text_sizer;
 
 pub use self::text_label::TextLabel;
 pub(crate) use self::text_label::make_widget_text_buffer;
-pub use self::panel::Panel;
-pub use self::node::Node;
 pub use self::label::{Label, StyledLabel, LabelPrim};
 pub use self::float3::Float3;
 pub use self::progress_bar::ProgressBar;
@@ -27,7 +22,6 @@ pub use self::status_bar::StatusBar;
 pub use self::splitter::Splitter;
 pub use self::list_item::{TextItem, InteractiveListItem};
 pub use self::separator::Separator;
-pub use self::serialize::serialize_widgets;
 pub use self::graph::{GraphNode, Graph, TaggedQuad, WireStyle, node_wires};
 pub use self::usage_bar::UsageBar;
 pub use self::info_box::InfoBox;
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
deleted file mode 100644
index 9ea1d45..0000000
--- a/src/widget/display/node.rs
+++ /dev/null
@@ -1,292 +0,0 @@
-//! Narrow-trait `Node` (Phase 5k) — a network-editor node box: draggable with grid snap
-//! (self-moving, via [`Input::drag_reposition`]), a geometry-visibility toggle sub-zone, and
-//! two controller capabilities ([`ParamController`] + [`GeomController`]) re-exposed through
-//! the `Input` hooks for the legacy `WidgetHost::as_*_controller` downcasts.
-
-use crate::colors;
-use crate::scene::layout::Rect;
-use crate::scene::paint::PaintCtx;
-use crate::widget::{
-    Adapted, ElementState, Event, EventCtx, GeomController, Input, Layout, MouseButton, Paint,
-    ParamController,
-};
-
-#[derive(Debug, Clone)]
-pub struct Node {
-    hovered: bool,
-    selected: bool,
-    dragging: bool,
-    drag_ox: f32,
-    drag_oy: f32,
-    bounds: Option<(f32, f32, f32, f32)>,
-    grid_snap_x: f32,
-    grid_snap_y: f32,
-    grid_origin_x: f32,
-    grid_origin_y: f32,
-    name: String,
-    pub parameters: Vec<(String, String, String)>,
-    geom_visible: bool,
-    geom_toggled: bool,
-    pub(crate) toggle_hovered: bool,
-}
-
-impl Node {
-    pub fn new(x: f32, y: f32, w: f32, h: f32, name: &str) -> Adapted<Node> {
-        let mut node = Adapted::new(Node {
-            hovered: false,
-            selected: false,
-            dragging: false,
-            drag_ox: 0.0,
-            drag_oy: 0.0,
-            bounds: None,
-            grid_snap_x: 0.0,
-            grid_snap_y: 0.0,
-            grid_origin_x: 0.0,
-            grid_origin_y: 0.0,
-            name: name.to_string(),
-            parameters: Vec::new(),
-            geom_visible: true,
-            geom_toggled: false,
-            toggle_hovered: false,
-        });
-        crate::widget::WidgetHost::set_rect(&mut node, x, y, w, h);
-        node
-    }
-
-    pub(crate) fn toggle_rect(rect: Rect) -> (f32, f32, f32, f32) {
-        (rect.x + rect.width - 30.0, rect.y + (rect.height - 18.0) / 2.0, 18.0, 18.0)
-    }
-
-    fn in_toggle(rect: Rect, px: f32, py: f32) -> bool {
-        let (tx, ty, tw, th) = Self::toggle_rect(rect);
-        px >= tx && px < tx + tw && py >= ty && py < ty + th
-    }
-
-    pub fn set_grid_snap(&mut self, gx: f32, gy: f32) {
-        self.grid_snap_x = gx;
-        self.grid_snap_y = gy;
-    }
-    pub fn set_grid_origin(&mut self, ox: f32, oy: f32) {
-        self.grid_origin_x = ox;
-        self.grid_origin_y = oy;
-    }
-    pub fn set_node_name(&mut self, name: &str) {
-        self.name = name.to_string();
-    }
-}
-
-impl Adapted<Node> {
-    pub fn with_params(mut self, params: &[(&str, &str)]) -> Self {
-        self.parameters =
-            params.iter().map(|(k, v)| (k.to_string(), v.to_string(), "string".to_string())).collect();
-        self
-    }
-
-    pub fn with_grid_snap(mut self, gx: f32, gy: f32) -> Self {
-        self.set_grid_snap(gx, gy);
-        self
-    }
-}
-
-impl Layout for Node {}
-
-impl Paint for Node {
-    fn color(&self) -> [f32; 4] {
-        if self.dragging {
-            colors::node_drag_color()
-        } else if self.selected {
-            colors::node_selected_color()
-        } else {
-            colors::node_color()
-        }
-    }
-
-    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
-        ctx.quad(rect, Paint::color(self));
-
-        let (tx, ty, tw, th) = Self::toggle_rect(rect);
-        let bg_color = if self.toggle_hovered { colors::TOGGLE_HOVER } else { colors::TOGGLE_OFF };
-        ctx.quad(Rect { x: tx, y: ty, width: tw, height: th }, bg_color);
-        if self.geom_visible {
-            let inset = 3.0;
-            ctx.quad(
-                Rect { x: tx + inset, y: ty + inset, width: tw - inset * 2.0, height: th - inset * 2.0 },
-                colors::TOGGLE_ON,
-            );
-        }
-
-        // Deliberately UNBOUNDED, and the only text in the toolkit that is.
-        // The name is drawn in the gutter to the right of the node box, so a
-        // clip to `rect` would erase every node name on the canvas — and the
-        // node has no idea how much gutter it has, because only the canvas
-        // placing it knows where the next node or the viewport edge is. The
-        // bound for this one belongs to the HOST: clip the node layer, not the
-        // node. Do not fix this by clipping to rect.
-        ctx.text(
-            self.name.clone(),
-            rect.x + rect.width + 8.0,
-            crate::layout::align_text_y(rect.y, rect.height, 14.0, 0.0),
-            14.0,
-            [0xcc, 0xcc, 0xd4],
-        );
-    }
-}
-
-impl Input for Node {
-    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
-        match event {
-            Event::PointerMove { x: px, y: py, .. } => {
-                let r = ectx.rect;
-                let was_hovered = self.hovered;
-                self.hovered =
-                    *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
-                let was_toggle = self.toggle_hovered;
-                self.toggle_hovered = Self::in_toggle(r, *px, *py);
-                was_hovered != self.hovered || was_toggle != self.toggle_hovered
-            }
-            Event::MouseButton {
-                button: MouseButton::Left,
-                state: ElementState::Pressed,
-                x: px,
-                y: py,
-                ..
-            } => {
-                if Self::in_toggle(ectx.rect, *px, *py) {
-                    self.geom_visible = !self.geom_visible;
-                    self.geom_toggled = true;
-                } else {
-                    self.drag_begin(*px, *py, ectx.rect);
-                }
-                true
-            }
-            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
-                if self.dragging {
-                    self.drag_end();
-                    true
-                } else {
-                    false
-                }
-            }
-            // Legacy `focus()`/`unfocus()` toggled selection; hosts reach them through the
-            // adapter's focus forwards, which arrive here as focus events.
-            Event::FocusIn => {
-                self.selected = true;
-                true
-            }
-            Event::FocusOut => {
-                self.selected = false;
-                true
-            }
-            _ => false,
-        }
-    }
-
-    fn draggable(&self, _rect: Rect) -> bool {
-        !self.toggle_hovered
-    }
-    fn is_dragging(&self) -> bool {
-        self.dragging
-    }
-
-    fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
-        self.dragging = true;
-        self.drag_ox = px - rect.x;
-        self.drag_oy = py - rect.y;
-    }
-
-    fn drag_reposition(&mut self, px: f32, py: f32, rect: Rect) -> Option<(f32, f32)> {
-        let nx = px - self.drag_ox;
-        let ny = py - self.drag_oy;
-        let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
-            (nx.clamp(bx, bx + bw - rect.width), ny.clamp(by, by + bh - rect.height))
-        } else {
-            (nx, ny)
-        };
-        let nx = if self.grid_snap_x > 0.0 {
-            let relative = nx - self.grid_origin_x;
-            (relative / self.grid_snap_x).round() * self.grid_snap_x + self.grid_origin_x
-        } else {
-            nx
-        };
-        let ny = if self.grid_snap_y > 0.0 {
-            let relative = ny - self.grid_origin_y;
-            (relative / self.grid_snap_y).round() * self.grid_snap_y + self.grid_origin_y
-        } else {
-            ny
-        };
-        if (nx - rect.x).abs() > 0.01 || (ny - rect.y).abs() > 0.01 {
-            Some((nx, ny))
-        } else {
-            None
-        }
-    }
-
-    fn drag_end(&mut self) {
-        self.dragging = false;
-    }
-
-    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
-        self.bounds = Some((bx, by, bw, bh));
-    }
-
-}
-
-impl ParamController for Node {
-    fn node_params(&self) -> Vec<(String, String, String)> {
-        self.parameters.clone()
-    }
-    fn set_display_params(&mut self, params: &[(String, String, String)]) {
-        self.parameters = params.to_vec();
-    }
-}
-
-impl GeomController for Node {
-    fn set_geom_visible(&mut self, visible: bool) {
-        self.geom_visible = visible;
-    }
-    fn geom_visible(&self) -> bool {
-        self.geom_visible
-    }
-    fn take_geom_toggle(&mut self) -> bool {
-        std::mem::take(&mut self.geom_toggled)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::context::UiContext;
-    use crate::widget::WidgetHost;
-
-    #[test]
-    fn toggle_click_flips_geom_and_press_starts_drag() {
-        let mut ctx = UiContext::new();
-        let mut node = Node::new(100.0, 100.0, 120.0, 40.0, "geo1");
-        ctx.register_host(&mut node);
-
-        // Toggle zone: (100+120-30, 100+11) => 18x18 at (190, 111).
-        assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 195.0, 115.0, &mut ctx));
-        let geom: &mut dyn GeomController = &mut *node;
-        assert!(!geom.geom_visible(), "toggle click hides geometry");
-        assert!(geom.take_geom_toggle(), "toggle flag set once");
-        assert!(!geom.take_geom_toggle(), "…and drained");
-
-        // A press outside the toggle starts a drag; reposition snaps to the drag origin.
-        assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 110.0, &mut ctx));
-        assert!(node.is_dragging());
-        assert!(node.drag_update(150.0, 130.0));
-        assert_eq!(WidgetHost::rect(&node), (140.0, 120.0, 120.0, 40.0), "moved by the pointer delta");
-        assert!(node.mouse_input(MouseButton::Left, ElementState::Released, 150.0, 130.0, &mut ctx));
-        assert!(!node.is_dragging());
-    }
-
-    #[test]
-    fn param_controller_roundtrips_through_element() {
-        let mut node = Node::new(0.0, 0.0, 10.0, 10.0, "n").with_params(&[("k", "v")]);
-        let params = ParamController::node_params(&*node);
-        assert_eq!(params, vec![("k".to_string(), "v".to_string(), "string".to_string())]);
-        ParamController::set_display_params(&mut *node, &[("a".to_string(), "b".to_string(), "int".to_string())]);
-        assert_eq!(node.parameters.len(), 1);
-        assert_eq!(node.parameters[0].2, "int");
-    }
-}
diff --git a/src/widget/display/panel.rs b/src/widget/display/panel.rs
deleted file mode 100644
index 0ad1218..0000000
--- a/src/widget/display/panel.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-//! Narrow-trait movable panel (Phase 5i leaf sweep). Self-moving via
-//! [`Input::drag_reposition`], with movement clamped to bounds pushed in through
-//! [`Input::set_drag_bounds`] (or the inherent `set_bounds`).
-
-use crate::colors;
-use crate::scene::layout::Rect;
-use crate::scene::paint::PaintCtx;
-use crate::widget::{Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
-
-pub struct Panel {
-    dragging: bool,
-    drag_ox: f32,
-    drag_oy: f32,
-    bounds: Option<(f32, f32, f32, f32)>,
-}
-
-impl Panel {
-    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Adapted<Panel> {
-        let mut p = Adapted::new(Panel { dragging: false, drag_ox: 0.0, drag_oy: 0.0, bounds: None });
-        WidgetHost::set_rect(&mut p, x, y, w, h);
-        p
-    }
-
-    pub fn set_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
-        self.bounds = Some((bx, by, bw, bh));
-    }
-}
-
-impl Layout for Panel {
-    fn inline_label(&self) -> bool {
-        true // legacy Panel never inflated for its label
-    }
-}
-
-impl Paint for Panel {
-    fn color(&self) -> [f32; 4] {
-        if self.dragging { colors::PANEL_DRAG } else { colors::PANEL_IDLE }
-    }
-
-    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
-        ctx.quad(rect, self.color());
-    }
-}
-
-impl Input for Panel {
-    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
-        match event {
-            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
-                self.dragging = true;
-                self.drag_ox = x - ectx.rect.x;
-                self.drag_oy = y - ectx.rect.y;
-                true
-            }
-            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
-                std::mem::take(&mut self.dragging)
-            }
-            _ => false,
-        }
-    }
-
-    fn draggable(&self, _rect: Rect) -> bool {
-        true
-    }
-    fn is_dragging(&self) -> bool {
-        self.dragging
-    }
-    fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
-        self.dragging = true;
-        self.drag_ox = px - rect.x;
-        self.drag_oy = py - rect.y;
-    }
-    fn drag_reposition(&mut self, px: f32, py: f32, rect: Rect) -> Option<(f32, f32)> {
-        let nx = px - self.drag_ox;
-        let ny = py - self.drag_oy;
-        let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
-            (nx.clamp(bx, bx + bw - rect.width), ny.clamp(by, by + bh - rect.height))
-        } else {
-            (nx, ny)
-        };
-        if (nx - rect.x).abs() > 0.01 || (ny - rect.y).abs() > 0.01 {
-            Some((nx, ny))
-        } else {
-            None
-        }
-    }
-    fn drag_end(&mut self) {
-        self.dragging = false;
-    }
-    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
-        self.bounds = Some((bx, by, bw, bh));
-    }
-}
diff --git a/src/widget/display/serialize.rs b/src/widget/display/serialize.rs
deleted file mode 100644
index aa4280a..0000000
--- a/src/widget/display/serialize.rs
+++ /dev/null
@@ -1,153 +0,0 @@
-use crate::widget::*;
-
-fn serialize_single_widget(w: &dyn WidgetHost, json: &mut String) {
-    let (x, y, width, height) = w.rect();
-    let label = w.label().or_else(|| w.base().label.clone()).unwrap_or_default();
-    let focused = w.base().focused;
-    let hovered = w.base().hovered;
-    // Concrete value lookup (6bd value shrink — `value` left `WidgetHost`): the
-    // `Input::value` implementors a serialized roster can hold are these five widgets;
-    // everything else always reported the default 0.
-    let a = w.as_any();
-    let value = a
-        .downcast_ref::<Checkbox>()
-        .map(Input::value)
-        .or_else(|| a.downcast_ref::<Dropdown>().map(Input::value))
-        .or_else(|| a.downcast_ref::<Slider>().map(Input::value))
-        .or_else(|| a.downcast_ref::<RangeSlider>().map(Input::value))
-        .or_else(|| a.downcast_ref::<Spinbox>().map(Input::value))
-        .unwrap_or(0);
-    let type_name = w.type_name();
-
-    // Escape JSON label
-    let escaped_label = label.replace('\\', "\\\\").replace('"', "\\\"");
-
-    json.push_str(&format!(
-        "{{\"type\":\"{}\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":{},\"hovered\":{},\"value\":{}",
-        type_name, escaped_label, x, y, width, height, focused, hovered, value
-    ));
-
-    // Child handling: there is no ctx here, so tree children were never reachable
-    // (the old lookup ran against a fresh empty UiContext). The one child this path
-    // could ever surface is the field-derived one — Paginator's strip
-    // (`Layout::container_children`) — kept via the 6aw concrete downcast.
-    let children: Vec<&(dyn WidgetHost + 'static)> = w
-        .as_any()
-        .downcast_ref::<Paginator>()
-        .map(|p| vec![&p.sidebar_menu as &(dyn WidgetHost + 'static)])
-        .unwrap_or_default();
-    let mut menu_items = Vec::new();
-    let mut is_menu_open = false;
-    let mut is_vertical = false;
-    let mut checked_states = Vec::new();
-    // Concrete capability lookup (Phase 6aw): the MenuController implementors a serialized
-    // roster can hold are Adapted<MenuBar> and Adapted<Paginator> — WidgetHost's discovery
-    // hooks are gone.
-    let mc: Option<&dyn MenuController> = w
-        .as_any()
-        .downcast_ref::<MenuBar>()
-        .map(|m| m as &dyn MenuController)
-        .or_else(|| w.as_any().downcast_ref::<Paginator>().map(|p| p as &dyn MenuController));
-    if let Some(mc) = mc {
-        menu_items = mc.menu_items();
-        is_menu_open = mc.is_menu_open();
-        is_vertical = mc.is_vertical();
-        checked_states = mc.menu_item_checked();
-    }
-
-    if type_name == "Menu" && is_menu_open && !menu_items.is_empty() {
-        json.push_str(",\"children\":[");
-        let mut max_len = 0;
-        for item in &menu_items {
-            max_len = max_len.max(item.len());
-        }
-        let dw = (max_len as f32 * 7.5 + 40.0).max(120.0);
-        let dx = if is_vertical { x + width } else { x };
-        let dy = if is_vertical { y } else { y + height };
-
-        for (i, item) in menu_items.iter().enumerate() {
-            if i > 0 {
-                json.push(',');
-            }
-            let item_y = dy + i as f32 * DROPDOWN_ITEM_H;
-            let checked = checked_states.get(i).copied().flatten().unwrap_or(false);
-            let item_escaped = item.replace('\\', "\\\\").replace('"', "\\\"");
-            json.push_str(&format!(
-                "{{\"type\":\"MenuItem\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":false,\"hovered\":false,\"value\":{}}}",
-                item_escaped, dx, item_y, dw, DROPDOWN_ITEM_H, if checked { 1 } else { 0 }
-            ));
-        }
-        json.push_str("]}");
-    } else if !children.is_empty() {
-        json.push_str(",\"children\":[");
-        let mut first = true;
-        for child in &children {
-            if !child.visible() {
-                continue;
-            }
-            if !first {
-                json.push(',');
-            }
-            first = false;
-            serialize_single_widget(*child, json);
-        }
-        json.push_str("]}");
-    } else {
-        json.push('}');
-    }
-}
-
-/// Serialize the visible widgets' menu state. Takes dyn refs (not boxes): the designer's
-/// roster is concretely typed since the Phase 6bb retype and lends a per-slot dyn view.
-pub fn serialize_widgets(widgets: &[&dyn WidgetHost]) -> String {
-    let mut json = String::new();
-    json.push('[');
-    let mut first = true;
-    for w in widgets {
-        if !w.visible() {
-            continue;
-        }
-        if !first {
-            json.push(',');
-        }
-        first = false;
-        serialize_single_widget(&**w, &mut json);
-    }
-    json.push(']');
-    json
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    /// The serialized `value` field must keep matching `Input::value` for every
-    /// value-bearing widget (the concrete lookup replaced the deleted
-    /// `WidgetHost::value` — a new `Input::value` implementor must be added to the
-    /// downcast chain in `serialize_single_widget`).
-    #[test]
-    fn serialized_value_matches_input_value() {
-        let mut cb = Checkbox::new();
-        assert!(cb.set_value_string("true"));
-        let dd = Dropdown::new(vec!["a".into(), "b".into(), "c".into()], 2);
-        let mut sl = Slider::new();
-        assert!(sl.set_value_string("0.7"));
-        let sb = Spinbox::new(7, 0, 10, 1);
-        let btn = Button::new(0.0, 0.0, 10.0, 10.0); // no Input::value — always 0
-
-        for (w, expect) in [
-            (&cb as &dyn WidgetHost, 1),
-            (&dd as &dyn WidgetHost, 2),
-            (&sl as &dyn WidgetHost, 70),
-            (&sb as &dyn WidgetHost, 7),
-            (&btn as &dyn WidgetHost, 0),
-        ] {
-            let json = serialize_widgets(&[w]);
-            assert!(
-                json.contains(&format!("\"value\":{expect}")),
-                "{} serialized without value {expect}: {json}",
-                w.type_name()
-            );
-        }
-    }
-}
diff --git a/src/widget/layout_helper.rs b/src/widget/layout_helper.rs
deleted file mode 100644
index 58c5289..0000000
--- a/src/widget/layout_helper.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-use crate::widget::WidgetHost;
-
-pub struct ColumnLayout {
-    pub x: f32,
-    pub y: f32,
-    pub width: f32,
-    pub gap: f32,
-    pub margin: f32,
-    current_y: f32,
-}
-
-impl ColumnLayout {
-    pub fn new(x: f32, y: f32, width: f32, gap: f32, margin: f32) -> Self {
-        Self {
-            x,
-            y,
-            width,
-            gap,
-            margin,
-            current_y: y + margin,
-        }
-    }
-
-    /// A column inside a pane plate: the pane rung's padding as the margin
-    /// and its gap between widgets (`plate_padding` / `plate_gap`).
-    pub fn pane(x: f32, y: f32, width: f32) -> Self {
-        Self::new(x, y, width, crate::layout::plate_gap(), crate::layout::plate_padding())
-    }
-
-    /// A column of controls with the control gap between them and no
-    /// margin of its own (`control_gap`).
-    pub fn controls(x: f32, y: f32, width: f32) -> Self {
-        Self::new(x, y, width, crate::layout::control_gap(), 0.0)
-    }
-
-    /// `height` is the CONTENT height; the widget's block adds its label strip.
-    pub fn add_widget(&mut self, widget: &mut dyn WidgetHost, height: f32) {
-        let total_h = height + widget.label_strip();
-        widget.set_rect(self.x + self.margin, self.current_y, self.width - 2.0 * self.margin, total_h);
-        self.current_y += total_h + self.gap;
-    }
-
-    pub fn add_row(&mut self, widgets: &[*mut dyn WidgetHost], height: f32, gap: f32) {
-        let count = widgets.len();
-        if count == 0 {
-            return;
-        }
-        let mut max_label_off = 0.0;
-        for &widget_ptr in widgets {
-            unsafe {
-                let off = (*widget_ptr).label_strip();
-                if off > max_label_off {
-                    max_label_off = off;
-                }
-            }
-        }
-        let total_h = height + max_label_off;
-        let total_width = self.width - 2.0 * self.margin;
-        let widget_w = (total_width - (count as f32 - 1.0) * gap) / count as f32;
-        let mut curr_x = self.x + self.margin;
-        for &widget_ptr in widgets {
-            unsafe {
-                // Content lines up on one row; a shorter label strip starts lower.
-                let off = (*widget_ptr).label_strip();
-                (*widget_ptr).set_rect(curr_x, self.current_y + max_label_off - off, widget_w, height + off);
-            }
-            curr_x += widget_w + gap;
-        }
-        self.current_y += total_h + self.gap;
-    }
-
-    pub fn current_y(&self) -> f32 {
-        self.current_y - self.gap + self.margin
-    }
-}
-
-pub struct RowLayout {
-    pub x: f32,
-    pub y: f32,
-    pub height: f32,
-    pub gap: f32,
-    pub margin: f32,
-    current_x: f32,
-}
-
-impl RowLayout {
-    pub fn new(x: f32, y: f32, height: f32, gap: f32, margin: f32) -> Self {
-        Self {
-            x,
-            y,
-            height,
-            gap,
-            margin,
-            current_x: x + margin,
-        }
-    }
-
-    pub fn add_widget(&mut self, widget: &mut dyn WidgetHost, width: f32) {
-        widget.set_rect(self.current_x, self.y + self.margin, width, self.height - 2.0 * self.margin);
-        self.current_x += width + self.gap;
-    }
-
-    pub fn current_x(&self) -> f32 {
-        self.current_x - self.gap + self.margin
-    }
-}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 48004f0..7878b6a 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -634,7 +634,6 @@ pub mod plate_dock;
 pub mod container;
 pub mod display;
 pub mod editor;
-pub mod layout_helper;
 pub mod shaping;
 #[cfg(feature = "markdown")]
 pub mod markdown;
@@ -678,7 +677,6 @@ pub mod side_swipe;
 // Re-exports
 pub use self::editor::TextEditorState;
 pub use self::line_edit::{EditOutcome, LineEdit};
-pub use self::layout_helper::{ColumnLayout, RowLayout};
 pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
 pub use self::side_swipe::{SideSwipe, SwipeDir};
 pub use self::scroll_motion::{Bounds, ScrollAxis, ScrollMotion, ScrollPhase, ScrollSettings, LINE_PX};
@@ -704,8 +702,8 @@ pub use self::container::{
 pub use self::display::{
     TextLabel, Label, StyledLabel, LabelPrim, TextItem, UsageBar,
     InfoBox, StatusDot, InteractiveListItem,
-    GraphNode, Graph, TaggedQuad, node_wires, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
-    DotStatus, Panel, ImageView, serialize_widgets,
+    GraphNode, Graph, TaggedQuad, node_wires, Float3, ProgressBar, StatusBar, Splitter, Separator,
+    DotStatus, ImageView,
     truncate_head, truncate_tail,
 };
 
diff --git a/src/widget/model.rs b/src/widget/model.rs
index bd1f03f..f29b09a 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -758,6 +758,12 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
         self.inner.sync_label("");
     }
 
+    /// Name the widget to a screen reader without drawing a label
+    /// ([`Widget::accessible_name`]); `None` falls back to the label.
+    pub fn set_accessible_name(&mut self, name: Option<&str>) {
+        self.base.accessible_name = name.map(str::to_string);
+    }
+
     // --- The value/polling drains (off `WidgetHost` in the 6bd value shrink): apps read
     // widget state through these concrete methods; each forwards to the narrow `Input`
     // hook. The last dyn readers went concrete-slot instead (TI roster, cloud JsonControl,