git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit1fd0a8d37cada80fbb79f1cf842707cccd841980
parent032bba3d2a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 21:54
refactor: page widgets are handles the context owns

Each page state gets new(ctx); its Default holds Handle::none() for the copies the refresh workers build and merge field by field. Page update functions and AppPage::propagate_widget_changes take the context; row lists remove and reinsert handles when rebuilt; per-frame widgets (a timer's status dot, the usage bars) are inserted, placed and removed; page buttons are reinserted per rebuild. Forms use widget_h / widget_w_h. Shadow A/B identical on every page but for live readings.

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

 src/app.rs                 |  42 +++---
 src/input_handler.rs       |  42 +++---
 src/main.rs                | 116 ++++++++--------
 src/pages/accounts.rs      | 193 ++++++++++++++-------------
 src/pages/audio.rs         | 135 ++++++++++---------
 src/pages/bluetooth.rs     |  31 +++--
 src/pages/browser.rs       | 148 ++++++++++++---------
 src/pages/default_apps.rs  |  53 ++++----
 src/pages/mod.rs           |  38 +++---
 src/pages/network.rs       |  41 +++---
 src/pages/notifications.rs |  65 +++++----
 src/pages/packages.rs      | 235 +++++++++++++++++---------------
 src/pages/power.rs         | 324 ++++++++++++++++++++++++---------------------
 src/pages/processes.rs     |   2 +-
 src/pages/services.rs      |  63 +++++----
 src/pages/storage.rs       |  63 +++++----
 src/pages/system_info.rs   |  57 +++++---
 src/pages/timers.rs        | 104 +++++++++------
 src/renderer.rs            |  52 +++-----
 19 files changed, 992 insertions(+), 812 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 248a1d5..4c9ed53 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,4 +1,3 @@
-use cce_ui::widget::Owned;
 use cce_ui::layout::RenderTarget;
 
 use crate::pages::audio;
@@ -35,24 +34,25 @@ pub struct AppState {
     pub notifications: notifications::NotificationsState,
 }
 
-impl Default for AppState {
-    fn default() -> Self {
+impl AppState {
+    /// Every page's state, their widgets inserted into `ctx`.
+    pub fn new(ctx: &mut cce_ui::context::UiContext) -> Self {
         Self {
             current_page: Page::ALL[0],
             audio: audio::AudioState::default(),
-            bluetooth: bluetooth::BluetoothState::default(),
-            power: power::PowerState::default(),
-            browser: browser::BrowserState::default(),
-            default_apps: default_apps::DefaultAppsState::default(),
-            network: network::NetworkState::default(),
+            bluetooth: bluetooth::BluetoothState::new(ctx),
+            power: power::PowerState::new(ctx),
+            browser: browser::BrowserState::new(ctx),
+            default_apps: default_apps::DefaultAppsState::new(ctx),
+            network: network::NetworkState::new(ctx),
             processes: processes::ProcessesState::default(),
-            services: services::ServicesState::default(),
-            system_info: system_info::SystemState::default(),
-            timers: timers::TimersState::default(),
-            storage: storage::StorageState::default(),
-            accounts: accounts::AccountsState::default_mock(),
-            packages: packages::PackagesState::default(),
-            notifications: notifications::NotificationsState::default(),
+            services: services::ServicesState::new(ctx),
+            system_info: system_info::SystemState::new(ctx),
+            timers: timers::TimersState::new(ctx),
+            storage: storage::StorageState::new(ctx),
+            accounts: accounts::AccountsState::new(ctx),
+            packages: packages::PackagesState::new(ctx),
+            notifications: notifications::NotificationsState::new(ctx),
         }
     }
 }
@@ -205,7 +205,7 @@ pub struct PageContent {
     /// (button, action, clip): clip is the innermost push_clip_rect at emission
     /// time (page coords) — the renderer clamps the drawn quad, label bounds,
     /// and the dispatch clone's hit rect to it.
-    pub buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction, Option<[f32; 4]>)>,
+    pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction, Option<[f32; 4]>)>,
     /// Glyphs placed with [`PageContent::icon`] (and a widget's own, through
     /// `RenderTarget::icon` — the expanded Dropdown's chevron).
     pub icons: Vec<PageIcon>,
@@ -320,7 +320,7 @@ impl PageContent {
             .with_hover_bg(hover_bg)
             .with_label_color(label_color);
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((Owned::new(btn), action, clip));
+        self.buttons.push((btn, action, clip));
     }
 
     /// A button whose face is a bundled cce-icons glyph instead of a label.
@@ -345,7 +345,7 @@ impl PageContent {
             btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(alpha);
         }
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((Owned::new(btn), action, clip));
+        self.buttons.push((btn, action, clip));
     }
 
     /// A row of a list, in the toolkit's list style (`Button::new_list_row`,
@@ -359,7 +359,7 @@ impl PageContent {
     pub fn list_row(&mut self, x: f32, y: f32, w: f32, h: f32, action: AppAction) {
         let btn = cce_ui::widget::Button::new_list_row(x, y, w, h);
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((Owned::new(btn), action, clip));
+        self.buttons.push((btn, action, clip));
     }
 
     /// [`button_icon`](Self::button_icon) with the glyph tinted
@@ -378,7 +378,7 @@ impl PageContent {
             btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(label_color[3]);
         }
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((Owned::new(btn), action, clip));
+        self.buttons.push((btn, action, clip));
     }
 
     pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
@@ -391,7 +391,7 @@ impl PageContent {
             .with_label_color(label_color)
             .with_left_align(true);
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((Owned::new(btn), action, clip));
+        self.buttons.push((btn, action, clip));
     }
 }
 
diff --git a/src/input_handler.rs b/src/input_handler.rs
index 67f38b6..66b36cd 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -165,7 +165,7 @@ impl SystemInterface {
         if self.search_open && state == cce_ui::widget::ElementState::Pressed && ly_no_scroll < (sh_logical - 42.0) {
             self.search_open = false;
             self.search_query.clear();
-            self.search_box.set_value_string("");
+            self.ui_context[self.search_box].set_value_string("");
             self.ui_context.clear_focus();
             self.needs_rebuild = true;
         }
@@ -187,8 +187,8 @@ impl SystemInterface {
         let dd_ev = cce_ui::widget::Event::MouseButton { button, state, x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
         let dd_root = self.page_dropdown.id();
         if self.ui_context.propagate_event(&dd_ev, dd_root) {
-            if self.page_dropdown.take_change() {
-                let idx = self.page_dropdown.selected;
+            if self.ui_context[self.page_dropdown].take_change() {
+                let idx = self.ui_context[self.page_dropdown].selected;
                 if idx < Page::ALL.len() {
                     self.ui_context.clear_focus();
                     self.focused_section = None;
@@ -216,8 +216,8 @@ impl SystemInterface {
                 button_handled = true;
             }
         }
-        for (btn, action) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
-            if btn.take_click() {
+        for (btn, action) in &self.page_buttons[self.scrollable_buttons_start_idx..] {
+            if self.ui_context[*btn].take_click() {
                 clicked_action = Some(action.clone());
                 button_handled = true;
                 self.needs_rebuild = true;
@@ -265,7 +265,7 @@ impl SystemInterface {
     }
 
     pub(crate) fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
-        self.app.get_current_page_mut().propagate_widget_changes(actions);
+        self.app.get_current_page_mut().propagate_widget_changes(actions, &mut self.ui_context);
     }
 
     pub(crate) fn handle_mouse_wheel_internal(&mut self, delta: &cce_ui::widget::MouseScrollDelta, px: f32, py: f32) -> bool {
@@ -312,7 +312,7 @@ impl SystemInterface {
             let moved = self.page_scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll_y));
             if moved {
                 self.shift_page_to(self.page_scroll_motion.y.pos());
-                self.page_scroll_bar.on_scroll();
+                self.ui_context[self.page_scroll_bar].on_scroll();
                 return true;
             }
         }
@@ -340,8 +340,8 @@ impl SystemInterface {
                 b[3] -= actual_dy;
             }
         }
-        for (btn, _) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
-            btn.base_mut().y -= actual_dy;
+        for (btn, _) in &self.page_buttons[self.scrollable_buttons_start_idx..] {
+            self.ui_context[*btn].base_mut().y -= actual_dy;
         }
         // The glyphs ride with the page too — a button's icon face and the
         // page's own icons — or they stand still while their rows scroll
@@ -353,7 +353,7 @@ impl SystemInterface {
             }
         }
         self.last_scroll_y = self.scroll_y;
-        self.page_scroll_bar.scroll_y = self.scroll_y;
+        self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
         true
     }
 
@@ -393,7 +393,7 @@ impl SystemInterface {
             event,
             Event::PointerMove { .. } | Event::MouseButton { .. } | Event::MouseWheel { .. }
         );
-        if is_pointer_event && !self.page_scroll_bar.dragging && !self.ui_context.is_dragging {
+        if is_pointer_event && !self.ui_context[self.page_scroll_bar].dragging && !self.ui_context.is_dragging {
             if let Event::PointerMove { x, y, .. }
             | Event::MouseButton { x, y, .. }
             | Event::MouseWheel { x, y, .. } = event
@@ -428,7 +428,7 @@ impl SystemInterface {
             }
         }
 
-        if self.page_scroll_bar.content_h > self.page_scroll_bar.viewport_h {
+        if self.ui_context[self.page_scroll_bar].content_h > self.ui_context[self.page_scroll_bar].viewport_h {
             let mut sb_event = event.clone();
             if let Event::PointerMove { y, local_y, .. }
             | Event::MouseButton { y, local_y, .. }
@@ -508,7 +508,7 @@ impl SystemInterface {
             {
                 self.search_open = false;
                 self.search_query.clear();
-                self.search_box.set_value_string("");
+                self.ui_context[self.search_box].set_value_string("");
                 self.ui_context.clear_focus();
                 self.needs_rebuild = true;
                 return true;
@@ -527,10 +527,10 @@ impl SystemInterface {
             if event.state == cce_ui::widget::ElementState::Pressed && !event.repeat {
                 if cce_ui::widget::match_key_shortcut(event, &settings_keys().open_search) {
                     self.search_open = true;
-                    self.search_box.set_value_string("");
+                    self.ui_context[self.search_box].set_value_string("");
                     self.search_query.clear();
-                    cce_ui::widget::WidgetHost::focus(&mut self.search_box);
-                    self.ui_context.set_focused(&mut self.search_box);
+                    cce_ui::widget::WidgetHost::focus(&mut self.ui_context[self.search_box]);
+                    self.ui_context.set_focused_id(self.search_box.id());
                     self.needs_rebuild = true;
                     return true;
                 }
@@ -687,7 +687,7 @@ impl SystemInterface {
                     // With smoothing off the axis jumped: land the page now.
                     self.shift_page_to(self.page_scroll_motion.y.pos());
                     // Keyboard scrolling raises the bar like the wheel does.
-                    self.page_scroll_bar.on_scroll();
+                    self.ui_context[self.page_scroll_bar].on_scroll();
                     self.needs_rebuild = true;
                     key_handled = true;
                 }
@@ -695,14 +695,14 @@ impl SystemInterface {
         }
 
         if self.search_open {
-            if self.search_box.take_change() {
-                self.search_query = self.search_box.text.clone();
+            if self.ui_context[self.search_box].take_change() {
+                self.search_query = self.ui_context[self.search_box].text.clone();
                 self.needs_rebuild = true;
             }
-            if !self.ui_context.is_focused(&self.search_box) {
+            if !self.ui_context.is_focused(&self.ui_context[self.search_box]) {
                 self.search_open = false;
                 self.search_query.clear();
-                self.search_box.set_value_string("");
+                self.ui_context[self.search_box].set_value_string("");
                 self.needs_rebuild = true;
             }
         }
diff --git a/src/main.rs b/src/main.rs
index 8250ea0..0462bfc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,4 @@
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
 use cce_ui::widget::hover_animation;
 use cce_ui::cosmic_text::{Buffer, FontSystem};
 
@@ -75,7 +75,7 @@ struct SystemInterface {
     /// (`PageContent::control_relief_marks`) — display_list slots each carve
     /// back between the rects the widget drew before and after it.
     popover_control_relief_marks: Vec<usize>,
-    page_buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction)>,
+    page_buttons: Vec<(Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction)>,
 
     sidebar_width: f32,
     header_height: f32,
@@ -139,12 +139,12 @@ struct SystemInterface {
     /// a frame that only replays the cached layout paints no widget, and
     /// would otherwise read as the field closing.
     text_claim: Option<[f32; 4]>,
-    page_dropdown: Owned<cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>>,
+    page_dropdown: Handle<cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>>,
     // Switcher + Page DISSOLVED (Phase 6u): the current page is app.current_page, page
     // scroll is scroll_y/max_scroll_y, and the page scrollbar is this app-owned widget
     // (rendered into the window assembly, evented directly). content_h feeds it — the
     // window pass reads last frame's value, exactly as the legacy Page did.
-    page_scroll_bar: Owned<cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>>,
+    page_scroll_bar: Handle<cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>>,
     content_h: f32,
     // Section wells: body box + title tab (page coords, pre-scroll) — carved by
     // display_list.
@@ -185,7 +185,7 @@ struct SystemInterface {
     scroll_logs: Vec<String>,
     search_open: bool,
     search_query: String,
-    search_box: Owned<cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>>,
+    search_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>>,
 
 }
 
@@ -219,7 +219,9 @@ impl cce_ui::engine::Application for SystemInterface {
         // The app keeps calloop's sender; `AppSender` converts into it.
         let sender: calloop::channel::Sender<Self::Message> = sender.into();
         cce_ui::scale::set_scale_factor(1.0);
-        let app = AppState::default();
+        // The context owns the widgets; the app keeps their handles.
+        let mut ui_context = cce_ui::context::UiContext::new();
+        let app = AppState::new(&mut ui_context);
 
         // ── Background refresh channels ──
         let initial_page_idx = INITIAL_PAGE_INDEX.load(std::sync::atomic::Ordering::SeqCst);
@@ -251,6 +253,11 @@ impl cce_ui::engine::Application for SystemInterface {
 
         let font_system = cce_ui::create_font_system();
 
+        let search_box = ui_context.insert(
+            cce_ui::widget::input::TextBox::new(String::new())
+                .with_placeholder("Search sections & parameters...")
+                .with_draw_bg_border(false),
+        );
         let mut this = Self {
             app: app_state,
             font_system,
@@ -303,8 +310,8 @@ impl cce_ui::engine::Application for SystemInterface {
             laid_out_page: None,
             refocus_rect: None,
             text_claim: None,
-            page_dropdown: Owned::new(page_dropdown),
-            page_scroll_bar: Owned::new(crate::scroll_bar::ScrollBar::new()),
+            page_dropdown: ui_context.insert(page_dropdown),
+            page_scroll_bar: ui_context.insert(crate::scroll_bar::ScrollBar::new()),
             content_h: 0.0,
             page_reliefs: Vec::new(),
             page_control_reliefs: Vec::new(),
@@ -317,13 +324,11 @@ impl cce_ui::engine::Application for SystemInterface {
             current_page_shared,
             seen_snapshots: std::collections::HashMap::new(),
             sender,
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context,
             scroll_logs: Vec::new(),
             search_open: false,
             search_query: String::new(),
-            search_box: Owned::new(cce_ui::widget::input::TextBox::new(String::new())
-                .with_placeholder("Search sections & parameters...")
-                .with_draw_bg_border(false)),
+            search_box,
         };
         this.app.system_info.sender = Some(this.sender.clone());
 
@@ -398,15 +403,15 @@ impl cce_ui::engine::Application for SystemInterface {
         // path's scrolls, which shift cached geometry without a rebuild and
         // used to leave the bar frozen until scrolling stopped. Sync first:
         // a thumb drag drives the page, anything else drives the thumb.
-        if self.page_scroll_bar.dragging {
-            self.scroll_y = self.page_scroll_bar.scroll_y;
+        if self.ui_context[self.page_scroll_bar].dragging {
+            self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
         } else {
-            self.page_scroll_bar.scroll_y = self.scroll_y;
+            self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
         }
         let page_bar = {
             use cce_ui::widget::WidgetHost;
-            let (bx, by, bw, bh) = self.page_scroll_bar.rect();
-            self.page_scroll_bar
+            let (bx, by, bw, bh) = self.ui_context[self.page_scroll_bar].rect();
+            self.ui_context[self.page_scroll_bar]
                 .layer_quads(Rect { x: bx, y: by, width: bw, height: bh })
         };
         // The idle copy: under the translucent window plate, every frame, so
@@ -602,15 +607,15 @@ impl cce_ui::engine::Application for SystemInterface {
 
         // The window chrome, as it paints itself: the page dropdown in the status bar
         // and, while open, the search box in its band.
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &*self.page_dropdown, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.page_dropdown], &mut pc);
         if self.search_open {
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &*self.search_box, &mut pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.search_box], &mut pc);
         }
 
         // The page scrollbar's fore copy: over the page content at the
         // raise's fade, while a scroll or drag holds it up and as it sinks
         // (popovers still stack above it).
-        let fade = self.page_scroll_bar.fade();
+        let fade = self.ui_context[self.page_scroll_bar].fade();
         if fade > 0.001 {
             for &(r, mut c) in &page_bar {
                 c[3] *= fade;
@@ -773,7 +778,7 @@ impl SystemInterface {
         // hold runs (keeps frames coming so the sink actually renders) and on
         // the raised flip itself. A redraw re-emits the bar at its new depth —
         // no layout rebuild needed, display_list draws it fresh each frame.
-        if self.page_scroll_bar.tick_activity(dt) {
+        if self.ui_context[self.page_scroll_bar].tick_activity(dt) {
             needs_redraw = true;
         }
         // The page's own wheel glide / flick coast: shifts the cached
@@ -831,7 +836,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("audio", &s) {
                 continue;
             }
-            audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s));
+            audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Audio {
                 self.needs_rebuild = true;
             }
@@ -840,7 +845,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("network", &s) {
                 continue;
             }
-            network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s));
+            network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Network {
                 self.needs_rebuild = true;
             }
@@ -849,7 +854,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("timers", &s) {
                 continue;
             }
-            pages::timers::update(&mut self.app.timers, pages::timers::TimersMessage::Refreshed(s));
+            pages::timers::update(&mut self.app.timers, pages::timers::TimersMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Timers {
                 self.needs_rebuild = true;
             }
@@ -858,7 +863,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("bluetooth", &s) {
                 continue;
             }
-            pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s));
+            pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Bluetooth {
                 self.needs_rebuild = true;
             }
@@ -867,7 +872,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("power", &s) {
                 continue;
             }
-            pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s));
+            pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Power {
                 self.needs_rebuild = true;
             }
@@ -891,7 +896,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("storage", &s) {
                 continue;
             }
-            storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s));
+            storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Storage {
                 self.needs_rebuild = true;
             }
@@ -900,7 +905,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("notifications", &s) {
                 continue;
             }
-            pages::notifications::update(&mut self.app.notifications, pages::notifications::NotificationsMessage::Refreshed(s));
+            pages::notifications::update(&mut self.app.notifications, pages::notifications::NotificationsMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Notifications {
                 self.needs_rebuild = true;
             }
@@ -909,7 +914,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("browser", &s) {
                 continue;
             }
-            pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s));
+            pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Browser {
                 self.needs_rebuild = true;
             }
@@ -918,7 +923,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("default_apps", &s) {
                 continue;
             }
-            pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s));
+            pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::DefaultApps {
                 self.needs_rebuild = true;
             }
@@ -927,7 +932,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("services", &s) {
                 continue;
             }
-            services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s));
+            services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Services {
                 self.needs_rebuild = true;
             }
@@ -937,7 +942,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("accounts", &s) {
                 continue;
             }
-            accounts::update(&mut self.app.accounts, accounts::AccountsMessage::Refreshed(s));
+            accounts::update(&mut self.app.accounts, accounts::AccountsMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Accounts {
                 self.needs_rebuild = true;
             }
@@ -952,7 +957,7 @@ impl SystemInterface {
             if !self.fresh_snapshot("packages", &s) {
                 continue;
             }
-            pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::Refreshed(s));
+            pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::Packages {
                 self.needs_rebuild = true;
             }
@@ -979,20 +984,20 @@ impl SystemInterface {
         }
         match action {
             AppAction::Exit | AppAction::Wake => {}
-            AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
-            AppAction::Network(m) => network::update(&mut self.app.network, m.clone()),
-            AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone()),
-            AppAction::Power(m) => pages::power::update(&mut self.app.power, m.clone()),
-            AppAction::Timers(m) => pages::timers::update(&mut self.app.timers, m.clone()),
+            AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone(), &mut self.ui_context),
+            AppAction::Network(m) => network::update(&mut self.app.network, m.clone(), &mut self.ui_context),
+            AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone(), &mut self.ui_context),
+            AppAction::Power(m) => pages::power::update(&mut self.app.power, m.clone(), &mut self.ui_context),
+            AppAction::Timers(m) => pages::timers::update(&mut self.app.timers, m.clone(), &mut self.ui_context),
             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
             AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
-            AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
-            AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone()),
-            AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
-            AppAction::Browser(m) => pages::browser::update(&mut self.app.browser, m.clone()),
+            AppAction::Services(m) => services::update(&mut self.app.services, m.clone(), &mut self.ui_context),
+            AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone(), &mut self.ui_context),
+            AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone(), &mut self.ui_context),
+            AppAction::Browser(m) => pages::browser::update(&mut self.app.browser, m.clone(), &mut self.ui_context),
             AppAction::Storage(m) => match m {
                 pages::storage::StorageMessage::StartBackup => {
-                    pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup);
+                    pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup, &mut self.ui_context);
                     let tx = self.tx_backup.clone();
                     let wake = self.sender.clone();
                     tokio::spawn(async move {
@@ -1001,7 +1006,7 @@ impl SystemInterface {
                         let _ = wake.send(AppAction::Wake);
                     });
                 }
-                _ => pages::storage::update(&mut self.app.storage, m.clone()),
+                _ => pages::storage::update(&mut self.app.storage, m.clone(), &mut self.ui_context),
             },
 
 
@@ -1016,23 +1021,24 @@ impl SystemInterface {
                             pages::accounts::AccountsMessage::StatusMessage(
                                 "A Google sign-in is already waiting on the browser.".to_string(),
                             ),
+                            &mut self.ui_context,
                         );
                     } else {
-                        pages::accounts::update(&mut self.app.accounts, m.clone());
+                        pages::accounts::update(&mut self.app.accounts, m.clone(), &mut self.ui_context);
                         let sender = self.sender.clone();
                         tokio::spawn(async move {
                             pages::accounts::run_google_login(sender).await;
                         });
                     }
                 }
-                _ => pages::accounts::update(&mut self.app.accounts, m.clone()),
+                _ => pages::accounts::update(&mut self.app.accounts, m.clone(), &mut self.ui_context),
             },
             AppAction::Packages(m) => match m {
                 pages::packages::PackagesMessage::StartUpdate => {
                     if self.app.packages.busy() {
                         return;
                     }
-                    pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::StartUpdate);
+                    pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::StartUpdate, &mut self.ui_context);
                     let tx = self.tx_update.clone();
                     let wake = self.sender.clone();
                     tokio::spawn(async move {
@@ -1042,7 +1048,7 @@ impl SystemInterface {
                     });
                 }
                 pages::packages::PackagesMessage::UpdateFinished(res) => {
-                    pages::packages::update(&mut self.app.packages, m.clone());
+                    pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                     if res.is_ok() {
                         let tx = self.tx_update.clone();
                         let wake = self.sender.clone();
@@ -1056,7 +1062,7 @@ impl SystemInterface {
                 pages::packages::PackagesMessage::SelectPackage(Some(ref name)) => {
                     let name_clone = name.clone();
                     let is_installed = self.app.packages.active_tab == pages::packages::PackageTab::Installed;
-                    pages::packages::update(&mut self.app.packages, m.clone());
+                    pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                     let tx = self.tx_update.clone();
                     let wake = self.sender.clone();
                     tokio::spawn(async move {
@@ -1067,7 +1073,7 @@ impl SystemInterface {
                 }
                 pages::packages::PackagesMessage::SelectAndScrollPackage(ref name) => {
                     let name_clone = name.clone();
-                    pages::packages::update(&mut self.app.packages, m.clone());
+                    pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                     let tx = self.tx_update.clone();
                     let wake = self.sender.clone();
                     tokio::spawn(async move {
@@ -1078,7 +1084,7 @@ impl SystemInterface {
                 }
                 pages::packages::PackagesMessage::PreviewRemoval(ref targets) => {
                     if !targets.is_empty() && !self.app.packages.busy() {
-                        pages::packages::update(&mut self.app.packages, m.clone());
+                        pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                         let targets = targets.clone();
                         let tx = self.tx_update.clone();
                         let wake = self.sender.clone();
@@ -1091,7 +1097,7 @@ impl SystemInterface {
                 }
                 pages::packages::PackagesMessage::StartUninstall(ref targets) => {
                     if !targets.is_empty() && !self.app.packages.busy() {
-                        pages::packages::update(&mut self.app.packages, m.clone());
+                        pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                         let targets = targets.clone();
                         let tx = self.tx_update.clone();
                         let wake = self.sender.clone();
@@ -1104,7 +1110,7 @@ impl SystemInterface {
                 }
                 pages::packages::PackagesMessage::SetInstallReason(ref targets, explicit) => {
                     if !targets.is_empty() && !self.app.packages.busy() {
-                        pages::packages::update(&mut self.app.packages, m.clone());
+                        pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                         let (targets, explicit) = (targets.clone(), *explicit);
                         let tx = self.tx_update.clone();
                         let wake = self.sender.clone();
@@ -1119,7 +1125,7 @@ impl SystemInterface {
                 // orphan set, which either can change) is pacman's to report.
                 pages::packages::PackagesMessage::UninstallFinished(..)
                 | pages::packages::PackagesMessage::InstallReasonSet(..) => {
-                    pages::packages::update(&mut self.app.packages, m.clone());
+                    pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
                     let tx = self.tx_update.clone();
                     let wake = self.sender.clone();
                     // The details pane shows the install reason; re-read it.
@@ -1134,7 +1140,7 @@ impl SystemInterface {
                         let _ = wake.send(AppAction::Wake);
                     });
                 }
-                _ => pages::packages::update(&mut self.app.packages, m.clone()),
+                _ => pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context),
             },
         }
     }
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index fbf458a..100cde4 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,5 +1,6 @@
 use crate::app::{form_button, form_divider, form_pairs, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::scene::layout::Rect;
 use cce_ui::widget::ScrollRegion;
@@ -81,10 +82,10 @@ pub struct AccountsState {
     pub accounts: Vec<AccountInfo>,
     pub selected_idx: Option<usize>,
     pub adding_new: bool,
-    pub email_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub password_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub imap_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub smtp_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub email_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub password_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub imap_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub smtp_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     pub status_msg: Option<String>,
     pub status_msg_timer: f32,
     pub oauth_listener_running: bool,
@@ -94,8 +95,8 @@ pub struct AccountsState {
     pub editing_email: Option<String>,
     /// Per-account OAuth credentials — the copy in `accounts.json` that
     /// cce-mail actually refreshes with, not the global template.
-    pub oauth_client_id_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub oauth_client_secret_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub oauth_client_id_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub oauth_client_secret_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     /// Per-address keyring status from the last snapshot, plus optimistic
     /// updates from Save/Delete (the 3s watcher pass corrects them).
     pub keyring: std::collections::HashMap<String, KeyringStatus>,
@@ -108,21 +109,22 @@ pub struct AccountsState {
 }
 
 impl AccountsState {
-    pub fn default_mock() -> Self {
+    /// The page's state, its form fields inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         let mut state = Self::default();
-        state.email_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address"));
+        state.email_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address"));
         state.password_box = {
             let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Password / App Password");
             tb.is_password = true;
-            Owned::new(tb)
+            ctx.insert(tb)
         };
-        state.imap_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server"));
-        state.smtp_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server"));
-        state.oauth_client_id_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID"));
+        state.imap_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server"));
+        state.smtp_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server"));
+        state.oauth_client_id_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID"));
         state.oauth_client_secret_box = {
             let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client Secret");
             tb.is_password = true;
-            Owned::new(tb)
+            ctx.insert(tb)
         };
         state.list = ScrollRegion::new(cce_ui::layout::spinbox_height(), LIST_GAP).with_sink_behind(true);
         state
@@ -771,10 +773,10 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                 b.text("Add New Account", 14.0, heading);
                 b.text("Gmail signs in with Google below; iCloud requires an App Password.", 11.0, TEXT_DIM);
             });
-            col.widget(&mut state.email_box, widget_h)
-                .widget(&mut state.password_box, widget_h)
-                .widget(&mut state.imap_box, widget_h)
-                .widget(&mut state.smtp_box, widget_h);
+            col.widget_h(ctx, state.email_box, widget_h)
+                .widget_h(ctx, state.password_box, widget_h)
+                .widget_h(ctx, state.imap_box, widget_h)
+                .widget_h(ctx, state.smtp_box, widget_h);
             col.row(|r| {
                 form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::AddAccountSave));
                 form_button(r, "Cancel", 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::AddAccountCancel));
@@ -798,16 +800,16 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
             // An OAuth account has no password to edit; a password one has no
             // client credentials. Neither ever shows the other's fields.
             if acc.is_oauth {
-                col.widget(&mut state.imap_box, widget_h).widget(&mut state.smtp_box, widget_h);
+                col.widget_h(ctx, state.imap_box, widget_h).widget_h(ctx, state.smtp_box, widget_h);
                 col.block(|b| {
                     b.text("Credentials this account refreshes tokens with, taking effect", 11.0, TEXT_DIM);
                     b.text("on the next refresh \u{2014} Re-login to re-issue the tokens now.", 11.0, TEXT_DIM);
                 });
-                col.widget(&mut state.oauth_client_id_box, widget_h).widget(&mut state.oauth_client_secret_box, widget_h);
+                col.widget_h(ctx, state.oauth_client_id_box, widget_h).widget_h(ctx, state.oauth_client_secret_box, widget_h);
             } else {
-                col.widget(&mut state.password_box, widget_h)
-                    .widget(&mut state.imap_box, widget_h)
-                    .widget(&mut state.smtp_box, widget_h);
+                col.widget_h(ctx, state.password_box, widget_h)
+                    .widget_h(ctx, state.imap_box, widget_h)
+                    .widget_h(ctx, state.smtp_box, widget_h);
             }
             col.row(|r| {
                 form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::EditAccountSave));
@@ -882,7 +884,7 @@ fn fill_box(tb: &mut cce_ui::widget::Adapted<TextBox>, value: &str) {
     tb.edit_buffer = value.to_string();
 }
 
-pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
+pub fn update(state: &mut AccountsState, msg: AccountsMessage, ctx: &mut UiContext) {
     match msg {
         AccountsMessage::Refreshed(snap) => {
             state.loaded = true;
@@ -893,7 +895,7 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             if let Some(ref e) = state.editing_email {
                 if !state.accounts.iter().any(|a| a.email == *e) {
                     state.editing_email = None;
-                    state.password_box.placeholder = None;
+                    ctx[state.password_box].placeholder = None;
                 }
             }
             if state.selected_idx.is_none() && !state.accounts.is_empty() {
@@ -912,22 +914,22 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
         AccountsMessage::AddAccountStart => {
             state.adding_new = true;
             state.editing_email = None;
-            fill_box(&mut state.email_box, "");
-            fill_box(&mut state.password_box, "");
-            fill_box(&mut state.imap_box, "");
-            fill_box(&mut state.smtp_box, "");
+            fill_box(&mut ctx[state.email_box], "");
+            fill_box(&mut ctx[state.password_box], "");
+            fill_box(&mut ctx[state.imap_box], "");
+            fill_box(&mut ctx[state.smtp_box], "");
             // Adding needs a real password; only editing may leave it blank.
-            state.password_box.placeholder = None;
+            ctx[state.password_box].placeholder = None;
         }
         AccountsMessage::AddAccountCancel => {
             state.adding_new = false;
             state.selected_idx = if state.accounts.is_empty() { None } else { Some(0) };
         }
         AccountsMessage::AddAccountSave => {
-            let email = live_text(&state.email_box);
-            let password = live_text(&state.password_box);
-            let imap = live_text(&state.imap_box);
-            let smtp = live_text(&state.smtp_box);
+            let email = live_text(&ctx[state.email_box]);
+            let password = live_text(&ctx[state.password_box]);
+            let imap = live_text(&ctx[state.imap_box]);
+            let smtp = live_text(&ctx[state.smtp_box]);
 
             if email.is_empty() || password.is_empty() || imap.is_empty() || smtp.is_empty() {
                 state.status_msg = Some("All fields must be filled!".to_string());
@@ -1067,8 +1069,8 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             state.adding_new = false;
             state.selected_idx = Some(idx);
 
-            fill_box(&mut state.imap_box, &acc.imap);
-            fill_box(&mut state.smtp_box, &acc.smtp);
+            fill_box(&mut ctx[state.imap_box], &acc.imap);
+            fill_box(&mut ctx[state.smtp_box], &acc.smtp);
             if acc.is_oauth {
                 // Show what this account actually authenticates with: its own
                 // pinned copy, or the global template it would fall back to.
@@ -1081,13 +1083,13 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
                         (id.unwrap_or(fallback.client_id), secret.unwrap_or(fallback.client_secret))
                     }
                 };
-                fill_box(&mut state.oauth_client_id_box, &id);
-                fill_box(&mut state.oauth_client_secret_box, &secret);
+                fill_box(&mut ctx[state.oauth_client_id_box], &id);
+                fill_box(&mut ctx[state.oauth_client_secret_box], &secret);
             } else {
                 // The password lives in the keyring. Never read a secret back
                 // just to prefill a field — blank means "keep what is stored".
-                fill_box(&mut state.password_box, "");
-                state.password_box.set_placeholder("unchanged \u{2014} type to replace");
+                fill_box(&mut ctx[state.password_box], "");
+                ctx[state.password_box].set_placeholder("unchanged \u{2014} type to replace");
             }
         }
         AccountsMessage::EditAccountSave => {
@@ -1100,16 +1102,16 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
 
             // Validate everything BEFORE touching state.accounts: a mid-way
             // bail would otherwise leave memory disagreeing with the file.
-            let imap = live_text(&state.imap_box);
-            let smtp = live_text(&state.smtp_box);
+            let imap = live_text(&ctx[state.imap_box]);
+            let smtp = live_text(&ctx[state.smtp_box]);
             if imap.is_empty() || smtp.is_empty() {
                 state.status_msg = Some("IMAP and SMTP must be filled!".to_string());
                 return;
             }
             let is_oauth = state.accounts[idx].is_oauth;
             let creds = if is_oauth {
-                let id = live_text(&state.oauth_client_id_box);
-                let secret = live_text(&state.oauth_client_secret_box);
+                let id = live_text(&ctx[state.oauth_client_id_box]);
+                let secret = live_text(&ctx[state.oauth_client_secret_box]);
                 if id.is_empty() || secret.is_empty() {
                     state.status_msg = Some("Both Client ID and Client Secret are required!".to_string());
                     return;
@@ -1118,7 +1120,7 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             } else {
                 None
             };
-            let password = if is_oauth { String::new() } else { live_text(&state.password_box) };
+            let password = if is_oauth { String::new() } else { live_text(&ctx[state.password_box]) };
 
             let mut msg = "Account updated".to_string();
             let mut new_password = None;
@@ -1160,12 +1162,12 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
                 return;
             }
             state.editing_email = None;
-            state.password_box.placeholder = None;
+            ctx[state.password_box].placeholder = None;
             state.status_msg = Some(msg);
         }
         AccountsMessage::EditAccountCancel => {
             state.editing_email = None;
-            state.password_box.placeholder = None;
+            ctx[state.password_box].placeholder = None;
         }
         AccountsMessage::ICloudLoginHelp => {
             let mut cmd = std::process::Command::new("xdg-open");
@@ -1235,24 +1237,24 @@ impl crate::pages::AppPage for AccountsState {
         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {
-        if self.adding_new && self.email_box.take_change() {
-            let email_val = self.email_box.text.trim().to_lowercase();
+    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+        if self.adding_new && ctx[self.email_box].take_change() {
+            let email_val = ctx[self.email_box].text.trim().to_lowercase();
             if email_val.ends_with("@gmail.com") {
-                self.imap_box.text = "imap.gmail.com:993".to_string();
-                self.imap_box.edit_buffer = "imap.gmail.com:993".to_string();
-                self.smtp_box.text = "smtp.gmail.com:465".to_string();
-                self.smtp_box.edit_buffer = "smtp.gmail.com:465".to_string();
+                ctx[self.imap_box].text = "imap.gmail.com:993".to_string();
+                ctx[self.imap_box].edit_buffer = "imap.gmail.com:993".to_string();
+                ctx[self.smtp_box].text = "smtp.gmail.com:465".to_string();
+                ctx[self.smtp_box].edit_buffer = "smtp.gmail.com:465".to_string();
             } else if email_val.ends_with("@icloud.com") {
-                self.imap_box.text = "imap.mail.me.com:993".to_string();
-                self.imap_box.edit_buffer = "imap.mail.me.com:993".to_string();
-                self.smtp_box.text = "smtp.mail.me.com:587".to_string();
-                self.smtp_box.edit_buffer = "smtp.mail.me.com:587".to_string();
+                ctx[self.imap_box].text = "imap.mail.me.com:993".to_string();
+                ctx[self.imap_box].edit_buffer = "imap.mail.me.com:993".to_string();
+                ctx[self.smtp_box].text = "smtp.mail.me.com:587".to_string();
+                ctx[self.smtp_box].edit_buffer = "smtp.mail.me.com:587".to_string();
             } else if email_val.ends_with("@outlook.com") || email_val.ends_with("@hotmail.com") {
-                self.imap_box.text = "outlook.office365.com:993".to_string();
-                self.imap_box.edit_buffer = "outlook.office365.com:993".to_string();
-                self.smtp_box.text = "smtp.office365.com:587".to_string();
-                self.smtp_box.edit_buffer = "smtp.office365.com:587".to_string();
+                ctx[self.imap_box].text = "outlook.office365.com:993".to_string();
+                ctx[self.imap_box].edit_buffer = "outlook.office365.com:993".to_string();
+                ctx[self.smtp_box].text = "smtp.office365.com:587".to_string();
+                ctx[self.smtp_box].edit_buffer = "smtp.office365.com:587".to_string();
             }
         }
     }
@@ -1300,10 +1302,11 @@ mod tests {
 
     #[test]
     fn test_accounts_page_view() {
-        let mut state = AccountsState::default_mock();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = AccountsState::new(&mut ui);
         state.loaded = true;
         let mut layout = PageFlow::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
         println!("PC BUTTONS COUNT: {}", pc.buttons.len());
         for (i, (btn, _, _)) in pc.buttons.iter().enumerate() {
             let base = btn.base();
@@ -1317,17 +1320,18 @@ mod tests {
 
     #[test]
     fn list_reserves_its_height_so_later_rows_clear_it() {
+        let mut ui = cce_ui::context::UiContext::new();
         // The scroll region advances the section by hand. SectionContext keeps a
         // parallel per-column Grid and its `spacing` recomputes
         // `content_y = grid.max_height()`, so reserving the height by bumping
         // `content_y` alone is silently discarded and every following row draws
         // back on top of the list. Caught live: "Add Account" and the detail
         // rows were painted over the account rows.
-        let mut state = AccountsState::default_mock();
+        let mut state = AccountsState::new(&mut ui);
         state.loaded = true;
         state.accounts = (0..12).map(|i| acct(&format!("a{i}@example.org"), false)).collect();
         let mut layout = PageFlow::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
 
         let list_bottom = state.list.y + state.list.h;
         let add = pc
@@ -1346,14 +1350,15 @@ mod tests {
 
     #[test]
     fn list_emits_only_the_rows_the_region_virtualizes_on() {
+        let mut ui = cce_ui::context::UiContext::new();
         // Row buttons are emitted under the same `get_item_draw_y` predicate the
         // region scrolls by, so a list longer than the cap paints the visible
         // window rather than all of its rows.
-        let mut state = AccountsState::default_mock();
+        let mut state = AccountsState::new(&mut ui);
         state.loaded = true;
         state.accounts = (0..40).map(|i| acct(&format!("a{i}@example.org"), false)).collect();
         let mut layout = PageFlow::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
 
         let rows = pc
             .buttons
@@ -1406,30 +1411,32 @@ mod tests {
     /// XDG_CONFIG_HOME. Only the early-return paths are exercised here.
     #[test]
     fn edit_prefills_the_account_but_never_the_password() {
-        let mut state = AccountsState::default_mock();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = AccountsState::new(&mut ui);
         state.accounts = vec![acct("a@example.org", false)];
 
-        update(&mut state, AccountsMessage::EditAccountStart(0));
+        update(&mut state, AccountsMessage::EditAccountStart(0), &mut ui);
 
         assert_eq!(state.editing_email.as_deref(), Some("a@example.org"));
-        assert_eq!(state.imap_box.text, "imap.example.org:993");
-        assert_eq!(state.smtp_box.text, "smtp.example.org:465");
+        assert_eq!(ui[state.imap_box].text, "imap.example.org:993");
+        assert_eq!(ui[state.smtp_box].text, "smtp.example.org:465");
         // The secret is in the keyring; a blank box plus a placeholder is how
         // "keep the stored one" is expressed.
-        assert!(state.password_box.text.is_empty());
-        assert!(state.password_box.placeholder.is_some());
+        assert!(ui[state.password_box].text.is_empty());
+        assert!(ui[state.password_box].placeholder.is_some());
     }
 
     #[test]
     fn editing_an_oauth_account_shows_its_pinned_credentials() {
-        let mut state = AccountsState::default_mock();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = AccountsState::new(&mut ui);
         state.accounts = vec![acct("g@gmail.com", true)];
 
-        update(&mut state, AccountsMessage::EditAccountStart(0));
+        update(&mut state, AccountsMessage::EditAccountStart(0), &mut ui);
 
-        assert_eq!(state.oauth_client_id_box.text, "pinned-id");
-        assert_eq!(state.oauth_client_secret_box.text, "pinned-secret");
-        assert!(state.oauth_client_secret_box.is_password, "the secret stays masked");
+        assert_eq!(ui[state.oauth_client_id_box].text, "pinned-id");
+        assert_eq!(ui[state.oauth_client_secret_box].text, "pinned-secret");
+        assert!(ui[state.oauth_client_secret_box].is_password, "the secret stays masked");
     }
 
     /// The form keys on the address, so a background refresh that reorders the
@@ -1438,34 +1445,35 @@ mod tests {
     /// the right row after the reorder.
     #[test]
     fn edit_follows_the_account_across_a_reorder() {
-        let mut state = AccountsState::default_mock();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = AccountsState::new(&mut ui);
         state.accounts = vec![acct("first@example.org", false), acct("second@example.org", false)];
 
-        update(&mut state, AccountsMessage::EditAccountStart(1));
+        update(&mut state, AccountsMessage::EditAccountStart(1), &mut ui);
         assert_eq!(state.editing_email.as_deref(), Some("second@example.org"));
 
         update(
             &mut state,
-            AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("second@example.org", false), acct("first@example.org", false)], keyring: Vec::new() }),
-        );
+            AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("second@example.org", false), acct("first@example.org", false)], keyring: Vec::new() }),&mut ui);
         assert_eq!(state.editing_email.as_deref(), Some("second@example.org"), "the refresh keeps the form open");
 
-        fill_box(&mut state.imap_box, "");
-        update(&mut state, AccountsMessage::EditAccountSave);
+        fill_box(&mut ui[state.imap_box], "");
+        update(&mut state, AccountsMessage::EditAccountSave, &mut ui);
         assert_eq!(state.status_msg.as_deref(), Some("IMAP and SMTP must be filled!"));
         assert!(state.editing_email.is_some(), "a failed save keeps the form open");
     }
 
     #[test]
     fn a_vanished_account_closes_the_edit_form() {
-        let mut state = AccountsState::default_mock();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = AccountsState::new(&mut ui);
         state.accounts = vec![acct("gone@example.org", false)];
-        update(&mut state, AccountsMessage::EditAccountStart(0));
+        update(&mut state, AccountsMessage::EditAccountStart(0), &mut ui);
 
-        update(&mut state, AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("other@example.org", false)], keyring: Vec::new() }));
+        update(&mut state, AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("other@example.org", false)], keyring: Vec::new() }), &mut ui);
 
         assert!(state.editing_email.is_none());
-        assert!(state.password_box.placeholder.is_none(), "the placeholder does not leak into the add form");
+        assert!(ui[state.password_box].placeholder.is_none(), "the placeholder does not leak into the add form");
     }
 
     fn get(target: &str) -> String {
@@ -1541,13 +1549,14 @@ mod tests {
     /// process, which is worse than the double-bind it prevents.
     #[test]
     fn oauth_listener_flag_tracks_the_flow() {
-        let mut state = AccountsState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = AccountsState::new(&mut ui);
         assert!(!state.oauth_listener_running);
 
-        update(&mut state, AccountsMessage::GoogleLoginInit);
+        update(&mut state, AccountsMessage::GoogleLoginInit, &mut ui);
         assert!(state.oauth_listener_running, "starting a login marks the port busy");
 
-        update(&mut state, AccountsMessage::GoogleLoginFinished);
+        update(&mut state, AccountsMessage::GoogleLoginFinished, &mut ui);
         assert!(!state.oauth_listener_running, "a finished flow frees the button");
     }
 }
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 5e60c53..f5020d5 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,5 +1,6 @@
 use crate::app::{form_button, form_divider, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::widget::{Spinbox, Slider};
 
@@ -26,10 +27,10 @@ pub struct AudioState {
     pub loaded: bool,
     pub sinks: Vec<AudioSink>,
     pub sources: Vec<AudioSource>,
-    pub sink_spinboxes: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>>,
-    pub source_spinboxes: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>>,
-    pub sink_sliders: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>>,
-    pub source_sliders: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>>,
+    pub sink_spinboxes: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
+    pub source_spinboxes: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
+    pub sink_sliders: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
+    pub source_sliders: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
 }
 
 #[derive(Debug, Clone)]
@@ -235,8 +236,9 @@ fn device_row<'w>(
     active: bool,
     muted: bool,
     volume: f32,
-    slider: &'w mut Owned<cce_ui::widget::Adapted<Slider>>,
-    spin: &'w mut Owned<cce_ui::widget::Adapted<Spinbox>>,
+    ctx: &mut UiContext,
+    slider: Handle<cce_ui::widget::Adapted<Slider>>,
+    spin: Handle<cce_ui::widget::Adapted<Spinbox>>,
     mute_action: AppAction,
 ) {
     let line_h = cce_ui::layout::form_line_height(12.0);
@@ -248,8 +250,8 @@ fn device_row<'w>(
         return;
     }
 
-    slider.set_value(volume);
-    spin.value = (volume * 100.0).round() as i32;
+    ctx[slider].set_value(volume);
+    ctx[spin].value = (volume * 100.0).round() as i32;
     let (mute_label, colors, mute_text) = if muted {
         ("Unmute", BTN_DANGER, TEXT_DANGER)
     } else {
@@ -259,8 +261,8 @@ fn device_row<'w>(
         r.draw(name_w, line_h, false, move |pc, c, _| {
             pc.text_with_bounds(&name, c.x, c.y, 12.0, TEXT_FG, Some([c.x, c.y - 12.0, c.x + c.width, c.y + 24.0]));
         });
-        r.widget(slider, cce_ui::layout::slider_height());
-        r.widget_w(spin, SPIN_W, cce_ui::layout::spinbox_height());
+        r.widget_h(ctx, slider, cce_ui::layout::slider_height());
+        r.widget_w_h(ctx, spin, SPIN_W, cce_ui::layout::spinbox_height());
         form_button(r, mute_label, MUTE_W, (colors.0, colors.1, mute_text), mute_action);
     });
 }
@@ -298,10 +300,10 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focu
         if state.sinks.is_empty() {
             col.text("No output devices found", 12.0, TEXT_DIM);
         }
-        let devices = state.sinks.iter().zip(state.sink_sliders.iter_mut()).zip(state.sink_spinboxes.iter_mut());
+        let devices = state.sinks.iter().zip(state.sink_sliders.iter().copied()).zip(state.sink_spinboxes.iter().copied());
         for ((sink, slider), spin) in devices {
             let action = AppAction::Audio(AudioMessage::SinkMute(sink.id));
-            device_row(&mut col, sink.name.clone(), name_w, sink.active, sink.muted, sink.volume, slider, spin, action);
+            device_row(&mut col, sink.name.clone(), name_w, sink.active, sink.muted, sink.volume, ctx, slider, spin, action);
         }
 
         form_divider(&mut col);
@@ -310,10 +312,10 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focu
         if state.sources.is_empty() {
             col.text("No input devices found", 12.0, TEXT_DIM);
         }
-        let devices = state.sources.iter().zip(state.source_sliders.iter_mut()).zip(state.source_spinboxes.iter_mut());
+        let devices = state.sources.iter().zip(state.source_sliders.iter().copied()).zip(state.source_spinboxes.iter().copied());
         for ((src, slider), spin) in devices {
             let action = AppAction::Audio(AudioMessage::SourceMute(src.id));
-            device_row(&mut col, src.name.clone(), name_w, src.active, src.muted, src.volume, slider, spin, action);
+            device_row(&mut col, src.name.clone(), name_w, src.active, src.muted, src.volume, ctx, slider, spin, action);
         }
         sec.place(form, ctx);
     });
@@ -322,16 +324,27 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focu
 }
 
 
-pub fn update(state: &mut AudioState, msg: AudioMessage) {
+/// `rows` resized to `n` widgets, one per device: the context takes the new ones in and
+/// gives the surplus back.
+fn fit<W: cce_ui::widget::WidgetHost + 'static>(rows: &mut Vec<Handle<W>>, n: usize, ctx: &mut UiContext, make: impl Fn() -> W) {
+    for h in rows.drain(n.min(rows.len())..) {
+        ctx.remove(h);
+    }
+    while rows.len() < n {
+        rows.push(ctx.insert(make()));
+    }
+}
+
+pub fn update(state: &mut AudioState, msg: AudioMessage, ctx: &mut UiContext) {
     match msg {
         AudioMessage::Refreshed(new) => {
             state.loaded = new.loaded;
             state.sinks = new.sinks;
             state.sources = new.sources;
-            state.sink_spinboxes.resize_with(state.sinks.len(), || Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
-            state.source_spinboxes.resize_with(state.sources.len(), || Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
-            state.sink_sliders.resize_with(state.sinks.len(), || Box::new(Owned::new(Slider::new().with_range(0.0, 1.0).with_scroll(true))));
-            state.source_sliders.resize_with(state.sources.len(), || Box::new(Owned::new(Slider::new().with_range(0.0, 1.0).with_scroll(true))));
+            fit(&mut state.sink_spinboxes, state.sinks.len(), ctx, || Spinbox::new(50, 0, 100, 1));
+            fit(&mut state.source_spinboxes, state.sources.len(), ctx, || Spinbox::new(50, 0, 100, 1));
+            fit(&mut state.sink_sliders, state.sinks.len(), ctx, || Slider::new().with_range(0.0, 1.0).with_scroll(true));
+            fit(&mut state.source_sliders, state.sources.len(), ctx, || Slider::new().with_range(0.0, 1.0).with_scroll(true));
         }
         AudioMessage::SinkVolume(id, vol) => {
             if let Some(sink) = state.sinks.iter_mut().find(|s| s.id == id) {
@@ -404,36 +417,40 @@ impl crate::pages::AppPage for AudioState {
         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
-        for (i, sb) in self.sink_spinboxes.iter_mut().enumerate() {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+        for (i, &sb) in self.sink_spinboxes.iter().enumerate() {
+            let sb = &mut ctx[sb];
             if sb.take_change() {
                 let id = self.sinks[i].id;
                 actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, sb.value as f32 / 100.0)));
             }
         }
-        for (i, sb) in self.source_spinboxes.iter_mut().enumerate() {
+        for (i, &sb) in self.source_spinboxes.iter().enumerate() {
+            let sb = &mut ctx[sb];
             if sb.take_change() {
                 let id = self.sources[i].id;
                 actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, sb.value as f32 / 100.0)));
             }
         }
-        for (i, slider) in self.sink_sliders.iter_mut().enumerate() {
-            if slider.take_change() {
+        for (i, &slider) in self.sink_sliders.iter().enumerate() {
+            if ctx[slider].take_change() {
+                let value = ctx[slider].value();
                 let id = self.sinks[i].id;
                 // Keep the paired spinbox display in step, as the old drag path did.
-                if let Some(sb) = self.sink_spinboxes.get_mut(i) {
-                    sb.value = slider.value();
+                if let Some(&sb) = self.sink_spinboxes.get(i) {
+                    ctx[sb].value = value;
                 }
-                actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, slider.value() as f32 / 100.0)));
+                actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, value as f32 / 100.0)));
             }
         }
-        for (i, slider) in self.source_sliders.iter_mut().enumerate() {
-            if slider.take_change() {
+        for (i, &slider) in self.source_sliders.iter().enumerate() {
+            if ctx[slider].take_change() {
+                let value = ctx[slider].value();
                 let id = self.sources[i].id;
-                if let Some(sb) = self.source_spinboxes.get_mut(i) {
-                    sb.value = slider.value();
+                if let Some(&sb) = self.source_spinboxes.get(i) {
+                    ctx[sb].value = value;
                 }
-                actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, slider.value() as f32 / 100.0)));
+                actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, value as f32 / 100.0)));
             }
         }
     }
@@ -452,6 +469,7 @@ mod tests {
     #[test]
     fn test_view_layout_grid() {
         use cce_ui::widget::{Spinbox, Slider};
+        let mut ui = UiContext::new();
         let mut state = AudioState {
             loaded: true,
             sinks: vec![
@@ -462,24 +480,24 @@ mod tests {
             ],
             sources: vec![],
             sink_spinboxes: vec![
-                Box::new(Owned::new(Spinbox::new(57, 0, 100, 1))),
-                Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
-                Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
-                Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
+                ui.insert(Spinbox::new(57, 0, 100, 1)),
+                ui.insert(Spinbox::new(50, 0, 100, 1)),
+                ui.insert(Spinbox::new(50, 0, 100, 1)),
+                ui.insert(Spinbox::new(50, 0, 100, 1)),
             ],
             source_spinboxes: vec![],
             sink_sliders: vec![
-                Box::new(Owned::new(Slider::new())),
-                Box::new(Owned::new(Slider::new())),
-                Box::new(Owned::new(Slider::new())),
-                Box::new(Owned::new(Slider::new())),
+                ui.insert(Slider::new()),
+                ui.insert(Slider::new()),
+                ui.insert(Slider::new()),
+                ui.insert(Slider::new()),
             ],
             source_sliders: vec![],
         };
         let mut layout = PageFlow::new();
         // One flag: section_widgets() returns a single group (the output ids
         // with input appended), so the view draws one section and reads [0].
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
         for (i, (c, x, y, w, h, r, _)) in pc.rects.iter().enumerate() {
             println!("TEST_PC_RECT {}: color={:?}, x={}, y={}, w={}, h={}, r={}", i, c, x, y, w, h, r);
         }
@@ -489,30 +507,25 @@ mod tests {
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
     }
 
+    /// A device's spinbox with its context menu open, given back by the context when a
+    /// refresh drops the device: the menu goes with it rather than outliving its widget.
     #[test]
-    #[allow(unused_assignments)]
-    fn test_boxed_spinbox_right_click_crash() {
+    fn a_removed_spinbox_takes_its_menu_with_it() {
         use cce_ui::widget::{WidgetHost, Spinbox};
+        let mut ui = UiContext::new();
         let mut state = AudioState::default();
-        state.sink_spinboxes.push(Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
-        let mut ctx = cce_ui::context::UiContext::new();
-        let sb = &mut state.sink_spinboxes[0];
-        sb.set_rect(0.0, 0.0, 100.0, 44.0);
-        let res = sb.mouse_input(
-            cce_ui::widget::MouseButton::Right,
-            cce_ui::widget::ElementState::Pressed,
-            50.0,
-            20.0,
-            &mut ctx,
-        );
-        assert!(res);
+        fit(&mut state.sink_spinboxes, 1, &mut ui, || Spinbox::new(50, 0, 100, 1));
+        let sb = state.sink_spinboxes[0];
+        ui[sb].set_rect(0.0, 0.0, 100.0, 44.0);
+        let res = ui.lend_h(sb, |w, ctx| {
+            w.mouse_input(cce_ui::widget::MouseButton::Right, cce_ui::widget::ElementState::Pressed, 50.0, 20.0, ctx)
+        });
+        assert_eq!(res, Some(true));
         assert!(cce_ui::widget::context_menu::is_visible());
 
-        // Now replace the state simulating config reload/refresh
-        let new_state = AudioState::default();
-        state = new_state;
-
-        // Assert that the context menu is hidden (cleared)
+        // The device is gone on the next refresh: its spinbox leaves the context.
+        fit(&mut state.sink_spinboxes, 0, &mut ui, || Spinbox::new(50, 0, 100, 1));
+        assert!(ui.get(sb).is_none());
         assert!(!cce_ui::widget::context_menu::is_visible());
     }
 }
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
index 1dfd990..3607c90 100644
--- a/src/pages/bluetooth.rs
+++ b/src/pages/bluetooth.rs
@@ -1,5 +1,6 @@
 use crate::app::{form_button, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::widget::{Adapted, Toggle};
 
@@ -19,7 +20,7 @@ pub struct BluetoothState {
     pub enabled: bool,
     pub devices: Vec<BluetoothDevice>,
     pub scanning: bool,
-    pub toggle: Owned<Adapted<Toggle>>,
+    pub toggle: Handle<Adapted<Toggle>>,
 }
 
 impl Default for BluetoothState {
@@ -31,11 +32,18 @@ impl Default for BluetoothState {
             enabled: false,
             devices: Vec::new(),
             scanning: false,
-            toggle: Owned::new(Toggle::new()),
+            toggle: Handle::none(),
         }
     }
 }
 
+impl BluetoothState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self { toggle: ctx.insert(Toggle::new()), ..Self::default() }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub enum BluetoothMessage {
     Refreshed(BluetoothState),
@@ -81,7 +89,7 @@ pub async fn fetch_bluetooth_page_state() -> BluetoothState {
         .unwrap_or(false);
 
     let devices = if enabled { fetch_devices().await } else { Vec::new() };
-    BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Owned::new(Toggle::new()) }
+    BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Handle::none() }
 }
 
 async fn fetch_devices() -> Vec<BluetoothDevice> {
@@ -178,10 +186,10 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
         } else {
             let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
             let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
-            state.toggle.set_toggled(state.enabled);
-            state.toggle.set_label(if state.enabled { "ON" } else { "OFF" });
+            ctx[state.toggle].set_toggled(state.enabled);
+            ctx[state.toggle].set_label(if state.enabled { "ON" } else { "OFF" });
             col.row(|r| {
-                r.widget_w(&mut state.toggle, bt_btn_w, cce_ui::layout::toggle_height());
+                r.widget_w_h(ctx, state.toggle, bt_btn_w, cce_ui::layout::toggle_height());
                 form_button(r, "Scan", scan_btn_w, (TOGGLE_OFF, BTN_HOVER, WHITE), AppAction::Bluetooth(BluetoothMessage::Scan));
             });
 
@@ -238,7 +246,7 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
     final_pc
 }
 
-pub fn update(state: &mut BluetoothState, msg: BluetoothMessage) {
+pub fn update(state: &mut BluetoothState, msg: BluetoothMessage, _ctx: &mut UiContext) {
     match msg {
         BluetoothMessage::Refreshed(new) => {
             state.loaded = new.loaded;
@@ -297,8 +305,8 @@ impl crate::pages::AppPage for BluetoothState {
         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
-        if self.toggle.take_change() {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+        if ctx[self.toggle].take_change() {
             actions.push(crate::app::AppAction::Bluetooth(BluetoothMessage::Toggle));
         }
     }
@@ -311,7 +319,8 @@ mod tests {
 
     #[test]
     fn section_widgets_mirror_branch_chain() {
-        let mut st = BluetoothState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = BluetoothState::new(&mut ui);
         // Each of the three early branches paints a message (and maybe a plain
         // PageContent button) but never `toggle` — the widget lives only in the
         // innermost `else`. Unlike a load gate, the middle two are steady
diff --git a/src/pages/browser.rs b/src/pages/browser.rs
index 94ffe63..2403f39 100644
--- a/src/pages/browser.rs
+++ b/src/pages/browser.rs
@@ -7,7 +7,8 @@
 
 use std::fs;
 
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageFlow, PageLayoutBuilder};
 use cce_ui::widget::input::{Dropdown, Toggle};
 use cce_ui::widget::TextBox;
@@ -62,27 +63,20 @@ pub struct BrowserState {
     pub vi_mode: bool,
     pub bar_position: String,
     pub color_scheme: String,
-    pub homepage_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub search_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
-    pub bar_position_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
-    pub color_scheme_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
-    pub download_dir_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub history_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
-    pub raindrop_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
-    pub vi_mode_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
+    pub homepage_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub search_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
+    pub bar_position_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
+    pub color_scheme_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
+    pub download_dir_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub history_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
+    pub raindrop_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
+    pub vi_mode_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
 }
 
 impl Default for BrowserState {
+    /// The data half, read from the config; the widgets are `new`'s.
     fn default() -> Self {
         let config = read_browser_config();
-        let mut homepage_box = TextBox::new(config.homepage.clone())
-            .with_draw_bg_border(true)
-            .with_label("Homepage");
-        homepage_box.edit_buffer = config.homepage.clone();
-        let mut download_dir_box = TextBox::new(config.download_dir.clone())
-            .with_draw_bg_border(true)
-            .with_label("Download Directory");
-        download_dir_box.edit_buffer = config.download_dir.clone();
         Self {
             loaded: true,
             homepage: config.homepage,
@@ -93,30 +87,56 @@ impl Default for BrowserState {
             vi_mode: config.vi_mode,
             bar_position: config.bar_position.clone(),
             color_scheme: config.color_scheme.clone(),
-            homepage_box: Owned::new(homepage_box),
-            search_menu: Owned::new(Dropdown::new(
+            homepage_box: Handle::none(),
+            search_menu: Handle::none(),
+            bar_position_menu: Handle::none(),
+            color_scheme_menu: Handle::none(),
+            download_dir_box: Handle::none(),
+            history_toggle: Handle::none(),
+            raindrop_toggle: Handle::none(),
+            vi_mode_toggle: Handle::none(),
+        }
+    }
+}
+
+impl BrowserState {
+    /// The page's state, its widgets inserted into `ctx` and filled from the config.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        let s = Self::default();
+        let mut homepage_box = TextBox::new(s.homepage.clone())
+            .with_draw_bg_border(true)
+            .with_label("Homepage");
+        homepage_box.edit_buffer = s.homepage.clone();
+        let mut download_dir_box = TextBox::new(s.download_dir.clone())
+            .with_draw_bg_border(true)
+            .with_label("Download Directory");
+        download_dir_box.edit_buffer = s.download_dir.clone();
+        Self {
+            homepage_box: ctx.insert(homepage_box),
+            search_menu: ctx.insert(Dropdown::new(
                 SEARCH_ENGINES.iter().map(|(_, label)| label.to_string()).collect(),
-                search_index(&config.search),
+                search_index(&s.search),
             )
             .with_label("Search Engine")),
-            bar_position_menu: Owned::new(Dropdown::new(
+            bar_position_menu: ctx.insert(Dropdown::new(
                 BAR_POSITIONS.iter().map(|(_, label)| label.to_string()).collect(),
-                bar_position_index(&config.bar_position),
+                bar_position_index(&s.bar_position),
             )
             .with_label("Navigation Bar Position")),
-            color_scheme_menu: Owned::new(Dropdown::new(
+            color_scheme_menu: ctx.insert(Dropdown::new(
                 COLOR_SCHEMES.iter().map(|(_, label)| label.to_string()).collect(),
-                color_scheme_index(&config.color_scheme),
+                color_scheme_index(&s.color_scheme),
             )
             .with_label("Page Color Scheme")),
-            download_dir_box: Owned::new(download_dir_box),
+            download_dir_box: ctx.insert(download_dir_box),
             // Left-aligned, as the designer's parameter pane sets its toggles:
             // a toggle's run is half its width, so the seam where run meets
             // well sits at the midpoint, and a centred label on a row-wide
             // toggle had it drawn straight through the text.
-            history_toggle: Owned::new(Toggle::new().with_label("Record History").with_left_align(true)),
-            raindrop_toggle: Owned::new(Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true)),
-            vi_mode_toggle: Owned::new(Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true)),
+            history_toggle: ctx.insert(Toggle::new().with_label("Record History").with_left_align(true)),
+            raindrop_toggle: ctx.insert(Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true)),
+            vi_mode_toggle: ctx.insert(Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true)),
+            ..s
         }
     }
 }
@@ -156,7 +176,7 @@ fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
     }
 }
 
-pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
+pub fn update(state: &mut BrowserState, msg: BrowserMessage, ctx: &mut UiContext) {
     match msg {
         BrowserMessage::SetSearch(key) => {
             state.search = key.clone();
@@ -183,13 +203,13 @@ pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
             write_config_value("vi-mode", &state.vi_mode.to_string());
         }
         BrowserMessage::Apply => {
-            state.homepage = live_text(&state.homepage_box);
+            state.homepage = live_text(&ctx[state.homepage_box]);
             if state.homepage.is_empty() {
                 state.homepage = DEFAULT_HOMEPAGE.to_string();
-                state.homepage_box.text = state.homepage.clone();
-                state.homepage_box.edit_buffer = state.homepage.clone();
+                ctx[state.homepage_box].text = state.homepage.clone();
+                ctx[state.homepage_box].edit_buffer = state.homepage.clone();
             }
-            state.download_dir = live_text(&state.download_dir_box);
+            state.download_dir = live_text(&ctx[state.download_dir_box]);
             write_config_value("homepage", &state.homepage);
             write_config_value("download-dir", &state.download_dir);
         }
@@ -202,15 +222,15 @@ pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
             state.bar_position = new.bar_position;
             state.color_scheme = new.color_scheme;
             // Don't clobber fields mid-edit with watcher refreshes.
-            if !state.homepage_box.editing && state.homepage != new.homepage {
+            if !ctx[state.homepage_box].editing && state.homepage != new.homepage {
                 state.homepage = new.homepage.clone();
-                state.homepage_box.text = new.homepage.clone();
-                state.homepage_box.edit_buffer = new.homepage;
+                ctx[state.homepage_box].text = new.homepage.clone();
+                ctx[state.homepage_box].edit_buffer = new.homepage;
             }
-            if !state.download_dir_box.editing && state.download_dir != new.download_dir {
+            if !ctx[state.download_dir_box].editing && state.download_dir != new.download_dir {
                 state.download_dir = new.download_dir.clone();
-                state.download_dir_box.text = new.download_dir.clone();
-                state.download_dir_box.edit_buffer = new.download_dir;
+                ctx[state.download_dir_box].text = new.download_dir.clone();
+                ctx[state.download_dir_box].edit_buffer = new.download_dir;
             }
         }
     }
@@ -282,14 +302,14 @@ impl AppPage for BrowserState {
         let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
         builder.add_section(&mut final_pc, "Browser Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
-            self.search_menu.selected = search_index(&self.search);
-            self.bar_position_menu.selected = bar_position_index(&self.bar_position);
-            self.color_scheme_menu.selected = color_scheme_index(&self.color_scheme);
-            self.history_toggle.set_toggled(self.history);
+            ctx[self.search_menu].selected = search_index(&self.search);
+            ctx[self.bar_position_menu].selected = bar_position_index(&self.bar_position);
+            ctx[self.color_scheme_menu].selected = color_scheme_index(&self.color_scheme);
+            ctx[self.history_toggle].set_toggled(self.history);
             // Needs a Raindrop token in the keyring (service=raindrop.io);
             // the browser shows the sync's status on cce://bookmarks.
-            self.raindrop_toggle.set_toggled(self.raindrop);
-            self.vi_mode_toggle.set_toggled(self.vi_mode);
+            ctx[self.raindrop_toggle].set_toggled(self.raindrop);
+            ctx[self.vi_mode_toggle].set_toggled(self.vi_mode);
 
             let (field_h, menu_h, toggle_h) = (
                 cce_ui::layout::textbox_height(),
@@ -298,14 +318,14 @@ impl AppPage for BrowserState {
             );
             let mut form = sec.form();
             form.column()
-                .widget(&mut self.homepage_box, field_h)
-                .widget(&mut self.search_menu, menu_h)
-                .widget(&mut self.bar_position_menu, menu_h)
-                .widget(&mut self.color_scheme_menu, menu_h)
-                .widget(&mut self.download_dir_box, field_h)
-                .widget(&mut self.history_toggle, toggle_h)
-                .widget(&mut self.raindrop_toggle, toggle_h)
-                .widget(&mut self.vi_mode_toggle, toggle_h)
+                .widget_h(ctx, self.homepage_box, field_h)
+                .widget_h(ctx, self.search_menu, menu_h)
+                .widget_h(ctx, self.bar_position_menu, menu_h)
+                .widget_h(ctx, self.color_scheme_menu, menu_h)
+                .widget_h(ctx, self.download_dir_box, field_h)
+                .widget_h(ctx, self.history_toggle, toggle_h)
+                .widget_h(ctx, self.raindrop_toggle, toggle_h)
+                .widget_h(ctx, self.vi_mode_toggle, toggle_h)
                 .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
                     pc.button(
                         "Apply",
@@ -325,39 +345,39 @@ impl AppPage for BrowserState {
         final_pc
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
-        if self.search_menu.take_change() {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
+        if ctx[self.search_menu].take_change() {
             let key = SEARCH_ENGINES
-                .get(self.search_menu.selected)
+                .get(ctx[self.search_menu].selected)
                 .map(|(k, _)| k.to_string())
                 .unwrap_or_else(|| "duckduckgo".to_string());
             actions.push(AppAction::Browser(BrowserMessage::SetSearch(key)));
         }
-        if self.bar_position_menu.take_change() {
+        if ctx[self.bar_position_menu].take_change() {
             let key = BAR_POSITIONS
-                .get(self.bar_position_menu.selected)
+                .get(ctx[self.bar_position_menu].selected)
                 .map(|(k, _)| k.to_string())
                 .unwrap_or_else(|| "top".to_string());
             actions.push(AppAction::Browser(BrowserMessage::SetBarPosition(key)));
         }
-        if self.color_scheme_menu.take_change() {
+        if ctx[self.color_scheme_menu].take_change() {
             let key = COLOR_SCHEMES
-                .get(self.color_scheme_menu.selected)
+                .get(ctx[self.color_scheme_menu].selected)
                 .map(|(k, _)| k.to_string())
                 .unwrap_or_else(|| "dark".to_string());
             actions.push(AppAction::Browser(BrowserMessage::SetColorScheme(key)));
         }
-        if self.history_toggle.take_change() {
+        if ctx[self.history_toggle].take_change() {
             actions.push(AppAction::Browser(BrowserMessage::ToggleHistory));
         }
-        if self.raindrop_toggle.take_change() {
+        if ctx[self.raindrop_toggle].take_change() {
             actions.push(AppAction::Browser(BrowserMessage::ToggleRaindrop));
         }
-        if self.vi_mode_toggle.take_change() {
+        if ctx[self.vi_mode_toggle].take_change() {
             actions.push(AppAction::Browser(BrowserMessage::ToggleViMode));
         }
         // Enter in either text field commits both.
-        if self.homepage_box.take_change() || self.download_dir_box.take_change() {
+        if ctx[self.homepage_box].take_change() || ctx[self.download_dir_box].take_change() {
             actions.push(AppAction::Browser(BrowserMessage::Apply));
         }
     }
diff --git a/src/pages/default_apps.rs b/src/pages/default_apps.rs
index 39b9b11..bbbd293 100644
--- a/src/pages/default_apps.rs
+++ b/src/pages/default_apps.rs
@@ -11,7 +11,8 @@
 //! entries, and startcce exports as `$TERMINAL` for everything else.
 
 use crate::app::{AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
 use cce_ui::widget::Dropdown;
 use std::collections::HashMap;
@@ -61,7 +62,7 @@ pub struct CategoryEntry {
     /// Applied value per dropdown option (desktop id for MIME categories, a
     /// command for Terminal; None = the "not set" placeholder row).
     pub option_ids: Vec<Option<String>>,
-    pub dropdown: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    pub dropdown: Handle<cce_ui::widget::Adapted<Dropdown>>,
 }
 
 #[derive(Debug, Clone)]
@@ -70,8 +71,9 @@ pub struct DefaultAppsState {
     pub categories: Vec<CategoryEntry>,
 }
 
-impl Default for DefaultAppsState {
-    fn default() -> Self {
+impl DefaultAppsState {
+    /// The page's state, its dropdowns inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         Self {
             loaded: false,
             categories: CATEGORIES
@@ -81,7 +83,7 @@ impl Default for DefaultAppsState {
                     kind,
                     info: CategoryInfo { candidates: Vec::new(), current: None },
                     option_ids: vec![None],
-                    dropdown: Owned::new(Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label)),
+                    dropdown: ctx.insert(Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label)),
                 })
                 .collect(),
         }
@@ -97,7 +99,7 @@ pub enum DefaultAppsMessage {
 
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 
-fn rebuild_entry_options(entry: &mut CategoryEntry) {
+fn rebuild_entry_options(entry: &mut CategoryEntry, ctx: &mut UiContext) {
     let mut options = Vec::new();
     let mut ids = Vec::new();
     let current_idx = entry
@@ -119,8 +121,9 @@ fn rebuild_entry_options(entry: &mut CategoryEntry) {
         .as_ref()
         .and_then(|cur| ids.iter().position(|i| i.as_deref() == Some(cur)))
         .unwrap_or(0);
-    entry.dropdown.options = options;
-    entry.dropdown.selected = selected;
+    let dropdown = &mut ctx[entry.dropdown];
+    dropdown.options = options;
+    dropdown.selected = selected;
     entry.option_ids = ids;
 }
 
@@ -136,7 +139,7 @@ pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _r
             col.text("Scanning installed applications...", 12.0, TEXT_DIM);
         } else {
             for entry in state.categories.iter_mut() {
-                col.widget(&mut entry.dropdown, cce_ui::layout::dropdown_height());
+                col.widget_h(ctx, entry.dropdown, cce_ui::layout::dropdown_height());
             }
         }
         sec.place(form, ctx);
@@ -145,17 +148,17 @@ pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _r
     final_pc
 }
 
-pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage) {
+pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage, ctx: &mut UiContext) {
     match msg {
         DefaultAppsMessage::Refreshed(info) => {
             state.loaded = true;
             for (entry, cat) in state.categories.iter_mut().zip(info.0.into_iter()) {
                 // Leave an open dropdown alone — the next refresh normalizes it.
-                if entry.info == cat || entry.dropdown.open {
+                if entry.info == cat || ctx[entry.dropdown].open {
                     continue;
                 }
                 entry.info = cat;
-                rebuild_entry_options(entry);
+                rebuild_entry_options(entry, ctx);
             }
         }
         DefaultAppsMessage::Set(cat_idx, opt_idx) => {
@@ -194,7 +197,7 @@ pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage) {
                 CategoryKind::Terminal => set_default_terminal(&id),
             }
             entry.info.current = Some(id);
-            rebuild_entry_options(entry);
+            rebuild_entry_options(entry, ctx);
         }
     }
 }
@@ -447,10 +450,10 @@ impl crate::pages::AppPage for DefaultAppsState {
         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
         for (i, entry) in self.categories.iter_mut().enumerate() {
-            if entry.dropdown.take_change() {
-                actions.push(AppAction::DefaultApps(DefaultAppsMessage::Set(i, entry.dropdown.selected)));
+            if ctx[entry.dropdown].take_change() {
+                actions.push(AppAction::DefaultApps(DefaultAppsMessage::Set(i, ctx[entry.dropdown].selected)));
             }
         }
     }
@@ -463,7 +466,8 @@ mod tests {
     #[test]
     fn section_widgets_mirror_load_gate() {
         use crate::pages::AppPage;
-        let mut st = DefaultAppsState::default();
+        let mut ui = UiContext::new();
+        let mut st = DefaultAppsState::new(&mut ui);
         // Still scanning: the dropdowns are not in the stack yet, so none of
         // them may be reported as a dispatch root.
         assert_eq!(st.section_widgets(), vec![Vec::new()]);
@@ -490,7 +494,8 @@ mod tests {
 
     #[test]
     fn rebuild_options_maps_current() {
-        let mut st = DefaultAppsState::default();
+        let mut ui = UiContext::new();
+        let mut st = DefaultAppsState::new(&mut ui);
         let e = &mut st.categories[0];
         e.info = CategoryInfo {
             candidates: vec![
@@ -499,16 +504,16 @@ mod tests {
             ],
             current: Some("b.desktop".into()),
         };
-        rebuild_entry_options(e);
-        assert_eq!(e.dropdown.options, vec!["Alpha".to_string(), "Beta".to_string()]);
-        assert_eq!(e.dropdown.selected, 1);
+        rebuild_entry_options(e, &mut ui);
+        assert_eq!(ui[e.dropdown].options, vec!["Alpha".to_string(), "Beta".to_string()]);
+        assert_eq!(ui[e.dropdown].selected, 1);
         assert_eq!(e.option_ids[1].as_deref(), Some("b.desktop"));
 
         // No current: placeholder row leads and is selected.
         e.info.current = None;
-        rebuild_entry_options(e);
-        assert_eq!(e.dropdown.options[0], NOT_SET);
-        assert_eq!(e.dropdown.selected, 0);
+        rebuild_entry_options(e, &mut ui);
+        assert_eq!(ui[e.dropdown].options[0], NOT_SET);
+        assert_eq!(ui[e.dropdown].selected, 0);
         assert!(e.option_ids[0].is_none());
     }
 }
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 5ca353c..ac89fc4 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -97,7 +97,7 @@ pub trait AppPage {
         ctx: &mut cce_ui::context::UiContext,
     ) -> crate::app::PageContent;
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>);
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut cce_ui::context::UiContext);
 
     fn handle_pointer_move(
         &mut self,
@@ -171,10 +171,9 @@ mod list_clip_tests {
     use super::*;
     use cce_ui::widget::ScrollRegion;
 
-    fn render(page: &mut dyn AppPage) -> crate::app::PageContent {
+    fn render(page: &mut dyn AppPage, ui: &mut cce_ui::context::UiContext) -> crate::app::PageContent {
         let mut layout = cce_ui::layout::PageFlow::new();
-        let mut ctx = cce_ui::context::UiContext::new();
-        page.view(10.0, 20.0, 820.0, 640.0, false, &[false; 4], &mut layout, &mut ctx)
+        page.view(10.0, 20.0, 820.0, 640.0, false, &[false; 4], &mut layout, ui)
     }
 
     /// A list sits inside its section's clip exactly when the clip it
@@ -208,7 +207,8 @@ mod list_clip_tests {
     /// are carved into; a framed one lays its own under its bg.
     #[test]
     fn every_inner_list_rides_its_centre_line_behind_the_plate() {
-        let app = crate::app::AppState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let app = crate::app::AppState::new(&mut ui);
         let lists: [(&str, &ScrollRegion); 7] = [
             ("accounts", &app.accounts.list),
             ("network", &app.network.wifi_list),
@@ -231,7 +231,8 @@ mod list_clip_tests {
 
     #[test]
     fn services_list_inside_its_section() {
-        let mut s = services::ServicesState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut s = services::ServicesState::new(&mut ui);
         s.loaded = true;
         s.services = (0..5)
             .map(|i| services::ServiceInfo {
@@ -242,13 +243,14 @@ mod list_clip_tests {
                 is_system: true,
             })
             .collect();
-        let pc = render(&mut s);
+        let pc = render(&mut s, &mut ui);
         assert_list_inside("services", &pc, &s.list);
     }
 
     #[test]
     fn timers_list_inside_its_section() {
-        let mut s = timers::TimersState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut s = timers::TimersState::new(&mut ui);
         s.loaded = true;
         s.timers = (0..5)
             .map(|i| timers::TimerInfo {
@@ -262,47 +264,51 @@ mod list_clip_tests {
                 editable: false,
             })
             .collect();
-        let pc = render(&mut s);
+        let pc = render(&mut s, &mut ui);
         assert_list_inside("timers", &pc, &s.list);
     }
 
     #[test]
     fn packages_list_inside_its_section() {
-        let mut s = packages::PackagesState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut s = packages::PackagesState::new(&mut ui);
         s.loaded = true;
         s.installed = (0..5)
             .map(|i| packages::PackageInfo { name: format!("p{i}"), version: "1.0".into(), ..Default::default() })
             .collect();
-        let pc = render(&mut s);
+        let pc = render(&mut s, &mut ui);
         assert_list_inside("packages", &pc, &s.installed_list);
     }
 
     #[test]
     fn network_list_inside_its_section() {
-        let mut s = network::NetworkState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut s = network::NetworkState::new(&mut ui);
         s.loaded = true;
         s.wifi_enabled = true;
         s.available = (0..3)
             .map(|i| network::WifiNetwork { ssid: format!("net{i}"), signal: 50, secured: true, in_use: i == 0 })
             .collect();
-        let pc = render(&mut s);
+        let pc = render(&mut s, &mut ui);
         assert_list_inside("network", &pc, &s.wifi_list);
     }
 
     #[test]
     fn accounts_list_inside_its_section() {
-        let mut s = accounts::AccountsState::default_mock();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut s = accounts::AccountsState::new(&mut ui);
         s.loaded = true;
         s.accounts = vec![serde_json::from_str(
             r#"{"email":"a@example.org","imap":"imap.example.org:993","smtp":"smtp.example.org:465","is_default":true,"password":""}"#,
         )
         .unwrap()];
-        let pc = render(&mut s);
+        let pc = render(&mut s, &mut ui);
         assert_list_inside("accounts", &pc, &s.list);
     }
 
     #[test]
     fn processes_list_inside_its_section() {
+        let mut ui = cce_ui::context::UiContext::new();
         let mut s = processes::ProcessesState { loaded: true, ..Default::default() };
         s.processes = (0..5)
             .map(|i| processes::ProcessRow {
@@ -315,7 +321,7 @@ mod list_clip_tests {
                 wakeups: None,
             })
             .collect();
-        let pc = render(&mut s);
+        let pc = render(&mut s, &mut ui);
         assert_list_inside("processes", &pc, &s.cpu_list);
     }
 }
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 27c0f15..a93fb09 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,5 +1,6 @@
 use crate::app::{AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::scene::layout::Rect;
@@ -23,7 +24,7 @@ pub struct NetworkState {
     pub device: String,
     pub available: Vec<WifiNetwork>,
     pub wifi_list: ScrollRegion,
-    pub wifi_toggle: Owned<Adapted<Toggle>>,
+    pub wifi_toggle: Handle<Adapted<Toggle>>,
 }
 
 impl Default for NetworkState {
@@ -37,11 +38,18 @@ impl Default for NetworkState {
             device: String::new(),
             available: Vec::new(),
             wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
-            wifi_toggle: Owned::new(Toggle::new()),
+            wifi_toggle: Handle::none(),
         }
     }
 }
 
+impl NetworkState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self { wifi_toggle: ctx.insert(Toggle::new()), ..Self::default() }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub enum NetworkMessage {
     Refreshed(NetworkState),
@@ -101,7 +109,7 @@ pub async fn fetch_network_state() -> NetworkState {
         wifi_enabled, connected_ssid, signal_strength: signal,
         ip_address, device, available,
         wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
-        wifi_toggle: Owned::new(Toggle::new()),
+        wifi_toggle: Handle::none(),
     }
 }
 
@@ -180,11 +188,11 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
             col.text("Loading WiFi interfaces...", 12.0, TEXT_DIM);
         } else {
             let wifi_btn_w = if sec_w < 200.0 { 40.0 } else { 60.0 };
-            state.wifi_toggle.set_toggled(state.wifi_enabled);
-            state.wifi_toggle.set_label(if state.wifi_enabled { "ON" } else { "OFF" });
+            ctx[state.wifi_toggle].set_toggled(state.wifi_enabled);
+            ctx[state.wifi_toggle].set_label(if state.wifi_enabled { "ON" } else { "OFF" });
             // At its own width, not the row's: the old wide "ON" plate.
             col.row(|r| {
-                r.widget_w(&mut state.wifi_toggle, wifi_btn_w, cce_ui::layout::toggle_height());
+                r.widget_w_h(ctx, state.wifi_toggle, wifi_btn_w, cce_ui::layout::toggle_height());
             });
 
             if state.wifi_enabled {
@@ -261,7 +269,7 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
     final_pc
 }
 
-pub fn update(state: &mut NetworkState, msg: NetworkMessage) {
+pub fn update(state: &mut NetworkState, msg: NetworkMessage, _ctx: &mut UiContext) {
     match msg {
         NetworkMessage::Refreshed(new) => {
             state.loaded = new.loaded;
@@ -317,8 +325,8 @@ impl crate::pages::AppPage for NetworkState {
         view(self, cx, cy, cw, ch, focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
-        if self.wifi_toggle.take_change() {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+        if ctx[self.wifi_toggle].take_change() {
             actions.push(crate::app::AppAction::Network(NetworkMessage::ToggleWifi));
         }
     }
@@ -360,31 +368,34 @@ mod tests {
 
     #[test]
     fn test_view_layout_grid() {
-        let mut state = NetworkState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = NetworkState::new(&mut ui);
         state.loaded = true;
         state.wifi_enabled = true;
         let mut layout = cce_ui::layout::PageFlow::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut ui);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
     }
 
     #[test]
     fn test_view_layout_connected() {
-        let mut state = NetworkState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = NetworkState::new(&mut ui);
         state.loaded = true;
         state.wifi_enabled = true;
         state.connected_ssid = "MyHomeWiFi".to_string();
         state.signal_strength = 80;
         state.ip_address = "192.168.1.50".to_string();
         let mut layout = cce_ui::layout::PageFlow::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut ui);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
     }
 
     #[test]
     fn section_widgets_mirror_load_gate() {
+        let mut ui = cce_ui::context::UiContext::new();
         use crate::pages::AppPage;
-        let mut st = NetworkState::default();
+        let mut st = NetworkState::new(&mut ui);
         // Not loaded: the view paints only "Loading WiFi interfaces...", so
         // reporting the toggle would be a root nothing registered this frame.
         assert_eq!(st.section_widgets(), vec![Vec::new()]);
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 31c7fd6..4c142f5 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -1,6 +1,7 @@
 use std::fs;
 use std::io::Write;
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::input::{Toggle, Dropdown, Spinbox};
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
 use crate::app::{AppAction, PageContent};
@@ -18,11 +19,11 @@ pub struct NotificationsConfig {
 pub struct NotificationsState {
     pub loaded: bool,
     pub enable: bool,
-    pub enable_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
+    pub enable_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
     pub bell: String,
-    pub bell_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    pub bell_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
     pub duration: i32,
-    pub duration_spinbox: Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
+    pub duration_spinbox: Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
 }
 
 impl Default for NotificationsState {
@@ -30,14 +31,26 @@ impl Default for NotificationsState {
         Self {
             loaded: false,
             enable: true,
+            enable_toggle: Handle::none(),
+            bell: "none".to_string(),
+            bell_menu: Handle::none(),
+            duration: 5,
+            duration_spinbox: Handle::none(),
+        }
+    }
+}
+
+impl NotificationsState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self {
             // Left-aligned: centred, the label straddled the seam at the
             // toggle's midpoint (see the Browser page's toggles).
-            enable_toggle: Owned::new(Toggle::new()
+            enable_toggle: ctx.insert(Toggle::new()
                 .with_label("Enable Notifications")
                 .with_left_align(true)
                 .with_config(&get_config_path(), "enable")),
-            bell: "none".to_string(),
-            bell_menu: Owned::new(Dropdown::new(
+            bell_menu: ctx.insert(Dropdown::new(
                 vec![
                     "None".to_string(),
                     "Bell".to_string(),
@@ -46,11 +59,11 @@ impl Default for NotificationsState {
                 ],
                 0,
             ).with_label("Notification Sound")),
-            duration: 5,
-            duration_spinbox: Owned::new(Spinbox::new(5, 1, 60, 1)
+            duration_spinbox: ctx.insert(Spinbox::new(5, 1, 60, 1)
                 .with_label("Notification Duration")
                 .with_unit("s")
                 .with_config(&get_config_path(), "duration")),
+            ..Self::default()
         }
     }
 }
@@ -64,7 +77,7 @@ pub enum NotificationsMessage {
     Refreshed(NotificationsConfig),
 }
 
-pub fn update(state: &mut NotificationsState, msg: NotificationsMessage) {
+pub fn update(state: &mut NotificationsState, msg: NotificationsMessage, _ctx: &mut UiContext) {
     match msg {
         NotificationsMessage::ToggleNotificationsEnable => {
             state.enable = !state.enable;
@@ -212,21 +225,21 @@ impl AppPage for NotificationsState {
         let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
         builder.add_section(&mut final_pc, "Notifications Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
-            self.enable_toggle.set_toggled(self.enable);
-            self.bell_menu.selected = match self.bell.as_str() {
+            ctx[self.enable_toggle].set_toggled(self.enable);
+            ctx[self.bell_menu].selected = match self.bell.as_str() {
                 "bell" => 1,
                 "dialog" => 2,
                 "message" => 3,
                 _ => 0,
             };
-            self.duration_spinbox.value = self.duration;
-            self.duration_spinbox.set_label("Notification Duration");
+            ctx[self.duration_spinbox].value = self.duration;
+            ctx[self.duration_spinbox].set_label("Notification Duration");
 
             let mut form = sec.form();
             form.column()
-                .widget(&mut self.enable_toggle, cce_ui::layout::toggle_height())
-                .widget(&mut self.bell_menu, cce_ui::layout::dropdown_height())
-                .widget(&mut self.duration_spinbox, cce_ui::layout::spinbox_height())
+                .widget_h(ctx, self.enable_toggle, cce_ui::layout::toggle_height())
+                .widget_h(ctx, self.bell_menu, cce_ui::layout::dropdown_height())
+                .widget_h(ctx, self.duration_spinbox, cce_ui::layout::spinbox_height())
                 .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
                     pc.button(
                         "Send Test Notification",
@@ -246,12 +259,12 @@ impl AppPage for NotificationsState {
         final_pc
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
-        if self.enable_toggle.take_change() {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
+        if ctx[self.enable_toggle].take_change() {
             actions.push(AppAction::Notifications(NotificationsMessage::ToggleNotificationsEnable));
         }
-        if self.bell_menu.take_change() {
-            let sound = match self.bell_menu.selected {
+        if ctx[self.bell_menu].take_change() {
+            let sound = match ctx[self.bell_menu].selected {
                 0 => "none",
                 1 => "bell",
                 2 => "dialog",
@@ -260,8 +273,8 @@ impl AppPage for NotificationsState {
             }.to_string();
             actions.push(AppAction::Notifications(NotificationsMessage::SetNotificationsBell(sound)));
         }
-        if self.duration_spinbox.take_change() {
-            actions.push(AppAction::Notifications(NotificationsMessage::SetNotificationsDuration(self.duration_spinbox.value)));
+        if ctx[self.duration_spinbox].take_change() {
+            actions.push(AppAction::Notifications(NotificationsMessage::SetNotificationsDuration(ctx[self.duration_spinbox].value)));
         }
     }
 }
@@ -272,11 +285,11 @@ pub(crate) mod tests {
 
     #[test]
     fn test_view_layout_grid() {
-        let mut state = NotificationsState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = NotificationsState::new(&mut ui);
         let mut layout = cce_ui::layout::PageFlow::new();
         let sec_focused = vec![false];
-        let mut ctx = cce_ui::context::UiContext::new();
-        let pc = state.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+        let pc = state.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ui);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
     }
 
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index a94b991..00fab5a 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,7 +1,8 @@
 use crate::app::{button_need, form_button, form_divider, wrap_to_width, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{TextBox, InteractiveListItem};
 
@@ -69,11 +70,11 @@ pub struct PackagesState {
     pub installed: Vec<PackageInfo>,
     pub updates: Vec<UpdateInfo>,
     pub active_tab: PackageTab,
-    pub search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     pub installed_list: ScrollRegion,
-    pub installed_items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+    pub installed_items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
     pub updates_list: ScrollRegion,
-    pub updates_items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+    pub updates_items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
     pub updating: bool,
     pub last_update_res: Option<Result<(), String>>,
     pub selected_package: Option<String>,
@@ -99,7 +100,7 @@ impl Default for PackagesState {
             installed: Vec::new(),
             updates: Vec::new(),
             active_tab: PackageTab::Installed,
-            search_box: Owned::new(TextBox::new(String::new()).with_placeholder("Filter Packages...")),
+            search_box: Handle::none(),
             installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
             installed_items: Vec::new(),
             updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
@@ -121,6 +122,16 @@ impl Default for PackagesState {
     }
 }
 
+impl PackagesState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self {
+            search_box: ctx.insert(TextBox::new(String::new()).with_placeholder("Filter Packages...")),
+            ..Self::default()
+        }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub enum PackagesMessage {
     Refreshed(PackagesState),
@@ -554,24 +565,28 @@ pub fn view(
         let text_on = [0.90, 0.90, 0.95, 1.0];
         let busy = state.busy();
 
-        let query = if state.search_box.editing {
-            state.search_box.edit_buffer.to_lowercase()
+        let query = if ctx[state.search_box].editing {
+            ctx[state.search_box].edit_buffer.to_lowercase()
         } else {
-            state.search_box.text.to_lowercase()
+            ctx[state.search_box].text.to_lowercase()
         };
         // What the list shows, worked out before the widgets are lent to the form.
-        let visible_installed = state.visible_installed();
+        let visible_installed = state.visible_installed(ctx);
         if state.installed_items.len() != visible_installed.len() {
-            state.installed_items.clear();
+            for h in state.installed_items.drain(..) {
+                ctx.remove(h);
+            }
             for _ in 0..visible_installed.len() {
-                state.installed_items.push(Owned::new(InteractiveListItem::new("")));
+                state.installed_items.push(ctx.insert(InteractiveListItem::new("")));
             }
         }
         let filtered_updates: Vec<&UpdateInfo> = state.updates.iter().filter(|p| p.name.to_lowercase().contains(&query)).collect();
         if state.updates_items.len() != filtered_updates.len() {
-            state.updates_items.clear();
+            for h in state.updates_items.drain(..) {
+                ctx.remove(h);
+            }
             for _ in 0..filtered_updates.len() {
-                state.updates_items.push(Owned::new(InteractiveListItem::new("")));
+                state.updates_items.push(ctx.insert(InteractiveListItem::new("")));
             }
         }
 
@@ -588,7 +603,7 @@ pub fn view(
                 AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)));
         });
 
-        col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
+        col.widget_h(ctx, state.search_box, cce_ui::layout::textbox_height());
 
         // ── Update System: a button as wide as its label, its status beside it ──
         let status_line = if state.updating {
@@ -842,8 +857,8 @@ pub fn view(
         let checked = &state.checked;
         let selected = state.selected_package.clone();
         let installed = &state.installed;
-        let (installed_list, installed_items) = (&mut state.installed_list, &mut state.installed_items);
-        let (updates_list, updates_items) = (&mut state.updates_list, &mut state.updates_items);
+        let (installed_list, installed_items) = (&mut state.installed_list, &state.installed_items);
+        let (updates_list, updates_items) = (&mut state.updates_list, &state.updates_items);
         col.fill(LIST_MIN_H, move |pc, rect, ctx| {
             let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
             let inset = cce_ui::layout::plate_padding();
@@ -860,7 +875,8 @@ pub fn view(
                     for (idx, pkg) in filtered.iter().enumerate() {
                         if let Some(draw_y) = installed_list.get_item_draw_y(idx, 4.0) {
                             // Rows dispatch as extra roots (the dissolved list is no parent).
-                            let item = &mut installed_items[idx];
+                            let row = installed_items[idx];
+                            let item = &mut ctx[row];
                             item.title = pkg.name.clone();
                             let reason = match (pkg.explicit, pkg.orphan) {
                                 (true, _) => "explicit",
@@ -874,7 +890,7 @@ pub fn view(
                                 Some(&pkg.name) == selected.as_ref()
                             };
                             let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
-                            render_widget(pc, item, cell.x, cell.y, cell.width, cell.height, ctx);
+                            render_widget_h(pc, row, cell.x, cell.y, cell.width, cell.height, ctx);
                         }
                     }
                     pc.pop_clip_rect();
@@ -895,12 +911,13 @@ pub fn view(
                     for (idx, pkg) in filtered_updates.iter().enumerate() {
                         if let Some(draw_y) = updates_list.get_item_draw_y(idx, 4.0) {
                             // Rows dispatch as extra roots (the dissolved list is no parent).
-                            let item = &mut updates_items[idx];
+                            let row = updates_items[idx];
+                            let item = &mut ctx[row];
                             item.title = pkg.name.clone();
                             item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
                             item.selected = Some(&pkg.name) == selected.as_ref();
                             let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
-                            render_widget(pc, item, cell.x, cell.y, cell.width, cell.height, ctx);
+                            render_widget_h(pc, row, cell.x, cell.y, cell.width, cell.height, ctx);
                         }
                     }
                     pc.pop_clip_rect();
@@ -918,7 +935,7 @@ pub fn view(
     final_pc
 }
 
-pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
+pub fn update(state: &mut PackagesState, msg: PackagesMessage, ctx: &mut UiContext) {
     match msg {
         PackagesMessage::Refreshed(new) => {
             state.loaded = new.loaded;
@@ -943,8 +960,12 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
             state.active_tab = tab;
             state.installed_list.set_scroll_y(0.0);
             state.updates_list.set_scroll_y(0.0);
-            state.installed_items.clear();
-            state.updates_items.clear();
+            for h in state.installed_items.drain(..) {
+                ctx.remove(h);
+            }
+            for h in state.updates_items.drain(..) {
+                ctx.remove(h);
+            }
             state.selected_package = None;
             state.selected_package_info = None;
             state.loading_info = false;
@@ -981,7 +1002,9 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
             if state.filter != filter {
                 state.filter = filter;
                 state.installed_list.set_scroll_y(0.0);
-                state.installed_items.clear();
+                for h in state.installed_items.drain(..) {
+                    ctx.remove(h);
+                }
             }
         }
         PackagesMessage::ToggleSelectMode => {
@@ -1001,7 +1024,7 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
             }
         }
         PackagesMessage::CheckAllVisible => {
-            for i in state.visible_installed() {
+            for i in state.visible_installed(ctx) {
                 state.checked.insert(state.installed[i].name.clone());
             }
         }
@@ -1074,7 +1097,7 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
             });
         }
         PackagesMessage::SelectAndScrollPackage(name) => {
-            state.select_and_scroll_to(&name);
+            state.select_and_scroll_to(&name, ctx);
         }
     }
 }
@@ -1087,19 +1110,19 @@ impl PackagesState {
     }
 
     /// The search query as typed so far (the box commits only on Enter).
-    fn query(&self) -> String {
-        if self.search_box.editing {
-            self.search_box.edit_buffer.to_lowercase()
+    fn query(&self, ctx: &UiContext) -> String {
+        if ctx[self.search_box].editing {
+            ctx[self.search_box].edit_buffer.to_lowercase()
         } else {
-            self.search_box.text.to_lowercase()
+            ctx[self.search_box].text.to_lowercase()
         }
     }
 
     /// Indices into `installed` of the rows the Installed tab shows, in order:
     /// the filter, then the search. The view paints exactly these and the click
     /// mapping resolves against them, so the two can never disagree.
-    pub fn visible_installed(&self) -> Vec<usize> {
-        let query = self.query();
+    pub fn visible_installed(&self, ctx: &UiContext) -> Vec<usize> {
+        let query = self.query(ctx);
         self.installed
             .iter()
             .enumerate()
@@ -1109,17 +1132,19 @@ impl PackagesState {
             .collect()
     }
 
-    pub fn select_and_scroll_to(&mut self, pkg_name: &str) {
+    pub fn select_and_scroll_to(&mut self, pkg_name: &str, ctx: &mut UiContext) {
         self.active_tab = PackageTab::Installed;
         // The scroll target below is an index into the whole list, so the
         // list must be unfiltered for it to land on the package.
         self.filter = InstalledFilter::All;
         self.select_mode = false;
         self.checked.clear();
-        self.installed_items.clear();
-        self.search_box.text.clear();
-        self.search_box.edit_buffer.clear();
-        self.search_box.editing = false;
+        for h in self.installed_items.drain(..) {
+            ctx.remove(h);
+        }
+        ctx[self.search_box].text.clear();
+        ctx[self.search_box].edit_buffer.clear();
+        ctx[self.search_box].editing = false;
         self.selected_package = Some(pkg_name.to_string());
         self.selected_package_info = None;
         self.loading_info = true;
@@ -1170,17 +1195,18 @@ impl crate::pages::AppPage for PackagesState {
         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
-        let query = if self.search_box.editing {
-            self.search_box.edit_buffer.to_lowercase()
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+        let query = if ctx[self.search_box].editing {
+            ctx[self.search_box].edit_buffer.to_lowercase()
         } else {
-            self.search_box.text.to_lowercase()
+            ctx[self.search_box].text.to_lowercase()
         };
 
         match self.active_tab {
             PackageTab::Installed => {
-                let visible = self.visible_installed();
-                for (idx, item) in self.installed_items.iter_mut().enumerate() {
+                let visible = self.visible_installed(ctx);
+                for (idx, &row) in self.installed_items.iter().enumerate() {
+                    let item = &mut ctx[row];
                     if item.just_clicked {
                         item.just_clicked = false;
                         if let Some(&i) = visible.get(idx) {
@@ -1198,7 +1224,8 @@ impl crate::pages::AppPage for PackagesState {
                 let filtered: Vec<&UpdateInfo> = self.updates.iter()
                     .filter(|p| p.name.to_lowercase().contains(&query))
                     .collect();
-                for (idx, item) in self.updates_items.iter_mut().enumerate() {
+                for (idx, &row) in self.updates_items.iter().enumerate() {
+                    let item = &mut ctx[row];
                     if item.just_clicked {
                         item.just_clicked = false;
                         if idx < filtered.len() {
@@ -1230,18 +1257,6 @@ impl crate::pages::AppPage for PackagesState {
             .collect()
     }
 
-    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        let (list, items) = match self.active_tab {
-            PackageTab::Installed => (&self.installed_list, &mut self.installed_items),
-            PackageTab::Updates => (&self.updates_list, &mut self.updates_items),
-        };
-        for (idx, i) in items.iter_mut().enumerate() {
-            if list.get_item_draw_y(idx, 4.0).is_none() {
-                continue;
-            }
-            ctx.register_host(i);
-        }
-    }
 
     fn handle_pointer_move(
         &mut self,
@@ -1288,7 +1303,8 @@ mod tests {
 
     #[test]
     fn refresh_preserves_selection_and_flags() {
-        let mut st = PackagesState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = PackagesState::new(&mut ui);
         st.loaded = true;
         st.installed = vec![PackageInfo { name: "foo".into(), version: "1".into(), ..Default::default() }];
         st.selected_package = Some("foo".into());
@@ -1300,14 +1316,14 @@ mod tests {
             installed: vec![PackageInfo { name: "foo".into(), version: "2".into(), ..Default::default() }],
             ..Default::default()
         };
-        update(&mut st, PackagesMessage::Refreshed(fresh));
+        update(&mut st, PackagesMessage::Refreshed(fresh), &mut ui);
         assert_eq!(st.selected_package.as_deref(), Some("foo"), "refresh must keep the selection");
         assert!(st.selected_package_info.is_some(), "refresh must keep fetched info");
         assert!(st.updating, "refresh must not clear the in-flight update flag");
 
         // A package that vanished from both lists does clear the selection.
         let fresh2 = PackagesState { loaded: true, ..Default::default() };
-        update(&mut st, PackagesMessage::Refreshed(fresh2));
+        update(&mut st, PackagesMessage::Refreshed(fresh2), &mut ui);
         assert!(st.selected_package.is_none());
         assert!(st.selected_package_info.is_none());
     }
@@ -1316,8 +1332,8 @@ mod tests {
         PackageInfo { name: name.into(), version: "1".into(), explicit, orphan }
     }
 
-    fn loaded_state() -> PackagesState {
-        let mut st = PackagesState::default();
+    fn loaded_state(ui: &mut UiContext) -> PackagesState {
+        let mut st = PackagesState::new(ui);
         st.loaded = true;
         st.installed = vec![
             pkg("alpha", true, false),
@@ -1340,40 +1356,41 @@ mod tests {
 
     #[test]
     fn filter_and_search_compose() {
-        let mut st = loaded_state();
-        assert_eq!(st.visible_installed().len(), 4);
-        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
-        assert_eq!(st.visible_installed(), vec![1, 3]);
-        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Explicit));
-        assert_eq!(st.visible_installed(), vec![0]);
-        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
-        st.search_box.text = "gamma".into();
-        assert_eq!(st.visible_installed(), vec![3]);
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
+        assert_eq!(st.visible_installed(&ui).len(), 4);
+        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+        assert_eq!(st.visible_installed(&ui), vec![1, 3]);
+        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Explicit), &mut ui);
+        assert_eq!(st.visible_installed(&ui), vec![0]);
+        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+        ui[st.search_box].text = "gamma".into();
+        assert_eq!(st.visible_installed(&ui), vec![3]);
     }
 
     /// A click on visible row N must name the package painted at row N, under
     /// a filter too — the view and the click mapping share `visible_installed`.
     #[test]
     fn row_click_resolves_through_the_filter() {
-        let mut st = loaded_state();
-        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
+        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
         let mut layout = cce_ui::layout::PageFlow::new();
-        let mut ctx = cce_ui::context::UiContext::new();
-        view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ctx);
+        view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
         assert_eq!(st.installed_items.len(), 2);
-        st.installed_items[1].just_clicked = true;
+        ui[st.installed_items[1]].just_clicked = true;
         let mut actions = Vec::new();
-        crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions);
+        crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions, &mut ui);
         match actions.as_slice() {
             [AppAction::Packages(PackagesMessage::SelectPackage(Some(n)))] => assert_eq!(n, "gamma-orphan"),
             other => panic!("unexpected actions: {other:?}"),
         }
 
         // In select mode the same click toggles the row instead.
-        update(&mut st, PackagesMessage::ToggleSelectMode);
-        st.installed_items[1].just_clicked = true;
+        update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
+        ui[st.installed_items[1]].just_clicked = true;
         let mut actions = Vec::new();
-        crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions);
+        crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions, &mut ui);
         match actions.as_slice() {
             [AppAction::Packages(PackagesMessage::ToggleChecked(n))] => assert_eq!(n, "gamma-orphan"),
             other => panic!("unexpected actions: {other:?}"),
@@ -1382,21 +1399,22 @@ mod tests {
 
     #[test]
     fn select_mode_checks_and_clears() {
-        let mut st = loaded_state();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
         st.selected_package = Some("alpha".into());
-        update(&mut st, PackagesMessage::ToggleSelectMode);
+        update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
         assert!(st.selected_package.is_none(), "select mode frees the details pane");
-        update(&mut st, PackagesMessage::ToggleChecked("beta".into()));
-        update(&mut st, PackagesMessage::ToggleChecked("beta".into()));
+        update(&mut st, PackagesMessage::ToggleChecked("beta".into()), &mut ui);
+        update(&mut st, PackagesMessage::ToggleChecked("beta".into()), &mut ui);
         assert!(st.checked.is_empty(), "a second click unchecks");
-        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
-        update(&mut st, PackagesMessage::CheckAllVisible);
+        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+        update(&mut st, PackagesMessage::CheckAllVisible, &mut ui);
         assert_eq!(st.checked.iter().cloned().collect::<Vec<_>>(), ["beta", "gamma-orphan"]);
         // A refresh drops checked names that are gone.
         let fresh = PackagesState { loaded: true, installed: vec![pkg("beta", false, true)], ..Default::default() };
-        update(&mut st, PackagesMessage::Refreshed(fresh));
+        update(&mut st, PackagesMessage::Refreshed(fresh), &mut ui);
         assert_eq!(st.checked.iter().cloned().collect::<Vec<_>>(), ["beta"]);
-        update(&mut st, PackagesMessage::ToggleSelectMode);
+        update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
         assert!(st.checked.is_empty(), "leaving select mode clears the selection");
     }
 
@@ -1405,35 +1423,37 @@ mod tests {
     /// where the info parser found no known key and showed nothing at all.
     #[test]
     fn refused_removal_stays_visible() {
-        let mut st = loaded_state();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
         let err = "error: failed to prepare transaction (could not satisfy dependencies)\n\
                    :: removing beta breaks dependency 'beta' required by alpha".to_string();
-        update(&mut st, PackagesMessage::PreviewRemoval(vec!["beta".into()]));
+        update(&mut st, PackagesMessage::PreviewRemoval(vec!["beta".into()]), &mut ui);
         assert!(st.previewing);
-        update(&mut st, PackagesMessage::RemovalPreviewed(vec!["beta".into()], Err(err.clone())));
+        update(&mut st, PackagesMessage::RemovalPreviewed(vec!["beta".into()], Err(err.clone())), &mut ui);
         assert!(!st.previewing);
         let mut layout = cce_ui::layout::PageFlow::new();
-        let pc = view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
         let texts: Vec<String> = pc.texts.iter().map(|t| t.0.clone()).collect();
         assert!(texts.iter().any(|t| t.contains("required by alpha")), "the reason is painted: {texts:?}");
 
         // And a failed transaction reports into last_action, not the details.
-        update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]));
-        update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Err(err)));
+        update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]), &mut ui);
+        update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Err(err)), &mut ui);
         assert!(!st.uninstalling);
         assert!(matches!(&st.last_action, Some(Err(e)) if e.contains("required by alpha")));
     }
 
     #[test]
     fn successful_removal_reports_the_whole_plan() {
-        let mut st = loaded_state();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
         st.checked.insert("beta".into());
         update(&mut st, PackagesMessage::RemovalPreviewed(
             vec!["beta".into()],
             Ok(parse_removal_preview("beta 1024\nlibbeta 2048\n")),
-        ));
-        update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]));
-        update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Ok(())));
+        ), &mut ui);
+        update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]), &mut ui);
+        update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Ok(())), &mut ui);
         assert!(st.removal.is_none());
         assert!(st.checked.is_empty());
         assert!(matches!(&st.last_action, Some(Ok(m)) if m == "Removed 2 packages"));
@@ -1441,10 +1461,11 @@ mod tests {
 
     #[test]
     fn marking_explicit_updates_rows_at_once() {
-        let mut st = loaded_state();
-        update(&mut st, PackagesMessage::SetInstallReason(vec!["beta".into()], true));
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
+        update(&mut st, PackagesMessage::SetInstallReason(vec!["beta".into()], true), &mut ui);
         assert!(st.busy());
-        update(&mut st, PackagesMessage::InstallReasonSet(vec!["beta".into()], true, Ok(())));
+        update(&mut st, PackagesMessage::InstallReasonSet(vec!["beta".into()], true, Ok(())), &mut ui);
         assert!(!st.busy());
         assert!(st.installed[1].explicit && !st.installed[1].orphan);
         assert!(matches!(&st.last_action, Some(Ok(m)) if m == "Marked beta as explicitly installed"));
@@ -1452,9 +1473,10 @@ mod tests {
 
     #[test]
     fn jump_to_package_clears_the_filter() {
-        let mut st = loaded_state();
-        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
-        update(&mut st, PackagesMessage::SelectAndScrollPackage("alpha".into()));
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded_state(&mut ui);
+        update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+        update(&mut st, PackagesMessage::SelectAndScrollPackage("alpha".into()), &mut ui);
         assert_eq!(st.filter, InstalledFilter::All);
         assert_eq!(st.selected_package.as_deref(), Some("alpha"));
     }
@@ -1491,9 +1513,10 @@ mod tests {
 
     #[test]
     fn test_view_layout_grid() {
-        let mut state = PackagesState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = PackagesState::new(&mut ui);
         let mut layout = cce_ui::layout::PageFlow::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false, false], &mut layout, &mut cce_ui::context::UiContext::new());
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false, false], &mut layout, &mut ui);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
     }
 }
diff --git a/src/pages/power.rs b/src/pages/power.rs
index 9a2accb..3a96601 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -44,7 +44,8 @@
 
 use crate::app::{AppAction, PageContent};
 use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageFlow, PageLayoutBuilder};
 use cce_ui::widget::{Adapted, Dropdown};
 use std::path::{Path, PathBuf};
@@ -130,7 +131,7 @@ pub struct PowerFacts {
 #[derive(Debug, Clone)]
 pub struct LeverSet {
     /// One dropdown per [`Lever::ALL`] entry, in that order.
-    pub dds: Vec<Owned<Adapted<Dropdown>>>,
+    pub dds: Vec<Handle<Adapted<Dropdown>>>,
     /// The plan value behind each row of each dropdown (options are display
     /// text). Row 0 is always the "Not set" row and holds the empty string;
     /// an empty Vec means the interface is absent on this host and the
@@ -138,12 +139,13 @@ pub struct LeverSet {
     pub rows: Vec<Vec<String>>,
 }
 
-impl Default for LeverSet {
-    fn default() -> Self {
+impl LeverSet {
+    /// The set, its dropdowns inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         Self {
             dds: Lever::ALL
                 .iter()
-                .map(|l| Owned::new(Dropdown::new(vec!["—".to_string()], 0).with_label(l.label())))
+                .map(|l| ctx.insert(Dropdown::new(vec!["—".to_string()], 0).with_label(l.label())))
                 .collect(),
             rows: vec![Vec::new(); Lever::ALL.len()],
         }
@@ -158,32 +160,33 @@ fn lever_index(lever: Lever) -> usize {
 pub struct PowerState {
     pub loaded: bool,
     pub facts: PowerFacts,
-    pub dd_limit: Owned<Adapted<Dropdown>>,
+    pub dd_limit: Handle<Adapted<Dropdown>>,
     /// The charge window per charge-limit dropdown row (options are display
     /// text).
     pub limit_values: Vec<ChargeLimit>,
     /// Which mode the lever section is editing. Page state, not plan state:
     /// it says what is on screen, never what the machine runs.
     pub editing: Mode,
-    pub dd_mode: Owned<Adapted<Dropdown>>,
+    pub dd_mode: Handle<Adapted<Dropdown>>,
     pub levers: LeverSet,
     /// One mode picker per [`Source::ALL`] entry, in that order.
-    pub dd_assign: Vec<Owned<Adapted<Dropdown>>>,
+    pub dd_assign: Vec<Handle<Adapted<Dropdown>>>,
 }
 
-impl Default for PowerState {
-    fn default() -> Self {
+impl PowerState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         Self {
             loaded: false,
             facts: PowerFacts::default(),
-            dd_limit: Owned::new(Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit")),
+            dd_limit: ctx.insert(Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit")),
             limit_values: Vec::new(),
             editing: Mode::default(),
-            dd_mode: Owned::new(Dropdown::new(mode_options(), 0).with_label("Mode")),
-            levers: LeverSet::default(),
+            dd_mode: ctx.insert(Dropdown::new(mode_options(), 0).with_label("Mode")),
+            levers: LeverSet::new(ctx),
             dd_assign: Source::ALL
                 .iter()
-                .map(|s| Owned::new(Dropdown::new(mode_options(), 0).with_label(s.label())))
+                .map(|s| ctx.insert(Dropdown::new(mode_options(), 0).with_label(s.label())))
                 .collect(),
         }
     }
@@ -619,17 +622,17 @@ fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
 /// value missing from the host's list gets appended as its own row. Skipped
 /// per dropdown while it is open (the default_apps rule: never yank an open
 /// menu out from under the pointer — the next refresh normalizes it).
-fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode) {
+fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode, ctx: &mut UiContext) {
     let running = mode == f.plan.assigned(f.source);
     for (i, lever) in Lever::ALL.iter().enumerate() {
-        if levers.dds[i].open {
+        if ctx[levers.dds[i]].open {
             continue;
         }
         let mut rows = choices(*lever, f);
         if rows.is_empty() {
             levers.rows[i].clear();
-            levers.dds[i].options = vec!["—".to_string()];
-            levers.dds[i].selected = 0;
+            ctx[levers.dds[i]].options = vec!["—".to_string()];
+            ctx[levers.dds[i]].selected = 0;
             continue;
         }
         let planned: Option<String> = f.plan.get(mode, *lever).map(str::to_string);
@@ -649,8 +652,8 @@ fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode) {
             values.push(v);
             options.push(d);
         }
-        levers.dds[i].selected = planned.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
-        levers.dds[i].options = options;
+        ctx[levers.dds[i]].selected = planned.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
+        ctx[levers.dds[i]].options = options;
         levers.rows[i] = values;
     }
 }
@@ -698,26 +701,26 @@ fn charge_rows(f: &PowerFacts) -> (Vec<ChargeLimit>, Vec<String>, usize) {
 }
 
 /// Rebuild every dropdown's options/selection from fresh facts.
-fn rebuild_options(state: &mut PowerState) {
+fn rebuild_options(state: &mut PowerState, ctx: &mut UiContext) {
     let f = &state.facts;
-    if !state.dd_limit.open {
+    if !ctx[state.dd_limit].open {
         let (values, options, selected) = charge_rows(f);
-        state.dd_limit.options = options;
-        state.dd_limit.selected = selected;
+        ctx[state.dd_limit].options = options;
+        ctx[state.dd_limit].selected = selected;
         state.limit_values = values;
     }
-    if !state.dd_mode.open {
-        state.dd_mode.options = mode_options();
-        state.dd_mode.selected = mode_index(state.editing);
+    if !ctx[state.dd_mode].open {
+        ctx[state.dd_mode].options = mode_options();
+        ctx[state.dd_mode].selected = mode_index(state.editing);
     }
-    fill_levers(&mut state.levers, &state.facts, state.editing);
+    fill_levers(&mut state.levers, &state.facts, state.editing, ctx);
     for source in Source::ALL {
         let i = source_index(source);
-        if state.dd_assign[i].open {
+        if ctx[state.dd_assign[i]].open {
             continue;
         }
-        state.dd_assign[i].options = mode_options();
-        state.dd_assign[i].selected = mode_index(state.facts.plan.assigned(source));
+        ctx[state.dd_assign[i]].options = mode_options();
+        ctx[state.dd_assign[i]].selected = mode_index(state.facts.plan.assigned(source));
     }
 }
 
@@ -744,6 +747,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
     }
 
     let PowerState { facts, dd_limit, editing, dd_mode, levers, dd_assign, .. } = state;
+    let (dd_limit, dd_mode) = (*dd_limit, *dd_mode);
     let editing = *editing;
     let sources = sources_shown(facts);
     let show_assign = assignment_shown(facts);
@@ -801,7 +805,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
         });
 
         if f.charge_limit.is_some() {
-            col.widget(&mut *dd_limit, menu_h);
+            col.widget_h(ctx, dd_limit, menu_h);
         }
 
         if f.battery_present {
@@ -835,7 +839,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
         builder.add_section(&mut final_pc, "Power Mode", focused(1), |sec| {
             let mut form = sec.form();
             let mut col = form.column();
-            col.widget(&mut *dd_mode, menu_h);
+            col.widget_h(ctx, dd_mode, menu_h);
             col.block(|b| {
                 if running {
                     b.text("Running now — picks apply immediately.", 11.0, GOOD);
@@ -846,9 +850,9 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
                 }
                 b.text("Not set leaves a lever alone.", 11.0, TEXT_DIM);
             });
-            for (row, dd) in levers.rows.iter().zip(levers.dds.iter_mut()) {
+            for (row, &dd) in levers.rows.iter().zip(levers.dds.iter()) {
                 if !row.is_empty() {
-                    col.widget(dd, menu_h);
+                    col.widget_h(ctx, dd, menu_h);
                 }
             }
             sec.place(form, ctx);
@@ -866,9 +870,9 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
                 b.text(format!("{} right now.", f.source.label()), 11.0, GOOD);
             });
             let wanted: Vec<usize> = sources.iter().map(|s| source_index(*s)).collect();
-            for (i, dd) in dd_assign.iter_mut().enumerate() {
+            for (i, &dd) in dd_assign.iter().enumerate() {
                 if wanted.contains(&i) {
-                    col.widget(dd, menu_h);
+                    col.widget_h(ctx, dd, menu_h);
                 }
             }
             sec.place(form, ctx);
@@ -878,7 +882,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
     final_pc
 }
 
-pub fn update(state: &mut PowerState, msg: PowerMessage) {
+pub fn update(state: &mut PowerState, msg: PowerMessage, ctx: &mut UiContext) {
     match msg {
         PowerMessage::Refreshed(facts) => {
             // The page opens on the mode the machine is actually running, so
@@ -891,7 +895,7 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
             state.loaded = true;
             if state.facts != facts {
                 state.facts = facts;
-                rebuild_options(state);
+                rebuild_options(state, ctx);
             }
         }
         PowerMessage::SetLimit(idx) => {
@@ -922,14 +926,14 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
             if limit.start.is_some() {
                 state.facts.charge_start = limit.start;
             }
-            rebuild_options(state);
+            rebuild_options(state, ctx);
         }
         PowerMessage::EditMode(idx) => {
             // Page-local: switching which mode is on screen writes nothing
             // and applies nothing, so it needs no privileged call.
             if let Some(mode) = Mode::ALL.get(idx).copied() {
                 state.editing = mode;
-                rebuild_options(state);
+                rebuild_options(state, ctx);
             }
         }
         PowerMessage::Set { lever, idx } => {
@@ -967,7 +971,7 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
                     set_live(lever, v, &mut state.facts);
                 }
             }
-            rebuild_options(state);
+            rebuild_options(state, ctx);
         }
         PowerMessage::Assign { source, idx } => {
             let Some(mode) = Mode::ALL.get(idx).copied() else {
@@ -994,7 +998,7 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
                     set_live(lever, &value, &mut state.facts);
                 }
             }
-            rebuild_options(state);
+            rebuild_options(state, ctx);
         }
     }
 }
@@ -1044,27 +1048,27 @@ impl crate::pages::AppPage for PowerState {
         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
-        if self.dd_limit.take_change() {
-            actions.push(AppAction::Power(PowerMessage::SetLimit(self.dd_limit.selected)));
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
+        if ctx[self.dd_limit].take_change() {
+            actions.push(AppAction::Power(PowerMessage::SetLimit(ctx[self.dd_limit].selected)));
         }
-        if self.dd_mode.take_change() {
-            actions.push(AppAction::Power(PowerMessage::EditMode(self.dd_mode.selected)));
+        if ctx[self.dd_mode].take_change() {
+            actions.push(AppAction::Power(PowerMessage::EditMode(ctx[self.dd_mode].selected)));
         }
         for (i, lever) in Lever::ALL.iter().enumerate() {
-            if self.levers.dds[i].take_change() {
+            if ctx[self.levers.dds[i]].take_change() {
                 actions.push(AppAction::Power(PowerMessage::Set {
                     lever: *lever,
-                    idx: self.levers.dds[i].selected,
+                    idx: ctx[self.levers.dds[i]].selected,
                 }));
             }
         }
         for source in Source::ALL {
             let i = source_index(source);
-            if self.dd_assign[i].take_change() {
+            if ctx[self.dd_assign[i]].take_change() {
                 actions.push(AppAction::Power(PowerMessage::Assign {
                     source,
-                    idx: self.dd_assign[i].selected,
+                    idx: ctx[self.dd_assign[i]].selected,
                 }));
             }
         }
@@ -1120,12 +1124,12 @@ mod tests {
 
     /// Loaded, editing whichever mode the machine is running — which is what
     /// the page opens on.
-    fn loaded() -> PowerState {
-        let mut st = PowerState::default();
+    fn loaded(ui: &mut UiContext) -> PowerState {
+        let mut st = PowerState::new(ui);
         st.loaded = true;
         st.facts = facts();
         st.editing = st.facts.plan.assigned(st.facts.source);
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, ui);
         st
     }
 
@@ -1137,59 +1141,63 @@ mod tests {
 
     #[test]
     fn the_lever_section_shows_the_edited_mode_behind_not_set() {
-        let st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let st = loaded(&mut ui);
         // On battery, so Power Saver is running; its plan says low-power.
         assert_eq!(st.editing, Mode::PowerSaver);
-        assert_eq!(st.dd_mode.options, ["Performance", "Balanced", "Power Saver"]);
-        assert_eq!(st.dd_mode.selected, 2);
+        assert_eq!(ui[st.dd_mode].options, ["Performance", "Balanced", "Power Saver"]);
+        assert_eq!(ui[st.dd_mode].selected, 2);
         assert_eq!(st.levers.rows[PROFILE], ["", "low-power", "balanced", "performance"]);
-        assert_eq!(st.levers.dds[PROFILE].selected, 1);
+        assert_eq!(ui[st.levers.dds[PROFILE]].selected, 1);
         // Nothing planned for turbo in this mode → Not set.
-        assert_eq!(st.levers.dds[TURBO].selected, 0);
+        assert_eq!(ui[st.levers.dds[TURBO]].selected, 0);
         assert_eq!(st.levers.rows[TURBO], ["", "on", "off"]);
     }
 
     #[test]
     fn the_running_mode_reports_the_live_value_on_its_not_set_row() {
-        let mut st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
         // Power Saver is running: sysfs says balanced and turbo on, and the
         // Not set row is where the page says so.
-        assert_eq!(st.levers.dds[PROFILE].options[0], "Not set — now Balanced");
-        assert_eq!(st.levers.dds[TURBO].options[0], "Not set — now Enabled");
+        assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set — now Balanced");
+        assert_eq!(ui[st.levers.dds[TURBO]].options[0], "Not set — now Enabled");
         // A mode that is not running has no live value to report.
         st.editing = Mode::Balanced;
-        rebuild_options(&mut st);
-        assert_eq!(st.levers.dds[PROFILE].options[0], "Not set");
-        assert_eq!(st.levers.dds[PROFILE].selected, 0);
+        rebuild_options(&mut st, &mut ui);
+        assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set");
+        assert_eq!(ui[st.levers.dds[PROFILE]].selected, 0);
     }
 
     #[test]
     fn the_page_opens_on_the_mode_the_machine_is_running() {
-        let mut st = PowerState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = PowerState::new(&mut ui);
         // Default state edits Balanced; the first read is on battery, which
         // runs Power Saver.
         assert_eq!(st.editing, Mode::Balanced);
-        update(&mut st, PowerMessage::Refreshed(facts()));
+        update(&mut st, PowerMessage::Refreshed(facts()), &mut ui);
         assert_eq!(st.editing, Mode::PowerSaver);
-        assert_eq!(st.dd_mode.selected, mode_index(Mode::PowerSaver));
+        assert_eq!(ui[st.dd_mode].selected, mode_index(Mode::PowerSaver));
         // A later read does not yank the section away from the user's pick.
-        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)));
+        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)), &mut ui);
         let mut plugged = facts();
         plugged.source = Source::Ac;
-        update(&mut st, PowerMessage::Refreshed(plugged));
+        update(&mut st, PowerMessage::Refreshed(plugged), &mut ui);
         assert_eq!(st.editing, Mode::Performance);
     }
 
     #[test]
     fn switching_the_edited_mode_swaps_the_lever_values() {
-        let mut st = loaded();
-        assert_eq!(st.levers.dds[PROFILE].selected, 1); // low-power
-        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)));
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
+        assert_eq!(ui[st.levers.dds[PROFILE]].selected, 1); // low-power
+        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)), &mut ui);
         assert_eq!(st.editing, Mode::Performance);
-        assert_eq!(st.dd_mode.selected, 0);
-        assert_eq!(st.levers.dds[PROFILE].selected, 3); // performance
-        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Balanced)));
-        assert_eq!(st.levers.dds[PROFILE].selected, 0); // nothing planned
+        assert_eq!(ui[st.dd_mode].selected, 0);
+        assert_eq!(ui[st.levers.dds[PROFILE]].selected, 3); // performance
+        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Balanced)), &mut ui);
+        assert_eq!(ui[st.levers.dds[PROFILE]].selected, 0); // nothing planned
         // Editing is page state: it changes no assignment and no plan.
         assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::PowerSaver);
         assert_eq!(st.facts.plan.get(Mode::Balanced, Lever::Profile), None);
@@ -1197,79 +1205,81 @@ mod tests {
 
     #[test]
     fn assignment_dropdowns_follow_the_plan_and_pick_a_mode_per_state() {
-        let mut st = loaded();
-        assert_eq!(st.dd_assign[source_index(Source::Ac)].selected, mode_index(Mode::Performance));
-        assert_eq!(st.dd_assign[source_index(Source::Battery)].selected, mode_index(Mode::PowerSaver));
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
+        assert_eq!(ui[st.dd_assign[source_index(Source::Ac)]].selected, mode_index(Mode::Performance));
+        assert_eq!(ui[st.dd_assign[source_index(Source::Battery)]].selected, mode_index(Mode::PowerSaver));
         // Reassigning the live state hands the machine to that mode, and the
         // lever section — still editing Power Saver — stops claiming to run.
         update(
             &mut st,
-            PowerMessage::Assign { source: Source::Battery, idx: mode_index(Mode::Balanced) },
-        );
+            PowerMessage::Assign { source: Source::Battery, idx: mode_index(Mode::Balanced) },&mut ui);
         assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::Balanced);
-        assert_eq!(st.dd_assign[source_index(Source::Battery)].selected, mode_index(Mode::Balanced));
+        assert_eq!(ui[st.dd_assign[source_index(Source::Battery)]].selected, mode_index(Mode::Balanced));
         assert_eq!(st.editing, Mode::PowerSaver);
-        assert_eq!(st.levers.dds[PROFILE].options[0], "Not set");
+        assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set");
         // Both states may run the same mode.
         update(
             &mut st,
-            PowerMessage::Assign { source: Source::Ac, idx: mode_index(Mode::Balanced) },
-        );
+            PowerMessage::Assign { source: Source::Ac, idx: mode_index(Mode::Balanced) },&mut ui);
         assert_eq!(st.facts.plan.assigned(Source::Ac), Mode::Balanced);
     }
 
     #[test]
     fn charge_limit_rows_are_windows_and_map_current_and_off_list_values() {
+        let mut ui = cce_ui::context::UiContext::new();
         let w = |start, end| ChargeLimit { start: Some(start), end: Some(end) };
-        let mut st = loaded();
+        let mut st = loaded(&mut ui);
         // Nothing planned: the battery's own 75/80 is the longevity preset.
         assert_eq!(st.limit_values, [w(0, 100), w(75, 80), w(55, 60)]);
-        assert_eq!(st.dd_limit.selected, 1);
+        assert_eq!(ui[st.dd_limit].selected, 1);
         // An off-list window gets its own row instead of a wrong match.
         st.facts.charge_limit = Some(70);
         st.facts.charge_start = Some(65);
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(st.limit_values[3], w(65, 70));
-        assert_eq!(st.dd_limit.selected, 3);
-        assert_eq!(st.dd_limit.options[3], "70% — current, from 65%");
+        assert_eq!(ui[st.dd_limit].selected, 3);
+        assert_eq!(ui[st.dd_limit].options[3], "70% — current, from 65%");
         // Once planned, the plan is what the row shows — the firmware
         // having forgotten it (0/100) is what the helper puts right.
         st.facts.plan.set_charge_limit(w(55, 60)).unwrap();
         st.facts.charge_limit = Some(100);
         st.facts.charge_start = Some(0);
-        rebuild_options(&mut st);
-        assert_eq!(st.dd_limit.selected, 2);
+        rebuild_options(&mut st, &mut ui);
+        assert_eq!(ui[st.dd_limit].selected, 2);
         // A battery with no start threshold is offered the ends alone.
         st.facts.plan = PowerPlan::default();
         st.facts.charge_start = None;
         st.facts.charge_limit = Some(80);
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(st.limit_values[1], ChargeLimit { start: None, end: Some(80) });
-        assert_eq!(st.dd_limit.selected, 1);
+        assert_eq!(ui[st.dd_limit].selected, 1);
     }
 
     #[test]
     fn a_charge_limit_pick_is_recorded_in_the_plan() {
-        let mut st = loaded();
-        update(&mut st, PowerMessage::SetLimit(0));
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
+        update(&mut st, PowerMessage::SetLimit(0), &mut ui);
         assert_eq!(st.facts.plan.charge_limit(), ChargeLimit { start: Some(0), end: Some(100) });
         assert_eq!((st.facts.charge_start, st.facts.charge_limit), (Some(0), Some(100)));
-        assert_eq!(st.dd_limit.selected, 0);
+        assert_eq!(ui[st.dd_limit].selected, 0);
         // No mode gained a lever from it.
         assert!(Mode::ALL.iter().all(|m| st.facts.plan.levers(*m).all(|(l, _)| l == Lever::Profile)));
     }
 
     #[test]
     fn open_dropdown_is_left_alone_on_refresh() {
-        let mut st = loaded();
-        st.levers.dds[PROFILE].open = true;
-        st.levers.dds[PROFILE].selected = 2;
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
+        ui[st.levers.dds[PROFILE]].open = true;
+        ui[st.levers.dds[PROFILE]].selected = 2;
         let mut newer = facts();
         newer.profile = "low-power".to_string();
         st.facts = newer;
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         // Open menu untouched; the others refreshed.
-        assert_eq!(st.levers.dds[PROFILE].selected, 2);
+        assert_eq!(ui[st.levers.dds[PROFILE]].selected, 2);
     }
 
     #[test]
@@ -1310,13 +1320,14 @@ mod tests {
 
     #[test]
     fn section_widgets_mirror_presence_gates() {
-        let mut st = PowerState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = PowerState::new(&mut ui);
         // Not loaded: one section, nothing reported (the view paints only the
         // loading line).
         assert_eq!(st.section_widgets(), vec![Vec::new()]);
         st.loaded = true;
         st.facts = facts();
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         let counts = |st: &mut PowerState| st.section_widgets().iter().map(Vec::len).collect::<Vec<_>>();
         // Every interface present: the charge limit, the mode picker plus all
         // eleven levers, and one assignment per adapter state.
@@ -1324,14 +1335,14 @@ mod tests {
         // A host without a charge-limit knob or turbo file reports fewer.
         st.facts.charge_limit = None;
         st.facts.turbo = None;
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(counts(&mut st), [0, 11, 2]);
         // No cpufreq governors and no NVIDIA driver: both drop out too.
         st.facts.governors.clear();
         st.facts.gpu_limit_w = None;
         st.facts.gpu_default_w = None;
         st.facts.gpu_min_w = None;
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(counts(&mut st), [0, 9, 2]);
         // No Intel render clocks, an ASPM-less kernel and no snd_hda_intel is
         // down to profile, epp, animations and the two idle timeouts — which
@@ -1340,120 +1351,125 @@ mod tests {
         st.facts.igpu_min_mhz = None;
         st.facts.aspm_policies.clear();
         st.facts.hda_idle_secs = None;
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(counts(&mut st), [0, 6, 2]);
         // A desktop: one adapter state, so there is nothing to assign.
         st.facts.battery_present = false;
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(counts(&mut st), [0, 6]);
     }
 
     #[test]
     fn igpu_rows_come_from_the_hardware_range() {
-        let mut st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
         // RP0, the rounded midpoint, RPn — no invented numbers, behind the
         // Not set row.
         assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100"]);
-        assert_eq!(st.levers.dds[IGPU].selected, 0);
+        assert_eq!(ui[st.levers.dds[IGPU]].selected, 0);
         // A planned cap that is none of the three earns its own row.
         st.facts.plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("1300")).unwrap();
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100", "1300"]);
-        assert_eq!(st.levers.dds[IGPU].selected, 4);
-        assert_eq!(st.levers.dds[IGPU].options[4], "1300 MHz");
+        assert_eq!(ui[st.levers.dds[IGPU]].selected, 4);
+        assert_eq!(ui[st.levers.dds[IGPU]].options[4], "1300 MHz");
         // And a live cap off the list still shows on the Not set row.
         st.facts.igpu_mhz = Some(1200);
-        rebuild_options(&mut st);
-        assert_eq!(st.levers.dds[IGPU].options[0], "Not set — now 1200 MHz");
+        rebuild_options(&mut st, &mut ui);
+        assert_eq!(ui[st.levers.dds[IGPU]].options[0], "Not set — now 1200 MHz");
     }
 
     #[test]
     fn audio_rows_are_the_three_timeouts_behind_not_set() {
-        let mut st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
         assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10"]);
-        assert_eq!(st.levers.dds[AUDIO].options[1], "Never suspend");
-        assert_eq!(st.levers.dds[AUDIO].selected, 0);
+        assert_eq!(ui[st.levers.dds[AUDIO]].options[1], "Never suspend");
+        assert_eq!(ui[st.levers.dds[AUDIO]].selected, 0);
         st.facts.plan.put(Mode::PowerSaver, Lever::AudioIdleSecs, Some("30")).unwrap();
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10", "30"]);
-        assert_eq!(st.levers.dds[AUDIO].options[4], "After 30 s idle");
+        assert_eq!(ui[st.levers.dds[AUDIO]].options[4], "After 30 s idle");
     }
 
     #[test]
     fn aspm_rows_come_from_the_kernels_own_list() {
-        let mut st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
         let i = lever_index(Lever::Aspm);
         st.facts.plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         // fetch strips the brackets; the selection lands behind Not set.
         assert_eq!(st.levers.rows[i], ["", "default", "performance", "powersave"]);
-        assert_eq!(st.levers.dds[i].selected, 3);
-        assert_eq!(st.levers.dds[i].options[3], "Powersave");
+        assert_eq!(ui[st.levers.dds[i]].selected, 3);
+        assert_eq!(ui[st.levers.dds[i]].options[3], "Powersave");
     }
 
     #[test]
     fn gpu_rows_are_default_and_min_with_an_off_list_live_value() {
-        let mut st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
         let i = lever_index(Lever::GpuLimitW);
         // Current == default: two rows, no duplicate.
         assert_eq!(st.levers.rows[i], ["", "80", "5"]);
-        assert_eq!(st.levers.dds[i].selected, 0);
+        assert_eq!(ui[st.levers.dds[i]].selected, 0);
         // A live limit that is neither default nor minimum is reported on the
         // Not set row rather than silently selecting the wrong one.
         st.facts.gpu_limit_w = Some(60);
-        rebuild_options(&mut st);
+        rebuild_options(&mut st, &mut ui);
         assert_eq!(st.levers.rows[i], ["", "80", "5"]);
-        assert_eq!(st.levers.dds[i].selected, 0);
-        assert_eq!(st.levers.dds[i].options[0], "Not set — now 60 W");
+        assert_eq!(ui[st.levers.dds[i]].selected, 0);
+        assert_eq!(ui[st.levers.dds[i]].options[0], "Not set — now 60 W");
     }
 
     #[test]
     fn the_three_sections_paint_and_the_assignment_one_drops_on_a_desktop() {
-        let mut ctx = cce_ui::context::UiContext::new();
-        let mut paint = |st: &mut PowerState, sections: usize| {
+        let mut ui = cce_ui::context::UiContext::new();
+        let paint = |st: &mut PowerState, sections: usize, ui: &mut UiContext| {
             let mut layout = cce_ui::layout::PageFlow::new();
             let sec_focused = vec![false; sections];
-            let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+            let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, ui);
             assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
         };
-        let mut st = loaded();
-        paint(&mut st, 3);
+        let mut st = loaded(&mut ui);
+        paint(&mut st, 3, &mut ui);
         // A desktop has one adapter state and so nothing to assign.
         st.facts.battery_present = false;
-        rebuild_options(&mut st);
-        paint(&mut st, 2);
+        rebuild_options(&mut st, &mut ui);
+        paint(&mut st, 2, &mut ui);
         // And the loading gate paints its one line.
-        let mut empty = PowerState::default();
-        paint(&mut empty, 1);
+        let mut empty = PowerState::new(&mut ui);
+        paint(&mut empty, 1, &mut ui);
     }
 
     #[test]
     fn a_stale_root_helper_is_named_on_the_page() {
-        let mut ctx = cce_ui::context::UiContext::new();
-        let mut lines = |st: &mut PowerState| -> Vec<String> {
+        let mut ui = cce_ui::context::UiContext::new();
+        let lines = |st: &mut PowerState, ui: &mut UiContext| -> Vec<String> {
             let mut layout = cce_ui::layout::PageFlow::new();
             let sec_focused = vec![false; 3];
-            let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+            let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, ui);
             pc.texts.iter().map(|t| t.0.clone()).collect()
         };
-        let mut st = loaded();
-        assert!(lines(&mut st).iter().any(|l| l.contains("Switches automatically")));
+        let mut st = loaded(&mut ui);
+        assert!(lines(&mut st, &mut ui).iter().any(|l| l.contains("Switches automatically")));
         // A helper too old to read a plan with modes applies nothing on plug
         // or unplug, and the page says so rather than claiming it switches.
         st.facts.automation = Automation::Stale;
-        let out = lines(&mut st);
+        let out = lines(&mut st, &mut ui);
         assert!(out.iter().any(|l| l.contains("out of date")), "{out:?}");
         assert!(out.iter().any(|l| l.contains("ccebuild install-system")), "{out:?}");
         st.facts.automation = Automation::Missing;
-        assert!(lines(&mut st).iter().any(|l| l.contains("not installed")));
+        assert!(lines(&mut st, &mut ui).iter().any(|l| l.contains("not installed")));
     }
 
     #[test]
     fn animations_is_offered_everywhere_and_reports_the_live_state() {
-        let mut st = loaded();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut st = loaded(&mut ui);
         assert_eq!(st.levers.rows[ANIMATIONS], ["", "on", "off"]);
-        assert_eq!(st.levers.dds[ANIMATIONS].options, ["Not set — now Enabled", "Enabled", "Disabled"]);
-        assert_eq!(st.levers.dds[ANIMATIONS].selected, 0);
+        assert_eq!(ui[st.levers.dds[ANIMATIONS]].options, ["Not set — now Enabled", "Enabled", "Disabled"]);
+        assert_eq!(ui[st.levers.dds[ANIMATIONS]].selected, 0);
         // A host with none of the hardware levers still has animations.
         st.facts = PowerFacts {
             plan: st.facts.plan.clone(),
@@ -1463,12 +1479,12 @@ mod tests {
             idle_sleep_secs: None,
             ..PowerFacts::default()
         };
-        rebuild_options(&mut st);
-        assert_eq!(st.levers.dds[ANIMATIONS].options[0], "Not set — now Disabled");
+        rebuild_options(&mut st, &mut ui);
+        assert_eq!(ui[st.levers.dds[ANIMATIONS]].options[0], "Not set — now Disabled");
         // A planned value is selected like any other lever's.
         st.facts.plan.put(Mode::PowerSaver, Lever::Animations, Some("off")).unwrap();
-        rebuild_options(&mut st);
-        assert_eq!(st.levers.dds[ANIMATIONS].selected, 2);
+        rebuild_options(&mut st, &mut ui);
+        assert_eq!(ui[st.levers.dds[ANIMATIONS]].selected, 2);
     }
 
     #[test]
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index c06a341..f7c3821 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -500,7 +500,7 @@ impl crate::pages::AppPage for ProcessesState {
         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut cce_ui::context::UiContext) {}
 
     fn handle_pointer_move(
         &mut self,
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 22fcfe7..8fd1f9d 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -1,7 +1,8 @@
 use crate::app::{form_button, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{TextBox, InteractiveListItem};
 
@@ -31,9 +32,9 @@ pub struct ServicesState {
     pub loaded: bool,
     pub services: Vec<ServiceInfo>,
     pub active_tab: ServiceTab,
-    pub search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     pub list: ScrollRegion,
-    pub items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+    pub items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
 }
 
 impl Default for ServicesState {
@@ -42,13 +43,23 @@ impl Default for ServicesState {
             loaded: false,
             services: Vec::new(),
             active_tab: ServiceTab::System,
-            search_box: Owned::new(TextBox::new(String::new()).with_label("Filter Services")),
+            search_box: Handle::none(),
             list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
             items: Vec::new(),
         }
     }
 }
 
+impl ServicesState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self {
+            search_box: ctx.insert(TextBox::new(String::new()).with_label("Filter Services")),
+            ..Self::default()
+        }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub enum ServicesMessage {
     Refreshed(Vec<ServiceInfo>),
@@ -85,25 +96,27 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
         let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Services", "User Services") };
 
         // Filter services
-        let query = if state.search_box.editing {
-            state.search_box.edit_buffer.to_lowercase()
+        let query = if ctx[state.search_box].editing {
+            ctx[state.search_box].edit_buffer.to_lowercase()
         } else {
-            state.search_box.text.to_lowercase()
+            ctx[state.search_box].text.to_lowercase()
         };
         let filtered_services: Vec<&ServiceInfo> = state.services.iter()
             .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
             .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
             .collect();
         if state.items.len() != filtered_services.len() {
-            state.items.clear();
+            for h in state.items.drain(..) {
+                ctx.remove(h);
+            }
             for _ in 0..filtered_services.len() {
-                state.items.push(Owned::new(InteractiveListItem::new("")));
+                state.items.push(ctx.insert(InteractiveListItem::new("")));
             }
         }
 
         let active_tab = state.active_tab;
         let list = &mut state.list;
-        let items = &mut state.items;
+        let items = &state.items;
         let mut col = form.column();
         col.row(|r| {
             form_button(r, label1, 0.0, tab_colors(active_tab == ServiceTab::System),
@@ -111,7 +124,7 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
             form_button(r, label2, 0.0, tab_colors(active_tab == ServiceTab::User),
                 AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)));
         });
-        col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
+        col.widget_h(ctx, state.search_box, cce_ui::layout::textbox_height());
         col.fill(LIST_MIN_H, move |pc, rect, ctx| {
             let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
             // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
@@ -172,10 +185,11 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
 
                         // Render InteractiveListItem background and text labels
                         // Rows dispatch as extra roots (the dissolved list is no parent).
-                        let item_btn = &mut items[idx];
+                        let item = items[idx];
+                        let item_btn = &mut ctx[item];
                         item_btn.title = service.name.clone();
                         item_btn.subtitle = Some(desc_truncated);
-                        render_widget(pc, item_btn, item_x, draw_y, item_w, item_h, ctx);
+                        render_widget_h(pc, item, item_x, draw_y, item_w, item_h, ctx);
 
                         let active_txt = [0.90, 0.90, 0.95, 1.0];
                         // No per-action tints: both controls wear the DE's themed
@@ -301,17 +315,21 @@ pub fn transport_running(active_state: &str, sub_state: &str) -> bool {
     }
 }
 
-pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
+pub fn update(state: &mut ServicesState, msg: ServicesMessage, ctx: &mut UiContext) {
     match msg {
         ServicesMessage::Refreshed(new_services) => {
             state.loaded = true;
             state.services = new_services;
-            state.items.clear();
+            for h in state.items.drain(..) {
+                ctx.remove(h);
+            }
         }
         ServicesMessage::SetTab(tab) => {
             state.active_tab = tab;
             state.list.set_scroll_y(0.0);
-            state.items.clear();
+            for h in state.items.drain(..) {
+                ctx.remove(h);
+            }
         }
         ServicesMessage::Start(name, is_system) => {
             if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
@@ -439,7 +457,7 @@ impl crate::pages::AppPage for ServicesState {
         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {}
 
     // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
     // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
@@ -454,15 +472,6 @@ impl crate::pages::AppPage for ServicesState {
             .collect()
     }
 
-    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        let (list, items) = (&self.list, &mut self.items);
-        for (idx, i) in items.iter_mut().enumerate() {
-            if list.get_item_draw_y(idx, 4.0).is_none() {
-                continue;
-            }
-            ctx.register_host(i);
-        }
-    }
 
     fn handle_pointer_move(
         &mut self,
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index 3f1c147..d4c582b 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,5 +1,6 @@
 use crate::app::{AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
 use std::fs;
 
@@ -35,7 +36,7 @@ pub struct StorageState {
     pub error_message: Option<String>,
     /// Retained so it can hold keyboard focus: ctrl+i descends into the Full
     /// System Backup section and lands here, and Enter/Space runs the backup.
-    pub backup_button: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    pub backup_button: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 }
 
 impl Default for StorageState {
@@ -51,16 +52,28 @@ impl Default for StorageState {
             last_backup_time: "Never".to_string(),
             backup_size: "0 B".to_string(),
             error_message: None,
-            backup_button: Owned::new(cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
-                // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
-                // has no `bevel`, so a raised plate silently draws nothing here
-                // (the label renders, the plate does not). The border path is also
-                // what carries the keyboard focus ring.
-                .with_raised(false)
-                .with_label("Run Backup")
-                .with_bg(BTN_BG)
-                .with_hover_bg(BTN_HOVER)
-                .with_label_color(WHITE)),
+            backup_button: Handle::none(),
+        }
+    }
+}
+
+impl StorageState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self {
+            backup_button: ctx.insert(
+                cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
+                    // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
+                    // has no `bevel`, so a raised plate silently draws nothing here
+                    // (the label renders, the plate does not). The border path is also
+                    // what carries the keyboard focus ring.
+                    .with_raised(false)
+                    .with_label("Run Backup")
+                    .with_bg(BTN_BG)
+                    .with_hover_bg(BTN_HOVER)
+                    .with_label_color(WHITE),
+            ),
+            ..Self::default()
         }
     }
 }
@@ -234,7 +247,7 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
     // Section 1: Local Storage
     builder.add_section(&mut final_pc, "Local Storage", sec_focused.first().copied().unwrap_or(false), |sec| {
         let disk_pct = if state.disk_total > 0.0 { state.disk_used / state.disk_total * 100.0 } else { 0.0 };
-        let mut disk_bar = Owned::new(
+        let disk_bar = ctx.insert(
             cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
                 .with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]),
         );
@@ -245,15 +258,17 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
         } else {
             let usage = format!("{:.0} / {:.0} GiB  ({:.0}%)", state.disk_used, state.disk_total, disk_pct);
             crate::app::form_pairs(&mut col, 12.0, vec![("Disk".into(), LABEL_FG, usage, TEXT_FG)]);
-            col.widget(&mut disk_bar, USAGE_BAR_H);
+            col.widget_h(ctx, disk_bar, USAGE_BAR_H);
         }
         sec.place(form, ctx);
+        // Drawn this frame only: out of the context once it is placed.
+        ctx.remove(disk_bar);
     });
 
     // Section 2: Memory
     builder.add_section(&mut final_pc, "Memory", sec_focused.get(1).copied().unwrap_or(false), |sec| {
         let ram_pct = if state.ram_total > 0.0 { state.ram_used / state.ram_total * 100.0 } else { 0.0 };
-        let mut ram_bar = Owned::new(
+        let ram_bar = ctx.insert(
             cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
                 .with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]),
         );
@@ -264,9 +279,11 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
         } else {
             let usage = format!("{:.1} / {:.1} GiB  ({:.0}%)", state.ram_used, state.ram_total, ram_pct);
             crate::app::form_pairs(&mut col, 12.0, vec![("RAM".into(), LABEL_FG, usage, TEXT_FG)]);
-            col.widget(&mut ram_bar, USAGE_BAR_H);
+            col.widget_h(ctx, ram_bar, USAGE_BAR_H);
         }
         sec.place(form, ctx);
+        // Drawn this frame only: out of the context once it is placed.
+        ctx.remove(ram_bar);
     });
 
     // Section 3: Full System Backup
@@ -279,9 +296,9 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
         } else {
             ("Run Backup", BTN_BG, BTN_HOVER)
         };
-        state.backup_button.set_label(btn_label);
-        state.backup_button.bg = Some(bg);
-        state.backup_button.hover_bg = Some(hover);
+        ctx[state.backup_button].set_label(btn_label);
+        ctx[state.backup_button].bg = Some(bg);
+        ctx[state.backup_button].hover_bg = Some(hover);
 
         let mut form = sec.form();
         let mut col = form.column();
@@ -301,7 +318,7 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
                 pairs.push(("Error:".into(), RED, err.clone(), RED));
             }
             crate::app::form_pairs(&mut col, 12.0, pairs);
-            col.widget(&mut state.backup_button, cce_ui::layout::button_height());
+            col.widget_h(ctx, state.backup_button, cce_ui::layout::button_height());
         }
         sec.place(form, ctx);
     });
@@ -309,7 +326,7 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
     final_pc
 }
 
-pub fn update(state: &mut StorageState, msg: StorageMessage) {
+pub fn update(state: &mut StorageState, msg: StorageMessage, _ctx: &mut UiContext) {
     match msg {
         StorageMessage::Refreshed(new) => {
             // Field-wise, so the retained button and an in-flight backup survive.
@@ -373,9 +390,9 @@ impl crate::pages::AppPage for StorageState {
         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
         // Mouse click and Enter/Space on the focused button both land here.
-        if self.backup_button.take_click() && !self.backup_in_progress {
+        if ctx[self.backup_button].take_click() && !self.backup_in_progress {
             actions.push(AppAction::Storage(StorageMessage::StartBackup));
         }
     }
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index e331056..f668ede 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,5 +1,6 @@
 use crate::app::{button_need, form_button, form_button_fit, wrap_to_width, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
 use cce_ui::widget::{Label, WidgetHostExt};
 
@@ -51,9 +52,9 @@ pub struct SystemState {
     pub gpus: Vec<String>,
     pub gpu_strings: Vec<String>,
     pub builds: Vec<InstalledBuild>,
-    pub cpu_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
-    pub cpu_usage_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
-    pub cpu_temp_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub cpu_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub cpu_usage_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub cpu_temp_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 
     // Power-related fields
 
@@ -61,8 +62,8 @@ pub struct SystemState {
     pub initialized: bool,
     pub sender: Option<calloop::channel::Sender<AppAction>>,
     pub sysfiles: SysFiles,
-    pub hostname_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
-    pub uptime_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub hostname_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub uptime_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 }
 
 impl std::fmt::Debug for SystemState {
@@ -90,16 +91,30 @@ impl Default for SystemState {
             gpus: Vec::new(),
             gpu_strings: Vec::new(),
             builds: Vec::new(),
-            cpu_label: Owned::new(Label::new("CPU Info")),
-            cpu_usage_label: Owned::new(Label::new("CPU Usage")),
-            cpu_temp_label: Owned::new(Label::new("CPU Temp")),
+            cpu_label: Handle::none(),
+            cpu_usage_label: Handle::none(),
+            cpu_temp_label: Handle::none(),
 
 
             initialized: false,
             sender: None,
             sysfiles: SysFiles::default(),
-            hostname_label: Owned::new(Label::new("")),
-            uptime_label: Owned::new(Label::new("")),
+            hostname_label: Handle::none(),
+            uptime_label: Handle::none(),
+        }
+    }
+}
+
+impl SystemState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self {
+            cpu_label: ctx.insert(Label::new("CPU Info")),
+            cpu_usage_label: ctx.insert(Label::new("CPU Usage")),
+            cpu_temp_label: ctx.insert(Label::new("CPU Temp")),
+            hostname_label: ctx.insert(Label::new("")),
+            uptime_label: ctx.insert(Label::new("")),
+            ..Self::default()
         }
     }
 }
@@ -689,20 +704,20 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
             state.builds = new.builds;
 
             if state.loaded {
-                state.hostname_label.set_text(&format!("{}  —  Linux {}", state.hostname, state.kernel));
-                state.uptime_label.set_text(&format!("Uptime: {}", state.uptime));
+                ctx[state.hostname_label].set_text(&format!("{}  —  Linux {}", state.hostname, state.kernel));
+                ctx[state.uptime_label].set_text(&format!("Uptime: {}", state.uptime));
 
                 let cpu_label_text = format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores);
                 let cpu_usage_text = format!("Usage  {:.0}%", state.cpu_usage);
                 let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
 
-                state.cpu_label.set_text(&cpu_label_text);
-                state.cpu_usage_label.set_text(&cpu_usage_text);
-                state.cpu_temp_label.set_text(&cpu_temp_text);
+                ctx[state.cpu_label].set_text(&cpu_label_text);
+                ctx[state.cpu_usage_label].set_text(&cpu_usage_text);
+                ctx[state.cpu_temp_label].set_text(&cpu_temp_text);
 
 
 
-                state.hostname_label.mark_dirty(ctx);
+                ctx.lend_h(state.hostname_label, |w, ctx| w.mark_dirty(ctx));
             }
 
             // First refresh doubles as the first system-files scan, so the
@@ -809,7 +824,7 @@ impl crate::pages::AppPage for SystemState {
         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {
+    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {
     }
 }
 
@@ -863,11 +878,11 @@ mod tests {
 
     #[test]
     fn test_view_layout_grid() {
-        let mut state = SystemState::default();
+        let mut ui = cce_ui::context::UiContext::new();
+        let mut state = SystemState::new(&mut ui);
         let mut layout = cce_ui::layout::PageFlow::new();
         let sec_focused = vec![false, false, false, false, false, false, false];
-        let mut ctx = cce_ui::context::UiContext::new();
-        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ui);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
     }
 }
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index 211929f..885d757 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -3,9 +3,10 @@
 //! timer units (system scope through pkexec).
 
 use crate::app::{button_need, form_button, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox};
 
@@ -36,13 +37,13 @@ pub struct TimersState {
     pub timers: Vec<TimerInfo>,
     pub active_tab: TimerTab,
     pub list: ScrollRegion,
-    pub items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+    pub items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
     pub creating: bool,
     /// Base unit name (without .timer) being edited, form shared with create.
     pub editing: Option<String>,
-    pub name_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub command_box: Owned<cce_ui::widget::Adapted<TextBox>>,
-    pub schedule_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub name_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub command_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+    pub schedule_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     pub status_msg: Option<String>,
 }
 
@@ -56,13 +57,25 @@ impl Default for TimersState {
             items: Vec::new(),
             creating: false,
             editing: None,
-            name_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
+            name_box: Handle::none(),
+            command_box: Handle::none(),
+            schedule_box: Handle::none(),
+            status_msg: None,
+        }
+    }
+}
+
+impl TimersState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
+        Self {
+            name_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
                 .with_placeholder("backup")),
-            command_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
+            command_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
                 .with_placeholder("/home/me/bin/backup.sh --fast")),
-            schedule_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
+            schedule_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
                 .with_placeholder("daily \u{2022} Mon 09:00 \u{2022} *-*-* 03:00:00")),
-            status_msg: None,
+            ..Self::default()
         }
     }
 }
@@ -436,15 +449,17 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
             .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
             .collect();
         if state.items.len() != filtered.len() {
-            state.items.clear();
+            for h in state.items.drain(..) {
+                ctx.remove(h);
+            }
             for _ in 0..filtered.len() {
-                state.items.push(Owned::new(InteractiveListItem::new("")));
+                state.items.push(ctx.insert(InteractiveListItem::new("")));
             }
         }
 
         let active_tab = state.active_tab;
         let list = &mut state.list;
-        let items = &mut state.items;
+        let items = &state.items;
         let mut col = form.column();
         col.row(|r| {
             form_button(r, label1, 0.0, tab_colors(active_tab == TimerTab::System),
@@ -468,10 +483,10 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
             };
             col.text(note, 11.0, TEXT_DIM);
             if state.creating {
-                col.widget(&mut state.name_box, field_h);
+                col.widget_h(ctx, state.name_box, field_h);
             }
-            col.widget(&mut state.command_box, field_h);
-            col.widget(&mut state.schedule_box, field_h);
+            col.widget_h(ctx, state.command_box, field_h);
+            col.widget_h(ctx, state.schedule_box, field_h);
             let save_label = if state.editing.is_some() { "Save" } else { "Create" };
             col.row(|r| {
                 form_button(r, save_label, button_need(save_label), ([0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], text_on),
@@ -537,17 +552,20 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                             subtitle_full
                         };
 
-                        let item_btn = &mut items[idx];
+                        let item = items[idx];
+                        let item_btn = &mut ctx[item];
                         item_btn.title = timer.unit.clone();
                         item_btn.subtitle = Some(subtitle);
                         // Cut at its cell, so a long schedule stops short of the buttons.
                         pc.push_clip_rect(item_rect.x, item_rect.y, item_rect.width, item_rect.height);
-                        render_widget(pc, item_btn, item_rect.x, item_rect.y, item_rect.width, item_rect.height, ctx);
+                        render_widget_h(pc, item, item_rect.x, item_rect.y, item_rect.width, item_rect.height, ctx);
                         pc.pop_clip_rect();
 
                         let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
-                        let mut dot = Owned::new(StatusDot::new(dot_state));
-                        render_widget(pc, &mut dot, dot_rect.x, dot_rect.y, dot_rect.width, dot_rect.height, ctx);
+                        // Drawn this frame only: out of the context once it is placed.
+                        let dot = ctx.insert(StatusDot::new(dot_state));
+                        render_widget_h(pc, dot, dot_rect.x, dot_rect.y, dot_rect.width, dot_rect.height, ctx);
+                        ctx.remove(dot);
 
                         let active_txt = [0.90, 0.90, 0.95, 1.0];
 
@@ -644,17 +662,21 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
     final_pc
 }
 
-pub fn update(state: &mut TimersState, msg: TimersMessage) {
+pub fn update(state: &mut TimersState, msg: TimersMessage, ctx: &mut UiContext) {
     match msg {
         TimersMessage::Refreshed(timers) => {
             state.loaded = true;
             state.timers = timers;
-            state.items.clear();
+            for h in state.items.drain(..) {
+                ctx.remove(h);
+            }
         }
         TimersMessage::SetTab(tab) => {
             state.active_tab = tab;
             state.list.set_scroll_y(0.0);
-            state.items.clear();
+            for h in state.items.drain(..) {
+                ctx.remove(h);
+            }
         }
         TimersMessage::RunNow(service, is_system) => {
             if !service.is_empty() {
@@ -677,7 +699,8 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
             state.creating = true;
             state.editing = None;
             state.status_msg = None;
-            for tb in [&mut state.name_box, &mut state.command_box, &mut state.schedule_box] {
+            for tb in [state.name_box, state.command_box, state.schedule_box] {
+                let tb = &mut ctx[tb];
                 tb.text = String::new();
                 tb.edit_buffer = String::new();
             }
@@ -700,20 +723,20 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
             state.creating = false;
             state.editing = Some(base.clone());
             state.status_msg = None;
-            state.name_box.text = base.clone();
-            state.name_box.edit_buffer = base;
-            state.command_box.text = command.clone();
-            state.command_box.edit_buffer = command;
-            state.schedule_box.text = schedule.clone();
-            state.schedule_box.edit_buffer = schedule;
+            ctx[state.name_box].text = base.clone();
+            ctx[state.name_box].edit_buffer = base;
+            ctx[state.command_box].text = command.clone();
+            ctx[state.command_box].edit_buffer = command;
+            ctx[state.schedule_box].text = schedule.clone();
+            ctx[state.schedule_box].edit_buffer = schedule;
         }
         TimersMessage::CreateSave => {
-            let command = live_text(&state.command_box);
-            let schedule = live_text(&state.schedule_box);
+            let command = live_text(&ctx[state.command_box]);
+            let schedule = live_text(&ctx[state.schedule_box]);
             let result = if let Some(base) = state.editing.clone() {
                 update_user_timer(&base, &command, &schedule)
             } else {
-                create_user_timer(&live_text(&state.name_box), &command, &schedule)
+                create_user_timer(&live_text(&ctx[state.name_box]), &command, &schedule)
             };
             match result {
                 Ok(msg) => {
@@ -722,7 +745,9 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
                     state.status_msg = Some(msg);
                     // Show the unit where it (re)appears on the next refresh.
                     state.active_tab = TimerTab::User;
-                    state.items.clear();
+                    for h in state.items.drain(..) {
+                        ctx.remove(h);
+                    }
                 }
                 Err(e) => {
                     state.status_msg = Some(e);
@@ -766,7 +791,7 @@ impl crate::pages::AppPage for TimersState {
         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
     }
 
-    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+    fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {}
 
     // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
     // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
@@ -781,15 +806,6 @@ impl crate::pages::AppPage for TimersState {
             .collect()
     }
 
-    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        let (list, items) = (&self.list, &mut self.items);
-        for (idx, i) in items.iter_mut().enumerate() {
-            if list.get_item_draw_y(idx, 4.0).is_none() {
-                continue;
-            }
-            ctx.register_host(i);
-        }
-    }
 
     fn handle_pointer_move(
         &mut self,
diff --git a/src/renderer.rs b/src/renderer.rs
index 63807ea..28fe205 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -65,7 +65,7 @@ impl SystemInterface {
         }
 
         let page_idx = Page::ALL.iter().position(|&p| p == self.app.current_page).unwrap_or(0);
-        self.page_dropdown.selected = page_idx;
+        self.ui_context[self.page_dropdown].selected = page_idx;
 
         // root plate container DISSOLVED (Phase 6s): top-level widgets stay parentless
         // (render_widget registers them); the window plate, the root aggregate's
@@ -75,7 +75,7 @@ impl SystemInterface {
         // Position sidebar and switcher below the titlebar
         let mut dummy_pc = PageContent::new();
         let dropdown_h = cce_ui::layout::dropdown_height();
-        let size = self.page_dropdown.measure(
+        let size = self.ui_context[self.page_dropdown].measure(
             cce_ui::widget::LayoutConstraints::new(0.0, 500.0, dropdown_h, dropdown_h),
             &self.ui_context,
         );
@@ -85,8 +85,8 @@ impl SystemInterface {
         let dropdown_y = logical_sh - self.status_height + dropdown_gap;
         // The dropdown sits flush against the window's rounded bottom-right corner; with the
         // root plate container dissolved, hand it the plate frame for its concentric-corner cut.
-        self.page_dropdown.set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
-        cce_ui::layout::render_widget(&mut dummy_pc, &mut self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
+        self.ui_context[self.page_dropdown].set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
+        cce_ui::layout::render_widget_h(&mut dummy_pc, self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
         // The dropdown is laid out by this pass and painted live in display_list
         // (`paint_root_into`), chevron and all.
         let switcher_h = if self.search_open {
@@ -102,16 +102,16 @@ impl SystemInterface {
         let sb_w = crate::scroll_bar::ScrollBar::width();
         // TODO(style): the bar's 4px vertical stand-off is the toolkit
         // track's own end inset, not a rung of the ladder.
-        self.page_scroll_bar.set_rect(
+        self.ui_context[self.page_scroll_bar].set_rect(
             lcx + (lcw - sb_w) * 0.5,
             self.header_height + 4.0,
             sb_w,
             switcher_h - 8.0,
         );
-        if self.page_scroll_bar.dragging {
-            self.scroll_y = self.page_scroll_bar.scroll_y;
+        if self.ui_context[self.page_scroll_bar].dragging {
+            self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
         }
-        self.page_scroll_bar.update(self.scroll_y, self.content_h, switcher_h);
+        self.ui_context[self.page_scroll_bar].update(self.scroll_y, self.content_h, switcher_h);
 
         // The window chrome — the page dropdown and, while open, the search box — is
         // painted live in display_list, each as it paints itself (`paint_root_into`).
@@ -145,9 +145,9 @@ impl SystemInterface {
             let box_h = cce_ui::layout::textbox_height();
             // Laid out here; painted live in display_list.
             let mut layout_only = PageContent::new();
-            cce_ui::layout::render_widget(
+            cce_ui::layout::render_widget_h(
                 &mut layout_only,
-                &mut self.search_box,
+                self.search_box,
                 self.sidebar_width + inset,
                 sh - 42.0 + (42.0 - box_h) / 2.0,
                 sw - self.sidebar_width - 2.0 * inset,
@@ -170,7 +170,7 @@ impl SystemInterface {
 
 
 
-        if self.search_open && !self.search_query.is_empty() && !self.page_scroll_bar.dragging {
+        if self.search_open && !self.search_query.is_empty() && !self.ui_context[self.page_scroll_bar].dragging {
             let query_lower = self.search_query.to_lowercase();
             let mut first_match_y = None;
             for (t, _, _, y, _, _, _) in &pc.texts {
@@ -216,13 +216,13 @@ impl SystemInterface {
             }
         }
 
-        if self.page_scroll_bar.dragging {
-            self.scroll_y = self.page_scroll_bar.scroll_y;
+        if self.ui_context[self.page_scroll_bar].dragging {
+            self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
         } else {
-            self.page_scroll_bar.scroll_y = self.scroll_y;
+            self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
         }
         self.content_h = max_y;
-        self.page_scroll_bar.update(self.scroll_y, max_y, lch);
+        self.ui_context[self.page_scroll_bar].update(self.scroll_y, max_y, lch);
 
         let scroll_offset_y = self.scroll_y;
 
@@ -350,7 +350,7 @@ impl SystemInterface {
             // The button's own idle and hover faces: the page's colours when
             // it passed them, else the toolkit's for the button's kind — a
             // list row's transparent-until-hover wash (`PageContent::list_row`).
-            let mut probe = (***btn).clone();
+            let mut probe = (**btn).clone();
             probe.set_hovered(false);
             let bg = cce_ui::widget::Paint::color(&probe);
             probe.set_hovered(true);
@@ -646,22 +646,14 @@ impl SystemInterface {
 
         self.widgets = widgets;
         self.texts = texts;
-        self.page_buttons = page_buttons;
+        // The dispatch copies are the context's: last frame's go back, this frame's go in.
+        for (h, _) in std::mem::take(&mut self.page_buttons) {
+            self.ui_context.remove(h);
+        }
+        let ctx = &mut self.ui_context;
+        self.page_buttons = page_buttons.into_iter().map(|(b, a)| (ctx.insert(b), a)).collect();
         self.page_icon_images = page_icon_images;
 
-        // The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots
-        // through the registry, and `clear_hierarchy` above wiped it. The view pass
-        // re-registers page widgets through `render_widget`; the chrome dispatch roots
-        // never go through it, so re-register them here. The page buttons are
-        // per-rebuild clones — registration follows the fresh allocations.
-        {
-            self.ui_context.register_host(&mut self.search_box);
-            self.ui_context.register_host(&mut self.page_dropdown);
-            self.ui_context.register_host(&mut self.page_scroll_bar);
-            for (btn, _) in self.page_buttons.iter_mut() {
-                self.ui_context.register_host(btn);
-            }
-        }
         // A Tab step's focus, handed to the fresh clone at the same rect (the
         // page buttons above are per-rebuild allocations; see `focus_stepped`).
         if let Some((fx, fy, fw, fh)) = self.refocus_rect.take() {