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

commitef2411d994586ed33d6e8e1a8252331622406476
parenta76a3adac3
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 18:53
refactor: the settings pages lay out on scene::layout, spaced by the ladder

All 14 pages declare each section's contents into a cce-ui Form instead of
placing them with the section cursor, and every inset and gap comes from the
spacing ladder rather than the page (+ 14, - 28, 44.0, spacing(4/6/10)).
Controls stand control_gap apart; runs of text lines are blocks with no gap.

New shared pieces in app.rs: form_button / form_button_fit (a page button as
a cell, fixed or sized to its label and sharing the row's slack), form_pairs
(label / value lines, the labels in a column as wide as the widest, values
wrapped beside them), form_divider, and button_need / wrap_to_width moved
here from the System page. section_kv_row, section_divider and
SectionContextExt went with the cursor.

What changed on screen, by choice: the ladder's control gap; Storage draws
each value beside its label (it drew it a line below); label / value lists
share a label column and wrap long values; buttons that stand alone are as
wide as their labels; audio's device names share one column so the sliders
line up; Bluetooth names are cut at the row's end rather than by a character
estimate. Lists that fill the page do so through Form::fill_height. The rows
inside the scrolling lists and the Processes table keep their own layout.

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

 src/app.rs                 | 150 +++++++-----
 src/pages/accounts.rs      | 423 +++++++++++++++-------------------
 src/pages/audio.rs         | 145 +++++-------
 src/pages/bluetooth.rs     | 135 ++++-------
 src/pages/browser.rs       |  71 +++---
 src/pages/default_apps.rs  |  12 +-
 src/pages/network.rs       | 132 ++++++-----
 src/pages/notifications.rs |  55 ++---
 src/pages/packages.rs      | 552 +++++++++++++++++++--------------------------
 src/pages/power.rs         | 120 +++++-----
 src/pages/processes.rs     | 337 ++++++++++++++-------------
 src/pages/services.rs      | 399 +++++++++++++++-----------------
 src/pages/storage.rs       | 146 +++++-------
 src/pages/system_info.rs   | 313 +++++++++----------------
 src/pages/timers.rs        | 437 ++++++++++++++++-------------------
 15 files changed, 1505 insertions(+), 1922 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index f5c5ec2..503008d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -502,69 +502,115 @@ pub fn section_margin() -> f32 {
     cce_ui::layout::SectionContext::<PageContent>::DEFAULT_MARGIN_X
 }
 
-/// A dim label / bright value pair on one line (the shared details idiom).
-pub fn section_kv_row(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>, label: &str, value: &str, value_color: [f32; 4]) {
-    let mut y = sc.content_y;
-    if y > sc.content_start_y {
-        y += sc.row_gap;
-    }
-    let lx = sc.ax(section_margin());
-    sc.pc.text(label, lx, y, 12.0, [0.53, 0.53, 0.60, 1.0]);
-    sc.pc.text(value, lx + 130.0, y, 12.0, value_color);
-    sc.content_y = y + 18.0;
-    for h in &mut sc.grid.col_heights {
-        *h = sc.content_y;
-    }
+/// Width a label needs on a button plate. Feeds [`form_button_fit`], so a row of
+/// buttons is divided by what is written on them rather than into equal
+/// slices — "Reboot" and "Hibernate" are not the same size and a row that
+/// pretends otherwise clips one and pads the other.
+///
+/// Asks the Button itself (`intrinsic_size`: its label shaped in the button
+/// font, plus its 8px inset each side) rather than measuring here. This used
+/// `measure_text_width` in the control-label font, an inked extent that runs
+/// ~20% short of the shaped run under Berkeley Mono, so the row handed every
+/// button less than its label and "Hibernate" / "Power Off" were cut off.
+pub fn button_need(label: &str) -> f32 {
+    cce_ui::widget::Button::new(0.0, 0.0, 0.0, 0.0)
+        .with_label(label)
+        .intrinsic_size()
+        .map_or(0.0, |s| s.width)
 }
 
-/// A hairline separating a well's zones.
-pub fn section_divider(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>) {
-    let y = sc.content_y + sc.row_gap + 4.0;
-    let x = sc.ax(section_margin());
-    let w = sc.cw - 2.0 * (sc.padding() + section_margin());
-    sc.pc.rect([1.0, 1.0, 1.0, 0.06], x, y, w, 1.0);
-    sc.content_y = y + 5.0;
-    for h in &mut sc.grid.col_heights {
-        *h = sc.content_y;
-    }
+/// A page button (`PageContent::button`) as a form piece, a button's height: `w` wide, or
+/// the width of its column (and, in a row, the row's slack) when `w` is 0. `colors` are the
+/// face, the hover face and the label.
+pub fn form_button<'w>(
+    g: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+    label: impl Into<String>,
+    w: f32,
+    colors: ([f32; 4], [f32; 4], [f32; 4]),
+    action: AppAction,
+) {
+    let label = label.into();
+    g.draw(w, cce_ui::layout::button_height(), w == 0.0, move |pc, r, _| {
+        pc.button(&label, r.x, r.y, r.width, r.height, colors.0, colors.1, colors.2, action);
+    });
 }
 
-pub trait SectionContextExt {
-    fn button(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction);
-    fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction);
-    /// [`PageContent::button_icon`] inside a section — `label` is the fallback
-    /// for a missing icon set, `alpha` dims the glyph.
-    #[allow(clippy::too_many_arguments)]
-    fn button_icon(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], alpha: f32, action: AppAction);
+/// A page button as a row cell that asks for `need` (its label's own width) and shares the
+/// row's slack with the other cells — a row of buttons sized to their labels.
+pub fn form_button_fit<'w>(
+    g: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+    label: impl Into<String>,
+    need: f32,
+    colors: ([f32; 4], [f32; 4], [f32; 4]),
+    action: AppAction,
+) {
+    let label = label.into();
+    g.draw(need, cce_ui::layout::button_height(), true, move |pc, r, _| {
+        pc.button(&label, r.x, r.y, r.width, r.height, colors.0, colors.1, colors.2, action);
+    });
 }
 
-impl<'a> SectionContextExt for cce_ui::layout::SectionContext<'a, PageContent> {
-    fn button(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction) {
-        self.pc.button(label, x, y, w, h, bg, hover_bg, label_color, action);
-        self.content_y = self.content_y.max(y + h);
-        for height in &mut self.grid.col_heights {
-            *height = height.max(self.content_y);
+/// `text` broken into lines no wider than `width` at `size`, measured as the
+/// renderer shapes it (the default UI face, as `PageContent::text` draws).
+/// Breaks after a space or before a `/`, so a path splits on its separators;
+/// a run with neither is cut wherever it overflows. A section's text is
+/// clipped to its content box, so anything that does not fit has to wrap or
+/// it is simply lost — a GPU name, the CPU model, a pending install path.
+pub fn wrap_to_width(text: &str, width: f32, size: f32) -> Vec<String> {
+    let offsets = cce_ui::geometry_font_system()
+        .lock()
+        .map(|mut fs| cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, None))
+        .unwrap_or_default();
+    if offsets.last().map_or(true, |&(_, total)| total <= width) {
+        return vec![text.to_string()];
+    }
+    let mut lines = Vec::new();
+    let (mut start, mut start_x) = (0usize, 0.0f32);
+    // Where the next line would begin if this one broke at the last chance.
+    let mut chance: Option<(usize, f32)> = None;
+    for pair in offsets.windows(2) {
+        let ((b, x), (next_b, next_x)) = (pair[0], pair[1]);
+        if next_x - start_x > width && b > start {
+            let (cut, cut_x) = chance.filter(|&(c, _)| c > start).unwrap_or((b, x));
+            lines.push(text[start..cut].trim_end().to_string());
+            (start, start_x, chance) = (cut, cut_x, None);
         }
-    }
-    
-    fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction) {
-        self.pc.button_left(label, x, y, w, h, bg, hover_bg, label_color, action);
-        self.content_y = self.content_y.max(y + h);
-        for height in &mut self.grid.col_heights {
-            *height = height.max(self.content_y);
+        match &text[b..next_b] {
+            " " => chance = Some((next_b, next_x)),
+            "/" if b > start => chance = Some((b, x)),
+            _ => {}
         }
     }
+    lines.push(text[start..].trim_end().to_string());
+    lines.retain(|l| !l.is_empty());
+    lines
+}
 
-    fn button_icon(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], alpha: f32, action: AppAction) {
-        self.pc.button_icon(icon, label, x, y, w, h, bg, hover_bg, label_color, alpha, action);
-        self.content_y = self.content_y.max(y + h);
-        for height in &mut self.grid.col_heights {
-            *height = height.max(self.content_y);
+/// Label / value pairs as one block of text: the labels in a column as wide as the widest of
+/// them, each value beside its label and wrapped to the room left of the content box, the
+/// label on the value's first line. Each pair is (label, label colour, value, value colour).
+pub fn form_pairs<'w>(
+    col: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+    size: f32,
+    pairs: Vec<(String, [f32; 4], String, [f32; 4])>,
+) {
+    use cce_ui::scene::layout::{CrossAlign, Style};
+    let label_w = pairs.iter().map(|p| cce_ui::layout::form_text_width(&p.0, size)).fold(0.0, f32::max);
+    let line_h = cce_ui::layout::form_line_height(size);
+    let value_w = (col.form_width() - label_w - cce_ui::layout::control_gap()).max(1.0);
+    // Lines of text, not controls: they stand a line apart, with no control gap between.
+    col.group(Style::column().cross_align(CrossAlign::Stretch), false, |lines| {
+        for (label, label_color, value, value_color) in pairs {
+            lines.group(Style::controls_row(), true, |row| {
+                row.draw(label_w, line_h, false, move |pc, r, _| pc.text(&label, r.x, r.y, size, label_color));
+                row.lines(wrap_to_width(&value, value_w, size), size, value_color);
+            });
         }
-    }
+    });
 }
 
-
-
-
+/// The hairline that parts a well's zones, as a form piece.
+pub fn form_divider(g: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>) {
+    g.rule([1.0, 1.0, 1.0, 0.06]);
+}
 
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 1fd08c7..f4a8a1d 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,8 +1,8 @@
-use crate::app::{AppAction, PageContent, SectionContextExt, section_divider, section_kv_row};
+use crate::app::{form_button, form_divider, form_pairs, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::widget::ScrollRegion;
-use cce_ui::widget::{TextBox, WidgetHost};
+use cce_ui::widget::TextBox;
 
 /// Secret Service entries are keyed by (service, address) — the same pair
 /// cce-mail resolves passwords through. `KEYRING_SERVICE_LEGACY` is the
@@ -633,198 +633,156 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
 
     let widget_h = cce_ui::layout::spinbox_height();
     let btn_h = cce_ui::layout::button_height();
-    let m = crate::app::section_margin();
-    let gap = cce_ui::layout::plate_gap();
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+        let mut form = sec.form();
         if !state.loaded {
-            sec.text("Loading online accounts...", 12.0, 0.0, 12.0, TEXT_DIM);
+            form.column().text("Loading online accounts...", 12.0, TEXT_DIM);
+            sec.place(form, ctx);
             return;
         }
-        let item_w = sec.cw - 2.0 * (sec.padding() + m);
-        let rx = sec.left;
+        let narrow = form.width() < 520.0;
+        // A login in flight is an active mode too — tint whichever button could
+        // have started it, so the "already waiting on the browser" reply is not
+        // the only clue.
+        let login_bg = if state.oauth_listener_running { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
+        let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
+        let sel = state.selected_idx.filter(|&i| i < state.accounts.len());
+        let mut col = form.column();
 
         // ── Account list: a scroll region, selection tinted, default marked ──
-        // Laid out on the section context BEFORE the vstack, the way network's
-        // wifi list is. A VStack derives each row's y from
-        // `max(grid.max_height(), content_y)`, so a region that advances only
-        // `content_y` is invisible to the grid half of that and every following
-        // row lands back on top of the list.
-        if !state.accounts.is_empty() {
+        // It grows with the account count up to LIST_MAX_ROWS and scrolls past it.
+        if state.accounts.is_empty() {
+            col.text("No accounts configured.", 12.0, TEXT_DIM);
+        } else {
             // Row height comes from the region, not from spinbox_height():
             // ScrollRegion floors item_height at the list font's line box, and
             // drawing at a different height than it virtualizes on would drift
             // the rows out from under their own hit boxes.
             let item_h = state.list.item_height;
-            // Across the section's content box — the box the section clips
-            // to. At `left + m` the framed list ran 2 * padding past it on
-            // both sides, cut off flat there.
-            let list_x = sec.content_left();
-            let list_y = sec.ay();
-            let list_w = sec.content_width();
             let rows_shown = state.accounts.len().min(LIST_MAX_ROWS);
             let list_h = rows_shown as f32 * (item_h + LIST_GAP) + 8.0;
-
-            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
-            state.list.set_rect(list_x, list_y, list_w, list_h);
-            state.list.update_bounds(state.accounts.len(), list_y, list_h);
-            state.list.push_prims(sec.pc);
-
-            let btn_w = list_w - 2.0 * m;
-            sec.pc.push_clip_rect(list_x, list_y, list_w, list_h);
-            for (idx, acc) in state.accounts.iter().enumerate() {
-                // Same predicate the region virtualizes on — a row scrolled out
-                // of the box is not emitted at all.
-                let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) else {
-                    continue;
-                };
-                // The stranded-vault tell, visible without selecting the row.
-                let missing = state.keyring.get(&acc.email) == Some(&KeyringStatus::Missing);
-                let is_selected = state.selected_idx == Some(idx) && !state.adding_new;
-                let (bg, hover) = if is_selected {
-                    (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
-                } else {
-                    ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
-                };
-                sec.pc.button_left(
-                    &acc.email,
-                    list_x + m,
-                    draw_y,
-                    btn_w,
-                    item_h,
-                    bg,
-                    hover,
-                    TEXT_BTN,
-                    AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
-                );
-                // The row's marks run on after the address: a `star` glyph
-                // and "default", a `warning` glyph and "no password". Each
-                // is its glyph and its word — the word alone when the icon
-                // set is missing — placed past the address as the renderer
-                // shapes it, in the button's own face.
-                let marks: &[(&str, &str, [f32; 4])] = match (acc.is_default, missing) {
-                    (true, true) => &[("star", "default", TEXT_BTN), ("warning", "no password", TEXT_WARN)],
-                    (true, false) => &[("star", "default", TEXT_BTN)],
-                    (false, true) => &[("warning", "no password", TEXT_WARN)],
-                    (false, false) => &[],
-                };
-                if !marks.is_empty() {
-                    let font = cce_ui::layout::button_font();
-                    let size = 12.0;
-                    let ty = crate::app::label_y_in(draw_y, item_h, size, Some(&font));
-                    let g = 11.0;
-                    let mut mx = list_x + m + cce_ui::layout::CONTROL_TEXT_INSET
-                        + crate::app::text_width(&acc.email, size, Some(&font));
-                    for (icon, word, color) in marks {
-                        mx += 18.0;
-                        if sec.pc.icon(icon, mx, draw_y + (item_h - g) / 2.0, g, g, *color) {
-                            mx += g + 4.0;
+            // The list's own inset of its rows inside its frame.
+            let m = crate::app::section_margin();
+            let list = &mut state.list;
+            let accounts = &state.accounts;
+            let keyring = &state.keyring;
+            let (selected_idx, adding_new) = (state.selected_idx, state.adding_new);
+            col.draw(0.0, list_h, false, move |pc, r, _| {
+                let (list_x, list_y, list_w, list_h) = (r.x, r.y, r.width, r.height);
+                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                list.set_rect(list_x, list_y, list_w, list_h);
+                list.update_bounds(accounts.len(), list_y, list_h);
+                list.push_prims(pc);
+                let btn_w = list_w - 2.0 * m;
+                pc.push_clip_rect(list_x, list_y, list_w, list_h);
+                for (idx, acc) in accounts.iter().enumerate() {
+                    // Same predicate the region virtualizes on — a row scrolled out
+                    // of the box is not emitted at all.
+                    let Some(draw_y) = list.get_item_draw_y(idx, 4.0) else {
+                        continue;
+                    };
+                    // The stranded-vault tell, visible without selecting the row.
+                    let missing = keyring.get(&acc.email) == Some(&KeyringStatus::Missing);
+                    let is_selected = selected_idx == Some(idx) && !adding_new;
+                    let (bg, hover) = if is_selected {
+                        (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
+                    } else {
+                        ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
+                    };
+                    pc.button_left(
+                        &acc.email,
+                        list_x + m,
+                        draw_y,
+                        btn_w,
+                        item_h,
+                        bg,
+                        hover,
+                        TEXT_BTN,
+                        AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
+                    );
+                    // The row's marks run on after the address: a `star` glyph
+                    // and "default", a `warning` glyph and "no password". Each
+                    // is its glyph and its word — the word alone when the icon
+                    // set is missing — placed past the address as the renderer
+                    // shapes it, in the button's own face.
+                    let marks: &[(&str, &str, [f32; 4])] = match (acc.is_default, missing) {
+                        (true, true) => &[("star", "default", TEXT_BTN), ("warning", "no password", TEXT_WARN)],
+                        (true, false) => &[("star", "default", TEXT_BTN)],
+                        (false, true) => &[("warning", "no password", TEXT_WARN)],
+                        (false, false) => &[],
+                    };
+                    if !marks.is_empty() {
+                        let font = cce_ui::layout::button_font();
+                        let size = 12.0;
+                        let ty = crate::app::label_y_in(draw_y, item_h, size, Some(&font));
+                        let g = 11.0;
+                        let mut mx = list_x + m + cce_ui::layout::CONTROL_TEXT_INSET
+                            + crate::app::text_width(&acc.email, size, Some(&font));
+                        for (icon, word, color) in marks {
+                            mx += 18.0;
+                            if pc.icon(icon, mx, draw_y + (item_h - g) / 2.0, g, g, *color) {
+                                mx += g + 4.0;
+                            }
+                            pc.text_with_font(word, mx, ty, size, TEXT_BTN, &font);
+                            mx += crate::app::text_width(word, size, Some(&font));
                         }
-                        sec.pc.text_with_font(word, mx, ty, size, TEXT_BTN, &font);
-                        mx += crate::app::text_width(word, size, Some(&font));
                     }
                 }
-            }
-            sec.pc.pop_clip_rect();
-            // The scrollbar's fore copy, over the rows at the raise's fade.
-            state.list.push_scrollbar_fore(sec.pc);
-            // Reserve the region's height through `spacing`, NOT `content_y +=`:
-            // SectionContext keeps a parallel per-column Grid, and its own
-            // `spacing` recomputes `content_y = grid.max_height()`. A manual
-            // `content_y` bump that leaves the grid untouched is therefore
-            // discarded by the next spacing call, and every following row lands
-            // back on top of the list. (Network's list gets away with the bare
-            // `content_y +=` only because nothing follows it in that section.)
-            sec.spacing(list_h + LIST_GAP);
-        }
-
-        let mut stack = sec.vstack(gap);
-        if state.accounts.is_empty() {
-            stack.context.text("No accounts configured.", 12.0, 0.0, 12.0, TEXT_DIM);
+                pc.pop_clip_rect();
+                // The scrollbar's fore copy, over the rows at the raise's fade.
+                list.push_scrollbar_fore(pc);
+            });
         }
 
-        stack.context.spacing(6.0);
-
         // ── Global actions ──
-        // Adding is the only one left. Google sign-in lives inside the form
-        // (next to Save) because adding an account is a single intent, and the
-        // Google API client id/secret is file-backed config edited in
-        // ~/.config/cce/google_client.json — set once or never, so it follows
-        // input.kdl's precedent of having no settings UI at all.
-        let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
-        // A login in flight is an active mode too — tint whichever button could
-        // have started it, so the "already waiting on the browser" reply is not
-        // the only clue.
-        let login_bg = if state.oauth_listener_running { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
-        let narrow = item_w < 520.0;
-
-        // Add, Edit, Delete in one row of squares. Edit and Delete used to live
-        // down in the selected-account zone; they belong next to Add because
-        // all three act on the account LIST, while everything below the divider
-        // is about one account's fields.
-        //
-        // Icon faces, so each is a square the height of a button rather than a
-        // share of the section width — the row is drawn as one full-width cell
-        // with the buttons placed inside it, since equal columns would stretch
-        // a 26px glyph across a third of the section.
-        //
-        // Edit and Delete need a selection, so they appear only with one. They
-        // are OMITTED rather than dimmed: an icon's only disabled state is
+        // Add, Edit, Delete in one row of squares. Edit and Delete act on the
+        // account LIST, so they sit beside Add; everything below the divider is
+        // about one account's fields. Icon faces, so each is a square the height
+        // of a button. Edit and Delete need a selection, so they appear only with
+        // one: OMITTED rather than dimmed, since an icon's only disabled state is
         // opacity, and a faint square that still takes the click reads as a
-        // control that ignored you. `narrow` still governs the fallback width —
-        // without an icon set these go back to being word buttons.
-        let sel = state.selected_idx.filter(|&i| i < state.accounts.len());
+        // control that ignored you. Without an icon set they are word buttons,
+        // and `narrow` picks their width. Google sign-in lives inside the add form.
         let icons_ok = cce_ui::upload_icon("plus", 32).is_some();
-        let (sq, bgap) = if icons_ok { (btn_h, 8.0) } else if narrow { (86.0, 6.0) } else { (110.0, 8.0) };
-        stack.add_row(1, gap, btn_h, |c, _, x, _w| {
-            // ONE y for the whole row. `c.ay()` reads the section's running
-            // content_y, and each button emitted advances it past its own
-            // bottom — normally right, because `add_row` resets content_y
-            // between COLUMNS. Three buttons inside a single column get no such
-            // reset, so re-reading `ay()` per button walked them diagonally
-            // down the page, one button-height at a time.
-            let y = c.ay();
-            c.button_icon("plus", "Add Account", x, y, sq, btn_h,
-                add_bg.0, add_bg.1, TEXT_BTN, 1.0,
-                AppAction::Accounts(AccountsMessage::AddAccountStart));
+        let sq = if icons_ok { btn_h } else if narrow { 86.0 } else { 110.0 };
+        col.row(|r| {
+            let icon_button = |r: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>, icon: &'static str, word: &'static str,
+                               colors: ([f32; 4], [f32; 4]), text: [f32; 4], action: AccountsMessage| {
+                r.draw(sq, btn_h, false, move |pc, c, _| {
+                    pc.button_icon(icon, word, c.x, c.y, c.width, c.height, colors.0, colors.1, text, 1.0, AppAction::Accounts(action));
+                });
+            };
+            icon_button(r, "plus", "Add Account", add_bg, TEXT_BTN, AccountsMessage::AddAccountStart);
             if let Some(i) = sel {
-                c.button_icon("pencil", "Edit", x + sq + bgap, y, sq, btn_h,
-                    BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN, 1.0,
-                    AppAction::Accounts(AccountsMessage::EditAccountStart(i)));
-                c.button_icon("trash", "Delete", x + 2.0 * (sq + bgap), y, sq, btn_h,
-                    BTN_DANGER.0, BTN_DANGER.1, TEXT_DANGER, 1.0,
-                    AppAction::Accounts(AccountsMessage::DeleteAccount(i)));
+                icon_button(r, "pencil", "Edit", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::EditAccountStart(i));
+                icon_button(r, "trash", "Delete", BTN_DANGER, TEXT_DANGER, AccountsMessage::DeleteAccount(i));
             }
         });
 
-        section_divider(stack.context);
+        form_divider(&mut col);
 
         // ── Context zone: add form / edit form / selected details ──
+        let heading = [0.35, 0.65, 0.90, 1.0];
+        let kv = |label: &str, value: &str, color: [f32; 4]| (label.to_string(), TEXT_DIM, value.to_string(), color);
         if state.adding_new {
-            stack.context.text("Add New Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
-            stack.context.text("Gmail signs in with Google below; iCloud requires an App Password.", 12.0, 0.0, 11.0, TEXT_DIM);
-
-            state.email_box.set_row_rect(rx + m, item_w);
-            stack.add_widget(&mut state.email_box, item_w, widget_h, ctx);
-            state.password_box.set_row_rect(rx + m, item_w);
-            stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
-            state.imap_box.set_row_rect(rx + m, item_w);
-            stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
-            state.smtp_box.set_row_rect(rx + m, item_w);
-            stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
-
-            stack.context.spacing(4.0);
-            stack.add_row(4, gap, btn_h, |c, i, x, w| {
-                let (label, colors, text_col, action) = match i {
-                    0 => ("Save", BTN_PRIMARY, TEXT_BTN, AccountsMessage::AddAccountSave),
-                    1 => ("Cancel", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::AddAccountCancel),
-                    // Four buttons in one row is the tightest cell on the page —
-                    // the full labels clip below ~440px of section width, so they
-                    // ride the same `narrow` switch the header row uses.
-                    2 => (if narrow { "Google" } else { "Login (Google)" }, login_bg, TEXT_BTN, AccountsMessage::GoogleLoginInit),
-                    _ => (if narrow { "iCloud" } else { "Login (iCloud)" }, BTN_NEUTRAL, TEXT_BTN, AccountsMessage::ICloudLoginHelp),
-                };
-                c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
+            col.block(|b| {
+                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.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));
+                // Four buttons in one row is the tightest cell on the page — the full
+                // labels clip below ~440px of section width, so they ride `narrow`.
+                form_button(r, if narrow { "Google" } else { "Login (Google)" }, 0.0, (login_bg.0, login_bg.1, TEXT_BTN),
+                    AppAction::Accounts(AccountsMessage::GoogleLoginInit));
+                form_button(r, if narrow { "iCloud" } else { "Login (iCloud)" }, 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN),
+                    AppAction::Accounts(AccountsMessage::ICloudLoginHelp));
             });
         } else if let Some(acc) = state
             .editing_email
@@ -832,99 +790,73 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
             .and_then(|e| state.accounts.iter().find(|a| a.email == *e))
             .cloned()
         {
-            stack.context.text("Edit Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
-            section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
-            stack.context.text("The address identifies the account \u{2014} delete and re-add to change it.", 12.0, 0.0, 11.0, TEXT_DIM);
+            col.text("Edit Account", 14.0, heading);
+            form_pairs(&mut col, 12.0, vec![kv("Email", &acc.email, TEXT_BTN)]);
+            col.text("The address identifies the account \u{2014} delete and re-add to change it.", 11.0, TEXT_DIM);
 
             // 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 {
-                state.imap_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
-                state.smtp_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
-
-                stack.context.spacing(4.0);
-                stack.context.text("Credentials this account refreshes tokens with, taking effect", 12.0, 0.0, 11.0, TEXT_DIM);
-                stack.context.text("on the next refresh \u{2014} Re-login to re-issue the tokens now.", 12.0, 0.0, 11.0, TEXT_DIM);
-                state.oauth_client_id_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.oauth_client_id_box, item_w, widget_h, ctx);
-                state.oauth_client_secret_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.oauth_client_secret_box, item_w, widget_h, ctx);
+                col.widget(&mut state.imap_box, widget_h).widget(&mut 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);
             } else {
-                state.password_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
-                state.imap_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
-                state.smtp_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
+                col.widget(&mut state.password_box, widget_h)
+                    .widget(&mut state.imap_box, widget_h)
+                    .widget(&mut state.smtp_box, widget_h);
             }
-
-            stack.context.spacing(4.0);
-            stack.add_row(2, gap, btn_h, |c, i, x, w| {
-                let (label, colors, action) = match i {
-                    0 => ("Save", BTN_PRIMARY, AccountsMessage::EditAccountSave),
-                    _ => ("Cancel", BTN_NEUTRAL, AccountsMessage::EditAccountCancel),
-                };
-                c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
+            col.row(|r| {
+                form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::EditAccountSave));
+                form_button(r, "Cancel", 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::EditAccountCancel));
             });
-        } else if let Some(selected_idx) = state.selected_idx {
-            if selected_idx < state.accounts.len() {
-                let acc = state.accounts[selected_idx].clone();
-
-                section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
-                let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
-                section_kv_row(stack.context, "Authentication", auth_type, TEXT_BTN);
-                // Where the password actually lives — the row that would have
-                // shown the 08-29 vault stranding at a glance. Only password
-                // accounts carry it; the probe skips OAuth and mock.
-                if let Some(status) = state.keyring.get(&acc.email) {
-                    let (text, color) = match status {
-                        KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
-                        KeyringStatus::OnDiskPlaintext => {
-                            ("on disk (plaintext) \u{2014} migrates to keyring", TEXT_WARN)
-                        }
-                        KeyringStatus::Missing => {
-                            ("MISSING \u{2014} mail cannot sign in; Edit to set it", TEXT_DANGER)
-                        }
-                    };
-                    section_kv_row(stack.context, "Password", text, color);
-                }
-                section_kv_row(stack.context, "IMAP", &acc.imap, TEXT_BTN);
-                section_kv_row(stack.context, "SMTP", &acc.smtp, TEXT_BTN);
-
-                stack.context.spacing(6.0);
-
-                // What is left of the per-account actions once Edit and Delete
-                // moved up beside Add. The row is sized to what is actually
-                // there — a fixed count left ragged gaps whenever an account
-                // was default or password — and with only these two left it can
-                // now be EMPTY, for a default password account, so it is
-                // skipped rather than drawn as a bare gap.
-                let mut actions: Vec<(&str, ([f32; 4], [f32; 4]), [f32; 4], AccountsMessage)> = Vec::new();
-                if !acc.is_default {
-                    actions.push(("Make Default", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::MakeDefault(selected_idx)));
-                }
-                if acc.is_oauth {
-                    let relogin = if narrow { "Re-login" } else { "Re-login (Browser)" };
-                    actions.push((relogin, login_bg, TEXT_BTN, AccountsMessage::GoogleLoginInit));
-                }
-                if !actions.is_empty() {
-                    stack.add_row(actions.len(), gap, btn_h, |c, i, x, w| {
-                        if let Some((label, colors, text_col, action)) = actions.get(i).cloned() {
-                            c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
-                        }
-                    });
-                }
+        } else if let Some(selected_idx) = sel {
+            let acc = state.accounts[selected_idx].clone();
+            let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
+            let mut pairs = vec![kv("Email", &acc.email, TEXT_BTN), kv("Authentication", auth_type, TEXT_BTN)];
+            // Where the password actually lives — the row that would have
+            // shown the 08-29 vault stranding at a glance. Only password
+            // accounts carry it; the probe skips OAuth and mock.
+            if let Some(status) = state.keyring.get(&acc.email) {
+                let (text, color) = match status {
+                    KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
+                    KeyringStatus::OnDiskPlaintext => ("on disk (plaintext) \u{2014} migrates to keyring", TEXT_WARN),
+                    KeyringStatus::Missing => ("MISSING \u{2014} mail cannot sign in; Edit to set it", TEXT_DANGER),
+                };
+                pairs.push(kv("Password", text, color));
+            }
+            pairs.push(kv("IMAP", &acc.imap, TEXT_BTN));
+            pairs.push(kv("SMTP", &acc.smtp, TEXT_BTN));
+            form_pairs(&mut col, 12.0, pairs);
+
+            // What is left of the per-account actions once Edit and Delete moved
+            // up beside Add. The row holds only what applies, and for a default
+            // password account that is nothing, so it is skipped.
+            let mut actions: Vec<(&str, ([f32; 4], [f32; 4]), AccountsMessage)> = Vec::new();
+            if !acc.is_default {
+                actions.push(("Make Default", BTN_NEUTRAL, AccountsMessage::MakeDefault(selected_idx)));
+            }
+            if acc.is_oauth {
+                let relogin = if narrow { "Re-login" } else { "Re-login (Browser)" };
+                actions.push((relogin, login_bg, AccountsMessage::GoogleLoginInit));
+            }
+            if !actions.is_empty() {
+                col.row(|r| {
+                    for (label, colors, action) in actions {
+                        form_button(r, label, 0.0, (colors.0, colors.1, TEXT_BTN), AppAction::Accounts(action));
+                    }
+                });
             }
         } else {
-            stack.context.text("Select an account to view details, or add one.", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("Select an account to view details, or add one.", 12.0, TEXT_DIM);
         }
 
         if let Some(ref msg) = state.status_msg {
-            stack.context.spacing(6.0);
-            stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
+            col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
         }
+        sec.place(form, ctx);
     });
     final_pc
 }
@@ -1361,6 +1293,7 @@ impl crate::pages::AppPage for AccountsState {
 
 #[cfg(test)]
 mod tests {
+    use cce_ui::widget::WidgetHost;
     use super::*;
     use cce_ui::layout::PageFlow;
 
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index f77b826..5e60c53 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,7 +1,7 @@
-use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
+use crate::app::{form_button, form_divider, AppAction, PageContent};
 use cce_ui::widget::Owned;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow};
-use cce_ui::widget::{Spinbox, Slider, WidgetHost};
+use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::widget::{Spinbox, Slider};
 
 #[derive(Debug, Clone)]
 pub struct AudioSink {
@@ -224,127 +224,98 @@ const BTN_DANGER: ([f32; 4], [f32; 4]) = ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20,
 const TEXT_BTN: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
 const TEXT_DANGER: [f32; 4] = [0.95, 0.55, 0.55, 1.0];
 
-/// One device on one line: name, volume slider, spinbox, mute — or a dim
-/// "inactive" note. The widgets stay index-aligned with the device vecs.
+/// One device on one line: name, volume slider, spinbox, mute — or a dim "inactive" note.
+/// The names stand in a column `name_w` wide, so every row's controls line up. The widgets
+/// stay index-aligned with the device vecs.
 #[allow(clippy::too_many_arguments)]
-fn device_row(
-    stack: &mut cce_ui::layout::VStack<'_, '_, PageContent>,
-    name: &str,
+fn device_row<'w>(
+    col: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+    name: String,
+    name_w: f32,
     active: bool,
     muted: bool,
     volume: f32,
-    slider: &mut Owned<cce_ui::widget::Adapted<Slider>>,
-    spin: &mut Owned<cce_ui::widget::Adapted<Spinbox>>,
+    slider: &'w mut Owned<cce_ui::widget::Adapted<Slider>>,
+    spin: &'w mut Owned<cce_ui::widget::Adapted<Spinbox>>,
     mute_action: AppAction,
-    ctx: &mut cce_ui::context::UiContext,
 ) {
+    let line_h = cce_ui::layout::form_line_height(12.0);
     if !active {
-        let sc = &mut *stack.context;
-        let mut y = sc.content_y;
-        if y > sc.content_start_y {
-            y += sc.row_gap;
-        }
-        let lx = sc.ax(12.0);
-        sc.pc.text(name, lx, y, 12.0, TEXT_DIM);
-        sc.pc.text("inactive", lx + 150.0, y, 12.0, TEXT_DIM);
-        sc.content_y = y + 18.0;
-        for h in &mut sc.grid.col_heights {
-            *h = sc.content_y;
-        }
+        col.row(|r| {
+            r.draw(name_w, line_h, false, move |pc, c, _| pc.text(&name, c.x, c.y, 12.0, TEXT_DIM));
+            r.text("inactive", 12.0, TEXT_DIM);
+        });
         return;
     }
 
     slider.set_value(volume);
     spin.value = (volume * 100.0).round() as i32;
-
-    let sb_h = cce_ui::layout::spinbox_height();
-    let sl_h = cce_ui::layout::slider_height();
-    let btn_h = cce_ui::layout::button_height();
-    let row_h = sb_h.max(sl_h).max(btn_h);
     let (mute_label, colors, mute_text) = if muted {
         ("Unmute", BTN_DANGER, TEXT_DANGER)
     } else {
         ("Mute", BTN_NEUTRAL, TEXT_BTN)
     };
-
-    stack.add_row(1, 0.0, row_h, |c, _, x, w| {
-        let name_w = 150.0;
-        let spin_w = 90.0;
-        let mute_w = 80.0;
-        let gap = 8.0;
-        let slider_w = (w - name_w - spin_w - mute_w - 2.0 * gap).max(60.0);
-        let y = c.ay();
-
-        c.pc.text(name, x, y + (row_h - 14.0) / 2.0, 12.0, TEXT_FG);
-        render_widget(c.pc, slider, x + name_w, y + (row_h - sl_h) / 2.0, slider_w, sl_h, ctx);
-        let spin_x = x + name_w + slider_w + gap;
-        spin.set_row_rect(spin_x, spin_w);
-        render_widget(c.pc, spin, spin_x, y + (row_h - sb_h) / 2.0, spin_w, sb_h, ctx);
-        c.button(
-            mute_label,
-            spin_x + spin_w + gap,
-            y + (row_h - btn_h) / 2.0,
-            mute_w,
-            btn_h,
-            colors.0,
-            colors.1,
-            mute_text,
-            mute_action.clone(),
-        );
+    col.row(|r| {
+        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());
+        form_button(r, mute_label, MUTE_W, (colors.0, colors.1, mute_text), mute_action);
     });
 }
 
+/// The volume readout's width: three digits, a unit and the −/+ run.
+const SPIN_W: f32 = 90.0;
+/// The mute button's width: "Unmute" at the button font.
+const MUTE_W: f32 = 80.0;
+
 pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+        let mut form = sec.form();
+        let mut col = form.column();
         if !state.loaded {
-            sec.text("Loading audio devices...", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("Loading audio devices...", 12.0, TEXT_DIM);
+            sec.place(form, ctx);
             return;
         }
-        let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-
-        stack.context.text("Output", 12.0, 0.0, 14.0, HEADING);
+        // One name column for both lists, as wide as the widest name (and never so wide that
+        // the slider has no room).
+        let name_w = state
+            .sinks
+            .iter()
+            .map(|d| d.name.as_str())
+            .chain(state.sources.iter().map(|d| d.name.as_str()))
+            .map(|n| cce_ui::layout::form_text_width(n, 12.0))
+            .fold(0.0, f32::max)
+            .min(col.form_width() / 3.0);
+
+        col.text("Output", 14.0, HEADING);
         if state.sinks.is_empty() {
-            stack.context.text("No output devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("No output devices found", 12.0, TEXT_DIM);
         }
-        let sinks = state.sinks.clone();
-        for (idx, sink) in sinks.iter().enumerate() {
-            device_row(
-                &mut stack,
-                &sink.name,
-                sink.active,
-                sink.muted,
-                sink.volume,
-                &mut state.sink_sliders[idx],
-                &mut state.sink_spinboxes[idx],
-                AppAction::Audio(AudioMessage::SinkMute(sink.id)),
-                ctx,
-            );
+        let devices = state.sinks.iter().zip(state.sink_sliders.iter_mut()).zip(state.sink_spinboxes.iter_mut());
+        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);
         }
 
-        section_divider(stack.context);
+        form_divider(&mut col);
 
-        stack.context.text("Input", 12.0, 0.0, 14.0, HEADING);
+        col.text("Input", 14.0, HEADING);
         if state.sources.is_empty() {
-            stack.context.text("No input devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("No input devices found", 12.0, TEXT_DIM);
         }
-        let sources = state.sources.clone();
-        for (idx, src) in sources.iter().enumerate() {
-            device_row(
-                &mut stack,
-                &src.name,
-                src.active,
-                src.muted,
-                src.volume,
-                &mut state.source_sliders[idx],
-                &mut state.source_spinboxes[idx],
-                AppAction::Audio(AudioMessage::SourceMute(src.id)),
-                ctx,
-            );
+        let devices = state.sources.iter().zip(state.source_sliders.iter_mut()).zip(state.source_spinboxes.iter_mut());
+        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);
         }
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
index f3609ad..1dfd990 100644
--- a/src/pages/bluetooth.rs
+++ b/src/pages/bluetooth.rs
@@ -1,6 +1,6 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{form_button, AppAction, PageContent};
 use cce_ui::widget::Owned;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow};
+use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
 use cce_ui::widget::{Adapted, Toggle};
 
 #[derive(Debug, Clone)]
@@ -160,114 +160,79 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
         let bt_sec_w = sec.cw;
-        let padding = sec.padding();
-        let row_gap = cce_ui::layout::label_margin();
-        let margin = padding.max(12.0);
         let font_size = 12.0;
-        let btn_h = cce_ui::layout::button_height();
+        let mut form = sec.form();
+        let mut col = form.column();
 
         if !state.loaded {
-            sec.text("Loading Bluetooth status...", margin, 0.0, font_size, TEXT_DIM);
-        } else if !state.installed {
-            sec.text("Bluetooth tools (bluez) not installed", margin, 0.0, font_size, TEXT_DIM);
-            let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
-            let yt = sec.ay();
-            sec.button("Install Tools", sec.ax(margin), yt, btn_w, btn_h,
-                TOGGLE_ON, BTN_HOVER, WHITE,
-                AppAction::Bluetooth(BluetoothMessage::InstallTools));
-            sec.content_y = yt + btn_h + row_gap;
-        } else if !state.service_active {
-            sec.text("Bluetooth service is stopped", margin, 0.0, font_size, TEXT_DIM);
+            col.text("Loading Bluetooth status...", font_size, TEXT_DIM);
+        } else if !state.installed || !state.service_active {
+            let (note, label, msg) = if !state.installed {
+                ("Bluetooth tools (bluez) not installed", "Install Tools", BluetoothMessage::InstallTools)
+            } else {
+                ("Bluetooth service is stopped", "Start Service", BluetoothMessage::StartService)
+            };
+            col.text(note, font_size, TEXT_DIM);
             let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
-            let yt = sec.ay();
-            sec.button("Start Service", sec.ax(margin), yt, btn_w, btn_h,
-                TOGGLE_ON, BTN_HOVER, WHITE,
-                AppAction::Bluetooth(BluetoothMessage::StartService));
-            sec.content_y = yt + btn_h + row_gap;
+            col.row(|r| form_button(r, label, btn_w, (TOGGLE_ON, BTN_HOVER, WHITE), AppAction::Bluetooth(msg)));
         } else {
-            let yt = sec.ay();
             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 };
-            let scan_btn_x = margin + bt_btn_w + row_gap;
-
             state.toggle.set_toggled(state.enabled);
             state.toggle.set_label(if state.enabled { "ON" } else { "OFF" });
-            // Hand-placed: sec.widget grid-places at full column width, which
-            // would sit the toggle under the Scan button.
-            let tx = sec.ax(margin);
-            let toggle_h = cce_ui::layout::toggle_height();
-            render_widget(sec.pc, &mut state.toggle, tx, yt, bt_btn_w, toggle_h, ctx);
-            sec.button("Scan", sec.ax(scan_btn_x), yt, scan_btn_w, btn_h,
-                TOGGLE_OFF, BTN_HOVER, WHITE,
-                AppAction::Bluetooth(BluetoothMessage::Scan));
-            sec.content_y = yt + btn_h.max(toggle_h) + row_gap;
+            col.row(|r| {
+                r.widget_w(&mut 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));
+            });
 
             if state.devices.is_empty() {
                 if state.enabled {
                     let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
-                    sec.text(no_devices_msg, margin, 0.0, font_size, TEXT_DIM);
+                    col.text(no_devices_msg, font_size, TEXT_DIM);
                 }
             } else {
-                let item_h = btn_h;
                 for dev in &state.devices {
                     let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
-                    let action_label = if dev.connected {
-                        if bt_sec_w < 250.0 { "Disc" } else { "Disconnect" }
-                    } else {
-                        if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
+                    let action_label = match (dev.connected, bt_sec_w < 250.0) {
+                        (true, true) => "Disc",
+                        (true, false) => "Disconnect",
+                        (false, true) => "Conn",
+                        (false, false) => "Connect",
                     };
-
-                    let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap - (font_size + 6.0)).max(20.0);
-                    let label_max_chars = ((label_max_w / 6.0) as usize).max(5);
-
                     let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
-                    let label = if is_unknown {
-                        if bt_sec_w < 350.0 {
-                            dev.mac.clone()
-                        } else {
-                            format!("Unknown Device ({})", dev.mac)
-                        }
+                    let label = match (is_unknown, bt_sec_w < 350.0) {
+                        (true, true) => dev.mac.clone(),
+                        (true, false) => format!("Unknown Device ({})", dev.mac),
+                        (false, true) => dev.name.clone(),
+                        (false, false) => format!("{} ({})", dev.name, dev.mac),
+                    };
+                    let (face, action) = if dev.connected {
+                        (TOGGLE_OFF, AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone())))
                     } else {
-                        if bt_sec_w < 350.0 {
-                            let name_truncated = if dev.name.len() > label_max_chars {
-                                format!("{}...", &dev.name[..label_max_chars.saturating_sub(3)])
-                            } else {
-                                dev.name.clone()
-                            };
-                            name_truncated
-                        } else {
-                            let full_label = format!("{} ({})", dev.name, dev.mac);
-                            if full_label.len() > label_max_chars {
-                                format!("{}...", &full_label[..label_max_chars.saturating_sub(3)])
-                            } else {
-                                full_label
-                            }
-                        }
+                        (TOGGLE_ON, AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone())))
                     };
-
-                    let yt = sec.ay();
-                    let btn_x = margin;
-                    let text_x = margin + btn_w + row_gap;
-                    let text_y_offset = (item_h - font_size) / 2.0;
-                    sec.button(action_label, sec.ax(btn_x), yt, btn_w, item_h,
-                        if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
-                        if dev.connected {
-                            AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone()))
-                        } else {
-                            AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone()))
+                    let connected = dev.connected;
+                    col.row(|r| {
+                        form_button(r, action_label, btn_w, (face, BTN_HOVER, WHITE), action);
+                        // A connected device wears a `check` glyph ahead of its name (it was
+                        // a ">" in the label); every row keeps the glyph's room so the names
+                        // line up either way. The name is cut where the row ends.
+                        let line_h = cce_ui::layout::form_line_height(font_size);
+                        r.draw(0.0, line_h, true, move |pc, cell, _| {
+                            let g = font_size;
+                            if connected {
+                                pc.icon("check", cell.x, cell.y + (line_h - g) / 2.0, g, g, ACCENT);
+                            }
+                            let tx = cell.x + g + cce_ui::layout::CONTROL_TEXT_INSET;
+                            let color = if connected { ACCENT } else { TEXT_FG };
+                            pc.text_with_bounds(&label, tx, cell.y, font_size, color,
+                                Some([tx, cell.y - font_size, cell.x + cell.width, cell.y + 2.0 * font_size]));
                         });
-                    // A connected device wears a `check` glyph ahead of its
-                    // name (it was a ">" in the label); every row keeps the
-                    // glyph's column so the names line up either way.
-                    let g = font_size;
-                    if dev.connected {
-                        sec.pc.icon("check", sec.ax(text_x), yt + (item_h - g) / 2.0, g, g, ACCENT);
-                    }
-                    sec.text(&label, text_x + g + 6.0, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
-                    sec.content_y = yt + item_h + row_gap;
+                    });
                 }
             }
         }
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/browser.rs b/src/pages/browser.rs
index a8f957a..94ffe63 100644
--- a/src/pages/browser.rs
+++ b/src/pages/browser.rs
@@ -10,9 +10,9 @@ use std::fs;
 use cce_ui::widget::Owned;
 use cce_ui::layout::{PageFlow, PageLayoutBuilder};
 use cce_ui::widget::input::{Dropdown, Toggle};
-use cce_ui::widget::{TextBox, WidgetHost};
+use cce_ui::widget::TextBox;
 
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
 use crate::pages::AppPage;
 
 /// config key, menu label — the browser maps the key onto a query URL.
@@ -282,53 +282,44 @@ 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| {
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            let sec_w = stack.context.cw;
-            let row_w = sec_w - 28.0;
-
-            self.homepage_box.set_row_rect(stack.context.left + 14.0, row_w);
-            stack.add_widget(&mut self.homepage_box, row_w, 44.0, ctx);
-
             self.search_menu.selected = search_index(&self.search);
-            self.search_menu.set_row_rect(stack.context.left + 14.0, row_w);
-            stack.add_widget(&mut self.search_menu, row_w, 44.0, ctx);
-
             self.bar_position_menu.selected = bar_position_index(&self.bar_position);
-            self.bar_position_menu.set_row_rect(stack.context.left + 14.0, row_w);
-            stack.add_widget(&mut self.bar_position_menu, row_w, 44.0, ctx);
-
             self.color_scheme_menu.selected = color_scheme_index(&self.color_scheme);
-            self.color_scheme_menu.set_row_rect(stack.context.left + 14.0, row_w);
-            stack.add_widget(&mut self.color_scheme_menu, row_w, 44.0, ctx);
-
-            self.download_dir_box.set_row_rect(stack.context.left + 14.0, row_w);
-            stack.add_widget(&mut self.download_dir_box, row_w, 44.0, ctx);
-
             self.history_toggle.set_toggled(self.history);
-            stack.add_widget(&mut self.history_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
-
             // 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);
-            stack.add_widget(&mut self.raindrop_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
-
             self.vi_mode_toggle.set_toggled(self.vi_mode);
-            stack.add_widget(&mut self.vi_mode_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
 
-            let btn_h = cce_ui::layout::button_height();
-            stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
-                ctx.button(
-                    "Apply",
-                    x,
-                    ctx.ay(),
-                    w,
-                    btn_h,
-                    [0.20, 0.40, 0.65, 1.0],
-                    [0.28, 0.50, 0.78, 1.0],
-                    [1.0, 1.0, 1.0, 1.0],
-                    AppAction::Browser(BrowserMessage::Apply),
-                );
-            });
+            let (field_h, menu_h, toggle_h) = (
+                cce_ui::layout::textbox_height(),
+                cce_ui::layout::dropdown_height(),
+                cce_ui::layout::toggle_height(),
+            );
+            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)
+                .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
+                    pc.button(
+                        "Apply",
+                        r.x,
+                        r.y,
+                        r.width,
+                        r.height,
+                        [0.20, 0.40, 0.65, 1.0],
+                        [0.28, 0.50, 0.78, 1.0],
+                        [1.0, 1.0, 1.0, 1.0],
+                        AppAction::Browser(BrowserMessage::Apply),
+                    );
+                });
+            sec.place(form, ctx);
         });
 
         final_pc
diff --git a/src/pages/default_apps.rs b/src/pages/default_apps.rs
index 139975f..39b9b11 100644
--- a/src/pages/default_apps.rs
+++ b/src/pages/default_apps.rs
@@ -13,7 +13,7 @@
 use crate::app::{AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
-use cce_ui::widget::{Dropdown, WidgetHost};
+use cce_ui::widget::Dropdown;
 use std::collections::HashMap;
 use std::path::PathBuf;
 
@@ -130,16 +130,16 @@ pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _r
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
-        let sec_w = sec.cw;
+        let mut form = sec.form();
+        let mut col = form.column();
         if !state.loaded {
-            sec.text("Scanning installed applications...", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("Scanning installed applications...", 12.0, TEXT_DIM);
         } else {
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
             for entry in state.categories.iter_mut() {
-                entry.dropdown.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-                stack.add_widget(&mut entry.dropdown, sec_w - 28.0, 44.0, ctx);
+                col.widget(&mut entry.dropdown, cce_ui::layout::dropdown_height());
             }
         }
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 9535080..2ce21b2 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -157,6 +157,8 @@ fn wifi_toggle(enable: bool) {
 
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The Wi-Fi list's own height: a window of rows that scrolls.
+const WIFI_LIST_H: f32 = 160.0;
 const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 const NET_BTN: [f32; 4] = [0.13, 0.20, 0.27, 1.0];
@@ -170,98 +172,90 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
     // ── WiFi (label-less well) ──
     builder.add_section_spanned(&mut final_pc, "", 1, root_focused, |sec| {
         let sec_w = sec.cw;
-        let padding = sec.padding();
-        let row_gap = cce_ui::layout::label_margin();
-        let margin = padding.max(12.0);
+        // The list's own inset of its rows inside its frame.
+        let margin = sec.padding().max(12.0);
+        let mut form = sec.form();
+        let mut col = form.column();
 
         if !state.loaded {
-            sec.text("Loading WiFi interfaces...", margin, 0.0, 12.0, TEXT_DIM);
+            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" });
-            // Hand-placed at its real width — sec.widget grid-places at full
-            // column width (the old wide "ON" plate).
-            let yt = sec.ay();
-            let tx = sec.ax(margin);
-            let toggle_h = cce_ui::layout::toggle_height();
-            cce_ui::layout::render_widget(sec.pc, &mut state.wifi_toggle, tx, yt, wifi_btn_w, toggle_h, ctx);
-            sec.content_y = yt + toggle_h + row_gap;
+            // 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());
+            });
 
             if state.wifi_enabled {
-                // Flowing text rows — sec.text advances content_y itself.
-                if !state.connected_ssid.is_empty() {
-                    let ssid_max_chars = ((sec_w - 2.0 * margin) / 7.0) as usize;
+                let text_w = col.form_width();
+                col.block(|b| {
+                    if state.connected_ssid.is_empty() {
+                        b.text("Not connected", 12.0, TEXT_DIM);
+                        return;
+                    }
+                    let ssid_max_chars = (text_w / 7.0) as usize;
                     let ssid_truncated = if state.connected_ssid.len() > ssid_max_chars {
                         format!("{}...", &state.connected_ssid[..ssid_max_chars.saturating_sub(3)])
                     } else {
                         state.connected_ssid.clone()
                     };
-                    sec.text(&format!("Connected: {}", ssid_truncated), margin, 0.0, 13.0, ACCENT);
-
+                    b.text(format!("Connected: {}", ssid_truncated), 13.0, ACCENT);
                     if sec_w < 220.0 {
-                        sec.text(&format!("Signal: {}%", state.signal_strength), margin, 0.0, 12.0, TEXT_DIM);
+                        b.text(format!("Signal: {}%", state.signal_strength), 12.0, TEXT_DIM);
                     } else {
-                        sec.text(&format!("Signal: {}%  IP: {}", state.signal_strength, state.ip_address),
-                            margin, 0.0, 12.0, TEXT_DIM);
+                        b.text(format!("Signal: {}%  IP: {}", state.signal_strength, state.ip_address), 12.0, TEXT_DIM);
                     }
-                } else {
-                    sec.text("Not connected", margin, 0.0, 12.0, TEXT_DIM);
-                }
-                sec.content_y += row_gap;
+                });
             }
 
             if state.wifi_enabled && !state.available.is_empty() {
-                // Across the section's content box — the box the section
-                // clips to. At `left + margin` the framed list ran
-                // 2 * padding past it on both sides, cut off flat there.
-                let list_box_x = sec.content_left();
-                let list_box_y = sec.ay();
-                let list_box_w = sec.content_width();
-                let list_box_h = 160.0;
-
-                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
-                state.wifi_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                state.wifi_list.update_bounds(state.available.len(), list_box_y, list_box_h);
-                state.wifi_list.push_prims(sec.pc);
-
-                let btn_w = list_box_w - 2.0 * margin;
-                let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
-
-                sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                for (idx, net) in state.available.iter().enumerate() {
-                    if let Some(draw_y) = state.wifi_list.get_item_draw_y(idx, 4.0) {
-                        let ssid_truncated = if net.ssid.len() > max_chars {
-                            format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
-                        } else {
-                            net.ssid.clone()
-                        };
-                        let label = format!("{}  ({}%)", ssid_truncated, net.signal);
-                        let active = net.in_use;
-                        let row_x = list_box_x + margin;
-                        let row_h = 26.0;
-                        sec.pc.button(&label, row_x, draw_y, btn_w, row_h,
-                            if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
-                            if active { ACCENT } else { TEXT_FG },
-                            AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
-                        // The network in use wears a `check` glyph at the
-                        // row's left (it was a ">" in the label), in the
-                        // plain text colour: ACCENT is the row's own green
-                        // and a glyph in it all but vanishes there.
-                        if active {
-                            let g = 12.0;
-                            sec.pc.icon("check", row_x + cce_ui::layout::CONTROL_TEXT_INSET,
-                                draw_y + (row_h - g) / 2.0, g, g, TEXT_FG);
+                let list = &mut state.wifi_list;
+                let available = &state.available;
+                col.draw(0.0, WIFI_LIST_H, false, move |pc, r, _| {
+                    // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                    list.set_rect(r.x, r.y, r.width, r.height);
+                    list.update_bounds(available.len(), r.y, r.height);
+                    list.push_prims(pc);
+
+                    let btn_w = r.width - 2.0 * margin;
+                    let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
+
+                    pc.push_clip_rect(r.x, r.y, r.width, r.height);
+                    for (idx, net) in available.iter().enumerate() {
+                        if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
+                            let ssid_truncated = if net.ssid.len() > max_chars {
+                                format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
+                            } else {
+                                net.ssid.clone()
+                            };
+                            let label = format!("{}  ({}%)", ssid_truncated, net.signal);
+                            let active = net.in_use;
+                            let row_x = r.x + margin;
+                            let row_h = 26.0;
+                            pc.button(&label, row_x, draw_y, btn_w, row_h,
+                                if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
+                                if active { ACCENT } else { TEXT_FG },
+                                AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
+                            // The network in use wears a `check` glyph at the
+                            // row's left (it was a ">" in the label), in the
+                            // plain text colour: ACCENT is the row's own green
+                            // and a glyph in it all but vanishes there.
+                            if active {
+                                let g = 12.0;
+                                pc.icon("check", row_x + cce_ui::layout::CONTROL_TEXT_INSET,
+                                    draw_y + (row_h - g) / 2.0, g, g, TEXT_FG);
+                            }
                         }
                     }
-                }
-                sec.pc.pop_clip_rect();
-                // The scrollbar's fore copy, over the rows at the raise's fade.
-                state.wifi_list.push_scrollbar_fore(sec.pc);
-                sec.content_y += list_box_h + row_gap;
+                    pc.pop_clip_rect();
+                    // The scrollbar's fore copy, over the rows at the raise's fade.
+                    list.push_scrollbar_fore(pc);
+                });
             }
         }
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 302a95d..31c7fd6 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -2,9 +2,8 @@ use std::fs;
 use std::io::Write;
 use cce_ui::widget::Owned;
 use cce_ui::widget::input::{Toggle, Dropdown, Spinbox};
-use cce_ui::widget::WidgetHost;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
 use crate::pages::AppPage;
 
 
@@ -213,45 +212,35 @@ 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| {
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            let sec_w = stack.context.cw;
             self.enable_toggle.set_toggled(self.enable);
-            stack.add_widget(&mut self.enable_toggle, sec_w - 28.0, cce_ui::layout::toggle_height(), ctx);
-
-            let selected_idx = match self.bell.as_str() {
-                "none" => 0,
+            self.bell_menu.selected = match self.bell.as_str() {
                 "bell" => 1,
                 "dialog" => 2,
                 "message" => 3,
                 _ => 0,
             };
-            self.bell_menu.selected = selected_idx;
-            self.bell_menu.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-            stack.add_widget(&mut self.bell_menu, sec_w - 28.0, 44.0, ctx);
-
             self.duration_spinbox.value = self.duration;
             self.duration_spinbox.set_label("Notification Duration");
-            self.duration_spinbox.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-            stack.add_widget(&mut self.duration_spinbox, sec_w - 28.0, 44.0, ctx);
-
-            let btn_h = cce_ui::layout::button_height();
-            let white_color = [1.0, 1.0, 1.0, 1.0];
-            let btn_bg = [0.20, 0.40, 0.65, 1.0];
-            let btn_hover = [0.28, 0.50, 0.78, 1.0];
-
-            stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
-                ctx.button(
-                    "Send Test Notification",
-                    x,
-                    ctx.ay(),
-                    w,
-                    btn_h,
-                    btn_bg,
-                    btn_hover,
-                    white_color,
-                    AppAction::Notifications(NotificationsMessage::SendTestNotification),
-                );
-            });
+
+            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())
+                .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
+                    pc.button(
+                        "Send Test Notification",
+                        r.x,
+                        r.y,
+                        r.width,
+                        r.height,
+                        [0.20, 0.40, 0.65, 1.0],
+                        [0.28, 0.50, 0.78, 1.0],
+                        [1.0, 1.0, 1.0, 1.0],
+                        AppAction::Notifications(NotificationsMessage::SendTestNotification),
+                    );
+                });
+            sec.place(form, ctx);
         });
 
         final_pc
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index b61594b..8208c1b 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,8 +1,8 @@
-use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
+use crate::app::{button_need, form_button, form_divider, wrap_to_width, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, SectionContext, RenderTarget};
-use cce_ui::widget::{WidgetHost, TextBox, InteractiveListItem};
+use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::widget::{TextBox, InteractiveListItem};
 
 #[derive(Debug, Clone, Default)]
 pub struct PackageInfo {
@@ -365,11 +365,6 @@ pub fn human_size(bytes: u64) -> String {
     }
 }
 
-/// A button wide enough for its label in the (monospace) button font.
-fn label_button_w(label: &str) -> f32 {
-    label.chars().count() as f32 * 9.5 + 28.0
-}
-
 /// "3 packages" / "1 package".
 fn count_noun(n: usize) -> String {
     if n == 1 { "1 package".to_string() } else { format!("{n} packages") }
@@ -523,6 +518,8 @@ const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
 const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 const RED: [f32; 4] = [0.85, 0.25, 0.25, 1.0];
+/// The least room the list keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
 
 pub fn view(
     state: &mut PackagesState,
@@ -534,65 +531,65 @@ pub fn view(
     layout: &mut PageFlow,
     ctx: &mut cce_ui::context::UiContext,
 ) -> PageContent {
-    let m = crate::app::section_margin();
     let mut final_pc = PageContent::new();
     let sec_w = 260.0f32;
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
-        let sec_w = sec.cw;
+        let mut form = sec.form();
         if !state.loaded {
-            sec.text("Loading package lists...", 12.0, 0.0, 12.0, TEXT_DIM);
+            form.column().text("Loading package lists...", 12.0, TEXT_DIM);
+            sec.place(form, ctx);
             return;
         }
+        // The list fills the page like the services list: the well's floor lands at the
+        // page's bottom.
+        form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+        let wrap_w = form.width();
 
-        // ── Tabs (with counts), filter, compact update row ──
-        let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-        let tab_h = cce_ui::layout::button_height();
         let active_bg = [0.20, 0.40, 0.65, 0.4];
         let inactive_bg = [0.10, 0.10, 0.16, 0.3];
         let hover_bg = [0.20, 0.20, 0.25, 0.15];
+        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()
+        } else {
+            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();
+        if state.installed_items.len() != visible_installed.len() {
+            state.installed_items.clear();
+            for _ in 0..visible_installed.len() {
+                state.installed_items.push(Owned::new(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 _ in 0..filtered_updates.len() {
+                state.updates_items.push(Owned::new(InteractiveListItem::new("")));
+            }
+        }
+
+        let mut col = form.column();
 
+        // ── Tabs (with counts) ──
         let label1 = format!("Installed ({})", state.installed.len());
         let label2 = format!("Updates ({})", state.updates.len());
-
-        stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
-            if i == 0 {
-                ctx.button(
-                    &label1,
-                    x,
-                    ctx.ay(),
-                    w,
-                    tab_h,
-                    if state.active_tab == PackageTab::Installed { active_bg } else { inactive_bg },
-                    hover_bg,
-                    [0.90, 0.90, 0.95, 1.0],
-                    AppAction::Packages(PackagesMessage::SetTab(PackageTab::Installed)),
-                );
-            } else {
-                ctx.button(
-                    &label2,
-                    x,
-                    ctx.ay(),
-                    w,
-                    tab_h,
-                    if state.active_tab == PackageTab::Updates { active_bg } else { inactive_bg },
-                    hover_bg,
-                    [0.90, 0.90, 0.95, 1.0],
-                    AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)),
-                );
-            }
+        let tab = state.active_tab;
+        col.row(|r| {
+            form_button(r, label1, 0.0, (if tab == PackageTab::Installed { active_bg } else { inactive_bg }, hover_bg, text_on),
+                AppAction::Packages(PackagesMessage::SetTab(PackageTab::Installed)));
+            form_button(r, label2, 0.0, (if tab == PackageTab::Updates { active_bg } else { inactive_bg }, hover_bg, text_on),
+                AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)));
         });
 
-        stack.context.spacing(4.0);
-
-        let search_w = sec_w - 2.0 * m;
-        let search_h = 46.0;
-        state.search_box.set_row_rect(stack.context.left + m, search_w);
-        stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
-        stack.context.spacing(4.0);
+        col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
 
-        // Update System: compact button + status text on one row.
+        // ── Update System: a button as wide as its label, its status beside it ──
         let status_line = if state.updating {
             "Updating system...".to_string()
         } else if state.updates.is_empty() {
@@ -606,30 +603,25 @@ pub fn view(
         } else {
             ("Update System", [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0])
         };
-        stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-            if i == 0 {
-                c.button(btn_lbl, x, c.ay(), w, tab_h, bg, hover, [0.90, 0.90, 0.95, 1.0],
-                    AppAction::Packages(PackagesMessage::StartUpdate));
-            } else if i == 1 {
-                let y = c.ay();
-                c.pc.text(&status_line, x, y + (tab_h - 14.0) / 2.0, 12.0, status_color);
-            }
+        col.row(|r| {
+            form_button(r, btn_lbl, button_need(btn_lbl), (bg, hover, text_on), AppAction::Packages(PackagesMessage::StartUpdate));
+            r.text_fill(status_line, 12.0, status_color);
         });
 
         if let Some(ref res) = state.last_update_res {
             match res {
-                Ok(_) => stack.context.text("Last update succeeded", 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]),
+                Ok(_) => {
+                    col.text("Last update succeeded", 12.0, [0.56, 0.83, 0.56, 1.0]);
+                }
                 Err(err) => {
-                    stack.context.text("Last update failed:", 12.0, 0.0, 12.0, RED);
-                    stack.context.text(err, 12.0, 0.0, 11.0, RED);
+                    col.block(|b| {
+                        b.text("Last update failed:", 12.0, RED);
+                        b.lines(wrap_to_width(err, wrap_w, 11.0), 11.0, RED);
+                    });
                 }
             }
         }
 
-        // Characters per line for wrapped status text at 11px.
-        let wrap_chars = (((sec_w - 2.0 * m - 24.0) / 6.5) as usize).max(20);
-        let busy = state.busy();
-
         // ── Filter row: All / Explicit / Orphans, and the select-mode switch ──
         if state.active_tab == PackageTab::Installed {
             let n_explicit = state.installed.iter().filter(|p| p.explicit).count();
@@ -642,55 +634,48 @@ pub fn view(
             let select_label = if state.select_mode { "Done Selecting" } else { "Select..." };
             let filter = state.filter;
             let select_mode = state.select_mode;
-            stack.add_row(4, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-                if let Some((f, label)) = filters.get(i) {
-                    let bg = if filter == *f { active_bg } else { inactive_bg };
-                    c.button(label, x, c.ay(), w, tab_h, bg, hover_bg, [0.90, 0.90, 0.95, 1.0],
-                        AppAction::Packages(PackagesMessage::SetFilter(*f)));
-                } else {
-                    let bg = if select_mode { active_bg } else { inactive_bg };
-                    c.button(select_label, x, c.ay(), w, tab_h, bg, hover_bg, [0.90, 0.90, 0.95, 1.0],
-                        AppAction::Packages(PackagesMessage::ToggleSelectMode));
+            col.row(|r| {
+                for (f, label) in filters {
+                    let face = if filter == f { active_bg } else { inactive_bg };
+                    form_button(r, label, 0.0, (face, hover_bg, text_on), AppAction::Packages(PackagesMessage::SetFilter(f)));
                 }
+                let face = if select_mode { active_bg } else { inactive_bg };
+                form_button(r, select_label, 0.0, (face, hover_bg, text_on), AppAction::Packages(PackagesMessage::ToggleSelectMode));
             });
 
             // ── Bulk actions over the checked rows ──
             if state.select_mode {
                 let n = state.checked.len();
-                let summary = if n == 0 {
-                    "Click rows to select".to_string()
-                } else {
-                    format!("{} selected", count_noun(n))
-                };
+                let summary = if n == 0 { "Click rows to select".to_string() } else { format!("{} selected", count_noun(n)) };
                 let targets: Vec<String> = state.checked.iter().cloned().collect();
                 let can_act = n > 0 && !busy;
-                let (act_bg, act_text) = if can_act { (TOGGLE_OFF, TEXT_FG) } else { (TOGGLE_OFF, TEXT_DIM) };
-                stack.add_row(5, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-                    let y = c.ay();
-                    match i {
-                        0 => c.pc.text(&summary, x, y + (tab_h - 14.0) / 2.0, 12.0, if n > 0 { ACCENT } else { TEXT_DIM }),
-                        1 => c.button("Select Visible", x, y, w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
-                            AppAction::Packages(PackagesMessage::CheckAllVisible)),
-                        2 => c.button("Clear", x, y, w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
-                            AppAction::Packages(PackagesMessage::ClearChecked)),
-                        3 => c.button(if state.marking { "Marking..." } else { "Mark Explicit" }, x, y, w, tab_h,
-                            act_bg, if can_act { BTN_HOVER } else { act_bg }, act_text,
-                            AppAction::Packages(PackagesMessage::SetInstallReason(targets.clone(), true))),
-                        _ => c.button("Remove...", x, y, w, tab_h,
+                let act_text = if can_act { TEXT_FG } else { TEXT_DIM };
+                let mark_label = if state.marking { "Marking..." } else { "Mark Explicit" };
+                col.row(|r| {
+                    r.text_fill(summary, 12.0, if n > 0 { ACCENT } else { TEXT_DIM });
+                    form_button(r, "Select Visible", button_need("Select Visible"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+                        AppAction::Packages(PackagesMessage::CheckAllVisible));
+                    form_button(r, "Clear", button_need("Clear"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+                        AppAction::Packages(PackagesMessage::ClearChecked));
+                    form_button(r, mark_label, button_need(mark_label),
+                        (TOGGLE_OFF, if can_act { BTN_HOVER } else { TOGGLE_OFF }, act_text),
+                        AppAction::Packages(PackagesMessage::SetInstallReason(targets.clone(), true)));
+                    form_button(r, "Remove...", button_need("Remove..."),
+                        (
                             if can_act { [0.25, 0.14, 0.14, 1.0] } else { TOGGLE_OFF },
                             if can_act { [0.40, 0.20, 0.20, 1.0] } else { TOGGLE_OFF },
                             if can_act { [0.95, 0.55, 0.55, 1.0] } else { TEXT_DIM },
-                            AppAction::Packages(PackagesMessage::PreviewRemoval(targets.clone()))),
-                    }
+                        ),
+                        AppAction::Packages(PackagesMessage::PreviewRemoval(targets)));
                 });
             }
         }
 
         // ── Removal confirmation: what pacman says it would take ──
         if state.previewing {
-            stack.context.text("Checking what would be removed...", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("Checking what would be removed...", 12.0, TEXT_DIM);
         } else if let Some(plan) = state.removal.clone() {
-            section_divider(stack.context);
+            form_divider(&mut col);
             match &plan.outcome {
                 Ok(pkgs) => {
                     let total: u64 = pkgs.iter().map(|(_, s)| *s).sum();
@@ -702,40 +687,36 @@ pub fn view(
                     } else {
                         format!("Remove {} ({}):", count_noun(pkgs.len()), human_size(total))
                     };
-                    stack.context.text(&head, 12.0, 0.0, 12.0, [0.95, 0.75, 0.55, 1.0]);
                     let names = pkgs.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
-                    let lines = wrap_text(&names, wrap_chars);
+                    let lines = wrap_to_width(&names, wrap_w, 11.0);
                     const MAX_LINES: usize = 6;
-                    for line in lines.iter().take(MAX_LINES) {
-                        stack.context.text(line, 12.0, 0.0, 11.0, TEXT_FG);
-                    }
-                    if lines.len() > MAX_LINES {
-                        let shown: usize = lines.iter().take(MAX_LINES).map(|l| l.split_whitespace().count()).sum();
-                        stack.context.text(&format!("... and {} more", pkgs.len().saturating_sub(shown)), 12.0, 0.0, 11.0, TEXT_DIM);
-                    }
+                    col.block(|b| {
+                        b.text(head, 12.0, [0.95, 0.75, 0.55, 1.0]);
+                        b.lines(lines.iter().take(MAX_LINES).cloned().collect(), 11.0, TEXT_FG);
+                        if lines.len() > MAX_LINES {
+                            let shown: usize = lines.iter().take(MAX_LINES).map(|l| l.split_whitespace().count()).sum();
+                            b.text(format!("... and {} more", pkgs.len().saturating_sub(shown)), 11.0, TEXT_DIM);
+                        }
+                    });
                     let confirm = if state.uninstalling { "Removing...".to_string() } else { format!("Remove {}", count_noun(pkgs.len())) };
                     let targets = plan.targets.clone();
-                    stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-                        let y = c.ay();
-                        if i == 0 {
-                            c.button(&confirm, x, y, w, tab_h, [0.30, 0.14, 0.14, 1.0], [0.45, 0.20, 0.20, 1.0], [0.98, 0.65, 0.65, 1.0],
-                                AppAction::Packages(PackagesMessage::StartUninstall(targets.clone())));
-                        } else if i == 1 {
-                            c.button("Cancel", x, y, w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
-                                AppAction::Packages(PackagesMessage::CancelRemoval));
-                        }
+                    col.row(|r| {
+                        let need = button_need(&confirm);
+                        form_button(r, confirm, need, ([0.30, 0.14, 0.14, 1.0], [0.45, 0.20, 0.20, 1.0], [0.98, 0.65, 0.65, 1.0]),
+                            AppAction::Packages(PackagesMessage::StartUninstall(targets)));
+                        form_button(r, "Cancel", button_need("Cancel"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+                            AppAction::Packages(PackagesMessage::CancelRemoval));
                     });
                 }
                 Err(err) => {
-                    stack.context.text(&format!("Can't remove {}:", plan.targets.join(", ")), 12.0, 0.0, 12.0, RED);
-                    for line in err.lines().flat_map(|l| wrap_text(l, wrap_chars)).take(8) {
-                        stack.context.text(&line, 12.0, 0.0, 11.0, [0.95, 0.55, 0.55, 1.0]);
-                    }
-                    stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-                        if i == 0 {
-                            c.button("Dismiss", x, c.ay(), w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
-                                AppAction::Packages(PackagesMessage::CancelRemoval));
-                        }
+                    let lines: Vec<String> = err.lines().flat_map(|l| wrap_to_width(l, wrap_w, 11.0)).take(8).collect();
+                    col.block(|b| {
+                        b.text(format!("Can't remove {}:", plan.targets.join(", ")), 12.0, RED);
+                        b.lines(lines, 11.0, [0.95, 0.55, 0.55, 1.0]);
+                    });
+                    col.row(|r| {
+                        form_button(r, "Dismiss", button_need("Dismiss"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+                            AppAction::Packages(PackagesMessage::CancelRemoval));
                     });
                 }
             }
@@ -744,11 +725,12 @@ pub fn view(
         // ── Outcome of the last remove / mark ──
         if let Some(ref res) = state.last_action {
             match res {
-                Ok(msg) => stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]),
+                Ok(msg) => {
+                    col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
+                }
                 Err(err) => {
-                    for (i, line) in err.lines().flat_map(|l| wrap_text(l, wrap_chars)).take(8).enumerate() {
-                        stack.context.text(&line, 12.0, 0.0, if i == 0 { 12.0 } else { 11.0 }, RED);
-                    }
+                    let lines: Vec<String> = err.lines().flat_map(|l| wrap_to_width(l, wrap_w, 11.0)).take(8).collect();
+                    col.lines(lines, 11.0, RED);
                 }
             }
         }
@@ -758,12 +740,12 @@ pub fn view(
         // package, and both together push the list off the page.
         let confirming = state.previewing || state.removal.is_some();
         if !confirming && (state.loading_info || state.selected_package.is_some()) {
-            section_divider(stack.context);
+            form_divider(&mut col);
         }
         if confirming {
             // Nothing: the confirmation above stands in for the details.
         } else if state.loading_info {
-            stack.context.text("Loading package details...", 12.0, 0.0, 12.0, TEXT_DIM);
+            col.text("Loading package details...", 12.0, TEXT_DIM);
         } else if let Some(pkg_name) = state.selected_package.clone() {
             if let Some(info_raw) = state.selected_package_info.clone() {
                 let mut parsed = parse_package_info(&info_raw);
@@ -771,235 +753,163 @@ pub fn view(
                     parsed.name = pkg_name.clone();
                 }
 
-                // Heading row: name left, Uninstall (compact, quiet-red) right.
-                {
-                    let sc = &mut *stack.context;
-                    let mut y = sc.content_y;
-                    if y > sc.content_start_y {
-                        y += sc.row_gap;
-                    }
-                    let lx = sc.ax(m);
-                    sc.pc.text(&parsed.name, lx, y, 14.0, [0.35, 0.65, 0.90, 1.0]);
-                    sc.content_y = y + 20.0;
-                    for h in &mut sc.grid.col_heights {
-                        *h = sc.content_y;
-                    }
-                    let is_installed = state.installed.iter().find(|p| p.name == pkg_name);
-                    if let (PackageTab::Installed, Some(info)) = (state.active_tab, is_installed) {
-                        // Right-aligned to the box every button row lays out in
-                        // (`row_layout`); a width derived from cw by hand ran
-                        // a few px past the well and cut the button's edge.
-                        let right = sc.row_layout(1, 0.0).first().map(|&(x, w)| x + w).unwrap_or(lx);
-                        let bw = label_button_w("Uninstall");
-                        let bx = right - bw;
-                        let (btn_lbl, bg, hover, text_col) = if busy {
-                            ("Uninstall", TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
+                // Heading row: the name, then the install-reason flip and Uninstall at the
+                // row's end. An explicit package can be handed back as a dependency (so it
+                // goes when nothing needs it) and a dependency kept for good.
+                let installed = state.installed.iter().find(|p| p.name == pkg_name).map(|p| p.explicit);
+                let on_installed = state.active_tab == PackageTab::Installed;
+                col.row(|r| {
+                    r.text_fill(parsed.name.clone(), 14.0, [0.35, 0.65, 0.90, 1.0]);
+                    if let (true, Some(explicit)) = (on_installed, installed) {
+                        let (mark_lbl, to_explicit) = if explicit { ("Mark as Dependency", false) } else { ("Mark Explicit", true) };
+                        form_button(r, mark_lbl, button_need(mark_lbl),
+                            (TOGGLE_OFF, if busy { TOGGLE_OFF } else { BTN_HOVER }, if busy { TEXT_DIM } else { TEXT_FG }),
+                            AppAction::Packages(PackagesMessage::SetInstallReason(vec![pkg_name.clone()], to_explicit)));
+                        let colors = if busy {
+                            (TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
                         } else {
-                            ("Uninstall", [0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0], [0.95, 0.55, 0.55, 1.0])
+                            ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0], [0.95, 0.55, 0.55, 1.0])
                         };
-                        // Centred on the 14px name's line.
-                        let bh = cce_ui::layout::button_height();
-                        let by = y + (17.0 - bh) / 2.0;
-                        sc.button(btn_lbl, bx, by, bw, bh, bg, hover, text_col,
+                        form_button(r, "Uninstall", button_need("Uninstall"), colors,
                             AppAction::Packages(PackagesMessage::PreviewRemoval(vec![pkg_name.clone()])));
-                        // The install-reason flip beside it: an explicit package can
-                        // be handed back as a dependency (so it goes when nothing
-                        // needs it) and a dependency kept for good.
-                        let (mark_lbl, to_explicit) = if info.explicit {
-                            ("Mark as Dependency", false)
-                        } else {
-                            ("Mark Explicit", true)
-                        };
-                        let mw = label_button_w(mark_lbl);
-                        let text_col = if busy { TEXT_DIM } else { TEXT_FG };
-                        sc.button(mark_lbl, bx - 4.0 - mw, by, mw, bh, TOGGLE_OFF, if busy { TOGGLE_OFF } else { BTN_HOVER }, text_col,
-                            AppAction::Packages(PackagesMessage::SetInstallReason(vec![pkg_name.clone()], to_explicit)));
-                    }
-                }
-
-                // Same-line kv rows, values wrapping at the value column.
-                let kv_wrap = |sc: &mut SectionContext<'_, PageContent>, key: &str, val: &str| {
-                    if val.is_empty() {
-                        return;
-                    }
-                    let mut y = sc.content_y + 4.0;
-                    let lx = sc.ax(m);
-                    let vx = lx + 118.0;
-                    let usable_w = sc.cw - 118.0 - 2.0 * (sc.padding() + m);
-                    let max_chars = ((usable_w / 6.0) as usize).max(15);
-                    sc.pc.text(key, lx, y, 11.0, TEXT_DIM);
-                    let lines = wrap_text(val, max_chars);
-                    for line in &lines {
-                        sc.pc.text(line, vx, y, 11.0, TEXT_FG);
-                        y += 15.0;
-                    }
-                    if lines.is_empty() {
-                        y += 15.0;
                     }
-                    sc.content_y = y + 1.0;
-                    for h in &mut sc.grid.col_heights {
-                        *h = sc.content_y;
-                    }
-                };
+                });
 
-                kv_wrap(stack.context, "Version", &parsed.version);
                 let orphan = state.installed.iter().any(|p| p.name == pkg_name && p.orphan);
                 let reason = if orphan {
                     format!("{} (orphan: nothing requires it now)", parsed.install_reason)
                 } else {
                     parsed.install_reason.clone()
                 };
-                kv_wrap(stack.context, "Install Reason", &reason);
-                kv_wrap(stack.context, "Size", &parsed.size);
-                kv_wrap(stack.context, "Licenses", &parsed.licenses);
-                kv_wrap(stack.context, "Website", &parsed.website);
-                kv_wrap(stack.context, "Packager", &parsed.packager);
-                kv_wrap(stack.context, "Built", &parsed.build_date);
-                kv_wrap(stack.context, "Description", &parsed.description);
-                kv_wrap(stack.context, "Commands", &parsed.commands);
+                let pairs = [
+                    ("Version", parsed.version.clone()),
+                    ("Install Reason", reason),
+                    ("Size", parsed.size.clone()),
+                    ("Licenses", parsed.licenses.clone()),
+                    ("Website", parsed.website.clone()),
+                    ("Packager", parsed.packager.clone()),
+                    ("Built", parsed.build_date.clone()),
+                    ("Description", parsed.description.clone()),
+                    ("Commands", parsed.commands.clone()),
+                ]
+                .into_iter()
+                .filter(|(_, v)| !v.is_empty())
+                .map(|(k, v)| (k.to_string(), TEXT_DIM, v, TEXT_FG))
+                .collect();
+                crate::app::form_pairs(&mut col, 11.0, pairs);
 
                 if !parsed.required_by.is_empty() && parsed.required_by != "None" {
-                    stack.context.text("Required By", 12.0, 0.0, 11.0, TEXT_DIM);
-
-                    let reqs: Vec<&str> = parsed.required_by.split_whitespace().collect();
-                    let cols_count = 4;
-                    let gap = 4.0;
-                    let btn_h = cce_ui::layout::button_height();
+                    let reqs: Vec<String> = parsed.required_by.split_whitespace().map(str::to_string).collect();
+                    const COLUMNS: usize = 4;
                     // A core library is required by hundreds (glibc: ~300). Every
                     // row of buttons pushes the list down, and past the page's
                     // bottom the list loses its box; a few rows say enough.
                     const MAX_REQ_ROWS: usize = 4;
-                    let shown = reqs.len().min(cols_count * MAX_REQ_ROWS);
-
-                    for chunk in reqs[..shown].chunks(cols_count) {
-                        let btn_y = stack.context.ay();
-                        let cols = stack.context.row_layout(cols_count, gap);
-                        for (i, &pkg) in chunk.iter().enumerate() {
-                            if let Some(&(x, w)) = cols.get(i) {
-                                let action = AppAction::Packages(PackagesMessage::SelectAndScrollPackage(pkg.to_string()));
-                                stack.context.button(pkg, x, btn_y, w, btn_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG, action);
+                    let shown = reqs.len().min(COLUMNS * MAX_REQ_ROWS);
+                    col.text("Required By", 11.0, TEXT_DIM);
+                    for chunk in reqs[..shown].chunks(COLUMNS) {
+                        col.row(|r| {
+                            for pkg in chunk {
+                                let action = AppAction::Packages(PackagesMessage::SelectAndScrollPackage(pkg.clone()));
+                                form_button(r, pkg.clone(), 0.0, (TOGGLE_OFF, BTN_HOVER, TEXT_FG), action);
                             }
-                        }
+                            // A short last row keeps the columns of the rows above it.
+                            for _ in chunk.len()..COLUMNS {
+                                r.space(0.0, true);
+                            }
+                        });
                     }
                     if reqs.len() > shown {
-                        stack.context.text(&format!("... and {} more", reqs.len() - shown), 12.0, 0.0, 11.0, TEXT_DIM);
+                        col.text(format!("... and {} more", reqs.len() - shown), 11.0, TEXT_DIM);
                     }
                 }
             } else {
-                stack.context.text("No details available.", 12.0, 0.0, 12.0, TEXT_DIM);
+                col.text("No details available.", 12.0, TEXT_DIM);
             }
         }
 
-        section_divider(stack.context);
-
-        // ── List fills the rest of the page, across the section's content
-        // box (the box the section clips to) like the services list ──
-        let list_box_x = sec.content_left();
-        let list_box_y = sec.ay();
-        let list_box_w = sec.content_width();
-        let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
+        form_divider(&mut col);
 
-        let query = if state.search_box.editing {
-            state.search_box.edit_buffer.to_lowercase()
+        // ── The list fills the rest of the page ──
+        let tab = state.active_tab;
+        let empty_msg = if state.filter == InstalledFilter::Orphans && query.is_empty() {
+            "No orphaned packages"
         } else {
-            state.search_box.text.to_lowercase()
+            "No packages match the query"
         };
-
-        match state.active_tab {
-            PackageTab::Installed => {
-                let visible = state.visible_installed();
-                let installed = &state.installed;
-                let filtered: Vec<&PackageInfo> = visible.iter().map(|&i| &installed[i]).collect();
-
-                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
-                state.installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                state.installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
-                state.installed_list.push_prims(sec.pc);
-                let item_h = state.installed_list.item_height;
-
-                if state.installed_items.len() != filtered.len() {
-                    state.installed_items.clear();
-                    for _ in 0..filtered.len() {
-                        state.installed_items.push(Owned::new(InteractiveListItem::new("")));
+        let select_mode = state.select_mode;
+        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);
+        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();
+            match tab {
+                PackageTab::Installed => {
+                    let filtered: Vec<&PackageInfo> = visible_installed.iter().map(|&i| &installed[i]).collect();
+                    // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                    installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                    installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
+                    installed_list.push_prims(pc);
+                    let item_h = installed_list.item_height;
+
+                    pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                    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];
+                            item.title = pkg.name.clone();
+                            let reason = match (pkg.explicit, pkg.orphan) {
+                                (true, _) => "explicit",
+                                (false, true) => "orphan",
+                                (false, false) => "dependency",
+                            };
+                            item.subtitle = Some(format!("{}  ·  {}", pkg.version, reason));
+                            item.selected = if select_mode {
+                                checked.contains(&pkg.name)
+                            } else {
+                                Some(&pkg.name) == selected.as_ref()
+                            };
+                            render_widget(pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                        }
                     }
-                }
-
-                sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                for (idx, pkg) in filtered.iter().enumerate() {
-                    if let Some(draw_y) = state.installed_list.get_item_draw_y(idx, 4.0) {
-                        // Rows dispatch as extra roots (the dissolved list is no parent).
-                        let item = &mut state.installed_items[idx];
-                        item.title = pkg.name.clone();
-                        let reason = match (pkg.explicit, pkg.orphan) {
-                            (true, _) => "explicit",
-                            (false, true) => "orphan",
-                            (false, false) => "dependency",
-                        };
-                        item.subtitle = Some(format!("{}  ·  {}", pkg.version, reason));
-                        item.selected = if state.select_mode {
-                            state.checked.contains(&pkg.name)
-                        } else {
-                            Some(&pkg.name) == state.selected_package.as_ref()
-                        };
-                        render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                    pc.pop_clip_rect();
+                    // The scrollbar's fore copy, over the rows at the raise's fade.
+                    installed_list.push_scrollbar_fore(pc);
+                    if filtered.is_empty() {
+                        pc.text(empty_msg, list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
                     }
                 }
-                sec.pc.pop_clip_rect();
-                // The scrollbar's fore copy, over the rows at the raise's fade.
-                state.installed_list.push_scrollbar_fore(sec.pc);
-
-                if filtered.is_empty() {
-                    let msg = if state.filter == InstalledFilter::Orphans && query.is_empty() {
-                        "No orphaned packages"
-                    } else {
-                        "No packages match the query"
-                    };
-                    sec.pc.text(msg, list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
-                }
-            }
-            PackageTab::Updates => {
-                let filtered: Vec<&UpdateInfo> = state.updates.iter()
-                    .filter(|p| p.name.to_lowercase().contains(&query))
-                    .collect();
-
-                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
-                state.updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                state.updates_list.update_bounds(filtered.len(), list_box_y, list_box_h);
-                state.updates_list.push_prims(sec.pc);
-                let item_h = state.updates_list.item_height;
-
-                if state.updates_items.len() != filtered.len() {
-                    state.updates_items.clear();
-                    for _ in 0..filtered.len() {
-                        state.updates_items.push(Owned::new(InteractiveListItem::new("")));
+                PackageTab::Updates => {
+                    // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                    updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                    updates_list.update_bounds(filtered_updates.len(), list_box_y, list_box_h);
+                    updates_list.push_prims(pc);
+                    let item_h = updates_list.item_height;
+
+                    pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                    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];
+                            item.title = pkg.name.clone();
+                            item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
+                            item.selected = Some(&pkg.name) == selected.as_ref();
+                            render_widget(pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                        }
                     }
-                }
-
-                sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                for (idx, pkg) in filtered.iter().enumerate() {
-                    if let Some(draw_y) = state.updates_list.get_item_draw_y(idx, 4.0) {
-                        // Rows dispatch as extra roots (the dissolved list is no parent).
-                        let item = &mut state.updates_items[idx];
-                        item.title = pkg.name.clone();
-                        item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
-                        item.selected = Some(&pkg.name) == state.selected_package.as_ref();
-                        render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                    pc.pop_clip_rect();
+                    // The scrollbar's fore copy, over the rows at the raise's fade.
+                    updates_list.push_scrollbar_fore(pc);
+                    if filtered_updates.is_empty() {
+                        pc.text("No updates match the query", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
                     }
                 }
-                sec.pc.pop_clip_rect();
-                // The scrollbar's fore copy, over the rows at the raise's fade.
-                state.updates_list.push_scrollbar_fore(sec.pc);
-
-                if filtered.is_empty() {
-                    sec.pc.text("No updates match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
-                }
             }
-        }
-
-        // End the section so the well's bottom wall sits one margin below
-        // the list: finish() places the wall at content_y + padding + margin,
-        // so the list's own bottom margin and that one cancel.
-        sec.content_y = list_box_y + list_box_h - sec.padding();
+        });
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/power.rs b/src/pages/power.rs
index 262ee3a..9a2accb 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -46,7 +46,7 @@ use crate::app::{AppAction, PageContent};
 use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
 use cce_ui::widget::Owned;
 use cce_ui::layout::{PageFlow, PageLayoutBuilder};
-use cce_ui::widget::{Adapted, Dropdown, WidgetHost};
+use cce_ui::widget::{Adapted, Dropdown};
 use std::path::{Path, PathBuf};
 
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
@@ -736,7 +736,9 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
     if !state.loaded {
         let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
         builder.add_section_spanned(&mut final_pc, "", 1, focused(0), |sec| {
-            sec.text("Reading power interfaces...", 12.0, 0.0, 12.0, TEXT_DIM);
+            let mut form = sec.form();
+            form.column().text("Reading power interfaces...", 12.0, TEXT_DIM);
+            sec.place(form, ctx);
         });
         return final_pc;
     }
@@ -747,12 +749,18 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
     let show_assign = assignment_shown(facts);
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w)
         .with_section_count(if show_assign { 3 } else { 2 });
+    let menu_h = cce_ui::layout::dropdown_height();
 
     // ── Battery: facts, the charge limit, and whether switching is wired up ──
     builder.add_section(&mut final_pc, "Battery", focused(0), |sec| {
-        let sec_w = sec.cw;
         let f = &*facts;
-        if f.battery_present {
+        let mut form = sec.form();
+        let mut col = form.column();
+        col.block(|b| {
+            if !f.battery_present {
+                b.text("No battery detected", 12.0, TEXT_DIM);
+                return;
+            }
             let status_color = match f.status.as_str() {
                 "Charging" | "Full" => GOOD,
                 "Discharging" => WARN,
@@ -765,7 +773,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
             if let Some(true) = f.ac_online {
                 line.push_str("  ·  on AC");
             }
-            sec.text(&line, 12.0, 0.0, 12.0, status_color);
+            b.text(line, 12.0, status_color);
 
             // Folded in from the System page's battery section, which showed
             // the same pack in a second place until 2026-08-23.
@@ -781,52 +789,38 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
                 detail.push_str(&format!("{} {}", humanize_secs(secs), what));
             }
             if !detail.is_empty() {
-                sec.text(&detail, 12.0, 0.0, 12.0, TEXT_DIM);
+                b.text(detail, 12.0, TEXT_DIM);
             }
-
             if let Some(h) = f.health_pct {
-                sec.text(
-                    &format!("Health: {}% of design capacity", h),
-                    12.0,
-                    0.0,
-                    12.0,
-                    if h >= 80 { TEXT_DIM } else { WARN },
-                );
+                b.text(format!("Health: {}% of design capacity", h), 12.0, if h >= 80 { TEXT_DIM } else { WARN });
             }
             let pack = format!("{} {}", f.vendor, f.model);
             if !pack.trim().is_empty() {
-                sec.text(pack.trim(), 11.0, 0.0, 11.0, TEXT_DIM);
+                b.text(pack.trim().to_string(), 11.0, TEXT_DIM);
             }
-        } else {
-            sec.text("No battery detected", 12.0, 0.0, 12.0, TEXT_DIM);
-        }
+        });
 
         if f.charge_limit.is_some() {
-            sec.spacing(10.0);
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            // TODO(style): the dropdown's 14px row inset is this page's own,
-            // two wider than the well margin its neighbours sit on.
-            dd_limit.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-            stack.add_widget(dd_limit, sec_w - 28.0, 44.0, ctx);
+            col.widget(&mut *dd_limit, menu_h);
         }
 
         if f.battery_present {
-            sec.spacing(10.0);
-            match f.automation {
+            col.block(|b| match f.automation {
                 Automation::Ready => {
-                    sec.text("Switches automatically on plug and unplug.", 12.0, 0.0, 11.0, TEXT_DIM);
+                    b.text("Switches automatically on plug and unplug.", 11.0, TEXT_DIM);
                 }
                 Automation::Missing => {
-                    sec.text("Automatic switching is not installed:", 12.0, 0.0, 11.0, WARN);
-                    sec.text("System › System Files installs it.", 12.0, 0.0, 11.0, WARN);
+                    b.text("Automatic switching is not installed:", 11.0, WARN);
+                    b.text("System › System Files installs it.", 11.0, WARN);
                 }
                 Automation::Stale => {
-                    sec.text("Automatic switching is out of date and", 12.0, 0.0, 11.0, WARN);
-                    sec.text("applies nothing on plug or unplug.", 12.0, 0.0, 11.0, WARN);
-                    sec.text("Run: ccebuild install-system", 12.0, 0.0, 11.0, WARN);
+                    b.text("Automatic switching is out of date and", 11.0, WARN);
+                    b.text("applies nothing on plug or unplug.", 11.0, WARN);
+                    b.text("Run: ccebuild install-system", 11.0, WARN);
                 }
-            }
+            });
         }
+        sec.place(form, ctx);
     });
 
     // ── The edited mode's levers, behind the picker that chooses it ──
@@ -839,30 +833,25 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
             .map(|s| when_text(*s))
             .collect();
         builder.add_section(&mut final_pc, "Power Mode", focused(1), |sec| {
-            let sec_w = sec.cw;
-            {
-                let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-                dd_mode.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-                stack.add_widget(dd_mode, sec_w - 28.0, 44.0, ctx);
-            }
-            sec.spacing(4.0);
-            if running {
-                sec.text("Running now — picks apply immediately.", 12.0, 0.0, 11.0, GOOD);
-            } else if applies_when.is_empty() {
-                sec.text("Assigned to no adapter state.", 12.0, 0.0, 11.0, TEXT_DIM);
-            } else {
-                sec.text(&format!("Applied when {}.", applies_when.join(" and ")), 12.0, 0.0, 11.0, TEXT_DIM);
-            }
-            sec.text("Not set leaves a lever alone.", 12.0, 0.0, 11.0, TEXT_DIM);
-            sec.spacing(6.0);
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            for i in 0..Lever::ALL.len() {
-                if levers.rows[i].is_empty() {
-                    continue;
+            let mut form = sec.form();
+            let mut col = form.column();
+            col.widget(&mut *dd_mode, menu_h);
+            col.block(|b| {
+                if running {
+                    b.text("Running now — picks apply immediately.", 11.0, GOOD);
+                } else if applies_when.is_empty() {
+                    b.text("Assigned to no adapter state.", 11.0, TEXT_DIM);
+                } else {
+                    b.text(format!("Applied when {}.", applies_when.join(" and ")), 11.0, TEXT_DIM);
+                }
+                b.text("Not set leaves a lever alone.", 11.0, TEXT_DIM);
+            });
+            for (row, dd) in levers.rows.iter().zip(levers.dds.iter_mut()) {
+                if !row.is_empty() {
+                    col.widget(dd, menu_h);
                 }
-                levers.dds[i].set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-                stack.add_widget(&mut levers.dds[i], sec_w - 28.0, 44.0, ctx);
             }
+            sec.place(form, ctx);
         });
     }
 
@@ -870,16 +859,19 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
     if show_assign {
         let f = &*facts;
         builder.add_section(&mut final_pc, "Mode Assignment", focused(2), |sec| {
-            let sec_w = sec.cw;
-            sec.text("Which mode runs in each adapter state.", 12.0, 0.0, 11.0, TEXT_DIM);
-            sec.text(&format!("{} right now.", f.source.label()), 12.0, 0.0, 11.0, GOOD);
-            sec.spacing(6.0);
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            for source in sources.iter().copied() {
-                let dd = &mut dd_assign[source_index(source)];
-                dd.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-                stack.add_widget(dd, sec_w - 28.0, 44.0, ctx);
+            let mut form = sec.form();
+            let mut col = form.column();
+            col.block(|b| {
+                b.text("Which mode runs in each adapter state.", 11.0, TEXT_DIM);
+                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() {
+                if wanted.contains(&i) {
+                    col.widget(dd, menu_h);
+                }
             }
+            sec.place(form, ctx);
         });
     }
 
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 898d468..20c1199 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -245,188 +245,185 @@ pub async fn fetch_processes_state() -> ProcessesState {
 
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The least room the table keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
 
-pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, _ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
     // ── Processes Section (label-less well) ──
     builder.add_section_spanned(&mut final_pc, "", 1, root_focused || sec_focused.first().copied().unwrap_or(false), |sec| {
+        let mut form = sec.form();
         if !state.loaded {
-            sec.text("Loading processes...", 12.0, 0.0, 12.0, TEXT_FG);
-        } else {
-            // The list fills the section's content box across — the box the
-            // section clips everything it draws to. Laid out at `left + 12`
-            // instead, it started 2 * padding outside that clip, which cut
-            // the first letter off the summary line and the left edge off
-            // every row's hover wash. Top and bottom keep the well margin.
-            let inset = crate::app::section_margin();
-            let list_box_x = sec.content_left();
-            let list_box_w = sec.content_width();
-            // Power summary line above the list; the list starts below it.
-            let summary_h = 18.0;
-            sec.pc.text(&summary_line(&state.power), list_box_x, sec.well_top() + inset + 2.0, 11.0, TEXT_DIM);
-            let list_box_y = sec.well_top() + inset + summary_h;
-            // Fill the page: the well's bottom wall lands at the page bottom,
-            // the list keeps its even inset inside the well.
-            let list_box_h = ((cy + ch) - inset - list_box_y).max(120.0);
-
-            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
-            // scrollable viewport starts below the header.
-            //
-            // Columns live in CONTENT space at fixed offsets; every draw
-            // subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
-            // Sized so the default 820px window shows the whole table without
-            // the h-bar: COMMAND gives up the 20px the content box's inset
-            // took.
-            const COL_PID: f32 = 12.0;
-            const COL_COMMAND: f32 = 80.0;
-            const COL_RSS: f32 = 380.0;
-            const COL_MEM: f32 = 460.0;
-            const COL_CPU: f32 = 525.0;
-            const COL_WATTS: f32 = 585.0;
-            const COL_WAKE: f32 = 635.0;
-            const COL_KILL: f32 = 700.0;
-            const CONTENT_W: f32 = 725.0;
-
-            // TODO(style): the column offsets, header hit-target nudges and
-            // in-row text centring below are this table's own layout.
-            let header_h = 22.0;
-            state.cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            state.cpu_list.set_content_w(CONTENT_W);
-            // No band for the horizontal scrollbar: it rides the rows'
-            // centre line, crossing the vertical one, behind the plate until
-            // a scroll raises it.
-            let rows_h = list_box_h - header_h - 6.0;
-            state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, rows_h);
-            state.cpu_list.push_prims(sec.pc);
-
-            let ox = state.cpu_list.scroll_x;
-
-            // Header row is background-less (the well shows through); only the
-            // divider separates it from the rows.
-            sec.pc.rect([0.18, 0.18, 0.24, 1.0], list_box_x + 1.0, list_box_y + header_h, list_box_w - 2.0, 1.0); // Divider
-
-            // Header labels pan with the columns, clipped to the box. The
-            // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
-            // shows brighter with a `chevron-down` glyph after it (the sort
-            // is always descending). Both memory headers toggle the same
-            // Mem sort — one bigger target, no distinction to learn.
-            let active = |k: ProcSort| state.sort == k;
-            let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
-            const HDR_SIZE: f32 = 11.0;
-            const HDR_Y: f32 = 5.0;
-            const CHEVRON: f32 = 9.0;
-            sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
-            let header = |pc: &mut PageContent, label: &str, col: f32, on: bool| {
-                let x = list_box_x + col - ox;
-                pc.text(label, x, list_box_y + HDR_Y, HDR_SIZE, hdr(on));
-                if on {
-                    let gx = x + crate::app::text_width(label, HDR_SIZE, None) + 3.0;
-                    let gy = list_box_y + (header_h - CHEVRON) / 2.0;
-                    pc.icon("chevron-down", gx, gy, CHEVRON, CHEVRON, hdr(on));
-                }
-            };
-            sec.pc.text("PID", list_box_x + COL_PID - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
-            sec.pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
-            header(sec.pc, "MEM", COL_RSS, active(ProcSort::Mem));
-            sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + HDR_Y, HDR_SIZE, hdr(active(ProcSort::Mem)));
-            header(sec.pc, "CPU %", COL_CPU, active(ProcSort::Cpu));
-            header(sec.pc, "W", COL_WATTS, active(ProcSort::Power));
-            header(sec.pc, "WAKE/s", COL_WAKE, active(ProcSort::Wakeups));
-
-            // Header hit targets, list-row style like the rows (plateless,
-            // washed on hover), inside the header clip so they pan and cut
-            // with the labels. They share no rect with the row buttons, so
-            // emission order is free here.
-            sec.pc.list_row(list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
-                AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
-            sec.pc.list_row(list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
-                AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
-            sec.pc.list_row(list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
-                AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
-            sec.pc.list_row(list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
-                AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
-            sec.pc.list_row(list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
-                AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
-            sec.pc.pop_clip_rect();
-
-            let row_h = 24.0;
-
-            // Visible process rows, clipped to the rows' viewport — not on
-            // into the reserve band below it, where the scroll region still
-            // hands back a row starting there and its Kill button stood alone
-            // under the list.
-            sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
-            for (idx, p) in state.processes.iter().enumerate() {
-                if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
-                    // The row itself, in the DE's list style: plateless,
-                    // washed on hover. A transparent plain button here wore a
-                    // control plate per row — the whole list read as a stack
-                    // of carved rings. Viewport-fixed on purpose: the hover
-                    // band spans the visible row whatever the horizontal pan.
-                    sec.pc.list_row(
-                        list_box_x + 2.0,
-                        draw_y,
-                        list_box_w - 16.0,
-                        row_h,
-                        AppAction::Processes(ProcessesMessage::None),
-                    );
-
-                    // A pending kill dims the row until the next refresh
-                    // settles it (gone, or alive again = the kill didn't take).
-                    let dim = state.killing.contains(&p.pid);
-                    let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
-                    let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
-                    let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
-                    let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
-
-                    sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
-                    sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
-                    sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
-                    sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
-                    sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
-                    sec.pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
-                    sec.pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
-
-                    // Kill button, in content space like the columns. Emitted
-                    // AFTER the row button on purpose: overlapping page
-                    // buttons all see the click and the LAST take_click wins
-                    // the dispatched action (input_handler's collect loop), so
-                    // the Kill button beats the row's no-op exactly because it comes later.
-                    if !dim {
-                        // The `x` glyph in the danger tint; "Kill" only if
-                        // the icon set is missing.
-                        let kill_h = cce_ui::layout::button_height();
-                        sec.pc.button_icon_tinted(
-                            "x",
-                            "Kill",
-                            list_box_x + COL_KILL - ox,
-                            draw_y + (row_h - kill_h) / 2.0,
-                            kill_h,
-                            kill_h,
-                            [0.0, 0.0, 0.0, 0.0],
-                            [0.75, 0.30, 0.30, 0.45],
-                            [0.85, 0.55, 0.55, 1.0],
-                            AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
+            form.column().text("Loading processes...", 12.0, TEXT_FG);
+            sec.place(form, ctx);
+            return;
+        }
+        // The table fills the page: the well's floor lands at the page's bottom.
+        form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+        let inset = crate::app::section_margin();
+        let cpu_list = &mut state.cpu_list;
+        let processes = &state.processes;
+        let killing = &state.killing;
+        let sort = state.sort;
+        let mut col = form.column();
+        // Power summary line above the table.
+        col.text(summary_line(&state.power), 11.0, TEXT_DIM);
+        col.fill(LIST_MIN_H, move |pc, r, _| {
+            let (list_box_x, list_box_y, list_box_w, list_box_h) = (r.x, r.y, r.width, r.height);
+                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
+                // scrollable viewport starts below the header.
+                //
+                // Columns live in CONTENT space at fixed offsets; every draw
+                // subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
+                // Sized so the default 820px window shows the whole table without
+                // the h-bar: COMMAND gives up the 20px the content box's inset
+                // took.
+                const COL_PID: f32 = 12.0;
+                const COL_COMMAND: f32 = 80.0;
+                const COL_RSS: f32 = 380.0;
+                const COL_MEM: f32 = 460.0;
+                const COL_CPU: f32 = 525.0;
+                const COL_WATTS: f32 = 585.0;
+                const COL_WAKE: f32 = 635.0;
+                const COL_KILL: f32 = 700.0;
+                const CONTENT_W: f32 = 725.0;
+
+                // TODO(style): the column offsets, header hit-target nudges and
+                // in-row text centring below are this table's own layout.
+                let header_h = 22.0;
+                cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                cpu_list.set_content_w(CONTENT_W);
+                // No band for the horizontal scrollbar: it rides the rows'
+                // centre line, crossing the vertical one, behind the plate until
+                // a scroll raises it.
+                let rows_h = list_box_h - header_h - 6.0;
+                cpu_list.update_bounds(processes.len(), list_box_y + header_h, rows_h);
+                cpu_list.push_prims(pc);
+
+                let ox = cpu_list.scroll_x;
+
+                // Header row is background-less (the well shows through); only the
+                // divider separates it from the rows.
+                pc.rect([0.18, 0.18, 0.24, 1.0], list_box_x + 1.0, list_box_y + header_h, list_box_w - 2.0, 1.0); // Divider
+
+                // Header labels pan with the columns, clipped to the box. The
+                // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
+                // shows brighter with a `chevron-down` glyph after it (the sort
+                // is always descending). Both memory headers toggle the same
+                // Mem sort — one bigger target, no distinction to learn.
+                let active = |k: ProcSort| sort == k;
+                let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
+                const HDR_SIZE: f32 = 11.0;
+                const HDR_Y: f32 = 5.0;
+                const CHEVRON: f32 = 9.0;
+                pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
+                let header = |pc: &mut PageContent, label: &str, col: f32, on: bool| {
+                    let x = list_box_x + col - ox;
+                    pc.text(label, x, list_box_y + HDR_Y, HDR_SIZE, hdr(on));
+                    if on {
+                        let gx = x + crate::app::text_width(label, HDR_SIZE, None) + 3.0;
+                        let gy = list_box_y + (header_h - CHEVRON) / 2.0;
+                        pc.icon("chevron-down", gx, gy, CHEVRON, CHEVRON, hdr(on));
+                    }
+                };
+                pc.text("PID", list_box_x + COL_PID - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
+                pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
+                header(pc, "MEM", COL_RSS, active(ProcSort::Mem));
+                pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + HDR_Y, HDR_SIZE, hdr(active(ProcSort::Mem)));
+                header(pc, "CPU %", COL_CPU, active(ProcSort::Cpu));
+                header(pc, "W", COL_WATTS, active(ProcSort::Power));
+                header(pc, "WAKE/s", COL_WAKE, active(ProcSort::Wakeups));
+
+                // Header hit targets, list-row style like the rows (plateless,
+                // washed on hover), inside the header clip so they pan and cut
+                // with the labels. They share no rect with the row buttons, so
+                // emission order is free here.
+                pc.list_row(list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
+                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
+                pc.list_row(list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
+                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
+                pc.list_row(list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
+                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
+                pc.list_row(list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
+                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
+                pc.list_row(list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
+                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
+                pc.pop_clip_rect();
+
+                let row_h = 24.0;
+
+                // Visible process rows, clipped to the rows' viewport — not on
+                // into the reserve band below it, where the scroll region still
+                // hands back a row starting there and its Kill button stood alone
+                // under the list.
+                pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
+                for (idx, p) in processes.iter().enumerate() {
+                    if let Some(draw_y) = cpu_list.get_item_draw_y(idx, 4.0) {
+                        // The row itself, in the DE's list style: plateless,
+                        // washed on hover. A transparent plain button here wore a
+                        // control plate per row — the whole list read as a stack
+                        // of carved rings. Viewport-fixed on purpose: the hover
+                        // band spans the visible row whatever the horizontal pan.
+                        pc.list_row(
+                            list_box_x + 2.0,
+                            draw_y,
+                            list_box_w - 16.0,
+                            row_h,
+                            AppAction::Processes(ProcessesMessage::None),
                         );
+
+                        // A pending kill dims the row until the next refresh
+                        // settles it (gone, or alive again = the kill didn't take).
+                        let dim = killing.contains(&p.pid);
+                        let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
+                        let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
+                        let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
+                        let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
+
+                        pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
+                        pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
+                        pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
+                        pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
+                        pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
+                        pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
+                        pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
+
+                        // Kill button, in content space like the columns. Emitted
+                        // AFTER the row button on purpose: overlapping page
+                        // buttons all see the click and the LAST take_click wins
+                        // the dispatched action (input_handler's collect loop), so
+                        // the Kill button beats the row's no-op exactly because it comes later.
+                        if !dim {
+                            // The `x` glyph in the danger tint; "Kill" only if
+                            // the icon set is missing.
+                            let kill_h = cce_ui::layout::button_height();
+                            pc.button_icon_tinted(
+                                "x",
+                                "Kill",
+                                list_box_x + COL_KILL - ox,
+                                draw_y + (row_h - kill_h) / 2.0,
+                                kill_h,
+                                kill_h,
+                                [0.0, 0.0, 0.0, 0.0],
+                                [0.75, 0.30, 0.30, 0.45],
+                                [0.85, 0.55, 0.55, 1.0],
+                                AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
+                            );
+                        }
                     }
                 }
-            }
-            sec.pc.pop_clip_rect();
-            // The scrollbar's fore copy, over the rows at the raise's fade.
-            state.cpu_list.push_scrollbar_fore(sec.pc);
+                pc.pop_clip_rect();
+                // The scrollbar's fore copy, over the rows at the raise's fade.
+                cpu_list.push_scrollbar_fore(pc);
             
-            if state.processes.is_empty() {
-                sec.pc.text("No active processes", list_box_x + inset, list_box_y + header_h + 16.0, 12.0, TEXT_DIM);
-            }
-
-            // End the section so the well's bottom wall sits one margin below
-            // the list: finish() places the wall at content_y + padding +
-            // margin, so the list's own bottom margin and that one cancel.
-            sec.content_y = list_box_y + list_box_h - sec.padding();
-        }
+                if processes.is_empty() {
+                    pc.text("No active processes", list_box_x + inset, list_box_y + header_h + 16.0, 12.0, TEXT_DIM);
+                }
+        });
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 15abe59..328a02a 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -1,8 +1,8 @@
-use crate::app::{PageContent, SectionContextExt};
+use crate::app::{form_button, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
-use cce_ui::widget::{TextBox, InteractiveListItem, WidgetHost};
+use cce_ui::widget::{TextBox, InteractiveListItem};
 
 #[derive(Debug, Clone)]
 pub struct ServiceInfo {
@@ -58,239 +58,202 @@ pub enum ServicesMessage {
 }
 
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The least room the list keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
 
 pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
-    let m = crate::app::section_margin();
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
         let sec_w = sec.cw;
+        let mut form = sec.form();
         if !state.loaded {
-            sec.text("Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
+            form.column().text("Loading systemd services...", 12.0, TEXT_DIM);
+            sec.place(form, ctx);
+            return;
+        }
+        // The list fills the page: the well's floor lands at the page's bottom.
+        form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+
+        let tab_colors = |on: bool| {
+            let face = if on { [0.20, 0.40, 0.65, 0.4] } else { [0.10, 0.10, 0.16, 0.3] };
+            (face, [0.20, 0.20, 0.25, 0.15], [0.90, 0.90, 0.95, 1.0])
+        };
+        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()
         } else {
-            // Tab header buttons: System Services, User Services
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            let tab_h = cce_ui::layout::button_height();
-            let active_bg = [0.20, 0.40, 0.65, 0.4];
-            let inactive_bg = [0.10, 0.10, 0.16, 0.3];
-            let hover_bg = [0.20, 0.20, 0.25, 0.15];
-
-            let label1 = if stack.context.cw < 250.0 { "System" } else { "System Services" };
-            let label2 = if stack.context.cw < 250.0 { "User" } else { "User Services" };
-
-            stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
-                if i == 0 {
-                    ctx.button(
-                        label1,
-                        x,
-                        ctx.ay(),
-                        w,
-                        tab_h,
-                        if state.active_tab == ServiceTab::System { active_bg } else { inactive_bg },
-                        hover_bg,
-                        [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)),
-                    );
-                } else {
-                    ctx.button(
-                        label2,
-                        x,
-                        ctx.ay(),
-                        w,
-                        tab_h,
-                        if state.active_tab == ServiceTab::User { active_bg } else { inactive_bg },
-                        hover_bg,
-                        [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)),
-                    );
-                }
-            });
-
-            stack.context.spacing(4.0);
-
-            // Search textbox
-            let search_w = sec_w - 2.0 * m;
-            let search_h = 46.0;
-
-            state.search_box.set_row_rect(stack.context.left + m, search_w);
-            stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
-            stack.context.spacing(cce_ui::layout::plate_gap());
-
-            // Scroll box list, across the section's content box — the box
-            // the section clips to. At `left + m` the list ran 2 * padding
-            // past it on both sides, and the clip cut its edges off flat.
-            let list_box_x = sec.content_left();
-            let list_box_y = sec.ay();
-            let list_box_w = sec.content_width();
-            // Fill the page: the well's bottom wall lands at the page bottom,
-            // the list keeps one margin above it.
-            let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
-
-            // Filter services
-            let query = if state.search_box.editing {
-                state.search_box.edit_buffer.to_lowercase()
-            } else {
-                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();
-
-            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
-            state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            state.list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
-            state.list.push_prims(sec.pc);
-
-            let item_h = state.list.item_height;
-
-            if state.items.len() != filtered_services.len() {
-                state.items.clear();
-                for _ in 0..filtered_services.len() {
-                    state.items.push(Owned::new(InteractiveListItem::new("")));
-                }
+            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 _ in 0..filtered_services.len() {
+                state.items.push(Owned::new(InteractiveListItem::new("")));
             }
+        }
 
-            sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            for (idx, service) in filtered_services.iter().enumerate() {
-                if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
-                    // Transport + Restart, on the LEFT where the status dot
-                    // used to be: the dot was reporting what the transport icon
-                    // already says (play = stopped, stop = running), so the
-                    // controls take the column it was using and the name/
-                    // description run from there to the row's right inset.
-                    //
-                    // Sized on whether the icon set is actually THERE:
-                    // `button_icon` falls back to the labels when it isn't, and
-                    // a square button doesn't clip a label so much as replace it
-                    // — the text centers, so both ends cut and "Restart" reads
-                    // "sta". `upload_icon` caches per (name, px), so asking
-                    // every row costs one hash lookup.
-                    let icons_ok = cce_ui::upload_icon("play", 32).is_some();
-                    let is_small = sec_w < 350.0;
-                    let btn_h = cce_ui::layout::button_height();
-                    let (btn_w, r_btn_w) = if icons_ok { (btn_h, btn_h) } else { (46.0, 54.0) };
-                    let btn_gap = if is_small { 4.0 } else { 6.0 };
-
-                    // TODO(style): the row's control run and text column
-                    // below are this list row's own layout.
-                    let toggle_x = list_box_x + 10.0;
-                    let restart_x = toggle_x + btn_w + btn_gap;
-                    // The row's text starts after the controls and ends the
-                    // controls' inset short of the right wall: no gutter for
-                    // the scrollbar, which rides the list's centre line.
-                    let item_x = restart_x + r_btn_w + 10.0;
-                    let item_w = (list_box_x + list_box_w - 10.0) - item_x;
-
-                    let btn_y = draw_y + (item_h - btn_h) / 2.0;
-
-                    // Service description, truncated to the room the row's text
-                    // column actually has (the item insets its labels by 8px).
-                    let text_max_w = item_w - 16.0;
-                    let max_chars = ((text_max_w / 6.0) as usize).max(10);
-                    // `failed` was the status dot's third state and has nowhere
-                    // else to show: a failed unit offers the same play button a
-                    // cleanly stopped one does, so without this the row gives no
-                    // sign it died. It leads the line, and it goes in BEFORE the
-                    // truncation so a long description can't be what pushes the
-                    // marker off the end.
-                    let failed = service.active_state == "failed" || service.sub_state == "failed";
-                    // Char-boundary safe: the byte slice this replaced would
-                    // panic whenever the cut landed inside a multi-byte
-                    // character, and unit descriptions are free text.
-                    let desc_truncated = cce_ui::widget::display::truncate_tail(
-                        &description_line(failed, &service.description),
-                        max_chars,
-                    );
-
-                    // Render InteractiveListItem background and text labels
-                    // Rows dispatch as extra roots (the dissolved list is no parent).
-                    let item_btn = &mut state.items[idx];
-                    item_btn.title = service.name.clone();
-                    item_btn.subtitle = Some(desc_truncated);
-                    render_widget(sec.pc, item_btn, 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
-                    // button face, and what they DO is carried by the icon.
-                    // (The toolkit picks these itself for a `Button` with no
-                    // override; this host paints buttons from its own collected
-                    // colours, so it has to ask for them.)
-                    let face = cce_ui::colors::button_background_color();
-                    let face_hover = cce_ui::colors::button_hover_color();
-
-                    // ONE transport button, showing the action it will take:
-                    // play on a stopped service, stop on a running one. The two
-                    // dimmed half-buttons this replaced were never both live —
-                    // exactly one of them did anything on any given row.
-                    //
-                    // `running` is NOT the `is_active` the status dot reads.
-                    // `update` sets a transitional state the instant the button
-                    // is clicked (activating/starting, deactivating/stopping),
-                    // and counting those as the state they are heading for is
-                    // what flips this icon under the pointer instead of leaving
-                    // it stale until the next refresh lands. The dot keeps
-                    // reporting the confirmed state: the button says what it
-                    // will do, the dot says what is true.
-                    let running = transport_running(&service.active_state, &service.sub_state);
-
-                    // Fallback labels only — an icon face never draws them.
-                    // Without the icons a row says it in WORDS, narrow or
-                    // not: a symbol drawn as a character is exactly what the
-                    // icon set exists to replace, so the buttons keep the
-                    // width a word needs instead.
-                    let (start_lbl, stop_lbl, restart_lbl) = ("Start", "Stop", "Restart");
-
-                    // Start/Stop, collapsed
-                    sec.pc.button_icon(
-                        if running { "stop" } else { "play" },
-                        if running { stop_lbl } else { start_lbl },
-                        toggle_x,
-                        btn_y,
-                        btn_w,
-                        btn_h,
-                        face,
-                        face_hover,
-                        active_txt,
-                        1.0,
-                        crate::app::AppAction::Services(if running {
-                            ServicesMessage::Stop(service.name.clone(), service.is_system)
-                        } else {
-                            ServicesMessage::Start(service.name.clone(), service.is_system)
-                        }),
-                    );
-
-                    // Restart button
-                    sec.pc.button_icon(
-                        "refresh",
-                        restart_lbl,
-                        restart_x,
-                        btn_y,
-                        r_btn_w,
-                        btn_h,
-                        face,
-                        face_hover,
-                        active_txt,
-                        1.0,
-                        crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
-                    );
+        let active_tab = state.active_tab;
+        let list = &mut state.list;
+        let items = &mut state.items;
+        let mut col = form.column();
+        col.row(|r| {
+            form_button(r, label1, 0.0, tab_colors(active_tab == ServiceTab::System),
+                AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)));
+            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.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.
+            list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
+            list.push_prims(pc);
+            let item_h = list.item_height;
+
+            pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                for (idx, service) in filtered_services.iter().enumerate() {
+                    if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
+                        // Transport + Restart, on the LEFT where the status dot
+                        // used to be: the dot was reporting what the transport icon
+                        // already says (play = stopped, stop = running), so the
+                        // controls take the column it was using and the name/
+                        // description run from there to the row's right inset.
+                        //
+                        // Sized on whether the icon set is actually THERE:
+                        // `button_icon` falls back to the labels when it isn't, and
+                        // a square button doesn't clip a label so much as replace it
+                        // — the text centers, so both ends cut and "Restart" reads
+                        // "sta". `upload_icon` caches per (name, px), so asking
+                        // every row costs one hash lookup.
+                        let icons_ok = cce_ui::upload_icon("play", 32).is_some();
+                        let is_small = sec_w < 350.0;
+                        let btn_h = cce_ui::layout::button_height();
+                        let (btn_w, r_btn_w) = if icons_ok { (btn_h, btn_h) } else { (46.0, 54.0) };
+                        let btn_gap = if is_small { 4.0 } else { 6.0 };
+
+                        // TODO(style): the row's control run and text column
+                        // below are this list row's own layout.
+                        let toggle_x = list_box_x + 10.0;
+                        let restart_x = toggle_x + btn_w + btn_gap;
+                        // The row's text starts after the controls and ends the
+                        // controls' inset short of the right wall: no gutter for
+                        // the scrollbar, which rides the list's centre line.
+                        let item_x = restart_x + r_btn_w + 10.0;
+                        let item_w = (list_box_x + list_box_w - 10.0) - item_x;
+
+                        let btn_y = draw_y + (item_h - btn_h) / 2.0;
+
+                        // Service description, truncated to the room the row's text
+                        // column actually has (the item insets its labels by 8px).
+                        let text_max_w = item_w - 16.0;
+                        let max_chars = ((text_max_w / 6.0) as usize).max(10);
+                        // `failed` was the status dot's third state and has nowhere
+                        // else to show: a failed unit offers the same play button a
+                        // cleanly stopped one does, so without this the row gives no
+                        // sign it died. It leads the line, and it goes in BEFORE the
+                        // truncation so a long description can't be what pushes the
+                        // marker off the end.
+                        let failed = service.active_state == "failed" || service.sub_state == "failed";
+                        // Char-boundary safe: the byte slice this replaced would
+                        // panic whenever the cut landed inside a multi-byte
+                        // character, and unit descriptions are free text.
+                        let desc_truncated = cce_ui::widget::display::truncate_tail(
+                            &description_line(failed, &service.description),
+                            max_chars,
+                        );
+
+                        // Render InteractiveListItem background and text labels
+                        // Rows dispatch as extra roots (the dissolved list is no parent).
+                        let item_btn = &mut items[idx];
+                        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);
+
+                        let active_txt = [0.90, 0.90, 0.95, 1.0];
+                        // No per-action tints: both controls wear the DE's themed
+                        // button face, and what they DO is carried by the icon.
+                        // (The toolkit picks these itself for a `Button` with no
+                        // override; this host paints buttons from its own collected
+                        // colours, so it has to ask for them.)
+                        let face = cce_ui::colors::button_background_color();
+                        let face_hover = cce_ui::colors::button_hover_color();
+
+                        // ONE transport button, showing the action it will take:
+                        // play on a stopped service, stop on a running one. The two
+                        // dimmed half-buttons this replaced were never both live —
+                        // exactly one of them did anything on any given row.
+                        //
+                        // `running` is NOT the `is_active` the status dot reads.
+                        // `update` sets a transitional state the instant the button
+                        // is clicked (activating/starting, deactivating/stopping),
+                        // and counting those as the state they are heading for is
+                        // what flips this icon under the pointer instead of leaving
+                        // it stale until the next refresh lands. The dot keeps
+                        // reporting the confirmed state: the button says what it
+                        // will do, the dot says what is true.
+                        let running = transport_running(&service.active_state, &service.sub_state);
+
+                        // Fallback labels only — an icon face never draws them.
+                        // Without the icons a row says it in WORDS, narrow or
+                        // not: a symbol drawn as a character is exactly what the
+                        // icon set exists to replace, so the buttons keep the
+                        // width a word needs instead.
+                        let (start_lbl, stop_lbl, restart_lbl) = ("Start", "Stop", "Restart");
+
+                        // Start/Stop, collapsed
+                        pc.button_icon(
+                            if running { "stop" } else { "play" },
+                            if running { stop_lbl } else { start_lbl },
+                            toggle_x,
+                            btn_y,
+                            btn_w,
+                            btn_h,
+                            face,
+                            face_hover,
+                            active_txt,
+                            1.0,
+                            crate::app::AppAction::Services(if running {
+                                ServicesMessage::Stop(service.name.clone(), service.is_system)
+                            } else {
+                                ServicesMessage::Start(service.name.clone(), service.is_system)
+                            }),
+                        );
+
+                        // Restart button
+                        pc.button_icon(
+                            "refresh",
+                            restart_lbl,
+                            restart_x,
+                            btn_y,
+                            r_btn_w,
+                            btn_h,
+                            face,
+                            face_hover,
+                            active_txt,
+                            1.0,
+                            crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
+                        );
+                    }
                 }
-            }
-            sec.pc.pop_clip_rect();
+            pc.pop_clip_rect();
             // The scrollbar's fore copy, over the rows at the raise's fade.
-            state.list.push_scrollbar_fore(sec.pc);
+            list.push_scrollbar_fore(pc);
 
             if filtered_services.is_empty() {
-                sec.pc.text("No services match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
+                let inset = cce_ui::layout::plate_padding();
+                pc.text("No services match the query", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
             }
-
-            // End the section so the well's bottom wall sits one margin below
-            // the list: finish() places the wall at content_y + padding +
-            // margin, so the list's own bottom margin and that one cancel.
-            sec.content_y = list_box_y + list_box_h - sec.padding();
-        }
+        });
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index c82b735..3f1c147 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,6 +1,6 @@
 use crate::app::{AppAction, PageContent};
 use cce_ui::widget::Owned;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow};
+use cce_ui::layout::{PageLayoutBuilder, PageFlow};
 use std::fs;
 
 /// What the background poll produces — the fetched numbers only, never the
@@ -223,6 +223,8 @@ const BTN_BG: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
 const BTN_HOVER: [f32; 4] = [0.28, 0.50, 0.78, 1.0];
 const BTN_DISABLED: [f32; 4] = [0.15, 0.18, 0.22, 1.0];
 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+/// A usage bar's own height: a thin band under its figures.
+const USAGE_BAR_H: f32 = 8.0;
 
 pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut final_pc = PageContent::new();
@@ -231,113 +233,77 @@ 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 sec_w = sec.cw;
+        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(
+            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]),
+        );
+        let mut form = sec.form();
+        let mut col = form.column();
         if !state.loaded {
-            sec.text("Loading storage usage...", 12.0, 0.0, 12.0, TEXT_FG);
+            col.text("Loading storage usage...", 12.0, TEXT_FG);
         } else {
-            let disk_pct = if state.disk_total > 0.0 {
-                state.disk_used / state.disk_total * 100.0
-            } else {
-                0.0
-            };
-
-            sec.text("Disk", 12.0, 0.0, 12.0, LABEL_FG);
-            sec.text(
-                &format!("{:.0} / {:.0} GiB  ({:.0}%)", state.disk_used, state.disk_total, disk_pct),
-                100.0, 0.0, 12.0, TEXT_FG,
-            );
-
-            let bar_w = sec_w - 2.0 * crate::app::section_margin();
-            let yt = sec.ay();
-            let disk_bar_x = sec.ax(crate::app::section_margin());
-            let mut disk_bar = Owned::new(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]));
-            render_widget(sec.pc, &mut disk_bar, disk_bar_x, yt, bar_w, 8.0, ctx);
+            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);
         }
+        sec.place(form, ctx);
     });
 
     // Section 2: Memory
     builder.add_section(&mut final_pc, "Memory", sec_focused.get(1).copied().unwrap_or(false), |sec| {
-        let sec_w = sec.cw;
+        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(
+            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]),
+        );
+        let mut form = sec.form();
+        let mut col = form.column();
         if !state.loaded {
-            sec.text("Loading memory usage...", 12.0, 0.0, 12.0, TEXT_FG);
+            col.text("Loading memory usage...", 12.0, TEXT_FG);
         } else {
-            let ram_pct = if state.ram_total > 0.0 {
-                state.ram_used / state.ram_total * 100.0
-            } else {
-                0.0
-            };
-
-            sec.text("RAM", 12.0, 0.0, 12.0, LABEL_FG);
-            sec.text(
-                &format!("{:.1} / {:.1} GiB  ({:.0}%)", state.ram_used, state.ram_total, ram_pct),
-                100.0, 0.0, 12.0, TEXT_FG,
-            );
-
-            let bar_w = sec_w - 2.0 * crate::app::section_margin();
-            let yt = sec.ay();
-            let ram_bar_x = sec.ax(crate::app::section_margin());
-            let mut ram_bar = Owned::new(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]));
-            render_widget(sec.pc, &mut ram_bar, ram_bar_x, yt, bar_w, 8.0, ctx);
+            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);
         }
+        sec.place(form, ctx);
     });
 
-    // Section 2: Full System Backup
+    // Section 3: Full System Backup
     builder.add_section(&mut final_pc, "Full System Backup", sec_focused.get(2).copied().unwrap_or(false), |sec| {
-        if !state.backup_loaded {
-            sec.text("Loading backup state...", 12.0, 0.0, 12.0, TEXT_DIM);
+        // Retained widget rather than an immediate button, so it can hold keyboard focus. Its
+        // label/colours are re-synced each frame from the backup state, the way cce-mail drives
+        // its retained btn_unread.
+        let (btn_label, bg, hover) = if state.backup_in_progress {
+            ("Backing up...", BTN_DISABLED, BTN_DISABLED)
         } else {
-            // Status Row
-            sec.text("Backup Status", 12.0, 0.0, 12.0, LABEL_FG);
-            let status_text = if state.backup_in_progress { "Backing up..." } else { "Idle" };
-            let status_color = if state.backup_in_progress { GREEN } else { TEXT_FG };
-            sec.text(status_text, 120.0, 0.0, 12.0, status_color);
-
-            // Last Backup Row
-            sec.text("Last Backup", 12.0, 0.0, 12.0, LABEL_FG);
-            sec.text(&state.last_backup_time, 120.0, 0.0, 12.0, TEXT_FG);
-
-            // Backup Size Row
-            sec.text("Archive Size", 12.0, 0.0, 12.0, LABEL_FG);
-            sec.text(&state.backup_size, 120.0, 0.0, 12.0, TEXT_FG);
-
-            // Target Directories Row
-            sec.text("Backup Targets", 12.0, 0.0, 12.0, LABEL_FG);
-            sec.text("Entire Filesystem (/)  [Preserving attributes]", 120.0, 0.0, 12.0, TEXT_DIM);
-
-            // Destination Archive Row
-            sec.text("Destination", 12.0, 0.0, 12.0, LABEL_FG);
-            sec.text("USB Drive (/mnt/usb or /run/media/...)", 120.0, 0.0, 12.0, TEXT_DIM);
+            ("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);
 
-            // Error message if present
+        let mut form = sec.form();
+        let mut col = form.column();
+        if !state.backup_loaded {
+            col.text("Loading backup state...", 12.0, TEXT_DIM);
+        } else {
+            let (status_text, status_color) =
+                if state.backup_in_progress { ("Backing up...", GREEN) } else { ("Idle", TEXT_FG) };
+            let mut pairs = vec![
+                ("Backup Status".into(), LABEL_FG, status_text.into(), status_color),
+                ("Last Backup".into(), LABEL_FG, state.last_backup_time.clone(), TEXT_FG),
+                ("Archive Size".into(), LABEL_FG, state.backup_size.clone(), TEXT_FG),
+                ("Backup Targets".into(), LABEL_FG, "Entire Filesystem (/)  [Preserving attributes]".into(), TEXT_DIM),
+                ("Destination".into(), LABEL_FG, "USB Drive (/mnt/usb or /run/media/...)".into(), TEXT_DIM),
+            ];
             if let Some(ref err) = state.error_message {
-                sec.text("Error:", 12.0, 0.0, 12.0, RED);
-                sec.text(err, 60.0, 0.0, 11.0, RED);
+                pairs.push(("Error:".into(), RED, err.clone(), RED));
             }
-
-            // Action Button
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            let btn_h = cce_ui::layout::button_height();
-            
-            // Retained widget rather than an immediate `sec.button`, so it can hold
-            // keyboard focus. Its label/colours are re-synced each frame from the
-            // backup state, the way cce-mail drives its retained btn_unread.
-            let (btn_label, bg, hover) = if state.backup_in_progress {
-                ("Backing up...", BTN_DISABLED, BTN_DISABLED)
-            } 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);
-
-            let btn = &mut state.backup_button;
-            stack.add_row(1, 0.0, btn_h, |sctx, _, x, w| {
-                let y = sctx.ay();
-                render_widget(sctx.pc, btn, x, y, w, btn_h, ctx);
-            });
+            crate::app::form_pairs(&mut col, 12.0, pairs);
+            col.widget(&mut state.backup_button, cce_ui::layout::button_height());
         }
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index 3b8dcea..e331056 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,7 +1,7 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{button_need, form_button, form_button_fit, wrap_to_width, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::layout::{PageLayoutBuilder, PageFlow};
-use cce_ui::widget::{Label, Button, WidgetHostExt};
+use cce_ui::widget::{Label, WidgetHostExt};
 
 
 #[derive(Debug, Clone)]
@@ -250,79 +250,11 @@ where
     });
 }
 
-/// Width a label needs on a button plate. Feeds `add_row_for`, so a row of
-/// buttons is divided by what is written on them rather than into equal
-/// slices — "Reboot" and "Hibernate" are not the same size and a row that
-/// pretends otherwise clips one and pads the other.
-///
-/// Asks the Button itself (`intrinsic_size`: its label shaped in the button
-/// font, plus its 8px inset each side) rather than measuring here. This used
-/// `measure_text_width` in the control-label font, an inked extent that runs
-/// ~20% short of the shaped run under Berkeley Mono, so the row handed every
-/// button less than its label and "Hibernate" / "Power Off" were cut off.
-fn button_need(label: &str) -> f32 {
-    Button::new(0.0, 0.0, 0.0, 0.0)
-        .with_label(label)
-        .intrinsic_size()
-        .map_or(0.0, |s| s.width)
-}
-
-/// `text` broken into lines no wider than `width` at `size`, measured as the
-/// renderer shapes it (the default UI face, as `PageContent::text` draws).
-/// Breaks after a space or before a `/`, so a path splits on its separators;
-/// a run with neither is cut wherever it overflows. A section's text is
-/// clipped to its content box, so anything that does not fit has to wrap or
-/// it is simply lost — a GPU name, the CPU model, a pending install path.
-fn wrap_to_width(text: &str, width: f32, size: f32) -> Vec<String> {
-    let offsets = cce_ui::geometry_font_system()
-        .lock()
-        .map(|mut fs| cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, None))
-        .unwrap_or_default();
-    if offsets.last().map_or(true, |&(_, total)| total <= width) {
-        return vec![text.to_string()];
-    }
-    let mut lines = Vec::new();
-    let (mut start, mut start_x) = (0usize, 0.0f32);
-    // Where the next line would begin if this one broke at the last chance.
-    let mut chance: Option<(usize, f32)> = None;
-    for pair in offsets.windows(2) {
-        let ((b, x), (next_b, next_x)) = (pair[0], pair[1]);
-        if next_x - start_x > width && b > start {
-            let (cut, cut_x) = chance.filter(|&(c, _)| c > start).unwrap_or((b, x));
-            lines.push(text[start..cut].trim_end().to_string());
-            (start, start_x, chance) = (cut, cut_x, None);
-        }
-        match &text[b..next_b] {
-            " " => chance = Some((next_b, next_x)),
-            "/" if b > start => chance = Some((b, x)),
-            _ => {}
-        }
-    }
-    lines.push(text[start..].trim_end().to_string());
-    lines.retain(|l| !l.is_empty());
-    lines
-}
-
-/// [`SectionContext::text`](cce_ui::layout::SectionContext::text), wrapped
-/// to the content box instead of clipped by it. Continuation lines follow at
-/// the line pitch `text` itself advances by, without the row gap, so one
-/// wrapped item still reads as one item.
-fn wrapped_text(sec: &mut cce_ui::layout::SectionContext<'_, PageContent>, text: &str, size: f32, color: [f32; 4]) {
-    use cce_ui::layout::RenderTarget;
-    let x = sec.content_left();
-    let right = x + sec.content_width();
-    let mut y = sec.content_y;
-    if y > sec.content_start_y {
-        y += sec.row_gap;
-    }
-    for line in wrap_to_width(text, sec.content_width(), size) {
-        sec.pc.text_with_bounds(&line, x, y, size, color, Some([x, y - size, right, y + 2.0 * size]));
-        y += size + 4.0;
-    }
-    sec.content_y = y;
-    for h in &mut sec.grid.col_heights {
-        *h = y;
-    }
+/// A line of text wrapped to the form's width rather than cut at it: its lines one under the
+/// next, so one wrapped item still reads as one item.
+fn wrapped_text(g: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>, text: &str, size: f32, color: [f32; 4]) {
+    let lines = wrap_to_width(text, g.form_width(), size);
+    g.lines(lines, size, color);
 }
 
 fn spawn_systemctl(action: &str) {
@@ -558,7 +490,7 @@ const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
 const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 
-pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, _ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
     // Six, matching the add_section calls below and the six groups
@@ -570,76 +502,75 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
 
     // ── 1. System Section ──
     builder.add_section(&mut final_pc, "System", sec_focused.first().copied().unwrap_or(false), |sec| {
-        if !state.loaded {
-            sec.text("Loading system information...", 12.0, 0.0, 14.0, TEXT_FG);
-        } else {
-            wrapped_text(sec, &format!("{}  —  Linux {}", state.hostname, state.kernel), 14.0, TEXT_FG);
-            wrapped_text(sec, &format!("Uptime: {}", state.uptime), 12.0, TEXT_DIM);
-        }
+        let mut form = sec.form();
+        form.column().block(|b| {
+            if !state.loaded {
+                b.text("Loading system information...", 14.0, TEXT_FG);
+            } else {
+                wrapped_text(b, &format!("{}  —  Linux {}", state.hostname, state.kernel), 14.0, TEXT_FG);
+                wrapped_text(b, &format!("Uptime: {}", state.uptime), 12.0, TEXT_DIM);
+            }
+        });
+        sec.place(form, ctx);
     });
 
     // ── 2. System Actions Section ──
     builder.add_section(&mut final_pc, "System Actions", sec_focused.get(1).copied().unwrap_or(false), |sec| {
-        let gap = cce_ui::layout::plate_gap();
-        let mut stack = sec.vstack(gap);
-        // The toolkit's button height, like every other cce-ui button — this
-        // row was a hand-set 32px, a size no other control in the DE wears.
-        let btn_h = cce_ui::layout::button_height();
-
-        const ACTIONS: [&str; 4] = ["Suspend", "Hibernate", "Reboot", "Power Off"];
-        let needs: Vec<f32> = ACTIONS.iter().map(|l| button_need(l)).collect();
-        // All four on one row when their labels fit; otherwise the safe pair
-        // over the destructive pair, rather than squeezing every plate below
-        // its label (`row_layout_for` scales an overfull row down, which is
-        // what clipped the labels).
-        let one_row = needs.iter().sum::<f32>() + gap * (needs.len() - 1) as f32
-            <= stack.context.content_width();
+        const ACTIONS: [(&str, [f32; 4], SystemMessage); 4] = [
+            ("Suspend", SAFE_BG, SystemMessage::Suspend),
+            ("Hibernate", SAFE_BG, SystemMessage::Hibernate),
+            ("Reboot", DANGER_BG, SystemMessage::Reboot),
+            ("Power Off", DANGER_BG, SystemMessage::PowerOff),
+        ];
+        let needs: Vec<f32> = ACTIONS.iter().map(|a| button_need(a.0)).collect();
+        let mut form = sec.form();
+        // All four on one row when their labels fit; otherwise the safe pair over the
+        // destructive pair, rather than squeezing every plate below its label.
+        let gap = cce_ui::layout::control_gap();
+        let one_row = needs.iter().sum::<f32>() + gap * (needs.len() - 1) as f32 <= form.width();
         let rows: &[std::ops::Range<usize>] = if one_row { &[0..4] } else { &[0..2, 2..4] };
-        for r in rows {
-            let first = r.start;
-            stack.add_row_for(&needs[r.clone()], gap, btn_h, |ctx, i, x, w| {
-                let (label, bg, msg) = match first + i {
-                    0 => ("Suspend", SAFE_BG, SystemMessage::Suspend),
-                    1 => ("Hibernate", SAFE_BG, SystemMessage::Hibernate),
-                    2 => ("Reboot", DANGER_BG, SystemMessage::Reboot),
-                    _ => ("Power Off", DANGER_BG, SystemMessage::PowerOff),
-                };
-                ctx.button(label, x, ctx.ay(), w, btn_h, bg, BTN_HOVER, WHITE, AppAction::SystemInfo(msg));
+        let mut col = form.column();
+        for range in rows {
+            col.row(|r| {
+                for i in range.clone() {
+                    let (label, bg, msg) = ACTIONS[i].clone();
+                    form_button_fit(r, label, needs[i], (bg, BTN_HOVER, WHITE), AppAction::SystemInfo(msg));
+                }
             });
         }
-
-        // No trailing gap after the last row: the well's own floor inset is
-        // the space below it, as it is beside and above.
-        stack.spacing = 0.0;
-        stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
-            ctx.button("Force Shutdown", x, ctx.ay(), w, btn_h,
-                DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::ForceShutdown));
-        });
+        form_button(&mut col, "Force Shutdown", 0.0, (DANGER_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::ForceShutdown));
+        sec.place(form, ctx);
     });
 
     // ── 3. CPU Section ──
     builder.add_section(&mut final_pc, "CPU", sec_focused.get(2).copied().unwrap_or(false), |sec| {
-        if !state.loaded {
-            sec.text("Loading CPU model and utilization...", 12.0, 0.0, 12.0, TEXT_FG);
-        } else {
-            // Same strings the old Label widgets carried, stacked vertically (the
-            // grid-column widget placement overlapped them at narrow widths).
-            wrapped_text(sec, &format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores), 12.0, TEXT_FG);
-            wrapped_text(sec, &format!("Usage  {:.0}%", state.cpu_usage), 12.0, TEXT_FG);
-            let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
-            wrapped_text(sec, &cpu_temp_text, 12.0, TEXT_FG);
-        }
+        let mut form = sec.form();
+        form.column().block(|b| {
+            if !state.loaded {
+                b.text("Loading CPU model and utilization...", 12.0, TEXT_FG);
+            } else {
+                wrapped_text(b, &format!("CPU  {}  ({} cores)", state.cpu_model, state.cpu_cores), 12.0, TEXT_FG);
+                wrapped_text(b, &format!("Usage  {:.0}%", state.cpu_usage), 12.0, TEXT_FG);
+                let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp  {:.0}°C", t)).unwrap_or_else(|| "Temp  N/A".to_string());
+                wrapped_text(b, &cpu_temp_text, 12.0, TEXT_FG);
+            }
+        });
+        sec.place(form, ctx);
     });
 
     // ── 4. GPU Section ──
-    builder.add_section(&mut final_pc, "GPU", sec_focused.get(3).copied().unwrap_or(false), |sec_gpu| {
-        if !state.loaded {
-            sec_gpu.text("Loading GPU models...", 12.0, 0.0, 12.0, TEXT_FG);
-        } else {
-            for gpu_text in state.gpu_strings.iter() {
-                wrapped_text(sec_gpu, gpu_text, 12.0, TEXT_FG);
+    builder.add_section(&mut final_pc, "GPU", sec_focused.get(3).copied().unwrap_or(false), |sec| {
+        let mut form = sec.form();
+        form.column().block(|b| {
+            if !state.loaded {
+                b.text("Loading GPU models...", 12.0, TEXT_FG);
+            } else {
+                for gpu_text in state.gpu_strings.iter() {
+                    wrapped_text(b, gpu_text, 12.0, TEXT_FG);
+                }
             }
-        }
+        });
+        sec.place(form, ctx);
     });
 
     // ── 5. System Files Section ──
@@ -650,8 +581,6 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
     // the greeter out of a half-built tree.
     builder.add_section(&mut final_pc, "System Files", sec_focused.get(4).copied().unwrap_or(false), |sec| {
         let sf = &state.sysfiles;
-        let mut stack = sec.vstack(6.0);
-
         let (status, color) = if sf.busy {
             ("Checking…".to_string(), TEXT_DIM)
         } else if !sf.scanned {
@@ -669,96 +598,76 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                 TEXT_FG,
             )
         };
-        stack.add_row(1, 0.0, 16.0, |c, _, x, _| {
-            let y = c.ay();
-            c.pc.text(&status, x, y + 4.0, 12.0, color);
-        });
-
-        // Name the files. "2 files differ" is not enough to authorize a root
-        // install on — which one is the greeter matters.
-        // A path pair is wider than the well, and the well clips: wrapped,
-        // continuation lines indented past the first, so each file still
-        // reads as one entry.
-        let wrap_w = stack.context.content_width();
-        for line in sf.pending.iter().take(8) {
-            let lines = wrap_to_width(line, wrap_w - 8.0, 11.0);
-            let (first, rest) = lines.split_first().map_or((String::new(), &[][..]), |(f, r)| (f.clone(), r));
-            let rest: Vec<String> = rest.iter().flat_map(|l| wrap_to_width(l, wrap_w - 20.0, 11.0)).collect();
-            stack.add_row(1, 0.0, 14.0 * (1 + rest.len()) as f32, move |c, _, x, _| {
-                let y = c.ay();
-                c.pc.text(&first, x + 8.0, y + 3.0, 11.0, TEXT_DIM);
-                for (k, l) in rest.iter().enumerate() {
-                    c.pc.text(l, x + 20.0, y + 3.0 + 14.0 * (k + 1) as f32, 11.0, TEXT_DIM);
+        let mut form = sec.form();
+        let wrap_w = form.width();
+        let mut col = form.column();
+        col.block(|b| {
+            b.text(status, 12.0, color);
+            // Name the files. "2 files differ" is not enough to authorize a root
+            // install on — which one is the greeter matters. A path pair is wider
+            // than the well, and the well clips: wrapped, continuation lines
+            // indented past the first, so each file still reads as one entry.
+            let indent = cce_ui::layout::CONTROL_TEXT_INSET;
+            for line in sf.pending.iter().take(8) {
+                let lines = wrap_to_width(line, wrap_w, 11.0);
+                let (first, rest) = lines.split_first().map_or((String::new(), &[][..]), |(f, r)| (f.clone(), r));
+                let rest: Vec<String> = rest.iter().flat_map(|l| wrap_to_width(l, wrap_w - indent, 11.0)).collect();
+                b.text(first, 11.0, TEXT_DIM);
+                if !rest.is_empty() {
+                    b.row(|r| {
+                        r.space(indent, false);
+                        r.lines(rest, 11.0, TEXT_DIM);
+                    });
                 }
-            });
-        }
+            }
+        });
 
         if let Some(ref res) = sf.result {
-            let (msg, col) = match res {
+            let (msg, color) = match res {
                 Ok(()) => ("Installed — takes effect at next login".to_string(), TEXT_DIM),
                 Err(e) => (format!("Failed: {}", e), DANGER_BG),
             };
-            let lines = wrap_to_width(&msg, stack.context.content_width(), 11.0);
-            stack.add_row(1, 0.0, 16.0 + 14.0 * (lines.len().max(1) - 1) as f32, move |c, _, x, _| {
-                let y = c.ay();
-                for (k, l) in lines.iter().enumerate() {
-                    c.pc.text(l, x, y + 4.0 + 14.0 * k as f32, 11.0, col);
-                }
-            });
+            wrapped_text(&mut col, &msg, 11.0, color);
         }
 
-        let btn_h = cce_ui::layout::button_height();
-        // The buttons close the well: no trailing gap below them.
-        stack.spacing = 0.0;
         let has_work = sf.scanned && !sf.pending.is_empty();
         let busy = sf.busy;
-        let needs = [button_need("Check"), button_need("Install (root)")];
-        stack.add_row_for(&needs, cce_ui::layout::plate_gap(), btn_h, move |c, i, x, w| {
-            match i {
-                0 => {
-                    c.button("Check", x, c.ay(), w, btn_h,
-                        SAFE_BG, BTN_HOVER, WHITE,
-                        AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
-                }
-                1 => {
-                    // Only offered when there is something to install. The
-                    // apply re-plans anyway, so a stale-enabled button would
-                    // be a no-op rather than a hazard — but it would also put
-                    // up a root prompt for nothing.
-                    if has_work && !busy {
-                        c.button("Install (root)", x, c.ay(), w, btn_h,
-                            DANGER_BG, BTN_HOVER, WHITE,
-                            AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
-                    }
-                }
-                _ => {}
+        let (check_need, install_need) = (button_need("Check"), button_need("Install (root)"));
+        col.row(|r| {
+            form_button_fit(r, "Check", check_need, (SAFE_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
+            // Only offered when there is something to install. The apply re-plans anyway,
+            // so a stale-enabled button would be a no-op rather than a hazard — but it
+            // would also put up a root prompt for nothing. Its room is kept either way, so
+            // Check does not change width.
+            if has_work && !busy {
+                form_button_fit(r, "Install (root)", install_need, (DANGER_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
+            } else {
+                r.space(install_need, true);
             }
         });
+        sec.place(form, ctx);
     });
 
     // ── 6. Builds Section ──
     // How long each installed cce binary took to build, slowest first.
     builder.add_section(&mut final_pc, "Builds", sec_focused.get(5).copied().unwrap_or(false), |sec| {
+        let mut form = sec.form();
+        let mut col = form.column();
         if !state.loaded {
-            sec.text("Loading installed builds...", 12.0, 0.0, 12.0, TEXT_FG);
-            return;
-        }
-        if state.builds.is_empty() {
-            sec.text("No cce binaries in ~/.local/bin", 12.0, 0.0, 12.0, TEXT_DIM);
-            return;
-        }
-        let mut stack = sec.vstack(0.0);
-        for b in &state.builds {
+            col.text("Loading installed builds...", 12.0, TEXT_FG);
+        } else if state.builds.is_empty() {
+            col.text("No cce binaries in ~/.local/bin", 12.0, TEXT_DIM);
+        } else {
             // "—" is a binary no ccebuild build has timed (a bare `cargo
             // build`, or one built before the timing was recorded).
-            let took = b.seconds.map(format_build_duration).unwrap_or_else(|| "—".to_string());
-            let name = b.name.clone();
-            stack.add_row(1, 0.0, 16.0, move |c, _, x, _| {
-                let y = c.ay();
-                c.pc.text(&name, x, y + 3.0, 11.0, TEXT_FG);
-                c.pc.text(&took, x + 140.0, y + 3.0, 11.0, TEXT_DIM);
-            });
+            let pairs = state
+                .builds
+                .iter()
+                .map(|b| (b.name.clone(), TEXT_FG, b.seconds.map(format_build_duration).unwrap_or_else(|| "—".to_string()), TEXT_DIM))
+                .collect();
+            crate::app::form_pairs(&mut col, 11.0, pairs);
         }
+        sec.place(form, ctx);
     });
 
     final_pc
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index fc3b665..b91fa50 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -2,11 +2,11 @@
 //! schedules, trigger the activated service immediately, and enable/disable
 //! timer units (system scope through pkexec).
 
-use crate::app::{PageContent, SectionContextExt};
+use crate::app::{button_need, form_button, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
-use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox, WidgetHost};
+use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum TimerTab {
@@ -82,6 +82,8 @@ pub enum TimersMessage {
 }
 
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The least room the list keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
 
 /// "46min" / "3h" / "5d 3h" — coarse two-unit humanization.
 fn humanize(secs: u64) -> String {
@@ -402,272 +404,227 @@ fn button_glyph(pc: &mut PageContent, compact: bool, icon: &str, word: &str, x:
 }
 
 pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
-    let m = crate::app::section_margin();
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
         let sec_w = sec.cw;
+        let mut form = sec.form();
         if !state.loaded {
-            sec.text("Loading systemd timers...", 12.0, 0.0, 12.0, TEXT_DIM);
-        } else {
-            // Tab header buttons: System Timers, User Timers
-            let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-            let tab_h = cce_ui::layout::button_height();
-            let active_bg = [0.20, 0.40, 0.65, 0.4];
-            let inactive_bg = [0.10, 0.10, 0.16, 0.3];
-            let hover_bg = [0.20, 0.20, 0.25, 0.15];
-
-            let label1 = if stack.context.cw < 250.0 { "System" } else { "System Timers" };
-            let label2 = if stack.context.cw < 250.0 { "User" } else { "User Timers" };
-
-            stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
-                if i == 0 {
-                    ctx.button(
-                        label1,
-                        x,
-                        ctx.ay(),
-                        w,
-                        tab_h,
-                        if state.active_tab == TimerTab::System { active_bg } else { inactive_bg },
-                        hover_bg,
-                        [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Timers(TimersMessage::SetTab(TimerTab::System)),
-                    );
-                } else {
-                    ctx.button(
-                        label2,
-                        x,
-                        ctx.ay(),
-                        w,
-                        tab_h,
-                        if state.active_tab == TimerTab::User { active_bg } else { inactive_bg },
-                        hover_bg,
-                        [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Timers(TimersMessage::SetTab(TimerTab::User)),
-                    );
-                }
-            });
-
-            stack.context.spacing(4.0);
-
-            // New Timer (user scope) — compact button; the form unfolds below.
-            let new_bg = if state.creating { active_bg } else { [0.13, 0.18, 0.14, 1.0] };
-            stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-                if i == 0 {
-                    c.button("New Timer", x, c.ay(), w, tab_h,
-                        new_bg, [0.25, 0.30, 0.26, 1.0], [0.90, 0.90, 0.95, 1.0],
-                        crate::app::AppAction::Timers(TimersMessage::CreateStart));
-                }
-            });
-
-            if state.creating || state.editing.is_some() {
-                let item_w = sec_w - 2.0 * (stack.context.padding() + m);
-                let rx = stack.context.left;
-                let widget_h = cce_ui::layout::spinbox_height();
-
-                if let Some(base) = &state.editing {
-                    stack.context.text(&format!("Editing {}.timer \u{2014} command and schedule rewrite in place.", base), 12.0, 0.0, 11.0, TEXT_DIM);
-                } else {
-                    stack.context.text("New user timer \u{2014} runs the command on the schedule.", 12.0, 0.0, 11.0, TEXT_DIM);
-                }
-
-                if state.creating {
-                    state.name_box.set_row_rect(rx + m, item_w);
-                    stack.add_widget(&mut state.name_box, item_w, widget_h, ctx);
-                }
-                state.command_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.command_box, item_w, widget_h, ctx);
-                state.schedule_box.set_row_rect(rx + m, item_w);
-                stack.add_widget(&mut state.schedule_box, item_w, widget_h, ctx);
-
-                stack.context.spacing(4.0);
-                let save_label = if state.editing.is_some() { "Save" } else { "Create" };
-                stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
-                    match i {
-                        0 => c.button(save_label, x, c.ay(), w, tab_h,
-                            [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], [0.90, 0.90, 0.95, 1.0],
-                            crate::app::AppAction::Timers(TimersMessage::CreateSave)),
-                        1 => c.button("Cancel", x, c.ay(), w, tab_h,
-                            [0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0], [0.90, 0.90, 0.95, 1.0],
-                            crate::app::AppAction::Timers(TimersMessage::CreateCancel)),
-                        _ => {}
-                    }
-                });
-            }
-
-            if let Some(ref msg) = state.status_msg {
-                stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
+            form.column().text("Loading systemd timers...", 12.0, TEXT_DIM);
+            sec.place(form, ctx);
+            return;
+        }
+        // The list fills the page like the services list: the well's floor lands at the
+        // page's bottom.
+        form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+
+        let active_bg = [0.20, 0.40, 0.65, 0.4];
+        let tab_colors = |on: bool| {
+            let face = if on { active_bg } else { [0.10, 0.10, 0.16, 0.3] };
+            (face, [0.20, 0.20, 0.25, 0.15], [0.90, 0.90, 0.95, 1.0])
+        };
+        let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Timers", "User Timers") };
+        let text_on = [0.90, 0.90, 0.95, 1.0];
+
+        let now = now_usec();
+        let filtered: Vec<&TimerInfo> = state.timers.iter()
+            .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
+            .collect();
+        if state.items.len() != filtered.len() {
+            state.items.clear();
+            for _ in 0..filtered.len() {
+                state.items.push(Owned::new(InteractiveListItem::new("")));
             }
+        }
 
-            stack.context.spacing(cce_ui::layout::plate_gap());
-
-            // Scroll box list, filling the page like the services list, and
-            // across the section's content box like it (the box the section
-            // clips to).
-            let list_box_x = sec.content_left();
-            let list_box_y = sec.ay();
-            let list_box_w = sec.content_width();
-            let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
-
-            let now = now_usec();
-            let filtered: Vec<&TimerInfo> = state.timers.iter()
-                .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
-                .collect();
-
-            state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            state.list.update_bounds(filtered.len(), list_box_y, list_box_h);
-            state.list.push_prims(sec.pc);
-
-            let item_h = state.list.item_height;
-
-            if state.items.len() != filtered.len() {
-                state.items.clear();
-                for _ in 0..filtered.len() {
-                    state.items.push(Owned::new(InteractiveListItem::new("")));
-                }
+        let active_tab = state.active_tab;
+        let list = &mut state.list;
+        let items = &mut state.items;
+        let mut col = form.column();
+        col.row(|r| {
+            form_button(r, label1, 0.0, tab_colors(active_tab == TimerTab::System),
+                AppAction::Timers(TimersMessage::SetTab(TimerTab::System)));
+            form_button(r, label2, 0.0, tab_colors(active_tab == TimerTab::User),
+                AppAction::Timers(TimersMessage::SetTab(TimerTab::User)));
+        });
+
+        // New Timer (user scope) — a button as wide as its label; the form unfolds below.
+        let new_bg = if state.creating { active_bg } else { [0.13, 0.18, 0.14, 1.0] };
+        col.row(|r| {
+            form_button(r, "New Timer", button_need("New Timer"), (new_bg, [0.25, 0.30, 0.26, 1.0], text_on),
+                AppAction::Timers(TimersMessage::CreateStart));
+        });
+
+        if state.creating || state.editing.is_some() {
+            let field_h = cce_ui::layout::spinbox_height();
+            let note = match &state.editing {
+                Some(base) => format!("Editing {}.timer \u{2014} command and schedule rewrite in place.", base),
+                None => "New user timer \u{2014} runs the command on the schedule.".to_string(),
+            };
+            col.text(note, 11.0, TEXT_DIM);
+            if state.creating {
+                col.widget(&mut state.name_box, field_h);
             }
+            col.widget(&mut state.command_box, field_h);
+            col.widget(&mut 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),
+                    AppAction::Timers(TimersMessage::CreateSave));
+                form_button(r, "Cancel", button_need("Cancel"), ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0], text_on),
+                    AppAction::Timers(TimersMessage::CreateCancel));
+            });
+        }
 
-            sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            for (idx, timer) in filtered.iter().enumerate() {
-                if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
-                    let is_small = sec_w < 350.0;
-                    // A narrow row's buttons are cce-icons glyphs (pencil,
-                    // play, stop, circle) — but only when the icon set is
-                    // THERE: without it they fall back to words, and a word
-                    // needs the wide button. `upload_icon` caches per
-                    // (name, px), so asking every row is one hash lookup.
-                    let compact = is_small && cce_ui::upload_icon("play", 32).is_some();
-                    // Glyph buttons are square at the control height.
-                    let btn_h = cce_ui::layout::button_height();
-                    let run_w = if compact { btn_h } else { 76.0 };
-                    let en_w = if compact { btn_h } else { 66.0 };
-                    let edit_w = if compact { btn_h } else { 50.0 };
-                    let btn_gap = if is_small { 4.0 } else { 6.0 };
-                    // TODO(style): the row's button run, dot and text
-                    // column below are this list row's own layout.
-                    let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
-
-                    let en_x = right_edge - en_w;
-                    let run_x = en_x - btn_gap - run_w;
-                    let edit_x = run_x - btn_gap - edit_w;
-
-                    let btn_y = draw_y + (item_h - btn_h) / 2.0;
-
-                    // Title + schedule subtitle (truncated to the space before the buttons).
-                    let text_left_edge = if timer.editable { edit_x } else { run_x };
-                    let text_max_w = (text_left_edge - 8.0) - (list_box_x + 32.0);
-                    let max_chars = ((text_max_w / 6.0) as usize).max(10);
-                    let subtitle_full = schedule_line(timer, now);
-                    let subtitle = if subtitle_full.len() > max_chars {
-                        format!("{}...", &subtitle_full[..subtitle_full.char_indices().take(max_chars.saturating_sub(3)).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(0)])
-                    } else {
-                        subtitle_full
-                    };
-
-                    let item_btn = &mut state.items[idx];
-                    item_btn.title = timer.unit.clone();
-                    item_btn.subtitle = Some(subtitle);
-                    render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
-
-                    let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
-                    let mut dot = Owned::new(StatusDot::new(dot_state));
-                    render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
-
-                    let active_txt = [0.90, 0.90, 0.95, 1.0];
-
-                    // Edit: only for user units living in ~/.config/systemd/user.
-                    if timer.editable {
-                        // `button_glyph` below: the glyph when compact,
-                        // else (or with no icon set) the word.
-                        button_glyph(
-                            sec.pc,
-                            compact,
-                            "pencil",
-                            "Edit",
-                            edit_x,
-                            btn_y,
-                            edit_w,
-                            btn_h,
-                            [0.15, 0.15, 0.20, 1.0],
-                            [0.22, 0.22, 0.28, 1.0],
-                            active_txt,
-                            crate::app::AppAction::Timers(TimersMessage::EditStart(timer.unit.clone())),
-                        );
-                    }
+        if let Some(ref msg) = state.status_msg {
+            col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
+        }
 
-                    // Run Now: start the activated service immediately.
-                    button_glyph(
-                        sec.pc,
-                        compact,
-                        "play",
-                        if is_small { "Run" } else { "Run Now" },
-                        run_x,
-                        btn_y,
-                        run_w,
-                        btn_h,
-                        [0.16, 0.35, 0.18, 0.4],
-                        [0.22, 0.45, 0.25, 0.6],
-                        active_txt,
-                        crate::app::AppAction::Timers(TimersMessage::RunNow(timer.activates.clone(), timer.is_system)),
-                    );
-
-                    // Enable/Disable the timer unit; static units have no toggle.
-                    match timer.file_state.as_str() {
-                        "enabled" | "enabled-runtime" => {
+        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);
+            list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            list.update_bounds(filtered.len(), list_box_y, list_box_h);
+            list.push_prims(pc);
+            let item_h = list.item_height;
+
+            pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                for (idx, timer) in filtered.iter().enumerate() {
+                    if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
+                        let is_small = sec_w < 350.0;
+                        // A narrow row's buttons are cce-icons glyphs (pencil,
+                        // play, stop, circle) — but only when the icon set is
+                        // THERE: without it they fall back to words, and a word
+                        // needs the wide button. `upload_icon` caches per
+                        // (name, px), so asking every row is one hash lookup.
+                        let compact = is_small && cce_ui::upload_icon("play", 32).is_some();
+                        // Glyph buttons are square at the control height.
+                        let btn_h = cce_ui::layout::button_height();
+                        let run_w = if compact { btn_h } else { 76.0 };
+                        let en_w = if compact { btn_h } else { 66.0 };
+                        let edit_w = if compact { btn_h } else { 50.0 };
+                        let btn_gap = if is_small { 4.0 } else { 6.0 };
+                        // TODO(style): the row's button run, dot and text
+                        // column below are this list row's own layout.
+                        let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
+
+                        let en_x = right_edge - en_w;
+                        let run_x = en_x - btn_gap - run_w;
+                        let edit_x = run_x - btn_gap - edit_w;
+
+                        let btn_y = draw_y + (item_h - btn_h) / 2.0;
+
+                        // Title + schedule subtitle (truncated to the space before the buttons).
+                        let text_left_edge = if timer.editable { edit_x } else { run_x };
+                        let text_max_w = (text_left_edge - 8.0) - (list_box_x + 32.0);
+                        let max_chars = ((text_max_w / 6.0) as usize).max(10);
+                        let subtitle_full = schedule_line(timer, now);
+                        let subtitle = if subtitle_full.len() > max_chars {
+                            format!("{}...", &subtitle_full[..subtitle_full.char_indices().take(max_chars.saturating_sub(3)).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(0)])
+                        } else {
+                            subtitle_full
+                        };
+
+                        let item_btn = &mut items[idx];
+                        item_btn.title = timer.unit.clone();
+                        item_btn.subtitle = Some(subtitle);
+                        render_widget(pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+
+                        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, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
+
+                        let active_txt = [0.90, 0.90, 0.95, 1.0];
+
+                        // Edit: only for user units living in ~/.config/systemd/user.
+                        if timer.editable {
+                            // `button_glyph` below: the glyph when compact,
+                            // else (or with no icon set) the word.
                             button_glyph(
-                                sec.pc,
+                                pc,
                                 compact,
-                                "stop",
-                                "Disable",
-                                en_x,
+                                "pencil",
+                                "Edit",
+                                edit_x,
                                 btn_y,
-                                en_w,
-                                btn_h,
-                                [0.25, 0.14, 0.14, 1.0],
-                                [0.40, 0.20, 0.20, 1.0],
-                                [0.95, 0.55, 0.55, 1.0],
-                                crate::app::AppAction::Timers(TimersMessage::Disable(timer.unit.clone(), timer.is_system)),
-                            );
-                        }
-                        "disabled" => {
-                            // `circle`, the filled dot the narrow row
-                            // always used: `play` is Run Now's beside it.
-                            button_glyph(
-                                sec.pc,
-                                compact,
-                                "circle",
-                                "Enable",
-                                en_x,
-                                btn_y,
-                                en_w,
+                                edit_w,
                                 btn_h,
                                 [0.15, 0.15, 0.20, 1.0],
                                 [0.22, 0.22, 0.28, 1.0],
                                 active_txt,
-                                crate::app::AppAction::Timers(TimersMessage::Enable(timer.unit.clone(), timer.is_system)),
+                                crate::app::AppAction::Timers(TimersMessage::EditStart(timer.unit.clone())),
                             );
                         }
-                        _ => {
-                            sec.pc.text("static", en_x + 8.0, btn_y + (btn_h - 12.0) / 2.0, 11.0, TEXT_DIM);
+
+                        // Run Now: start the activated service immediately.
+                        button_glyph(
+                            pc,
+                            compact,
+                            "play",
+                            if is_small { "Run" } else { "Run Now" },
+                            run_x,
+                            btn_y,
+                            run_w,
+                            btn_h,
+                            [0.16, 0.35, 0.18, 0.4],
+                            [0.22, 0.45, 0.25, 0.6],
+                            active_txt,
+                            crate::app::AppAction::Timers(TimersMessage::RunNow(timer.activates.clone(), timer.is_system)),
+                        );
+
+                        // Enable/Disable the timer unit; static units have no toggle.
+                        match timer.file_state.as_str() {
+                            "enabled" | "enabled-runtime" => {
+                                button_glyph(
+                                    pc,
+                                    compact,
+                                    "stop",
+                                    "Disable",
+                                    en_x,
+                                    btn_y,
+                                    en_w,
+                                    btn_h,
+                                    [0.25, 0.14, 0.14, 1.0],
+                                    [0.40, 0.20, 0.20, 1.0],
+                                    [0.95, 0.55, 0.55, 1.0],
+                                    crate::app::AppAction::Timers(TimersMessage::Disable(timer.unit.clone(), timer.is_system)),
+                                );
+                            }
+                            "disabled" => {
+                                // `circle`, the filled dot the narrow row
+                                // always used: `play` is Run Now's beside it.
+                                button_glyph(
+                                    pc,
+                                    compact,
+                                    "circle",
+                                    "Enable",
+                                    en_x,
+                                    btn_y,
+                                    en_w,
+                                    btn_h,
+                                    [0.15, 0.15, 0.20, 1.0],
+                                    [0.22, 0.22, 0.28, 1.0],
+                                    active_txt,
+                                    crate::app::AppAction::Timers(TimersMessage::Enable(timer.unit.clone(), timer.is_system)),
+                                );
+                            }
+                            _ => {
+                                pc.text("static", en_x + 8.0, btn_y + (btn_h - 12.0) / 2.0, 11.0, TEXT_DIM);
+                            }
                         }
                     }
                 }
-            }
-            sec.pc.pop_clip_rect();
+            pc.pop_clip_rect();
             // The scrollbar's fore copy, over the rows at the raise's fade.
-            state.list.push_scrollbar_fore(sec.pc);
+            list.push_scrollbar_fore(pc);
 
             if filtered.is_empty() {
-                sec.pc.text("No timers in this scope", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
+                let inset = cce_ui::layout::plate_padding();
+                pc.text("No timers in this scope", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
             }
-
-            // End the section so the well's bottom wall sits one margin below
-            // the list: finish() places the wall at content_y + padding +
-            // margin, so the list's own bottom margin and that one cancel.
-            sec.content_y = list_box_y + list_box_h - sec.padding();
-        }
+        });
+        sec.place(form, ctx);
     });
 
     final_pc