git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit2a4b48f0d676ce6ea8b0fbad598381061c5aad27
parent7ab2dce6ce
authorLucas Galante <[email protected]>
date2026-09-19 00:09
Windows dissolve in and out, and the compositor owns both

Every window and Overlay layer surface now fades in when it maps and back
out when its client asks to close, by ramping the opacity of the client's
scene subtree. That carries the scenefx backdrop blur, drop shadow and
bevel with it, so a whole window crossfades against the desktop instead of
each of its elements crossfading against each other.

The open half is automatic. Window::map starts the ramp (wants_map_fade
excludes the status segments, the wallpaper and the grid: they map once at
login, where a dissolve reads as the desktop failing to draw), and
handle_layer_surface_map generalizes the fade-in Overlay surfaces already
had. map_fade rides the border-fade timer as a third multiplier inside
effective_opacity, beside adjust_dim, so nothing new is polled.

The close half needs one word from the client, because a surface that is
already destroyed cannot be faded: `fade-out` on the control socket starts
the ramp and answers with its duration, and the client holds its surface
mapped and its process alive for exactly that long before exiting. The
target is resolved from the caller's pid via SO_PEERCRED rather than a name
in the command — the kernel vouches for it, and a client always knows its
own pid even when it has no app_id. An unknown-command reply from an older
compositor parses as 0, which the client reads as "exit now", so a new
client against an old compositor behaves exactly as it did before.

Durations are `surface { fade in_ms=140 out_ms=120 }`, clamped to 2s
because out_ms is a deadline a client blocks on. They are answered back
over the socket rather than duplicated client-side, so the two halves
cannot drift when the config changes.

The ramp is linear, unlike the borders' exponential approach: an
exponential close fade never actually reaches zero, and something is
waiting on it. Layer surfaces keep their own timer because they are not in
wm.windows and there is no list to sweep.

This exists because a client cannot fade its own window. However
transparent it draws itself its surface stays fully present, so the blur
behind it hangs at full strength over a dissolving window — and in cce-ui
specifically, shader-lit output (SDF plate rims, specular) is not vertex
alpha and does not fade with the geometry at all. cce-cloud carried exactly
that, and hid the un-fading rims by dropping the plate batches, which
deleted the window's whole background on the fade's first frame.

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

 CLAUDE.md                    |  13 +++++
 WORKSPACE.md                 |  44 +++++++++++++++
 src/server/config.rs         |  48 ++++++++++++++++
 src/server/ipc_server.rs     |  33 ++++++++++-
 src/server/layer_shell.rs    | 132 ++++++++++++++++++++++++++++++++++---------
 src/server/window.rs         |  93 +++++++++++++++++++++++++++++-
 src/server/window_manager.rs |  67 +++++++++++++++++++++-
 7 files changed, 399 insertions(+), 31 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index adf15b9..0d89bc9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -401,6 +401,19 @@ sat outside the edges and the ring that followed hugged them.
   `rendering_requested.opacity` directly, or the dim is clobbered on the
   next commit. Anything that can change who covers whom re-arms the fade:
   `arrange_views`, `raise_window`, and every `op_update` step.
+- The **open/close dissolve** is a third multiplier on the same machinery:
+  `Window::map_fade`, stepped by `step_map_fade` on the border-fade timer and
+  folded into `effective_opacity` beside `adjust_dim`. `Window::map` starts the
+  open ramp (`start_map_fade`; `wants_map_fade` excludes status segments, the
+  wallpaper and the grid), and the `fade-out` control-socket command starts the
+  close ramp for whichever windows and Overlay layer surfaces belong to the
+  CALLER — resolved from `IpcRequest::peer_pid` (SO_PEERCRED), never from a
+  name in the command. Layer surfaces run the same ramp on their own timer
+  (`LayerSurface::start_fade`) because they are not in `wm.windows`. The ramp
+  is LINEAR, unlike the borders' exponential approach: an exponential close
+  fade never reaches zero, and the client is holding its surface open against a
+  deadline. Durations are `surface { fade in_ms out_ms }`; see
+  "Window fades" in WORKSPACE.md for the client half of the contract.
 - `handle_width` under `border` in config.kdl is the diameter. `taper`,
   `swell_curve`, `bulge`, `corner_length` and `segment_gap` belonged to the
   retired ring profiles (an even ring, then a wave of hills and valleys):
diff --git a/WORKSPACE.md b/WORKSPACE.md
index 975d7bf..0a2fc2c 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -309,6 +309,50 @@ Clients and compositor communicate over Unix sockets keyed by `$WAYLAND_DISPLAY`
   composited over, which is the one thing a Wayland client can never see for
   itself. (The `viewport` topic went with the viewport-tag feature.)
 
+## Window fades (DE-wide open/close dissolve)
+
+Every window and overlay the user opens dissolves in when it maps and out when
+it closes. **The fade is the compositor's, in both directions** — it ramps the
+opacity of the client's scene subtree
+(`river_scene_node_set_opacity`, which also carries the scenefx backdrop blur,
+drop shadow and bevel), so a whole window crossfades against the desktop rather
+than each of its elements crossfading against each other.
+
+- **In** is automatic and needs nothing from the client: `Window::map` starts
+  the ramp for toplevels, `handle_layer_surface_map` for Overlay-layer
+  surfaces. Desktop furniture opts out — status segments, the wallpaper, the
+  grid layer (`Window::wants_map_fade`), and the Background/Bottom/Top layers —
+  because those map once at login, where a dissolve reads as the desktop
+  failing to draw.
+- **Out** needs one thing from the client, because a surface that is already
+  destroyed cannot be faded: it sends `fade-out` on the control socket, is
+  answered with a duration in ms, and keeps its surface mapped and its process
+  alive for exactly that long before exiting. `cce_ui::ipc::request_close_fade()`
+  is that call, and `window_runner` already makes it for every `Application`, so
+  an ordinary cce client gets the close fade for free. An app driving its own
+  event loop calls it itself — `cce-cloud` is the worked example.
+- The duration is `surface { fade in_ms=140 out_ms=120 }`, clamped to 2s. It is
+  answered back over the socket rather than duplicated in the client, so the
+  two halves cannot drift when the config changes. `0` disables that direction,
+  and a client that gets `0` exits immediately.
+- The target is resolved from the caller's **pid** (SO_PEERCRED on the control
+  socket), not from a name in the command: the kernel vouches for it, and a
+  client always knows its own pid even when it has no app_id.
+
+**Do not fade a window from inside the client.** It cannot work: the surface
+stays fully present to the compositor however transparent the client draws
+itself, so the blur behind it hangs at full strength over a dissolving window —
+and in cce-ui specifically, shader-lit output (SDF plate rims, specular) is not
+vertex-alpha and does not fade with the geometry at all. `cce-cloud` carried
+exactly that for months; its close fade dropped the plate batches to hide the
+un-fading rims, which deleted the window's whole background on the fade's first
+frame, since a plate batch **is** its cover quad.
+
+Note the usual toolkit-staleness trap (above): the fade-IN is entirely
+compositor-side and appears the moment a new `cce-fx` is running, but the
+fade-OUT rides `cce-ui`, so a client nobody rebuilt fades in and then vanishes.
+That asymmetry is the symptom of a missed sweep, not of a broken fade.
+
 ## Repo hygiene
 
 The repo root and `cce-compositor/scratch/` are littered with **ad-hoc debugging artifacts** — many
diff --git a/src/server/config.rs b/src/server/config.rs
index 6f88473..d5cc2fb 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -41,6 +41,14 @@ pub struct Layout {
     /// Opacity a Floating window is dimmed to while it overlaps the window
     /// whose resize handles are up (adjust mode), 0..1. 1.0 disables.
     pub border_overlap_opacity: f32,
+    /// How long a window or overlay dissolves in when it maps, in ms. 0
+    /// disables the open fade and windows appear at full strength.
+    pub fade_in_ms: u32,
+    /// How long a client that asked to close dissolves out over, in ms. This
+    /// is also the deadline the client waits on before it exits, so it is
+    /// answered back over the control socket rather than assumed — see the
+    /// `fade-out` command. 0 disables the close fade.
+    pub fade_out_ms: u32,
     /// Shape of the handle ring's swell along a side. Below 1 the ring gains
     /// its thickness early — a corner that visibly swells, then a long slow
     /// approach to the middle. Above 1 stays thin near the corner and gains
@@ -214,6 +222,8 @@ impl Default for Layout {
             border_taper: 0.35,
             border_handle_width: 32.0,
             border_overlap_opacity: 0.4,
+            fade_in_ms: 140,
+            fade_out_ms: 120,
             border_swell_curve: 0.45,
             border_corner_bulge: 48.0,
             border_corner_length: 0,
@@ -581,6 +591,12 @@ pub struct SurfaceConfig {
     pub border_handle_width: f64,
     #[serde(default = "default_border_overlap_opacity")]
     pub border_overlap_opacity: f64,
+    /// `surface { fade in_ms=.. out_ms=.. }` — the DE-wide open/close
+    /// dissolve. Milliseconds; 0 on either disables that direction.
+    #[serde(default = "default_fade_in_ms")]
+    pub fade_in_ms: i64,
+    #[serde(default = "default_fade_out_ms")]
+    pub fade_out_ms: i64,
     #[serde(default = "default_border_swell_curve")]
     pub border_swell_curve: f64,
     #[serde(default = "default_border_corner_bulge")]
@@ -687,6 +703,8 @@ impl Default for SurfaceConfig {
             border_taper: default_border_taper(),
             border_handle_width: default_border_handle_width(),
             border_overlap_opacity: default_border_overlap_opacity(),
+            fade_in_ms: default_fade_in_ms(),
+            fade_out_ms: default_fade_out_ms(),
             border_swell_curve: default_border_swell_curve(),
             border_corner_bulge: default_border_corner_bulge(),
             border_corner_length: 0,
@@ -798,6 +816,8 @@ fn default_border_corner_radius() -> i64 {
 fn default_border_taper() -> f64 { 0.35 }
 fn default_border_handle_width() -> f64 { 32.0 }
 fn default_border_overlap_opacity() -> f64 { 0.4 }
+fn default_fade_in_ms() -> i64 { 140 }
+fn default_fade_out_ms() -> i64 { 120 }
 fn default_border_swell_curve() -> f64 { 0.45 }
 fn default_border_corner_bulge() -> f64 { 48.0 }
 
@@ -2145,6 +2165,30 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                             }
                         }
                     }
+                    // `surface { fade in_ms=140 out_ms=120 }` — the DE-wide
+                    // open/close dissolve, read here so both halves of it
+                    // (the compositor's scene-node ramp and the deadline a
+                    // closing client waits on) come from one place.
+                    if let Some(fade_node) = surface_children.nodes().iter().find(|n| n.name().value() == "fade") {
+                        found_nested = true;
+                        for entry in fade_node.entries() {
+                            if let Some(id) = entry.name() {
+                                match id.value() {
+                                    "in_ms" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.fade_in_ms = val;
+                                        }
+                                    }
+                                    "out_ms" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.fade_out_ms = val;
+                                        }
+                                    }
+                                    _ => {}
+                                }
+                            }
+                        }
+                    }
                     if let Some(border_node) = surface_children.nodes().iter().find(|n| n.name().value() == "border") {
                         found_nested = true;
                         for entry in border_node.entries() {
@@ -2548,6 +2592,10 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.border_taper = config.surface.border_taper.clamp(0.05, 1.0) as f32;
     state.layout.border_handle_width = config.surface.border_handle_width.max(4.0) as f32;
     state.layout.border_overlap_opacity = config.surface.border_overlap_opacity.clamp(0.0, 1.0) as f32;
+    // Capped at 2s: the close fade is a deadline a client blocks on before it
+    // exits, so a mistyped 20000 would hang every quit for 20 seconds.
+    state.layout.fade_in_ms = config.surface.fade_in_ms.clamp(0, 2000) as u32;
+    state.layout.fade_out_ms = config.surface.fade_out_ms.clamp(0, 2000) as u32;
     state.layout.border_swell_curve = config.surface.border_swell_curve.clamp(0.1, 6.0) as f32;
     state.layout.border_corner_bulge = config.surface.border_corner_bulge.max(0.0) as f32;
     state.layout.border_corner_length = config.surface.border_corner_length.max(0) as i32;
diff --git a/src/server/ipc_server.rs b/src/server/ipc_server.rs
index 21fd68c..9964f7f 100644
--- a/src/server/ipc_server.rs
+++ b/src/server/ipc_server.rs
@@ -9,6 +9,13 @@ use std::thread;
 pub struct IpcRequest {
     pub command: String,
     pub reply_tx: mpsc::Sender<String>,
+    /// PID of the process on the other end of the socket, from SO_PEERCRED.
+    /// A command that acts on "whoever is asking" (`fade-out`) resolves its
+    /// target with this instead of trusting a name the caller supplies: the
+    /// kernel vouches for it, and a client always knows its own pid even
+    /// when it does not know its app_id. 0 when the credentials were
+    /// unreadable, which every such command treats as no target.
+    pub peer_pid: i32,
 }
 
 /// The server-thread end of the request channel. Every `send` is followed by
@@ -120,7 +127,31 @@ fn ipc_server_main(tx: IpcSender, display_socket: Option<String>) {
     }
 }
 
+/// PID of the process on the other end of a Unix socket, via SO_PEERCRED.
+/// 0 when the credentials cannot be read — the kernel supplies them for every
+/// AF_UNIX peer, so that only happens on a socket already going away.
+/// (`UnixStream::peer_cred` is still nightly-only, hence the raw getsockopt.)
+fn socket_peer_pid(stream: &UnixStream) -> i32 {
+    let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
+    let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
+    let rc = unsafe {
+        libc::getsockopt(
+            stream.as_raw_fd(),
+            libc::SOL_SOCKET,
+            libc::SO_PEERCRED,
+            &mut cred as *mut libc::ucred as *mut libc::c_void,
+            &mut len,
+        )
+    };
+    if rc == 0 {
+        cred.pid
+    } else {
+        0
+    }
+}
+
 fn handle_client(mut stream: UnixStream, tx: IpcSender) {
+    let peer_pid = socket_peer_pid(&stream);
     let mut buf = [0u8; 4096];
     match stream.read(&mut buf) {
         Ok(0) => {}
@@ -142,7 +173,7 @@ fn handle_client(mut stream: UnixStream, tx: IpcSender) {
                     std::time::Duration::from_millis(1000)
                 };
                 let (reply_tx, reply_rx) = mpsc::channel();
-                if tx.send(IpcRequest { command: cmd, reply_tx }) {
+                if tx.send(IpcRequest { command: cmd, reply_tx, peer_pid }) {
                     if let Ok(reply) = reply_rx.recv_timeout(timeout) {
                         let _ = stream.write_all(reply.as_bytes());
                     } else {
diff --git a/src/server/layer_shell.rs b/src/server/layer_shell.rs
index 4b414c4..da3dd74 100644
--- a/src/server/layer_shell.rs
+++ b/src/server/layer_shell.rs
@@ -296,7 +296,17 @@ pub struct LayerSurface {
     pub wlr_layer_surface: *mut ffi::wlr_layer_surface_v1,
     pub scene_layer_surface: *mut ffi::wlr_scene_layer_surface_v1,
     pub popup_tree: *mut ffi::wlr_scene_tree,
+    /// Where the open/close dissolve currently stands, 0.0 (invisible) to
+    /// 1.0. Applied to the whole scene subtree, so the scenefx backdrop blur
+    /// behind the surface fades with it (`river_scene_node_set_opacity`) —
+    /// which is the thing a client fading its own pixels can never do.
     pub opacity: f32,
+    /// Where `opacity` is easing to: 1.0 for an open fade, 0.0 for a close.
+    pub opacity_target: f32,
+    /// Linear per-tick step, from the configured duration at the moment the
+    /// fade starts. Linear rather than exponential because a close fade has
+    /// to actually reach zero before the client's exit deadline.
+    pub opacity_step: f32,
     pub animation_timer: *mut ffi::wl_event_source,
 
     pub destroy: ffi::wl_listener,
@@ -331,6 +341,8 @@ impl LayerSurface {
             scene_layer_surface,
             popup_tree,
             opacity: 1.0,
+            opacity_target: 1.0,
+            opacity_step: 1.0,
             animation_timer: std::ptr::null_mut(),
             destroy: std::mem::zeroed(),
             map: std::mem::zeroed(),
@@ -410,18 +422,30 @@ unsafe extern "C" fn handle_layer_surface_destroy(listener: *mut ffi::wl_listene
     let _ = Box::from_raw(layer_surface);
 }
 
+/// Steps one layer surface's dissolve toward `opacity_target` and re-arms
+/// itself until it lands. Unlike the window fade — which rides the window
+/// manager's shared border-fade timer — each layer surface keeps its own,
+/// because a layer surface is not in `wm.windows` and there is no list to
+/// sweep.
 unsafe extern "C" fn handle_animation_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let layer_surface = data as *mut LayerSurface;
 
-    let new_opacity = ((*layer_surface).opacity + 0.08).min(1.0);
-    (*layer_surface).opacity = new_opacity;
+    let target = (*layer_surface).opacity_target;
+    let delta = target - (*layer_surface).opacity;
+    let settled = if delta.abs() <= (*layer_surface).opacity_step {
+        (*layer_surface).opacity = target;
+        true
+    } else {
+        (*layer_surface).opacity += (*layer_surface).opacity_step * delta.signum();
+        false
+    };
 
     ffi::river_scene_node_set_opacity(
         (*(*layer_surface).scene_layer_surface).tree as *mut ffi::wlr_scene_node,
-        new_opacity,
+        (*layer_surface).opacity,
     );
 
-    if new_opacity >= 1.0 {
+    if settled {
         if !(*layer_surface).animation_timer.is_null() {
             ffi::wl_event_source_remove((*layer_surface).animation_timer);
             (*layer_surface).animation_timer = std::ptr::null_mut();
@@ -435,6 +459,75 @@ unsafe extern "C" fn handle_animation_tick(data: *mut std::ffi::c_void) -> std::
     0
 }
 
+impl LayerSurface {
+    /// Begin a dissolve toward `target` (0.0 out, 1.0 in) over `ms`. A `ms`
+    /// of 0 snaps, so callers can treat this as "put the surface at
+    /// `target`" whether or not fading is configured on.
+    pub unsafe fn start_fade(&mut self, target: f32, ms: u32) {
+        self.opacity_target = target.clamp(0.0, 1.0);
+        if !self.animation_timer.is_null() {
+            ffi::wl_event_source_remove(self.animation_timer);
+            self.animation_timer = std::ptr::null_mut();
+        }
+        if ms == 0 {
+            self.opacity = self.opacity_target;
+            ffi::river_scene_node_set_opacity(
+                (*self.scene_layer_surface).tree as *mut ffi::wlr_scene_node,
+                self.opacity,
+            );
+            return;
+        }
+        // Ticks at 16 ms; at least one step so a sub-frame duration still
+        // lands rather than dividing by zero.
+        let ticks = ((ms as f32) / 16.0).max(1.0);
+        self.opacity_step = ((self.opacity_target - self.opacity).abs() / ticks).max(1.0e-4);
+        ffi::river_scene_node_set_opacity(
+            (*self.scene_layer_surface).tree as *mut ffi::wlr_scene_node,
+            self.opacity,
+        );
+
+        let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+        let timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_animation_tick),
+            self as *mut LayerSurface as *mut _,
+        );
+        if timer.is_null() {
+            log::error!("Failed to create layer surface animation timer");
+            // No timer means no ramp; land on the target rather than leave
+            // the surface stranded at whatever it was mid-fade.
+            self.opacity = self.opacity_target;
+            ffi::river_scene_node_set_opacity(
+                (*self.scene_layer_surface).tree as *mut ffi::wlr_scene_node,
+                self.opacity,
+            );
+        } else {
+            self.animation_timer = timer;
+            ffi::wl_event_source_timer_update(timer, 16);
+        }
+    }
+
+    /// PID of the client owning this layer surface, from its wl_resource.
+    /// 0 when it cannot be read. Used to resolve a `fade-out` to the caller.
+    pub unsafe fn client_pid(&self) -> i32 {
+        let surface = (*self.wlr_layer_surface).surface;
+        if surface.is_null() {
+            return 0;
+        }
+        let res = ffi::river_wlr_surface_get_resource(surface);
+        if res.is_null() {
+            return 0;
+        }
+        let client = ffi::wl_resource_get_client(res);
+        if client.is_null() {
+            return 0;
+        }
+        let (mut pid, mut uid, mut gid) = (0, 0, 0);
+        ffi::wl_client_get_credentials(client, &mut pid, &mut uid, &mut gid);
+        pid
+    }
+}
+
 unsafe fn update_scheduled_focus_and_dirty_windowing<F>(server: *mut Server, f: F)
 where F: FnOnce() {
     let seats = &mut (*server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
@@ -476,30 +569,17 @@ unsafe extern "C" fn handle_layer_surface_map(listener: *mut ffi::wl_listener, _
 
     let server = (*layer_surface).server;
 
+    // Overlay layer only: these are the transient surfaces the user opens
+    // (the launcher, the notifier), so a dissolve reads as the thing
+    // arriving. The Background/Bottom/Top layers are the desktop's own
+    // furniture — wallpaper, status bar — and map once at login, where a
+    // fade reads as the desktop failing to draw.
     if (*wlr_layer_surface).current.layer == ffi::zwlr_layer_shell_v1_layer_ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY {
-        (*layer_surface).opacity = 0.0;
-        ffi::river_scene_node_set_opacity(
-            (*(*layer_surface).scene_layer_surface).tree as *mut ffi::wlr_scene_node,
-            0.0,
-        );
-
-        if !(*layer_surface).animation_timer.is_null() {
-            ffi::wl_event_source_remove((*layer_surface).animation_timer);
-            (*layer_surface).animation_timer = std::ptr::null_mut();
-        }
-
-        let event_loop = ffi::wl_display_get_event_loop((*server).wl_server);
-        let timer = ffi::wl_event_loop_add_timer(
-            event_loop,
-            Some(handle_animation_tick),
-            layer_surface as *mut _,
-        );
-        if timer.is_null() {
-            log::error!("Failed to create layer surface animation timer");
-        } else {
-            (*layer_surface).animation_timer = timer;
-            ffi::wl_event_source_timer_update(timer, 16);
+        let ms = (*server).wm.layout.fade_in_ms;
+        if ms > 0 {
+            (*layer_surface).opacity = 0.0;
         }
+        (*layer_surface).start_fade(1.0, ms);
     }
 
     update_scheduled_focus_and_dirty_windowing(server, || {
diff --git a/src/server/window.rs b/src/server/window.rs
index 6c2ef43..ae24d6e 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -441,6 +441,21 @@ pub struct Window {
     /// `step_adjust_dim` on the border-fade timer; applied through
     /// `effective_opacity`.
     pub adjust_dim: f32,
+    /// The map/close fade, 0.0 (invisible) to 1.0 (fully drawn). A window
+    /// starts at 0 when it maps and eases to 1; a client that asks to close
+    /// (`fade-out` on the control socket) eases it back to 0 and then exits.
+    /// Applied through `effective_opacity`, so it MULTIPLIES the arrange
+    /// pass's own opacity and the adjust-mode dim rather than fighting them.
+    /// Stepped by `step_map_fade` on the border-fade timer.
+    pub map_fade: f32,
+    /// Where `map_fade` is easing to: 1.0 while the window lives, 0.0 once a
+    /// close fade has been asked for.
+    pub map_fade_target: f32,
+    /// Linear per-tick step for `map_fade`, derived from the configured
+    /// duration at the moment the fade starts. Linear, not the borders'
+    /// exponential approach: an exponential close fade never actually
+    /// reaches zero, and the client is waiting on a deadline to exit.
+    pub map_fade_step: f32,
     pub decorations_above: ffi::wl_list,
     pub decorations_above_tree: *mut ffi::wlr_scene_tree,
     pub popup_tree: *mut ffi::wlr_scene_tree,
@@ -760,6 +775,12 @@ impl Window {
             border_hover_drawn: None,
             border_reveal: [0.0; 8],
             adjust_dim: 0.0,
+            // 1.0, not 0.0: a window only starts its fade in `map()`, and
+            // one that never fades (fading disabled, a status segment) must
+            // render at full strength from its first frame.
+            map_fade: 1.0,
+            map_fade_target: 1.0,
+            map_fade_step: 1.0,
             decorations_above: std::mem::zeroed(),
             decorations_above_tree,
             popup_tree,
@@ -1972,6 +1993,17 @@ impl Window {
             }
         }
 
+        // The open dissolve. Last in `map`, so the window is fully placed and
+        // its scene tree built before the ramp touches it — and so a window
+        // that failed to map never starts one. `start_map_fade` snaps rather
+        // than ramps when fading is off or this surface opts out (status
+        // segments, wallpaper), so there is no second branch here.
+        let fade_ms = (*self.server).wm.layout.fade_in_ms;
+        if self.wants_map_fade() && fade_ms > 0 {
+            self.map_fade = 0.0;
+        }
+        self.start_map_fade(1.0, fade_ms);
+
         (*self.server).wm.dirty_windowing();
         Ok(())
     }
@@ -3779,10 +3811,67 @@ impl Window {
 
     /// The opacity the scene tree gets: the requested one, scaled down by the
     /// adjust-mode overlap dim (`adjust_dim`, 0..1) toward
-    /// `border.overlap_opacity`.
+    /// `border.overlap_opacity`, and again by the map/close fade
+    /// (`map_fade`), which rests at 1.0 whenever no fade is in flight.
     pub unsafe fn effective_opacity(&self) -> f32 {
         let floor = (*self.server).wm.layout.border_overlap_opacity;
-        self.rendering_requested.opacity * (1.0 - self.adjust_dim.clamp(0.0, 1.0) * (1.0 - floor))
+        self.rendering_requested.opacity
+            * (1.0 - self.adjust_dim.clamp(0.0, 1.0) * (1.0 - floor))
+            * self.map_fade.clamp(0.0, 1.0)
+    }
+
+    /// Whether this window takes the map/close fade at all. Surfaces that are
+    /// part of the desktop itself rather than something the user opened — the
+    /// status segments, the wallpaper, the grid layer — are left alone: they
+    /// map once at login and a dissolve there reads as the desktop failing to
+    /// draw. Same exclusion list `adjust_dim_wanted` uses, for the same
+    /// reason: these are not windows the user thinks of as opening.
+    pub unsafe fn wants_map_fade(&self) -> bool {
+        !self.is_status_bar() && !self.is_wallpaper() && !self.is_grid()
+    }
+
+    /// Begin a fade toward `target` (0.0 out, 1.0 in) over `ms`, and arm the
+    /// timer that steps it. A `ms` of 0 (or fading disabled) snaps instead,
+    /// so every caller can treat this as "put the window at `target`".
+    pub unsafe fn start_map_fade(&mut self, target: f32, ms: u32) {
+        self.map_fade_target = target.clamp(0.0, 1.0);
+        if ms == 0 || !self.wants_map_fade() {
+            self.map_fade = self.map_fade_target;
+            ffi::river_scene_node_set_opacity(
+                self.tree as *mut ffi::wlr_scene_node,
+                self.effective_opacity(),
+            );
+            return;
+        }
+        // Ticks at 16 ms; at least one step, so a sub-frame duration still
+        // lands on the target rather than dividing by zero.
+        let ticks = ((ms as f32) / 16.0).max(1.0);
+        self.map_fade_step = ((self.map_fade_target - self.map_fade).abs() / ticks).max(1.0e-4);
+        ffi::river_scene_node_set_opacity(
+            self.tree as *mut ffi::wlr_scene_node,
+            self.effective_opacity(),
+        );
+        (*self.server).wm.arm_border_fade();
+    }
+
+    /// Advance the map/close fade one tick toward `map_fade_target`, applying
+    /// the opacity as it goes. Returns true while still in motion, like
+    /// `step_adjust_dim`.
+    pub unsafe fn step_map_fade(&mut self) -> bool {
+        let delta = self.map_fade_target - self.map_fade;
+        if delta.abs() <= self.map_fade_step {
+            if self.map_fade == self.map_fade_target {
+                return false;
+            }
+            self.map_fade = self.map_fade_target;
+        } else {
+            self.map_fade += self.map_fade_step * delta.signum();
+        }
+        ffi::river_scene_node_set_opacity(
+            self.tree as *mut ffi::wlr_scene_node,
+            self.effective_opacity(),
+        );
+        true
     }
 
     /// Whether this window should be dimmed right now: adjust mode is on,
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 68103e5..11baed1 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -164,6 +164,10 @@ pub struct WindowManager {
     /// dispatch; if it is still here afterwards, the command answered
     /// synchronously and the drain sends its return value.
     pub pending_ipc_reply: Option<std::sync::mpsc::Sender<String>>,
+    /// PID of the client that sent the IPC command currently being
+    /// dispatched, alongside `pending_ipc_reply`. `fade-out` resolves "the
+    /// caller's own window" with it. 0 outside a dispatch.
+    pub pending_ipc_peer_pid: i32,
     pub startup: Vec<crate::config::StartupConfig>,
     pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
     pub status_sender: Option<crate::status_server::StatusSender>,
@@ -548,6 +552,7 @@ impl WindowManager {
         self.desk_zoom = 1.0;
         self.pending_screenshot = None;
         self.pending_ipc_reply = None;
+        self.pending_ipc_peer_pid = 0;
         self.mode = WindowManagerMode::Normal;
         self.on_app_exit = crate::config::OnAppExit::FocusPrevious;
         self.grid_cells_enabled = true;
@@ -4573,6 +4578,59 @@ impl WindowManager {
         // instant SetCamera arm, seat op starts).
         let action = parts[0];
         match action {
+            // "I am closing — dissolve me out, and tell me how long that
+            // takes." The client keeps its surface alive for the reply's
+            // worth of milliseconds and then exits; the compositor ramps the
+            // scene node's opacity down in the meantime, which takes the
+            // backdrop blur and the window's shadow and bevel with it. A
+            // client fading its OWN pixels cannot do that — its surface stays
+            // fully present to the compositor, so the blur behind it hangs at
+            // full strength over a dissolving window (what cce-cloud's
+            // hand-rolled close fade looked like).
+            //
+            // The target is the CALLER, resolved through SO_PEERCRED rather
+            // than a name in the command: the kernel vouches for the pid, and
+            // a client always knows its own even when it has no app_id. The
+            // reply is the duration in ms, always — a client that gets "0"
+            // simply exits at once, which is what a disabled fade means.
+            "fade-out" => {
+                let ms = self.layout.fade_out_ms;
+                let pid = self.pending_ipc_peer_pid;
+                if pid <= 0 {
+                    return "0\n".to_string();
+                }
+                let mut faded = false;
+                let windows: Vec<*mut crate::window::Window> =
+                    self.windows.iter().copied().collect();
+                for window in windows {
+                    if window.is_null() || (*window).closed {
+                        continue;
+                    }
+                    if (*window).unreliable_pid() == pid && (*window).wants_map_fade() {
+                        (*window).start_map_fade(0.0, ms);
+                        faded = true;
+                    }
+                }
+                // The same client may own layer surfaces instead of (or as
+                // well as) windows — cce-cloud's launcher is one.
+                let surfaces: Vec<*mut crate::layer_shell::LayerSurface> =
+                    (*self.server).layer_shell.surfaces.iter().copied().collect();
+                for surface in surfaces {
+                    if surface.is_null() {
+                        continue;
+                    }
+                    if (*surface).client_pid() == pid {
+                        (*surface).start_fade(0.0, ms);
+                        faded = true;
+                    }
+                }
+                if !faded {
+                    // Nothing of the caller's is on screen — no reason to
+                    // make it wait.
+                    return "0\n".to_string();
+                }
+                return format!("{}\n", ms);
+            }
             // Scene introspection: dump EVERY buffer in the whole scene —
             // layer, layout position, dest/natural size, owning client pid.
             // Nothing on screen can hide from this.
@@ -6248,7 +6306,9 @@ unsafe extern "C" fn handle_ipc_event(fd: std::os::raw::c_int, _mask: u32, data:
             // command answered synchronously and its return value is the
             // reply.
             (*wm).pending_ipc_reply = Some(req.reply_tx);
+            (*wm).pending_ipc_peer_pid = req.peer_pid;
             let reply = (*wm).process_ipc_command(&req.command);
+            (*wm).pending_ipc_peer_pid = 0;
             if let Some(tx) = (*wm).pending_ipc_reply.take() {
                 let _ = tx.send(reply);
             }
@@ -6957,8 +7017,8 @@ pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ff
     0
 }
 
-/// Steps every window's border hover fade — and any in-flight
-/// fullscreen-toggle animation — until all of them have settled. Windows at
+/// Steps every window's border hover fade, map/close dissolve, and any
+/// in-flight fullscreen-toggle animation — until all of them have settled. Windows at
 /// rest cost one comparison per zone and no repaint, so leaving this running
 /// for the tail of a fade is cheap.
 unsafe extern "C" fn handle_border_fade_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
@@ -6975,6 +7035,9 @@ unsafe extern "C" fn handle_border_fade_tick(data: *mut std::ffi::c_void) -> std
         if (*window).step_adjust_dim() {
             moving = true;
         }
+        if (*window).step_map_fade() {
+            moving = true;
+        }
         if (*window).step_fs_anim() {
             (*window).render_finish();
             moving = true;