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

commit2d702a78c6a51391ecdd51b14abb762e2c5fb2fc
parente349ffffb1
authorLucas Galante <[email protected]>
date2026-08-23 11:46
refactor: split the widget roster out of app.rs into src/slots.rs

The `*_IDX` constants, `WidgetSlots` and its dispatch arms lived in the middle
of app.rs, so adding or reordering a slot meant editing the 5.7k-line app model.
They move verbatim to their own module, along with `PassivePlate` and `Canvas`
— the two app-owned widgets that exist only to fill a slot — and the typed
accessors that assert each slot's concrete type (`viewport()`, `graph_mut()`,
`menu(idx)`, …), which were `State` methods routing purely through `self.slots`.
`State` keeps one-line forwarders so the ~40 call sites are untouched.

app.rs drops 270 lines; no behavior change.

 CLAUDE.md          |  15 ++-
 src/app.rs         | 301 +++-----------------------------------------------
 src/application.rs |   3 +-
 src/main.rs        |   4 +-
 src/project.rs     |   3 +-
 src/render.rs      |   5 +-
 src/slots.rs       | 315 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/window.rs      |   3 +-
 8 files changed, 353 insertions(+), 296 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9650f5f..2370e95 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,14 +70,21 @@ geometry AND text — is the engine's single paint path: `display_list` returns
 engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its own;
 `glyphon` remains a dependency only for the standalone `vk-smoke` bin).
 
-- `src/app.rs` (~5k lines) — the heart: `State` (the entire app model), `HttpAction` /
-  `CustomEvent`, node-template loading, pane layout. Top-level widgets live in fixed
-  slots addressed by `*_IDX` constants (`VIEWPORT_IDX`, `PARAM_IDX`, `NETWORK_PANEL_IDX`,
-  … up to `WIDGET_COUNT`) rather than a dynamic tree. `tick_frame` (simulation:
+- `src/app.rs` (~5.4k lines) — the heart: `State` (the entire app model), `HttpAction` /
+  `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
   two halves of the old render loop. GPU mesh updates are staged CPU-side
   (`pending_*` fields, `spheres_dirty`) and flushed in `stage_frame` because only
   the engine hooks see the renderer.
+- `src/slots.rs` — the widget roster. Top-level widgets live in fixed slots on
+  `WidgetSlots` addressed by `*_IDX` constants (`VIEWPORT_IDX`, `PARAM_IDX`,
+  `NETWORK_PANEL_IDX`, … up to `WIDGET_COUNT`) rather than a dynamic tree; every slot
+  is statically typed, and index-driven paths (draw order, focus cycling, broadcast
+  loops) go through `get_dyn`/`get_dyn_mut`. The typed accessors that assert a slot's
+  concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too — `State`
+  keeps one-line forwarders. Adding a slot means the constant, the field and the four
+  dispatch arms, all in this file. `PassivePlate` and `Canvas`, the two app-owned
+  slot-only widgets, are also here.
 - `src/application.rs` — the `Application` impl: translates engine hooks into
   `WindowEvent`s, detached-window CSD, HTTP-server startup, exit autosave.
 - `src/window.rs` — `WindowEvent` plus the post-event side-effect pass
diff --git a/src/app.rs b/src/app.rs
index 90498d7..551c595 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -42,6 +42,7 @@ use cce_ui::colors;
 use glam::{Mat4, Vec3};
 
 use crate::geometry::*;
+use crate::slots::*;
 use crate::shortcut::{ShortcutManager, Action};
 use cce_ui::vk::{SceneDraw, TextSpan};
 use cce_ui::engine::Vertex;
@@ -71,146 +72,6 @@ impl ModifiersState {
     pub fn state(&self) -> Self { *self }
 }
 
-pub const HEADER_IDX: usize = 0;
-pub const CONTENT_IDX: usize = 1;
-pub const SPLITTER1_IDX: usize = 2;
-pub const VIEWPORT_IDX: usize = 3;
-pub const SPLITTER2_IDX: usize = 4;
-pub const PARAM_IDX: usize = 5;
-pub const CANVAS_IDX: usize = 6;
-pub const LEFT_MENUBAR_IDX: usize = 7;
-pub const RIGHT_MENUBAR_IDX: usize = 8;
-pub const PARAM_MENUBAR_IDX: usize = 9;
-pub const STATUS_IDX: usize = 10;
-pub const BREADCRUMB_IDX: usize = 11;
-pub const SPREADSHEET_IDX: usize = 12;
-pub const SPREADSHEET_MENUBAR_IDX: usize = 13;
-pub const NETWORK_PANEL_IDX: usize = 14;
-pub const PLAYBAR_IDX: usize = 15;
-
-pub const WIDGET_COUNT: usize = 16;
-
-/// The roster, concretely typed (Phase 6bb): every slot's type is statically known — the
-/// old `Vec<Box<dyn WidgetHost>>` erased that and pinned `WidgetHost`'s full surface through the
-/// broadcast loops. Boxed as a whole so registered widget pointers stay stable while the
-/// containing `State` moves. The `*_IDX` constants keep addressing the same slots through
-/// `get_dyn`/`get_dyn_mut` for the genuinely index-driven paths (draw order, focus cycling,
-/// broadcast loops); everything else reaches the concrete field.
-pub struct WidgetSlots {
-    pub header: Adapted<MenuBar>,
-    pub content: Adapted<Graph>,
-    pub splitter1: Adapted<Splitter>,
-    pub viewport: Adapted<Viewport3D>,
-    pub splitter2: Adapted<Splitter>,
-    pub param: Adapted<ParametersBg>,
-    pub canvas: Adapted<Canvas>,
-    pub left_menubar: Adapted<MenuBar>,
-    pub right_menubar: Adapted<MenuBar>,
-    pub param_menubar: Adapted<MenuBar>,
-    pub status: Adapted<StatusBar>,
-    pub breadcrumb: Adapted<Breadcrumb>,
-    pub spreadsheet: Adapted<Spreadsheet>,
-    pub spreadsheet_menubar: Adapted<MenuBar>,
-    pub network_panel: Adapted<PassivePlate>,
-    pub playbar: Adapted<Playbar>,
-}
-
-impl WidgetSlots {
-
-    // Per-slot drag queries (the ControlPanel endgame took `draggable`/`is_dragging`
-    // off `WidgetHost`): the roster routes an index to the concrete slot's inherent
-    // `Adapted` read, like the other value drains.
-    pub fn draggable(&self, idx: usize) -> bool {
-        match idx {
-            HEADER_IDX => self.header.draggable(),
-            CONTENT_IDX => self.content.draggable(),
-            SPLITTER1_IDX => self.splitter1.draggable(),
-            VIEWPORT_IDX => self.viewport.draggable(),
-            SPLITTER2_IDX => self.splitter2.draggable(),
-            PARAM_IDX => self.param.draggable(),
-            CANVAS_IDX => self.canvas.draggable(),
-            LEFT_MENUBAR_IDX => self.left_menubar.draggable(),
-            RIGHT_MENUBAR_IDX => self.right_menubar.draggable(),
-            PARAM_MENUBAR_IDX => self.param_menubar.draggable(),
-            STATUS_IDX => self.status.draggable(),
-            BREADCRUMB_IDX => self.breadcrumb.draggable(),
-            SPREADSHEET_IDX => self.spreadsheet.draggable(),
-            SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.draggable(),
-            NETWORK_PANEL_IDX => self.network_panel.draggable(),
-            PLAYBAR_IDX => self.playbar.draggable(),
-            _ => panic!("widget slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn is_dragging(&self, idx: usize) -> bool {
-        match idx {
-            HEADER_IDX => self.header.is_dragging(),
-            CONTENT_IDX => self.content.is_dragging(),
-            SPLITTER1_IDX => self.splitter1.is_dragging(),
-            VIEWPORT_IDX => self.viewport.is_dragging(),
-            SPLITTER2_IDX => self.splitter2.is_dragging(),
-            PARAM_IDX => self.param.is_dragging(),
-            CANVAS_IDX => self.canvas.is_dragging(),
-            LEFT_MENUBAR_IDX => self.left_menubar.is_dragging(),
-            RIGHT_MENUBAR_IDX => self.right_menubar.is_dragging(),
-            PARAM_MENUBAR_IDX => self.param_menubar.is_dragging(),
-            STATUS_IDX => self.status.is_dragging(),
-            BREADCRUMB_IDX => self.breadcrumb.is_dragging(),
-            SPREADSHEET_IDX => self.spreadsheet.is_dragging(),
-            SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.is_dragging(),
-            NETWORK_PANEL_IDX => self.network_panel.is_dragging(),
-            PLAYBAR_IDX => self.playbar.is_dragging(),
-            _ => panic!("widget slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
-        match idx {
-            HEADER_IDX => &self.header,
-            CONTENT_IDX => &self.content,
-            SPLITTER1_IDX => &self.splitter1,
-            VIEWPORT_IDX => &self.viewport,
-            SPLITTER2_IDX => &self.splitter2,
-            PARAM_IDX => &self.param,
-            CANVAS_IDX => &self.canvas,
-            LEFT_MENUBAR_IDX => &self.left_menubar,
-            RIGHT_MENUBAR_IDX => &self.right_menubar,
-            PARAM_MENUBAR_IDX => &self.param_menubar,
-            STATUS_IDX => &self.status,
-            BREADCRUMB_IDX => &self.breadcrumb,
-            SPREADSHEET_IDX => &self.spreadsheet,
-            SPREADSHEET_MENUBAR_IDX => &self.spreadsheet_menubar,
-            NETWORK_PANEL_IDX => &self.network_panel,
-            PLAYBAR_IDX => &self.playbar,
-            _ => panic!("widget slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
-        match idx {
-            HEADER_IDX => &mut self.header,
-            CONTENT_IDX => &mut self.content,
-            SPLITTER1_IDX => &mut self.splitter1,
-            VIEWPORT_IDX => &mut self.viewport,
-            SPLITTER2_IDX => &mut self.splitter2,
-            PARAM_IDX => &mut self.param,
-            CANVAS_IDX => &mut self.canvas,
-            LEFT_MENUBAR_IDX => &mut self.left_menubar,
-            RIGHT_MENUBAR_IDX => &mut self.right_menubar,
-            PARAM_MENUBAR_IDX => &mut self.param_menubar,
-            STATUS_IDX => &mut self.status,
-            BREADCRUMB_IDX => &mut self.breadcrumb,
-            SPREADSHEET_IDX => &mut self.spreadsheet,
-            SPREADSHEET_MENUBAR_IDX => &mut self.spreadsheet_menubar,
-            NETWORK_PANEL_IDX => &mut self.network_panel,
-            PLAYBAR_IDX => &mut self.playbar,
-            _ => panic!("widget slot index out of range: {idx}"),
-        }
-    }
-}
-
-
-
 pub const HEADER_H: f32 = 0.0;
 pub const STATUS_H: f32 = 0.0;
 pub const MENUBAR_H: f32 = 0.0;
@@ -640,98 +501,6 @@ impl DesignSettings {
     }
 }
 
-/// Dissolved cce-ui `Plate` (Phase 6as): a passive panel wearing the parameter
-/// plate's fill — same tint, opacity, and blur-behind marker (`param_plate_fill`),
-/// so the network plate's bevel rolls exactly like the params plate's and tracks a
-/// live retint/opacity/blur toggle — scaled by the network fade. No children, no
-/// events.
-pub struct PassivePlate {
-    pub network_opacity: f32,
-    /// Circular hit shape while the network pane is round (the legacy Plate marker).
-    curved_circle: Option<(f32, f32, f32)>,
-}
-
-impl PassivePlate {
-    pub fn new() -> cce_ui::widget::Adapted<PassivePlate> {
-        cce_ui::widget::Adapted::new(Self {
-            network_opacity: 1.0,
-            curved_circle: None,
-        })
-    }
-
-    pub fn set_network_opacity(&mut self, opacity: f32) {
-        self.network_opacity = opacity;
-    }
-
-    pub fn set_curved_circle(&mut self, circle: Option<(f32, f32, f32)>) {
-        self.curved_circle = circle;
-    }
-}
-
-impl cce_ui::widget::Layout for PassivePlate {}
-
-impl cce_ui::widget::Paint for PassivePlate {
-    /// Nothing: the plate's fill AND border are drawn by `append_widget_plate` (or, in
-    /// circular mode, the circle+arc branch) in the designer's hand-ordered paint walk.
-    /// The default `paint` would emit a plain square-cornered quad of the whole rect,
-    /// which the walk then re-drew through `extra_quads` ON TOP of the rounded plate —
-    /// square corners over the rounded ones.
-    fn paint(&self, _rect: cce_ui::scene::layout::Rect, _ctx: &mut cce_ui::scene::paint::PaintCtx) {}
-
-    fn color(&self) -> [f32; 4] {
-        // `param_plate_fill` folds in the plate opacity and the blur marker; the
-        // network fade scales the (possibly negative) alpha without flipping its sign.
-        let mut c = cce_ui::colors::param_plate_fill();
-        c[3] *= self.network_opacity;
-        c
-    }
-
-    fn corner_style(&self, _rect: cce_ui::scene::layout::Rect) -> Option<(f32, (bool, bool, bool, bool))> {
-        let r = cce_ui::layout::plate_corner_radius();
-        let on = r > 0.0;
-        Some((r, (on, on, on, on)))
-    }
-
-    fn solid_border(&self) -> Option<([f32; 4], f32)> {
-        if let Some(bc) = cce_ui::colors::plate_border_color() {
-            Some((bc, cce_ui::colors::plate_border_thickness()))
-        } else {
-            None
-        }
-    }
-}
-
-impl cce_ui::widget::Input for PassivePlate {
-    fn hit(&self, rect: cce_ui::scene::layout::Rect, x: f32, y: f32) -> bool {
-        if let Some((cx, cy, r)) = self.curved_circle {
-            let dx = x - cx;
-            let dy = y - cy;
-            return dx * dx + dy * dy <= r * r;
-        }
-        // Exclusive right/bottom edges, like the legacy Plate hit test.
-        x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
-    }
-}
-
-
-/// App-owned copy of the dissolved cce-ui `Canvas` (Phase 6ay part 2): the transparent
-/// hit-through pane behind the network area. Verbatim; dies with the machinery retype.
-pub struct Canvas;
-
-impl Canvas {
-    pub fn new() -> cce_ui::widget::Adapted<Canvas> { cce_ui::widget::Adapted::new(Canvas) }
-}
-
-impl cce_ui::widget::Layout for Canvas {}
-
-impl cce_ui::widget::Paint for Canvas {
-    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
-}
-
-impl cce_ui::widget::Input for Canvas {
-    // Hit-through: the pane never claims the pointer (the graph decides its own hits).
-    fn hit(&self, _rect: cce_ui::scene::layout::Rect, _x: f32, _y: f32) -> bool { false }
-}
 
 pub fn get_next_visible_pane(
     current_pane: usize,
@@ -1044,27 +813,12 @@ pub struct State {
 }
 
 impl State {
-    pub fn viewport(&self) -> &Viewport3D {
-        self.slots.viewport
-            .as_any()
-            .downcast_ref::<Viewport3D>()
-            .expect("VIEWPORT_IDX must be a Viewport3D")
-    }
+    pub fn viewport(&self) -> &Viewport3D { self.slots.viewport() }
 
-    pub fn viewport_mut(&mut self) -> &mut Viewport3D {
-        self.slots.viewport
-            .as_any_mut()
-            .downcast_mut::<Viewport3D>()
-            .expect("VIEWPORT_IDX must be a Viewport3D")
-    }
+    pub fn viewport_mut(&mut self) -> &mut Viewport3D { self.slots.viewport_mut() }
 
-    /// Roster index of the slot at `target_addr` (a thin widget address — the comparison
-    /// never dereferences; callers pass `ptr as *const ()`).
     pub fn find_widget_index(&self, target_addr: *const ()) -> Option<usize> {
-        (0..WIDGET_COUNT).position(|i| {
-            let w_ptr = self.slots.get_dyn(i) as *const dyn WidgetHost as *const ();
-            w_ptr == target_addr
-        })
+        self.slots.find_index(target_addr)
     }
 
     pub fn has_any_open_menu(&self, idx: usize) -> bool {
@@ -1098,50 +852,25 @@ impl State {
         false
     }
 
-    /// The menu-capable roster entries are exactly the `Adapted<MenuBar>` bars (Phase 6aw
-    /// concrete typing); `None` for everything else.
-    pub fn menubar_at(&self, idx: usize) -> Option<&MenuBar> {
-        // Adapted::as_any exposes the INNER widget, so the downcast targets MenuBar itself.
-        self.slots.get_dyn(idx).as_any().downcast_ref::<MenuBar>()
-    }
+    // Typed roster accessors: the concrete-type asserts live on `WidgetSlots` (src/slots.rs);
+    // these forward so the ~40 call sites keep reading `self.menu(..)` / `self.graph_mut()`.
+    pub fn menubar_at(&self, idx: usize) -> Option<&MenuBar> { self.slots.menubar_at(idx) }
 
+    pub fn menu(&self, idx: usize) -> &dyn cce_ui::widget::MenuController { self.slots.menu(idx) }
 
-    // Roster accessors on CONCRETE types (Phase 6aw, controller decision option 2): each
-    // index's type is known statically, so the capability traits are reached by downcast +
-    // Deref instead of WidgetHost's deleted as_*_controller discovery hooks. Signatures keep
-    // returning the narrow trait objects so the ~40 call sites stay unchanged. The dynamic
-    // `idx` of menu()/menu_mut() only ever receives the five menubar indexes.
-    pub fn menu(&self, idx: usize) -> &dyn cce_ui::widget::MenuController {
-        self.slots.get_dyn(idx).as_any().downcast_ref::<MenuBar>().expect("not a MenuBar")
-    }
+    pub fn menu_mut(&mut self, idx: usize) -> &mut dyn cce_ui::widget::MenuController { self.slots.menu_mut(idx) }
 
-    pub fn menu_mut(&mut self, idx: usize) -> &mut dyn cce_ui::widget::MenuController {
-        self.slots.get_dyn_mut(idx).as_any_mut().downcast_mut::<MenuBar>().expect("not a MenuBar")
-    }
+    pub fn graph(&self) -> &dyn cce_ui::widget::GraphController { self.slots.graph() }
 
-    pub fn graph(&self) -> &dyn cce_ui::widget::GraphController {
-        self.slots.content.as_any().downcast_ref::<Graph>().expect("CONTENT_IDX must be a Graph")
-    }
+    pub fn graph_mut(&mut self) -> &mut dyn cce_ui::widget::GraphController { self.slots.graph_mut() }
 
-    pub fn graph_mut(&mut self) -> &mut dyn cce_ui::widget::GraphController {
-        self.slots.content.as_any_mut().downcast_mut::<Graph>().expect("CONTENT_IDX must be a Graph")
-    }
+    pub fn param(&self) -> &dyn cce_ui::widget::ParamController { self.slots.param() }
 
-    pub fn param(&self) -> &dyn cce_ui::widget::ParamController {
-        self.slots.param.as_any().downcast_ref::<ParametersBg>().expect("PARAM_IDX must be a ParametersBg")
-    }
+    pub fn param_mut(&mut self) -> &mut dyn cce_ui::widget::ParamController { self.slots.param_mut() }
 
-    pub fn param_mut(&mut self) -> &mut dyn cce_ui::widget::ParamController {
-        self.slots.param.as_any_mut().downcast_mut::<ParametersBg>().expect("PARAM_IDX must be a ParametersBg")
-    }
+    pub fn spreadsheet_mut(&mut self) -> &mut dyn cce_ui::widget::SpreadsheetController { self.slots.spreadsheet_mut() }
 
-    pub fn spreadsheet_mut(&mut self) -> &mut dyn cce_ui::widget::SpreadsheetController {
-        self.slots.spreadsheet.as_any_mut().downcast_mut::<Spreadsheet>().expect("SPREADSHEET_IDX must be a Spreadsheet")
-    }
-
-    pub fn path_mut(&mut self) -> &mut dyn cce_ui::widget::PathController {
-        self.slots.breadcrumb.as_any_mut().downcast_mut::<Breadcrumb>().expect("BREADCRUMB_IDX must be a Breadcrumb")
-    }
+    pub fn path_mut(&mut self) -> &mut dyn cce_ui::widget::PathController { self.slots.path_mut() }
 
     pub fn has_unsaved_changes(&self) -> bool {
         if let Ok(current_json) = serde_json::to_string(&self.fs_root) {
diff --git a/src/application.rs b/src/application.rs
index d0a873c..cad9b4d 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -16,7 +16,8 @@ use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 use wayland_client::QueueHandle;
 
 use crate::api::start_mcp_server;
-use crate::app::{CustomEvent, PendingWindowDrag, State, TouchPhase, LEFT_MENUBAR_IDX};
+use crate::app::{CustomEvent, PendingWindowDrag, State, TouchPhase};
+use crate::slots::LEFT_MENUBAR_IDX;
 use crate::window::{LocalPosition, WindowEvent};
 
 /// Pointer travel (logical px) before a chrome press becomes an interactive
diff --git a/src/main.rs b/src/main.rs
index adb1a4f..821fc1e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,6 +13,7 @@ pub mod geometry;
 pub mod project;
 pub mod render;
 pub mod shortcut;
+pub mod slots;
 pub mod thumbnail;
 
 #[cfg(test)]
@@ -62,7 +63,8 @@ fn main() {
 #[cfg(test)]
 mod tests {
     use crate::test_prelude::*;
-    use crate::app::{get_next_visible_pane, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, DesignSettings, FsNode, Project, ProjectViewState};
+    use crate::app::{get_next_visible_pane, DesignSettings, FsNode, Project, ProjectViewState};
+    use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX};
     use crate::shortcut::{Shortcut, ShortcutManager, Action};
     use crate::geometry::{GAttribute, GVertex, Geometry, line_vertices};
 
diff --git a/src/project.rs b/src/project.rs
index 1bc5bae..148a9f6 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -1,7 +1,8 @@
 use std::fs;
 use std::path::Path;
 
-use crate::app::{State, Project, FsNode, ProjectViewState, CONTENT_IDX, ParamDef};
+use crate::app::{State, Project, FsNode, ProjectViewState, ParamDef};
+use crate::slots::CONTENT_IDX;
 
 fn color_to_hex(rgb: [f32; 3]) -> String {
     format!("#{:02x}{:02x}{:02x}",
diff --git a/src/render.rs b/src/render.rs
index 221292a..86b5904 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -2,8 +2,9 @@
 use cce_ui::colors;
 use cce_ui::widget::WidgetHost;
 
-use crate::app::{
-    State, FsNode, WIDGET_COUNT,
+use crate::app::{State, FsNode};
+use crate::slots::{
+    WIDGET_COUNT,
     CONTENT_IDX, VIEWPORT_IDX, PARAM_IDX,
     BREADCRUMB_IDX, HEADER_IDX, RIGHT_MENUBAR_IDX,
     SPREADSHEET_MENUBAR_IDX, SPREADSHEET_IDX,
diff --git a/src/slots.rs b/src/slots.rs
new file mode 100644
index 0000000..fc49b04
--- /dev/null
+++ b/src/slots.rs
@@ -0,0 +1,315 @@
+//! The widget roster: the app's top-level widgets in fixed, statically typed slots.
+//!
+//! The designer does not build a dynamic widget tree — every top-level pane, bar and
+//! plate lives in a named field of [`WidgetSlots`], and the `*_IDX` constants address
+//! those same fields positionally for the genuinely index-driven paths (draw order,
+//! focus cycling, broadcast loops). Split out of `app.rs` so that adding or reordering
+//! a slot is a change to one file: the constant, the field, and the four dispatch
+//! arms are all here, as are the typed accessors that assert each slot's concrete type.
+
+use cce_ui::widget::{
+    Adapted, Breadcrumb, Graph, MenuBar, ParametersBg, Splitter, Spreadsheet, StatusBar,
+    WidgetHost,
+};
+
+use crate::playbar::Playbar;
+use crate::viewport_3d::Viewport3D;
+
+pub const HEADER_IDX: usize = 0;
+pub const CONTENT_IDX: usize = 1;
+pub const SPLITTER1_IDX: usize = 2;
+pub const VIEWPORT_IDX: usize = 3;
+pub const SPLITTER2_IDX: usize = 4;
+pub const PARAM_IDX: usize = 5;
+pub const CANVAS_IDX: usize = 6;
+pub const LEFT_MENUBAR_IDX: usize = 7;
+pub const RIGHT_MENUBAR_IDX: usize = 8;
+pub const PARAM_MENUBAR_IDX: usize = 9;
+pub const STATUS_IDX: usize = 10;
+pub const BREADCRUMB_IDX: usize = 11;
+pub const SPREADSHEET_IDX: usize = 12;
+pub const SPREADSHEET_MENUBAR_IDX: usize = 13;
+pub const NETWORK_PANEL_IDX: usize = 14;
+pub const PLAYBAR_IDX: usize = 15;
+
+pub const WIDGET_COUNT: usize = 16;
+
+/// The roster, concretely typed (Phase 6bb): every slot's type is statically known — the
+/// old `Vec<Box<dyn WidgetHost>>` erased that and pinned `WidgetHost`'s full surface through the
+/// broadcast loops. Boxed as a whole so registered widget pointers stay stable while the
+/// containing `State` moves. The `*_IDX` constants keep addressing the same slots through
+/// `get_dyn`/`get_dyn_mut` for the genuinely index-driven paths (draw order, focus cycling,
+/// broadcast loops); everything else reaches the concrete field.
+pub struct WidgetSlots {
+    pub header: Adapted<MenuBar>,
+    pub content: Adapted<Graph>,
+    pub splitter1: Adapted<Splitter>,
+    pub viewport: Adapted<Viewport3D>,
+    pub splitter2: Adapted<Splitter>,
+    pub param: Adapted<ParametersBg>,
+    pub canvas: Adapted<Canvas>,
+    pub left_menubar: Adapted<MenuBar>,
+    pub right_menubar: Adapted<MenuBar>,
+    pub param_menubar: Adapted<MenuBar>,
+    pub status: Adapted<StatusBar>,
+    pub breadcrumb: Adapted<Breadcrumb>,
+    pub spreadsheet: Adapted<Spreadsheet>,
+    pub spreadsheet_menubar: Adapted<MenuBar>,
+    pub network_panel: Adapted<PassivePlate>,
+    pub playbar: Adapted<Playbar>,
+}
+
+impl WidgetSlots {
+
+    // Per-slot drag queries (the ControlPanel endgame took `draggable`/`is_dragging`
+    // off `WidgetHost`): the roster routes an index to the concrete slot's inherent
+    // `Adapted` read, like the other value drains.
+    pub fn draggable(&self, idx: usize) -> bool {
+        match idx {
+            HEADER_IDX => self.header.draggable(),
+            CONTENT_IDX => self.content.draggable(),
+            SPLITTER1_IDX => self.splitter1.draggable(),
+            VIEWPORT_IDX => self.viewport.draggable(),
+            SPLITTER2_IDX => self.splitter2.draggable(),
+            PARAM_IDX => self.param.draggable(),
+            CANVAS_IDX => self.canvas.draggable(),
+            LEFT_MENUBAR_IDX => self.left_menubar.draggable(),
+            RIGHT_MENUBAR_IDX => self.right_menubar.draggable(),
+            PARAM_MENUBAR_IDX => self.param_menubar.draggable(),
+            STATUS_IDX => self.status.draggable(),
+            BREADCRUMB_IDX => self.breadcrumb.draggable(),
+            SPREADSHEET_IDX => self.spreadsheet.draggable(),
+            SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.draggable(),
+            NETWORK_PANEL_IDX => self.network_panel.draggable(),
+            PLAYBAR_IDX => self.playbar.draggable(),
+            _ => panic!("widget slot index out of range: {idx}"),
+        }
+    }
+
+    pub fn is_dragging(&self, idx: usize) -> bool {
+        match idx {
+            HEADER_IDX => self.header.is_dragging(),
+            CONTENT_IDX => self.content.is_dragging(),
+            SPLITTER1_IDX => self.splitter1.is_dragging(),
+            VIEWPORT_IDX => self.viewport.is_dragging(),
+            SPLITTER2_IDX => self.splitter2.is_dragging(),
+            PARAM_IDX => self.param.is_dragging(),
+            CANVAS_IDX => self.canvas.is_dragging(),
+            LEFT_MENUBAR_IDX => self.left_menubar.is_dragging(),
+            RIGHT_MENUBAR_IDX => self.right_menubar.is_dragging(),
+            PARAM_MENUBAR_IDX => self.param_menubar.is_dragging(),
+            STATUS_IDX => self.status.is_dragging(),
+            BREADCRUMB_IDX => self.breadcrumb.is_dragging(),
+            SPREADSHEET_IDX => self.spreadsheet.is_dragging(),
+            SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.is_dragging(),
+            NETWORK_PANEL_IDX => self.network_panel.is_dragging(),
+            PLAYBAR_IDX => self.playbar.is_dragging(),
+            _ => panic!("widget slot index out of range: {idx}"),
+        }
+    }
+
+    pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
+        match idx {
+            HEADER_IDX => &self.header,
+            CONTENT_IDX => &self.content,
+            SPLITTER1_IDX => &self.splitter1,
+            VIEWPORT_IDX => &self.viewport,
+            SPLITTER2_IDX => &self.splitter2,
+            PARAM_IDX => &self.param,
+            CANVAS_IDX => &self.canvas,
+            LEFT_MENUBAR_IDX => &self.left_menubar,
+            RIGHT_MENUBAR_IDX => &self.right_menubar,
+            PARAM_MENUBAR_IDX => &self.param_menubar,
+            STATUS_IDX => &self.status,
+            BREADCRUMB_IDX => &self.breadcrumb,
+            SPREADSHEET_IDX => &self.spreadsheet,
+            SPREADSHEET_MENUBAR_IDX => &self.spreadsheet_menubar,
+            NETWORK_PANEL_IDX => &self.network_panel,
+            PLAYBAR_IDX => &self.playbar,
+            _ => panic!("widget slot index out of range: {idx}"),
+        }
+    }
+
+    pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
+        match idx {
+            HEADER_IDX => &mut self.header,
+            CONTENT_IDX => &mut self.content,
+            SPLITTER1_IDX => &mut self.splitter1,
+            VIEWPORT_IDX => &mut self.viewport,
+            SPLITTER2_IDX => &mut self.splitter2,
+            PARAM_IDX => &mut self.param,
+            CANVAS_IDX => &mut self.canvas,
+            LEFT_MENUBAR_IDX => &mut self.left_menubar,
+            RIGHT_MENUBAR_IDX => &mut self.right_menubar,
+            PARAM_MENUBAR_IDX => &mut self.param_menubar,
+            STATUS_IDX => &mut self.status,
+            BREADCRUMB_IDX => &mut self.breadcrumb,
+            SPREADSHEET_IDX => &mut self.spreadsheet,
+            SPREADSHEET_MENUBAR_IDX => &mut self.spreadsheet_menubar,
+            NETWORK_PANEL_IDX => &mut self.network_panel,
+            PLAYBAR_IDX => &mut self.playbar,
+            _ => panic!("widget slot index out of range: {idx}"),
+        }
+    }
+
+    /// Roster index of the slot at `target_addr` (a thin widget address — the comparison
+    /// never dereferences; callers pass `ptr as *const ()`).
+    pub fn find_index(&self, target_addr: *const ()) -> Option<usize> {
+        (0..WIDGET_COUNT).position(|i| {
+            let w_ptr = self.get_dyn(i) as *const dyn WidgetHost as *const ();
+            w_ptr == target_addr
+        })
+    }
+
+    // Roster accessors on CONCRETE types (Phase 6aw, controller decision option 2): each
+    // index's type is known statically, so the capability traits are reached by downcast +
+    // Deref instead of WidgetHost's deleted as_*_controller discovery hooks. Signatures keep
+    // returning the narrow trait objects so the ~40 call sites stay unchanged. The dynamic
+    // `idx` of menu()/menu_mut() only ever receives the five menubar indexes.
+    pub fn viewport(&self) -> &Viewport3D {
+        self.viewport
+            .as_any()
+            .downcast_ref::<Viewport3D>()
+            .expect("VIEWPORT_IDX must be a Viewport3D")
+    }
+
+    pub fn viewport_mut(&mut self) -> &mut Viewport3D {
+        self.viewport
+            .as_any_mut()
+            .downcast_mut::<Viewport3D>()
+            .expect("VIEWPORT_IDX must be a Viewport3D")
+    }
+
+    /// The menu-capable roster entries are exactly the `Adapted<MenuBar>` bars (Phase 6aw
+    /// concrete typing); `None` for everything else.
+    pub fn menubar_at(&self, idx: usize) -> Option<&MenuBar> {
+        // Adapted::as_any exposes the INNER widget, so the downcast targets MenuBar itself.
+        self.get_dyn(idx).as_any().downcast_ref::<MenuBar>()
+    }
+
+    pub fn menu(&self, idx: usize) -> &dyn cce_ui::widget::MenuController {
+        self.get_dyn(idx).as_any().downcast_ref::<MenuBar>().expect("not a MenuBar")
+    }
+
+    pub fn menu_mut(&mut self, idx: usize) -> &mut dyn cce_ui::widget::MenuController {
+        self.get_dyn_mut(idx).as_any_mut().downcast_mut::<MenuBar>().expect("not a MenuBar")
+    }
+
+    pub fn graph(&self) -> &dyn cce_ui::widget::GraphController {
+        self.content.as_any().downcast_ref::<Graph>().expect("CONTENT_IDX must be a Graph")
+    }
+
+    pub fn graph_mut(&mut self) -> &mut dyn cce_ui::widget::GraphController {
+        self.content.as_any_mut().downcast_mut::<Graph>().expect("CONTENT_IDX must be a Graph")
+    }
+
+    pub fn param(&self) -> &dyn cce_ui::widget::ParamController {
+        self.param.as_any().downcast_ref::<ParametersBg>().expect("PARAM_IDX must be a ParametersBg")
+    }
+
+    pub fn param_mut(&mut self) -> &mut dyn cce_ui::widget::ParamController {
+        self.param.as_any_mut().downcast_mut::<ParametersBg>().expect("PARAM_IDX must be a ParametersBg")
+    }
+
+    pub fn spreadsheet_mut(&mut self) -> &mut dyn cce_ui::widget::SpreadsheetController {
+        self.spreadsheet.as_any_mut().downcast_mut::<Spreadsheet>().expect("SPREADSHEET_IDX must be a Spreadsheet")
+    }
+
+    pub fn path_mut(&mut self) -> &mut dyn cce_ui::widget::PathController {
+        self.breadcrumb.as_any_mut().downcast_mut::<Breadcrumb>().expect("BREADCRUMB_IDX must be a Breadcrumb")
+    }
+}
+
+
+/// Dissolved cce-ui `Plate` (Phase 6as): a passive panel wearing the parameter
+/// plate's fill — same tint, opacity, and blur-behind marker (`param_plate_fill`),
+/// so the network plate's bevel rolls exactly like the params plate's and tracks a
+/// live retint/opacity/blur toggle — scaled by the network fade. No children, no
+/// events.
+pub struct PassivePlate {
+    pub network_opacity: f32,
+    /// Circular hit shape while the network pane is round (the legacy Plate marker).
+    curved_circle: Option<(f32, f32, f32)>,
+}
+
+impl PassivePlate {
+    pub fn new() -> cce_ui::widget::Adapted<PassivePlate> {
+        cce_ui::widget::Adapted::new(Self {
+            network_opacity: 1.0,
+            curved_circle: None,
+        })
+    }
+
+    pub fn set_network_opacity(&mut self, opacity: f32) {
+        self.network_opacity = opacity;
+    }
+
+    pub fn set_curved_circle(&mut self, circle: Option<(f32, f32, f32)>) {
+        self.curved_circle = circle;
+    }
+}
+
+impl cce_ui::widget::Layout for PassivePlate {}
+
+impl cce_ui::widget::Paint for PassivePlate {
+    /// Nothing: the plate's fill AND border are drawn by `append_widget_plate` (or, in
+    /// circular mode, the circle+arc branch) in the designer's hand-ordered paint walk.
+    /// The default `paint` would emit a plain square-cornered quad of the whole rect,
+    /// which the walk then re-drew through `extra_quads` ON TOP of the rounded plate —
+    /// square corners over the rounded ones.
+    fn paint(&self, _rect: cce_ui::scene::layout::Rect, _ctx: &mut cce_ui::scene::paint::PaintCtx) {}
+
+    fn color(&self) -> [f32; 4] {
+        // `param_plate_fill` folds in the plate opacity and the blur marker; the
+        // network fade scales the (possibly negative) alpha without flipping its sign.
+        let mut c = cce_ui::colors::param_plate_fill();
+        c[3] *= self.network_opacity;
+        c
+    }
+
+    fn corner_style(&self, _rect: cce_ui::scene::layout::Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+        let r = cce_ui::layout::plate_corner_radius();
+        let on = r > 0.0;
+        Some((r, (on, on, on, on)))
+    }
+
+    fn solid_border(&self) -> Option<([f32; 4], f32)> {
+        if let Some(bc) = cce_ui::colors::plate_border_color() {
+            Some((bc, cce_ui::colors::plate_border_thickness()))
+        } else {
+            None
+        }
+    }
+}
+
+impl cce_ui::widget::Input for PassivePlate {
+    fn hit(&self, rect: cce_ui::scene::layout::Rect, x: f32, y: f32) -> bool {
+        if let Some((cx, cy, r)) = self.curved_circle {
+            let dx = x - cx;
+            let dy = y - cy;
+            return dx * dx + dy * dy <= r * r;
+        }
+        // Exclusive right/bottom edges, like the legacy Plate hit test.
+        x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
+    }
+}
+
+
+/// App-owned copy of the dissolved cce-ui `Canvas` (Phase 6ay part 2): the transparent
+/// hit-through pane behind the network area. Verbatim; dies with the machinery retype.
+pub struct Canvas;
+
+impl Canvas {
+    pub fn new() -> cce_ui::widget::Adapted<Canvas> { cce_ui::widget::Adapted::new(Canvas) }
+}
+
+impl cce_ui::widget::Layout for Canvas {}
+
+impl cce_ui::widget::Paint for Canvas {
+    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
+}
+
+impl cce_ui::widget::Input for Canvas {
+    // Hit-through: the pane never claims the pointer (the graph decides its own hits).
+    fn hit(&self, _rect: cce_ui::scene::layout::Rect, _x: f32, _y: f32) -> bool { false }
+}
diff --git a/src/window.rs b/src/window.rs
index 215cadf..4ceb939 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -10,7 +10,8 @@ use std::path::Path;
 
 use cce_ui::widget::WidgetHost;
 use crate::shortcut::Action;
-use crate::app::{State, CustomEvent, McpAction, TouchPhase, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
+use crate::app::{State, CustomEvent, McpAction, TouchPhase, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
+use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT};
 
 #[derive(Debug, Clone, Copy)]
 pub struct LocalPosition {