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

commit19eadded6371ebd4ab999ab826f796b04e2f60ed
parent80f85d8345
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 08:57
feat(scrollbars): the page bar and every list bar ride a centre line

The page scrollbar rides the page's centre line over the sections, with
no lane of its own (the lane, its flip and the relayout it cost go), in
the shared track and thumb colours: its idle copy under the root plate
every frame, its fore copy faded in over the content where it flipped.

Every inner list (accounts, network, packages x2, processes, services,
timers) is sink-behind, so its bar rides its centre line too, behind
the plate until a scroll raises it. A framed list lays its idle copy
under its own bg; the frameless ones are handed to the host through
AppPage::frameless_lists, whose idle copies go under the root plate
they are carved into. Each page draws the fore copy after its rows.
Services' row text no longer stops short of a scrollbar gutter, and
the processes table no longer reserves a band for the horizontal bar,
which crosses the vertical one. Packages' scroll-to-package raises the
bar. every_inner_list_rides_its_centre_line_behind_the_plate guards it.

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

 src/main.rs            | 36 ++++++++++++++++++++++++++----------
 src/pages/accounts.rs  |  4 +++-
 src/pages/mod.rs       | 39 +++++++++++++++++++++++++++++++++++++++
 src/pages/network.rs   |  6 ++++--
 src/pages/packages.rs  | 17 +++++++++++++++--
 src/pages/processes.rs | 24 +++++++++++++++---------
 src/pages/services.rs  | 18 ++++++++++++------
 src/pages/timers.rs    |  8 +++++++-
 src/renderer.rs        | 36 ++++++++++++------------------------
 src/scroll_bar.rs      | 43 ++++++++++++-------------------------------
 10 files changed, 145 insertions(+), 86 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 78800ea..bc9069d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -406,12 +406,25 @@ impl cce_ui::engine::Application for SystemInterface {
             self.page_scroll_bar
                 .layer_quads(Rect { x: bx, y: by, width: bw, height: bh })
         };
-        // Sunk layer: under the translucent window plate, so idle the bar
-        // reads as sunk INTO the window rather than gone, and the plate
-        // occludes it from input. Track and thumb are pills (the designer look).
-        if !self.page_scroll_bar.raised() {
-            for &(r, c) in &page_bar {
-                pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
+        // The idle copy: under the translucent window plate, every frame, so
+        // the bar reads as sunk INTO the window rather than gone, and the
+        // plate occludes it from input. Track and thumb are pills (the
+        // designer look). The fore copy fades in over it, below.
+        for &(r, c) in &page_bar {
+            pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
+        }
+        // The frameless inner lists' idle copies go under the plate too: they
+        // sit on wells carved into it, so this is the plate behind them. In
+        // page coordinates, so shifted by the page scroll and clipped to the
+        // page view as the wells are. Their fore copies are the pages'.
+        {
+            let view = self.page_view(width, height);
+            let scroll_y = self.scroll_y;
+            for list in self.app.get_current_page().frameless_lists() {
+                let mut shown = list.clone();
+                shown.y -= scroll_y;
+                shown.viewport_y -= scroll_y;
+                pc.clip(view, |pc| shown.push_scrollbar_prims(pc));
             }
         }
 
@@ -588,10 +601,13 @@ impl cce_ui::engine::Application for SystemInterface {
             draw_placed_icon(&mut pc, icon);
         }
 
-        // The page scrollbar's raised layer: over the page content while a
-        // scroll or drag holds it up (popovers still stack above it).
-        if self.page_scroll_bar.raised() {
-            for &(r, c) in &page_bar {
+        // The page scrollbar's fore copy: over the page content at the
+        // raise's fade, while a scroll or drag holds it up and as it sinks
+        // (popovers still stack above it).
+        let fade = self.page_scroll_bar.fade();
+        if fade > 0.001 {
+            for &(r, mut c) in &page_bar {
+                c[3] *= fade;
                 pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
             }
         }
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index cf559c0..cb8f662 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -122,7 +122,7 @@ impl AccountsState {
             tb.is_password = true;
             tb
         };
-        state.list = ScrollRegion::new(cce_ui::layout::spinbox_height(), LIST_GAP);
+        state.list = ScrollRegion::new(cce_ui::layout::spinbox_height(), LIST_GAP).with_sink_behind(true);
         state
     }
 }
@@ -725,6 +725,8 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                 }
             }
             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
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 21ee7be..cede48b 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -151,6 +151,17 @@ pub trait AppPage {
     fn tick(&mut self, _dt: f32) -> bool {
         false
     }
+
+    /// The page's FRAMELESS inner lists, as laid out this frame. Their
+    /// scrollbars ride their centre lines behind the plate: a framed list
+    /// lays its idle copy under its own bg (`ScrollRegion::push_prims`), but
+    /// a frameless one sits straight on a well carved into the root plate,
+    /// so the host draws its idle copy before the root plate
+    /// (`push_scrollbar_prims`). The fore copy is the page's, after its rows
+    /// (`push_scrollbar_fore`). Default: none.
+    fn frameless_lists(&self) -> Vec<&cce_ui::widget::ScrollRegion> {
+        Vec::new()
+    }
 }
 
 
@@ -190,6 +201,34 @@ mod list_clip_tests {
         );
     }
 
+    /// Every inner list's scrollbar is the DE's one design: it rides the
+    /// list's centre line, behind the plate until a scroll raises it
+    /// (sinking IS centring for a `ScrollRegion`). The frameless ones are
+    /// handed to the host, whose idle copy goes under the root plate they
+    /// are carved into; a framed one lays its own under its bg.
+    #[test]
+    fn every_inner_list_rides_its_centre_line_behind_the_plate() {
+        let app = crate::app::AppState::default();
+        let lists: [(&str, &ScrollRegion); 7] = [
+            ("accounts", &app.accounts.list),
+            ("network", &app.network.wifi_list),
+            ("packages installed", &app.packages.installed_list),
+            ("packages updates", &app.packages.updates_list),
+            ("processes", &app.processes.cpu_list),
+            ("services", &app.services.list),
+            ("timers", &app.timers.list),
+        ];
+        for (name, list) in lists {
+            assert!(list.sink_behind, "{name}: its bar sinks behind the plate");
+        }
+        for page in [&app.services as &dyn AppPage, &app.timers, &app.processes, &app.packages] {
+            assert_eq!(page.frameless_lists().len(), 1, "a frameless list is handed to the host");
+        }
+        for page in [&app.accounts as &dyn AppPage, &app.network] {
+            assert!(page.frameless_lists().is_empty(), "a framed list lays its own idle copy");
+        }
+    }
+
     #[test]
     fn services_list_inside_its_section() {
         let mut s = services::ServicesState::default();
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 216d695..1a73156 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -34,7 +34,7 @@ impl Default for NetworkState {
             ip_address: String::new(),
             device: String::new(),
             available: Vec::new(),
-            wifi_list: ScrollRegion::new(26.0, 4.0),
+            wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
             wifi_toggle: Toggle::new(),
         }
     }
@@ -98,7 +98,7 @@ pub async fn fetch_network_state() -> NetworkState {
         loaded: true,
         wifi_enabled, connected_ssid, signal_strength: signal,
         ip_address, device, available,
-        wifi_list: ScrollRegion::new(26.0, 4.0),
+        wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
         wifi_toggle: Toggle::new(),
     }
 }
@@ -255,6 +255,8 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
                     }
                 }
                 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;
             }
         }
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index f85d235..3be9936 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -55,9 +55,9 @@ impl Default for PackagesState {
             updates: Vec::new(),
             active_tab: PackageTab::Installed,
             search_box: TextBox::new(String::new()).with_placeholder("Filter Packages..."),
-            installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false),
+            installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
             installed_items: Vec::new(),
-            updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false),
+            updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
             updates_items: Vec::new(),
             updating: false,
             last_update_res: None,
@@ -561,6 +561,8 @@ pub fn view(
                     }
                 }
                 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() {
                     sec.pc.text("No packages match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
@@ -596,6 +598,8 @@ pub fn view(
                     }
                 }
                 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);
@@ -703,6 +707,7 @@ impl PackagesState {
             let item_height_full = self.installed_list.item_height + self.installed_list.item_gap;
             let target_y = idx as f32 * item_height_full - 164.0;
             self.installed_list.set_scroll_y(target_y);
+            self.installed_list.notify_scrolled();
         }
     }
 }
@@ -846,6 +851,14 @@ impl crate::pages::AppPage for PackagesState {
     fn tick(&mut self, dt: f32) -> bool {
         self.installed_list.tick(dt) | self.updates_list.tick(dt)
     }
+
+    /// Only the active tab's list is laid out and drawn.
+    fn frameless_lists(&self) -> Vec<&ScrollRegion> {
+        vec![match self.active_tab {
+            PackageTab::Installed => &self.installed_list,
+            PackageTab::Updates => &self.updates_list,
+        }]
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 9ebb07c..cb56bee 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -70,7 +70,7 @@ impl Default for ProcessesState {
         Self {
             loaded: false,
             processes: Vec::new(),
-            cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
+            cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false).with_sink_behind(true),
             killing: std::collections::HashSet::new(),
             sort: ProcSort::Cpu,
             power: PowerSummary::default(),
@@ -236,7 +236,7 @@ pub async fn fetch_processes_state() -> ProcessesState {
     ProcessesState {
         loaded: true,
         processes,
-        cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
+        cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false).with_sink_behind(true),
         killing: std::collections::HashSet::new(),
         sort: ProcSort::Cpu,
         power,
@@ -277,9 +277,9 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             //
             // 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, Kill
-            // column clear of the scrollbar, without the h-bar: COMMAND gives
-            // up the 20px the content box's inset took.
+            // 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;
@@ -295,10 +295,10 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             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);
-            // The bottom scrollbar needs its own band: rows must stop above
-            // it or the last row draws under the pills.
-            let bottom_reserve = if state.cpu_list.h_scroll_active() { 18.0 } else { 6.0 };
-            let rows_h = list_box_h - header_h - bottom_reserve;
+            // 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);
 
@@ -414,6 +414,8 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                 }
             }
             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);
             
             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);
@@ -516,6 +518,10 @@ impl crate::pages::AppPage for ProcessesState {
     fn tick(&mut self, dt: f32) -> bool {
         self.cpu_list.tick(dt)
     }
+
+    fn frameless_lists(&self) -> Vec<&ScrollRegion> {
+        vec![&self.cpu_list]
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 9526e0e..cf321dd 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -41,7 +41,7 @@ impl Default for ServicesState {
             services: Vec::new(),
             active_tab: ServiceTab::System,
             search_box: TextBox::new(String::new()).with_label("Filter Services"),
-            list: ScrollRegion::new(36.0, 6.0).with_frame(false),
+            list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
             items: Vec::new(),
         }
     }
@@ -159,7 +159,7 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                     // 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 scrollbar gutter.
+                    // 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
@@ -176,11 +176,11 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                     // 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 still ends
-                    // short of the scrollbar gutter, exactly where it did when
-                    // the controls were on the right.
+                    // 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 - 20.0) - item_x;
+                    let item_w = (list_box_x + list_box_w - 10.0) - item_x;
 
                     let btn_y = draw_y + (item_h - 22.0) / 2.0;
                     let btn_h = 22.0;
@@ -278,6 +278,8 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                 }
             }
             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);
 
             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);
@@ -529,6 +531,10 @@ impl crate::pages::AppPage for ServicesState {
     fn tick(&mut self, dt: f32) -> bool {
         self.list.tick(dt)
     }
+
+    fn frameless_lists(&self) -> Vec<&ScrollRegion> {
+        vec![&self.list]
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index 65da023..eac299c 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -50,7 +50,7 @@ impl Default for TimersState {
             loaded: false,
             timers: Vec::new(),
             active_tab: TimerTab::System,
-            list: ScrollRegion::new(36.0, 6.0).with_frame(false),
+            list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
             items: Vec::new(),
             creating: false,
             editing: None,
@@ -654,6 +654,8 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                 }
             }
             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);
 
             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);
@@ -846,6 +848,10 @@ impl crate::pages::AppPage for TimersState {
     fn tick(&mut self, dt: f32) -> bool {
         self.list.tick(dt)
     }
+
+    fn frameless_lists(&self) -> Vec<&ScrollRegion> {
+        vec![&self.list]
+    }
 }
 
 #[cfg(test)]
diff --git a/src/renderer.rs b/src/renderer.rs
index de14d98..e42fbde 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -184,16 +184,16 @@ impl SystemInterface {
         } else {
             logical_sh - self.header_height - self.status_height
         };
-        // The page scrollbar, on the designer parameter-pane geometry: the DE
-        // width widened, stood off the window's right edge by the configured
-        // inset instead of hugging it. Updated with LAST frame's content
-        // height — the legacy window pass also ran before this frame's content
-        // was measured.
+        // The page scrollbar, the DE's one design: it rides the page's
+        // CENTRE line, over the sections with no lane of its own, idling
+        // behind the root plate until a scroll raises it. Updated with LAST
+        // frame's content height — the legacy window pass also ran before
+        // this frame's content was measured.
         let sb_w = crate::scroll_bar::ScrollBar::width();
-        // TODO(style): the bar's 4px vertical stand-off pairs with the
-        // toolkit's `scrollbar_inset()` knob, not a rung of the ladder.
+        // TODO(style): the bar's 4px vertical stand-off is the toolkit
+        // track's own end inset, not a rung of the ladder.
         self.page_scroll_bar.set_rect(
-            logical_sw - sb_w - cce_ui::layout::scrollbar_inset(),
+            lcx + (lcw - sb_w) * 0.5,
             self.header_height + 4.0,
             sb_w,
             switcher_h - 8.0,
@@ -360,16 +360,6 @@ impl SystemInterface {
         }
         self.content_h = max_y;
         self.page_scroll_bar.update(self.scroll_y, max_y, lch);
-        // Give the bar its lane exactly while it shows. The flip lays the page
-        // out once more on the next frame (`needs_rebuild` below) rather than
-        // twice in this one: a view registers widgets and popovers as it
-        // draws. It cannot oscillate — a page that overflows at full width
-        // only grows taller in the narrower lane, and one that fits in the
-        // lane fits at full width too.
-        let lane_flipped = (max_y > lch) != self.page_scroll_bar.lane;
-        if lane_flipped {
-            self.page_scroll_bar.lane = !self.page_scroll_bar.lane;
-        }
 
         let scroll_offset_y = self.scroll_y;
 
@@ -834,7 +824,7 @@ impl SystemInterface {
                 self.ui_context.set_focused_id(id);
             }
         }
-        self.needs_rebuild = lane_flipped;
+        self.needs_rebuild = false;
         self.laid_out_page = Some(self.app.current_page);
         self.last_scroll_y = self.scroll_y;
         self.ui_context.clear_dirty();
@@ -850,11 +840,9 @@ impl SystemInterface {
         let margin = cce_ui::layout::root_plate_inset();
         let cx = cx + margin;
         let cy = cy + margin;
-        // While the page scrolls its right margin starts at the scrollbar's
-        // inner edge, not the window's: the bar no longer covers the right
-        // column's wall and content.
-        let lane = if self.page_scroll_bar.lane { crate::scroll_bar::ScrollBar::lane_width() } else { 0.0 };
-        let cw = (cw - 2.0 * margin - lane).max(1.0);
+        // No lane for the page scrollbar: it rides the page's centre line,
+        // over the sections, behind the root plate until a scroll raises it.
+        let cw = (cw - 2.0 * margin).max(1.0);
         let ch = (ch - 2.0 * margin).max(1.0);
         let mut layout = AdaptiveGrid::new(cce_ui::layout::grid_min_col_width(), cce_ui::layout::root_plate_gap());
         // Page root dissolved (6u): the ctrl-nav entry focuses section 0, so root focus is
diff --git a/src/scroll_bar.rs b/src/scroll_bar.rs
index 964ea15..345e45d 100644
--- a/src/scroll_bar.rs
+++ b/src/scroll_bar.rs
@@ -15,11 +15,6 @@ pub struct ScrollBar {
     pub content_h: f32,
     pub viewport_h: f32,
     pub dragging: bool,
-    /// The page is laid out narrower by [`ScrollBar::lane_width`] so the bar
-    /// has a lane of its own beside the sections instead of riding over the
-    /// right column. Set from the page's measured height each layout (see
-    /// `rebuild_layout_inner`), so it is on exactly while the bar shows.
-    pub lane: bool,
     hovered: bool,
     /// The shared raise/sink hysteresis (the designer parameter-pane treatment):
     /// idle the bar sinks behind the translucent window plate and takes no
@@ -34,22 +29,15 @@ impl ScrollBar {
             content_h: 0.0,
             viewport_h: 0.0,
             dragging: false,
-            lane: false,
             hovered: false,
             activity: ScrollbarActivity::new(),
         })
     }
 
-    /// The bar's width: the DE scrollbar width widened (at stock width it
-    /// reads too slim against the page's wells).
+    /// The bar's width: the DE's centred-bar width (it rides the page's
+    /// centre line, over the sections, with no lane of its own).
     pub fn width() -> f32 {
-        cce_ui::layout::scrollbar_width() * 1.6
-    }
-
-    /// The window's right-edge strip the bar occupies — its width plus its
-    /// stand-off from the window edge. The page reserves it while it scrolls.
-    pub fn lane_width() -> f32 {
-        Self::width() + cce_ui::layout::scrollbar_inset()
+        cce_ui::layout::centred_scrollbar_width()
     }
 
     pub fn update(&mut self, scroll_y: f32, content_h: f32, viewport_h: f32) {
@@ -62,10 +50,10 @@ impl ScrollBar {
         self.content_h > self.viewport_h
     }
 
-    /// Whether the bar currently rides in front of the content (and takes
-    /// input) rather than idling behind the window plate.
-    pub fn raised(&self) -> bool {
-        self.activity.raised()
+    /// How far the fore copy has faded in, 0..=1: the raise and the sink are
+    /// a fade over the idle copy, not a flip.
+    pub fn fade(&self) -> f32 {
+        self.activity.fade()
     }
 
     /// A scroll landed (wheel fast path, keyboard): refresh the hold and raise
@@ -85,22 +73,15 @@ impl ScrollBar {
         self.activity.tick(dt, visible, self.dragging) || holding
     }
 
-    /// The track + thumb quads for the host's two-layer emission: drawn under
-    /// the window plate while sunk, over the page content while raised. Colors
-    /// keep the widget's hover/drag tint.
+    /// The track + thumb quads for the host's two copies: the idle one under
+    /// the window plate every frame, the fore one over the page content at
+    /// [`Self::fade`]. The DE's shared track and thumb colours.
     pub fn layer_quads(&self, rect: Rect) -> Vec<(Rect, [f32; 4])> {
         let mut out = Vec::new();
         if self.content_h > self.viewport_h && rect.height > 0.0 {
-            out.push((rect, [0.15, 0.15, 0.20, 0.3]));
+            out.push((rect, cce_ui::color::scrollbar_track_color()));
             if let Some((tx, ty, tw, th)) = self.thumb_rect(rect) {
-                let thumb_color = if self.dragging {
-                    [0.70, 0.70, 0.75, 0.6]
-                } else if self.hovered && self.activity.raised() {
-                    [0.65, 0.65, 0.70, 0.5]
-                } else {
-                    [0.60, 0.60, 0.65, 0.4]
-                };
-                out.push((Rect { x: tx, y: ty, width: tw, height: th }, thumb_color));
+                out.push((Rect { x: tx, y: ty, width: tw, height: th }, cce_ui::color::scrollbar_thumb_color()));
             }
         }
         out