git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit9aa6b235d4dda4cd7e6060472851d59ccbc431b0
parentb8c5f6aeea
authorLucas Galante <[email protected]>
date2026-09-19 00:09
Hand the close fade to the compositor, background plate and all

The popup's own fade multiplied every vertex and image alpha by a factor
and then dropped the SDF-plate batches outright. A plate batch IS its cover
quad and the window background is a plate, so the first frame of every
close fade deleted the entire background and left the rows, icons and text
dissolving over nothing. The drop was there for a reason — shader-lit plate
output (recess rims, specular) is not vertex alpha and would have lingered
at full strength — which is the tell that this could never be fixed here:
per-element alpha cannot express a window fade.

Nor can any client. A surface stays fully present to the compositor however
transparent it draws itself, so the backdrop blur behind the popup never
faded with it either.

So trigger_close now calls cce_ui::ipc::request_close_fade(), keeps the
surface mapped until the deadline it answers with, and exits. The
compositor ramps the scene node's opacity, blur included. Nothing is
redrawn in between: the pixels stay put while the fade happens under them.
The popup also gains an open dissolve, which it never had — same mechanism,
started at map, and free.

upload_vertices and prepare_text lose the fade entirely, and State loses
fade_factor along with render's parameter. AppState's fade_out/fade_start/
fade_factor collapse to one fade_until deadline.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md   |  31 ++++-
 src/main.rs | 452 ++++++++++++++++++++++++++++++++++++++++++++++++------------
 2 files changed, 389 insertions(+), 94 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 71fac3a..2e4a588 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -104,10 +104,9 @@ backend): `collect_display_list` builds a `PaintCtx` (beveled window plate, then
 active widget's `paint_self` walk — bevel/recess prims included), `upload_vertices`
 runs it through `tessellate_display_list` into `State::vertex_data` + `frame_batches`
 (`Batch2D`, physical-px scissors) + `plate_features`, `prepare_text` builds `TextSpan`s
-from the paint walk (buffers shaped at logical size, spans scaled to physical; fade
-alpha rides `default_color`), and `render` is `draw_frame_2d(Frame2D { .. })`. During
-the close fade, shader-lit plate batches (recess rims) are dropped — their shading is
-not vertex-alpha and would linger at full strength. `tessellate` must carry the tessellator's image
+from the paint walk (buffers shaped at logical size, spans scaled to physical), and
+`render` is `draw_frame_2d(Frame2D { .. })`. Nothing in this path knows about the
+open/close fade any more — see "Fading in and out" below. `tessellate` must carry the tessellator's image
 list across to `Frame2D::images`: images ride a separate pipeline from the vertex
 batches, and that return value was dropped (with `images: &[]` hardcoded) until
 2026-08-16, which made `PaintCtx::image` a silent no-op *in this app only* while
@@ -125,7 +124,29 @@ layer-shell **Overlay** surface with exclusive keyboard. The window continuously
 auto-sizes to its content (`update_desired_size` — list rows measured with
 `cce_ui::cosmic_text` buffers, the JSON panel's widgets with
 `cce_ui::widget::display::measure_text`)
-and closes through a ~150ms fade-out (`trigger_close` → `fade_factor`).
+and closes through the DE-wide dissolve (`trigger_close`, below).
+
+### Fading in and out
+
+The popup dissolves in when it maps and back out when it closes, and **neither
+is drawn by this app** — both are the compositor ramping the opacity of the
+surface's scene node, which carries the backdrop blur behind the popup down
+with it. All this side does on close is ask and then wait:
+`trigger_close` calls `cce_ui::ipc::request_close_fade()` (one `fade-out` line
+on the compositor's control socket, answered with a duration in ms), stores
+the deadline in `fade_until`, and keeps the surface mapped until the loop
+sees it pass. Nothing is redrawn in between — the pixels stay put while the
+scene node fades under them.
+
+This replaced a hand-rolled fade that multiplied every vertex and image alpha
+by a factor and then dropped the SDF-plate batches outright. A plate batch
+**is** its cover quad, and the window background is a plate, so the first
+frame of every close fade deleted the entire background and left the rows and
+text dissolving over nothing. That is the failure mode to remember: per-element
+alpha cannot express a window fade, because a client's surface stays fully
+present to the compositor no matter how transparent it draws itself — the blur
+behind it does not fade, and shader-lit output (plate rims, specular) never
+honoured the vertex alpha in the first place.
 
 `-x/-y` is a *cursor* position (the compositor hands the raw pointer to the desktop
 and window-border context menus), not a final window origin — `Placement` fits it to
diff --git a/src/main.rs b/src/main.rs
index c5d5140..6b2e32d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -591,6 +591,70 @@ fn spawn_detached(program: &str, args: &[&str]) {
 
 
 
+/// One row of the System tab: the label the list shows and the command that
+/// label runs.
+struct SystemCommand {
+    name: &'static str,
+    program: &'static str,
+    args: &'static [&'static str],
+}
+
+/// The System tab's rows — window-manager verbs driven through `ccectl` (the
+/// DE's control CLI already exposes every one of them, so there is nothing to
+/// reimplement here) plus the session and power commands that are not the
+/// compositor's to run.
+///
+/// The window verbs act on the window BEHIND this popup, not on the popup:
+/// the compositor's `focused_window` skips overlay UI and names `cce-cloud`
+/// explicitly among it, falling back to the most recent real window. That is
+/// what makes "Close Window" from a launcher mean anything at all.
+///
+/// Hardcoded rather than config-driven: these are the DE's own verbs, and a
+/// row naming a command `ccectl` does not have is a row that silently does
+/// nothing.
+const SYSTEM_COMMANDS: &[SystemCommand] = &[
+    SystemCommand { name: "Close Window", program: "ccectl", args: &["close"] },
+    SystemCommand { name: "Minimize Window", program: "ccectl", args: &["minimize"] },
+    SystemCommand { name: "Toggle Fullscreen", program: "ccectl", args: &["fullscreen"] },
+    SystemCommand { name: "Center Window", program: "ccectl", args: &["center-window"] },
+    SystemCommand { name: "Overlay Window Left", program: "ccectl", args: &["overlay-left"] },
+    SystemCommand { name: "Overlay Window Right", program: "ccectl", args: &["overlay-right"] },
+    SystemCommand { name: "Next Tiling Mode", program: "ccectl", args: &["mode-next"] },
+    SystemCommand { name: "Next Tiling Mode (Shared)", program: "ccectl", args: &["mode-next-shared"] },
+    SystemCommand { name: "Retile Windows", program: "ccectl", args: &["retile"] },
+    SystemCommand { name: "Toggle Overview", program: "ccectl", args: &["overview"] },
+    SystemCommand { name: "Zoom In", program: "ccectl", args: &["zoom-in"] },
+    SystemCommand { name: "Zoom Out", program: "ccectl", args: &["zoom-out"] },
+    SystemCommand { name: "Reset Zoom", program: "ccectl", args: &["zoom-reset"] },
+    SystemCommand { name: "Take Screenshot", program: "ccectl", args: &["screenshot"] },
+    SystemCommand { name: "Reload Configuration", program: "ccectl", args: &["reload"] },
+    SystemCommand { name: "Restart Compositor", program: "ccectl", args: &["restart-compositor"] },
+    SystemCommand { name: "Log Out", program: "ccectl", args: &["exit"] },
+    SystemCommand { name: "Turn Off Display", program: "ccectl", args: &["idle", "display", "off"] },
+    SystemCommand { name: "Suspend", program: "systemctl", args: &["suspend"] },
+    SystemCommand { name: "Reboot", program: "systemctl", args: &["reboot"] },
+    SystemCommand { name: "Power Off", program: "systemctl", args: &["poweroff"] },
+];
+
+/// The titles the tabbed launcher shows, in strip order. Tab 0 is the mode's
+/// own list; tab 1 is [`SYSTEM_COMMANDS`].
+const SYSTEM_TAB_TITLE: &str = "System";
+
+/// Run `item` if it is a System-tab row, and say whether it was. These rows
+/// are the DE's own verbs rather than apps: they go straight to `ccectl` (or
+/// systemd), with none of the desktop-entry or place-next handling an app
+/// launch gets.
+fn run_system_item(fuzzel: &FuzzelWidget, item: &str) -> bool {
+    if fuzzel.active_tab == 0 {
+        return false;
+    }
+    let Some(cmd) = SYSTEM_COMMANDS.iter().find(|c| c.name == item) else {
+        return false;
+    };
+    spawn_detached(cmd.program, cmd.args);
+    true
+}
+
 pub struct FuzzelWidget {
     x: f32,
     y: f32,
@@ -618,12 +682,45 @@ pub struct FuzzelWidget {
     /// glide, a keystroke refiltering the list, a keyboard snap.
     cursor: Option<(f32, f32)>,
     pub scroll_box: ScrollRegion,
+    /// The pages the list is split into. Fewer than two means no tab strip and
+    /// no chrome height for one, which is what keeps Dmenu, Path and the
+    /// Super-Tab window switcher laid out exactly as they were.
+    ///
+    /// The ACTIVE page's items and query live in `all_items` / `query`, not in
+    /// its `TabPage` — every existing caller reads them there, and only
+    /// [`Self::switch_tab`] moves them across. A page's own copies are
+    /// therefore stale for as long as it is the active one.
+    pub tabs: Vec<TabPage>,
+    pub active_tab: usize,
+    /// Tab under the pointer, mirroring `hovered` for the rows.
+    tab_hovered: Option<usize>,
+}
+
+/// One page of the tabbed list. See [`FuzzelWidget::tabs`] for which copy of
+/// `items` / `query` is the authoritative one.
+pub struct TabPage {
+    title: String,
+    items: Vec<String>,
+    query: String,
 }
 
 /// Icon edge length inside a 25px row, and the gap between it and the label.
 const ICON_PX: f32 = 17.0;
 const ICON_GAP: f32 = 8.0;
 
+/// The list chrome's metrics. These were repeated as bare `let pad = 15.0;`
+/// locals in every one of the paint, scroll and hit-test paths; the tab strip
+/// shifts the whole list down by its own height, so the offset has to be
+/// derived in one place or the rows, the clip and the click go out of step.
+const PAD: f32 = 15.0;
+const SEARCH_H: f32 = 35.0;
+const ITEM_H: f32 = 25.0;
+/// Height the tab strip claims off the top: a 22px segmented run and the gap
+/// between it and the search well.
+const TAB_STRIP_H: f32 = 30.0;
+/// Tab-title size — a step under the row labels, as a control label is.
+const TAB_FONT_PX: f32 = 12.0;
+
 impl FuzzelWidget {
     pub fn new(prompt: String) -> cce_ui::widget::Adapted<FuzzelWidget> {
         cce_ui::widget::Adapted::new(Self {
@@ -645,20 +742,160 @@ impl FuzzelWidget {
             // the rows on scroll. The region already sits inset from the popup
             // edge, so the stock 4px edge inset reads right here.
             scroll_box: ScrollRegion::new(22.0, 0.0).with_sink_behind(true),
+            tabs: Vec::new(),
+            active_tab: 0,
+            tab_hovered: None,
         })
     }
 
     pub fn set_items(&mut self, items: Vec<String>) {
         self.all_items = items;
+        self.recompute_icon_gutter();
+        self.filter();
+    }
+
+    /// Split the list into tabs. Tab 0 is the mode's own list — its items keep
+    /// arriving through [`Self::set_items`] — and every later tab carries the
+    /// items it is given here. A single tab (or none) draws no strip.
+    pub fn set_tabs(&mut self, tabs: Vec<(String, Vec<String>)>) {
+        self.tabs = tabs
+            .into_iter()
+            .map(|(title, items)| TabPage { title, items, query: String::new() })
+            .collect();
+        self.active_tab = 0;
+        if let Some(first) = self.tabs.first_mut() {
+            self.all_items = std::mem::take(&mut first.items);
+        }
+        self.recompute_icon_gutter();
+        self.filter();
+    }
+
+    /// Replace tab `idx`'s items wherever they are parked. The stdin/socket
+    /// feed always addresses tab 0 through this, never `set_items` directly:
+    /// the ingest compares against the items it last pushed, and on any other
+    /// tab that comparison would differ every time and clobber the list the
+    /// user is reading.
+    pub fn set_tab_items(&mut self, idx: usize, items: Vec<String>) {
+        if idx == self.active_tab {
+            self.set_items(items);
+        } else if let Some(page) = self.tabs.get_mut(idx) {
+            page.items = items;
+        }
+    }
+
+    /// The items tab `idx` holds right now — from `all_items` when it is the
+    /// active tab, from its parked page otherwise.
+    pub fn tab_items(&self, idx: usize) -> &[String] {
+        if idx == self.active_tab {
+            &self.all_items
+        } else {
+            self.tabs.get(idx).map(|p| p.items.as_slice()).unwrap_or(&[])
+        }
+    }
+
+    /// Move to tab `idx`, parking the current tab's items and query in its
+    /// page and unpacking the target's — so switching back lands on the same
+    /// query and the same rows. False when nothing moved.
+    pub fn switch_tab(&mut self, idx: usize) -> bool {
+        if idx >= self.tabs.len() || idx == self.active_tab {
+            return false;
+        }
+        self.tabs[self.active_tab].items = std::mem::take(&mut self.all_items);
+        self.tabs[self.active_tab].query = std::mem::take(&mut self.query);
+        self.active_tab = idx;
+        self.all_items = std::mem::take(&mut self.tabs[idx].items);
+        self.query = std::mem::take(&mut self.tabs[idx].query);
+        self.selected = 0;
+        self.scroll_box.scroll_y = 0.0;
+        self.recompute_icon_gutter();
         self.filter();
+        true
+    }
+
+    /// Step one tab forward (or back) with wrap — what Tab and Shift+Tab do
+    /// once the list has more than one. False when there is nothing to step
+    /// through, which is the signal for those keys to fall back to their old
+    /// job of cycling the highlight.
+    pub fn cycle_tab(&mut self, forward: bool) -> bool {
+        let n = self.tabs.len();
+        if n < 2 {
+            return false;
+        }
+        let idx = if forward { (self.active_tab + 1) % n } else { (self.active_tab + n - 1) % n };
+        self.switch_tab(idx)
+    }
+
+    /// Height the tab strip takes off the top of the popup — 0 below two tabs.
+    pub fn tab_strip_h(&self) -> f32 {
+        if self.tabs.len() > 1 { TAB_STRIP_H } else { 0.0 }
+    }
+
+    /// The segmented run itself, inset from the popup edge like the search
+    /// well under it. `None` when no strip is drawn.
+    fn tab_strip_rect(&self) -> Option<cce_ui::scene::layout::Rect> {
+        (self.tabs.len() > 1).then(|| cce_ui::scene::layout::Rect {
+            x: self.x + PAD,
+            y: self.y + PAD,
+            width: self.w - PAD * 2.0,
+            height: TAB_STRIP_H - 8.0,
+        })
+    }
+
+    /// Segment `i` of the run — equal shares of its width.
+    fn tab_rect(&self, i: usize) -> Option<cce_ui::scene::layout::Rect> {
+        let strip = self.tab_strip_rect()?;
+        let seg_w = strip.width / self.tabs.len() as f32;
+        Some(cce_ui::scene::layout::Rect {
+            x: strip.x + i as f32 * seg_w,
+            y: strip.y,
+            width: seg_w,
+            height: strip.height,
+        })
+    }
+
+    /// The tab under `(px, py)` — the one predicate the strip's hover wash and
+    /// its click share, as `row_at` is for the rows.
+    pub fn tab_at(&self, px: f32, py: f32) -> Option<usize> {
+        let strip = self.tab_strip_rect()?;
+        if px < strip.x || px >= strip.x + strip.width || py < strip.y || py >= strip.y + strip.height {
+            return None;
+        }
+        let n = self.tabs.len();
+        Some((((px - strip.x) / (strip.width / n as f32)).floor() as usize).min(n - 1))
+    }
+
+    /// Y of the search well's top edge: under the tab strip, where there is one.
+    fn search_y(&self) -> f32 {
+        self.y + PAD + self.tab_strip_h()
+    }
+
+    /// Y of the list viewport's top edge — the number the scroll math, the row
+    /// hit-test, the clip and the empty-state label all have to agree on.
+    fn list_y(&self) -> f32 {
+        self.search_y() + SEARCH_H + 10.0
+    }
+
+    /// Height of the list viewport: everything left between it and the bottom
+    /// inset.
+    fn list_h(&self) -> f32 {
+        (self.y + self.h - PAD) - self.list_y()
+    }
+
+    /// Reserve the icon column only when some item on the ACTIVE tab resolved
+    /// an icon, so the System tab's rows sit flush left while the Apps tab
+    /// keeps its gutter. Within a tab the gutter still applies to every row
+    /// (see [`Self::set_item_icons`]).
+    fn recompute_icon_gutter(&mut self) {
+        let any = self.all_items.iter().any(|t| self.icons.contains_key(t));
+        self.icon_gutter = if any { ICON_PX + ICON_GAP } else { 0.0 };
     }
 
     /// Give rows an icon column. Only Apps mode calls this — Dmenu/Path items
     /// are arbitrary strings with nothing to look an icon up by, and they keep
     /// the flush-left layout they have always had because the gutter stays 0.
     pub fn set_item_icons(&mut self, icons: std::collections::HashMap<String, (u32, u32, u32)>) {
-        self.icon_gutter = if icons.is_empty() { 0.0 } else { ICON_PX + ICON_GAP };
         self.icons = icons;
+        self.recompute_icon_gutter();
     }
 
     /// The square an icon is fitted into for the row drawn at `draw_y`.
@@ -687,13 +924,8 @@ impl FuzzelWidget {
     }
 
     pub fn update_scroll(&mut self) {
-        let item_h = 25.0;
-        let pad = 15.0;
-        let search_h = 35.0;
-        let viewport_y = self.y + pad + search_h + 10.0;
-        let viewport_h = self.h - (pad + search_h + 10.0) - pad;
-        let content_h = self.filtered_items.len() as f32 * item_h;
-        self.scroll_box.update_bounds_raw(content_h, viewport_y, viewport_h);
+        let content_h = self.filtered_items.len() as f32 * ITEM_H;
+        self.scroll_box.update_bounds_raw(content_h, self.list_y(), self.list_h());
         self.refresh_hover();
     }
 
@@ -704,7 +936,7 @@ impl FuzzelWidget {
     /// and a row `get_draw_y` places in the viewport — partially visible
     /// rows included, drawn cut by the clip, so an edge sliver counts.
     fn row_at(&self, px: f32, py: f32) -> Option<usize> {
-        let item_h = 25.0;
+        let item_h = ITEM_H;
         if !self.scroll_box.hit(px, py) || self.scroll_box.hit_scrollbar(px, py) {
             return None;
         }
@@ -735,16 +967,16 @@ impl FuzzelWidget {
     /// change, so callers can skip the re-upload when nothing moved.
     pub fn refresh_hover(&mut self) -> bool {
         let now = self.cursor.and_then(|(px, py)| self.row_at(px, py));
-        let changed = now != self.hovered;
+        let tab_now = self.cursor.and_then(|(px, py)| self.tab_at(px, py));
+        let changed = now != self.hovered || tab_now != self.tab_hovered;
         self.hovered = now;
+        self.tab_hovered = tab_now;
         changed
     }
 
     pub fn snap_to_selected(&mut self) {
-        let item_h = 25.0;
-        let pad = 15.0;
-        let search_h = 35.0;
-        let viewport_h = self.h - (pad + search_h + 10.0) - pad;
+        let item_h = ITEM_H;
+        let viewport_h = self.list_h();
         let content_h = self.filtered_items.len() as f32 * item_h;
 
         if self.filtered_items.is_empty() {
@@ -779,11 +1011,7 @@ impl cce_ui::widget::Layout for FuzzelWidget {
         self.w = rect.width;
         self.h = rect.height;
 
-        let pad = 15.0;
-        let search_h = 35.0;
-        let viewport_y = rect.y + pad + search_h + 10.0;
-        let viewport_h = rect.height - (pad + search_h + 10.0) - pad;
-        self.scroll_box.set_rect(rect.x + pad, viewport_y, rect.width - pad * 2.0, viewport_h);
+        self.scroll_box.set_rect(self.x + PAD, self.list_y(), self.w - PAD * 2.0, self.list_h());
         self.update_scroll();
     }
 }
@@ -795,14 +1023,54 @@ impl cce_ui::widget::Paint for FuzzelWidget {
 
     fn paint(&self, _rect: cce_ui::scene::layout::Rect, ctx: &mut cce_ui::scene::paint::PaintCtx) {
         use cce_ui::scene::layout::Rect;
-        let pad = 15.0;
-        let search_h = 35.0;
+        let pad = PAD;
+
+        // Tab strip — one well carved into the window plate with the segments
+        // butting together on its floor and the active one raised back out of
+        // it, which is the toolkit's recessed ButtonStrip treatment rendered
+        // by hand (this widget paints straight onto the PaintCtx; nesting a
+        // real ButtonStrip would need a child layout pass it does not have).
+        if let Some(strip) = self.tab_strip_rect() {
+            let radius = cce_ui::layout::button_corner_radius();
+            let depth = cce_ui::layout::bevel_width().min(strip.height * 0.2);
+            let (floor, radii) =
+                cce_ui::layout::carve_inside(strip, (radius, radius, radius, radius), depth);
+            ctx.recess(floor, radii, depth);
+            let inset = depth * 0.5;
+            let seg_r = (radius - inset).max(0.0);
+            for i in 0..self.tabs.len() {
+                let Some(r) = self.tab_rect(i) else { continue };
+                let seg = Rect {
+                    x: r.x + inset,
+                    y: r.y + inset,
+                    width: (r.width - 2.0 * inset).max(0.0),
+                    height: (r.height - 2.0 * inset).max(0.0),
+                };
+                if i == self.active_tab {
+                    // Faceless on purpose: the floor shows through the raised
+                    // plate, so the active tab reads as part of the strip
+                    // rather than a chip dropped on it.
+                    ctx.control_plate(
+                        &cce_ui::widget::ControlPlate::control(
+                            seg,
+                            seg_r,
+                            cce_ui::widget::PlateStance::Raised,
+                            [0.0; 4],
+                        )
+                        .with_depth(depth),
+                    );
+                } else if self.tab_hovered == Some(i) {
+                    ctx.rounded_rect(seg, seg_r, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
+                }
+            }
+        }
 
         // Search bar — a well recessed into the plate, its rim lit in the
         // highlight accent (the toolkit's focused-well treatment; the query
         // line always holds keyboard focus here). Replaces the flat fill +
         // 1px border quads.
-        let well = Rect { x: self.x + pad, y: self.y + pad, width: self.w - pad * 2.0, height: search_h };
+        let search_h = SEARCH_H;
+        let well = Rect { x: self.x + pad, y: self.search_y(), width: self.w - pad * 2.0, height: search_h };
         ctx.quad(well, [0.10, 0.10, 0.14, 1.0]);
         let depth = cce_ui::layout::bevel_width().min(search_h * 0.2);
         let hc = cce_ui::color::highlight_primary_color();
@@ -828,7 +1096,7 @@ impl cce_ui::widget::Paint for FuzzelWidget {
         };
         ctx.clip(viewport, |ctx| {
             // Selected Item Highlight
-            let item_h = 25.0;
+            let item_h = ITEM_H;
             if !self.filtered_items.is_empty() {
                 let virtual_selected_y = self.selected as f32 * item_h;
                 if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_selected_y, item_h) {
@@ -869,7 +1137,7 @@ impl cce_ui::widget::Paint for FuzzelWidget {
             // only rows `get_draw_y` places in the viewport are emitted, so a
             // 300-app list still costs one image quad per visible row.
             if self.icon_gutter > 0.0 {
-                let item_h = 25.0;
+                let item_h = ITEM_H;
                 for (idx, item_text) in self.filtered_items.iter().enumerate() {
                     let Some((image, iw, ih)) = self.icons.get(item_text).copied() else { continue };
                     if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
@@ -1093,7 +1361,6 @@ struct State {
     mode: LauncherMode,
     apps: Vec<AppInfo>,
 
-    fade_factor: f32,
     max_width: u32,
     max_height: u32,
     select_item: Option<String>,
@@ -1430,7 +1697,6 @@ impl State {
             mode,
             apps,
 
-            fade_factor: 1.0,
             max_width: width,
             // For json mode the initial `height` is a crude pre-layout estimate
             // (it ignores per-widget label offsets), so it must not double as the
@@ -1671,23 +1937,16 @@ impl State {
 
     fn upload_vertices(&mut self) {
         let dl = self.collect_display_list();
-        let (mut verts, mut batches, mut images, features) =
+        let (verts, batches, images, features) =
             tessellate(&dl, self.width, self.height, self.scale as f32);
-        if self.fade_factor < 1.0 {
-            for v in &mut verts {
-                v.color[3] *= self.fade_factor;
-            }
-            // Images carry their own alpha rather than a vertex color, so they
-            // need the fade applied here too or every app icon would stay fully
-            // opaque over a list dissolving underneath it.
-            for i in &mut images {
-                i.alpha *= self.fade_factor;
-            }
-            // SDF-plate overlays (the recess rims) are shader-lit, not
-            // vertex-alpha — drop them during the close fade rather than let
-            // their shading linger at full strength over fading geometry.
-            batches.retain(|b| b.plate.is_none());
-        }
+        // No close fade is applied here any more, and deliberately so. This
+        // used to multiply every vertex and image alpha by a fade factor and
+        // then DROP the SDF-plate batches outright — which took the window's
+        // whole background plate with them, since a plate batch IS its cover
+        // quad, leaving the rows and text dissolving over nothing. The fade is
+        // the compositor's now (`cce_ui::ipc::request_close_fade`): it ramps
+        // this surface's scene-node opacity, which fades the backdrop blur
+        // behind the popup along with it.
         // The GPU upload happens in VkRenderer::draw_frame_2d, which consumes
         // vertex_data every frame.
         self.vertex_data = verts;
@@ -1713,7 +1972,6 @@ impl State {
             buffers.push(make_text_buffer(&mut self.font_system, &label.text, label.font_size));
         }
 
-        let alpha = self.fade_factor.clamp(0.0, 1.0);
         let spans: Vec<TextSpan> = buffers
             .iter()
             .zip(widget_labels.iter())
@@ -1737,7 +1995,8 @@ impl State {
                     label.color[0] as f32 / 255.0,
                     label.color[1] as f32 / 255.0,
                     label.color[2] as f32 / 255.0,
-                    alpha,
+                    // TextLabel carries RGB only; labels are opaque.
+                    1.0,
                 ],
                 rotation: None,
                 clip_circle: [0.0; 3],
@@ -1764,11 +2023,10 @@ impl State {
         }
     }
 
-    fn render(&mut self, fade_factor: f32) -> bool {
+    fn render(&mut self) -> bool {
         let now = std::time::Instant::now();
         self.last_tick = now;
 
-        self.fade_factor = fade_factor;
         self.upload_vertices();
         self.prepare_text();
         self.renderer.as_mut().unwrap().draw_frame_2d(Frame2D {
@@ -1815,19 +2073,28 @@ struct AppState {
     ctrl_pressed: bool,
     super_pressed: bool,
     switcher_mode: bool,
-    fade_out: bool,
-    fade_start: Option<std::time::Instant>,
-    fade_factor: f32,
+    /// When the compositor's close dissolve ends and this popup may go, set
+    /// by `trigger_close`. `None` while the popup is live. The surface has to
+    /// stay mapped until then — the fade is the compositor ramping this
+    /// surface's scene-node opacity, and a destroyed surface cuts it off.
+    fade_until: Option<std::time::Instant>,
     cce_toplevel: Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>,
     selected_item: Option<String>,
 }
 
 impl AppState {
     fn trigger_close(&mut self) {
-        if !self.fade_out {
-            self.fade_out = true;
-            self.fade_start = Some(std::time::Instant::now());
-            self.redraw = true;
+        if self.fade_until.is_some() {
+            return;
+        }
+        // The compositor owns the dissolve and its duration; all this side
+        // does is hold the surface open for as long as it asks. A zero
+        // answer — fading configured off, or no compositor — means go now.
+        let fade = cce_ui::ipc::request_close_fade();
+        if fade.is_zero() {
+            self.exit = true;
+        } else {
+            self.fade_until = Some(std::time::Instant::now() + fade);
         }
     }
 
@@ -2835,9 +3102,7 @@ fn run_standalone() {
         ctrl_pressed: false,
         super_pressed: switcher_mode,
         switcher_mode,
-        fade_out: false,
-        fade_start: None,
-        fade_factor: 1.0,
+        fade_until: None,
         cce_toplevel: None,
         selected_item: None,
     };
@@ -2907,15 +3172,12 @@ fn run_standalone() {
 
     let mut last_tick = std::time::Instant::now();
     loop {
-        if app.fade_out {
-            if let Some(start) = app.fade_start {
-                let elapsed = start.elapsed().as_secs_f32();
-                app.fade_factor = (1.0 - elapsed / 0.15).max(0.0);
-                if app.fade_factor <= 0.0 {
-                    app.exit = true;
-                } else {
-                    app.redraw = true;
-                }
+        // The compositor is dissolving the popup out; hold the surface open
+        // until its deadline, then go. Nothing to redraw in the meantime —
+        // the pixels stay put and the scene node's opacity does the work.
+        if let Some(until) = app.fade_until {
+            if std::time::Instant::now() >= until {
+                app.exit = true;
             }
         }
 
@@ -2957,7 +3219,7 @@ fn run_standalone() {
         if app.redraw {
             app.redraw = false;
             if let Some(state) = &mut app.state {
-                let _ = state.render(app.fade_factor);
+                let _ = state.render();
             }
         }
     }
@@ -3113,9 +3375,7 @@ fn run_daemon(socket_path: &str) {
         ctrl_pressed: false,
         super_pressed: false,
         switcher_mode: false,
-        fade_out: false,
-        fade_start: None,
-        fade_factor: 1.0,
+        fade_until: None,
         cce_toplevel: None,
         selected_item: None,
     };
@@ -3402,9 +3662,7 @@ fn run_daemon(socket_path: &str) {
         app.state = Some(state);
 
         app.exit = false;
-        app.fade_out = false;
-        app.fade_start = None;
-        app.fade_factor = 1.0;
+        app.fade_until = None;
 
         // The reader thread only signals for lines that arrive after this
         // point; the initial_stdin items are already sitting in stdin_state,
@@ -3418,15 +3676,12 @@ fn run_daemon(socket_path: &str) {
         let mut first_frame_logged = false;
         let mut last_tick = std::time::Instant::now();
         while !app.exit {
-            if app.fade_out {
-                if let Some(start) = app.fade_start {
-                    let elapsed = start.elapsed().as_secs_f32();
-                    app.fade_factor = (1.0 - elapsed / 0.15).max(0.0);
-                    if app.fade_factor <= 0.0 {
-                        app.exit = true;
-                    } else {
-                        app.redraw = true;
-                    }
+            // The compositor is dissolving the popup out; hold the surface open
+            // until its deadline, then go. Nothing to redraw in the meantime —
+            // the pixels stay put and the scene node's opacity does the work.
+            if let Some(until) = app.fade_until {
+                if std::time::Instant::now() >= until {
+                    app.exit = true;
                 }
             }
 
@@ -3476,7 +3731,7 @@ fn run_daemon(socket_path: &str) {
             if app.redraw {
                 app.redraw = false;
                 if let Some(st) = &mut app.state {
-                    let _ = st.render(app.fade_factor);
+                    let _ = st.render();
                     if !first_frame_logged {
                         first_frame_logged = true;
                         log::info!("[timing] request -> first frame: {:?}", t_request.elapsed());
@@ -4116,8 +4371,28 @@ mod tests {
 impl FuzzelWidget {
     fn own_labels(&self) -> Vec<TextLabel> {
         let mut labels = Vec::new();
-        let pad = 15.0;
-        let search_h = 35.0;
+        let pad = PAD;
+
+        // Tab titles, centred on their segments. Outside the list clip, like
+        // the rest of the chrome.
+        for i in 0..self.tabs.len() {
+            let Some(r) = self.tab_rect(i) else { continue };
+            let title = &self.tabs[i].title;
+            let tw = cce_ui::widget::display::measure_text(title, TAB_FONT_PX);
+            labels.push(TextLabel {
+                text: title.clone(),
+                x: r.x + (r.width - tw) / 2.0,
+                y: r.y + (r.height - TAB_FONT_PX) / 2.0 - 1.0,
+                font_size: TAB_FONT_PX,
+                color: if i == self.active_tab {
+                    [0xff, 0xff, 0xff]
+                } else if self.tab_hovered == Some(i) {
+                    [0xe6, 0xe6, 0xee]
+                } else {
+                    [0x99, 0x99, 0xa6]
+                },
+            });
+        }
 
         let query_text = if self.query.is_empty() {
             format!("{}{}", self.prompt, "Type to search...")
@@ -4133,17 +4408,16 @@ impl FuzzelWidget {
         labels.push(TextLabel {
             text: query_text,
             x: self.x + pad + 10.0,
-            y: self.y + pad + 9.0,
+            y: self.search_y() + 9.0,
             font_size: 14.0,
             color: query_color,
         });
 
         if self.filtered_items.is_empty() {
-            let list_y = self.y + pad + search_h + 10.0;
             labels.push(TextLabel {
                 text: "No matches found".to_string(),
                 x: self.x + pad + 10.0,
-                y: list_y + 4.0,
+                y: self.list_y() + 4.0,
                 font_size: 13.0,
                 color: [0x88, 0x88, 0x99],
             });
@@ -4156,8 +4430,8 @@ impl FuzzelWidget {
     /// in) the viewport. Emitted under the paint walk's list clip, separately
     /// from [`Self::own_labels`], which draws chrome outside it.
     fn row_labels(&self) -> Vec<TextLabel> {
-        let pad = 15.0;
-        let item_h = 25.0;
+        let pad = PAD;
+        let item_h = ITEM_H;
         let mut labels = Vec::new();
         for (idx, item_text) in self.filtered_items.iter().enumerate() {
             let virtual_y = idx as f32 * item_h;