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

commit43ef5ff66eb8e05f87c275047a838f9fc61045da
parentf2ab384bf0
authorLucas Galante <[email protected]>
date2026-05-13 06:52
add tag visibility, output scale, status socket, deferred spawn model, and fd-closing fixes

- Tags: show/hide windows on inactive tags, focus transfer on tag switch
- Output: native wlr-output-management scale setting, VT-switch-back fix
- Status: real-time status socket server (replaces pkill signal pattern)
- Spawn: replace raw nix::unistd::fork with Command+pre_exec that closes
  inherited FDs 3+, preventing Wayland socket corruption from child
  processes (especially fuzzel which holds the fd for seconds)
- Config: [[startup]] array with once= field, env= section, float scale
- Borders: remove swaybg/bg_color (swaybg removed)
- Tiling: add grid, vsplit, hsplit layouts; cascade color interpolation
- IPC: expand commands (view, toggle, set-tag, tag-layout, focus-next)
- Restart: env var re-injection on restart

 protocol/wlr-output-management-unstable-v1.xml | 611 +++++++++++++++++
 src/borders.rs                                 |  44 +-
 src/config.rs                                  | 143 ++--
 src/ipc.rs                                     | 110 ++-
 src/lib.rs                                     |   1 +
 src/main.rs                                    | 156 ++---
 src/protocol.rs                                |  22 +-
 src/restart.rs                                 |  45 +-
 src/status.rs                                  |  24 +-
 src/status_server.rs                           | 335 ++++++++++
 src/tiling.rs                                  | 132 +++-
 src/types.rs                                   |  63 +-
 src/wayland.rs                                 | 892 +++++++++++++++++++++++--
 src/wm.rs                                      | 190 ++++--
 start-river.sh                                 |   8 +
 15 files changed, 2407 insertions(+), 369 deletions(-)

diff --git a/protocol/wlr-output-management-unstable-v1.xml b/protocol/wlr-output-management-unstable-v1.xml
new file mode 100644
index 0000000..541284a
--- /dev/null
+++ b/protocol/wlr-output-management-unstable-v1.xml
@@ -0,0 +1,611 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="wlr_output_management_unstable_v1">
+  <copyright>
+    Copyright © 2019 Purism SPC
+
+    Permission to use, copy, modify, distribute, and sell this
+    software and its documentation for any purpose is hereby granted
+    without fee, provided that the above copyright notice appear in
+    all copies and that both that copyright notice and this permission
+    notice appear in supporting documentation, and that the name of
+    the copyright holders not be used in advertising or publicity
+    pertaining to distribution of the software without specific,
+    written prior permission.  The copyright holders make no
+    representations about the suitability of this software for any
+    purpose.  It is provided "as is" without express or implied
+    warranty.
+
+    THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+    SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+    FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+    SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+    AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+    ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+    THIS SOFTWARE.
+  </copyright>
+
+  <description summary="protocol to configure output devices">
+    This protocol exposes interfaces to obtain and modify output device
+    configuration.
+
+    Warning! The protocol described in this file is experimental and
+    backward incompatible changes may be made. Backward compatible changes
+    may be added together with the corresponding interface version bump.
+    Backward incompatible changes are done by bumping the version number in
+    the protocol and interface names and resetting the interface version.
+    Once the protocol is to be declared stable, the 'z' prefix and the
+    version number in the protocol and interface names are removed and the
+    interface version number is reset.
+  </description>
+
+  <interface name="zwlr_output_manager_v1" version="4">
+    <description summary="output device configuration manager">
+      This interface is a manager that allows reading and writing the current
+      output device configuration.
+
+      Output devices that display pixels (e.g. a physical monitor or a virtual
+      output in a window) are represented as heads. Heads cannot be created nor
+      destroyed by the client, but they can be enabled or disabled and their
+      properties can be changed. Each head may have one or more available modes.
+
+      Whenever a head appears (e.g. a monitor is plugged in), it will be
+      advertised via the head event. Immediately after the output manager is
+      bound, all current heads are advertised.
+
+      Whenever a head's properties change, the relevant wlr_output_head events
+      will be sent. Not all head properties will be sent: only properties that
+      have changed need to.
+
+      Whenever a head disappears (e.g. a monitor is unplugged), a
+      wlr_output_head.finished event will be sent.
+
+      After one or more heads appear, change or disappear, the done event will
+      be sent. It carries a serial which can be used in a create_configuration
+      request to update heads properties.
+
+      The information obtained from this protocol should only be used for output
+      configuration purposes. This protocol is not designed to be a generic
+      output property advertisement protocol for regular clients. Instead,
+      protocols such as xdg-output should be used.
+    </description>
+
+    <event name="head">
+      <description summary="introduce a new head">
+        This event introduces a new head. This happens whenever a new head
+        appears (e.g. a monitor is plugged in) or after the output manager is
+        bound.
+      </description>
+      <arg name="head" type="new_id" interface="zwlr_output_head_v1"/>
+    </event>
+
+    <event name="done">
+      <description summary="sent all information about current configuration">
+        This event is sent after all information has been sent after binding to
+        the output manager object and after any subsequent changes. This applies
+        to child head and mode objects as well. In other words, this event is
+        sent whenever a head or mode is created or destroyed and whenever one of
+        their properties has been changed. Not all state is re-sent each time
+        the current configuration changes: only the actual changes are sent.
+
+        This allows changes to the output configuration to be seen as atomic,
+        even if they happen via multiple events.
+
+        A serial is sent to be used in a future create_configuration request.
+      </description>
+      <arg name="serial" type="uint" summary="current configuration serial"/>
+    </event>
+
+    <request name="create_configuration">
+      <description summary="create a new output configuration object">
+        Create a new output configuration object. This allows to update head
+        properties.
+      </description>
+      <arg name="id" type="new_id" interface="zwlr_output_configuration_v1"/>
+      <arg name="serial" type="uint"/>
+    </request>
+
+    <request name="stop">
+      <description summary="stop sending events">
+        Indicates the client no longer wishes to receive events for output
+        configuration changes. However the compositor may emit further events,
+        until the finished event is emitted.
+
+        The client must not send any more requests after this one.
+      </description>
+    </request>
+
+    <event name="finished" type="destructor">
+      <description summary="the compositor has finished with the manager">
+        This event indicates that the compositor is done sending manager events.
+        The compositor will destroy the object immediately after sending this
+        event, so it will become invalid and the client should release any
+        resources associated with it.
+      </description>
+    </event>
+  </interface>
+
+  <interface name="zwlr_output_head_v1" version="4">
+    <description summary="output device">
+      A head is an output device. The difference between a wl_output object and
+      a head is that heads are advertised even if they are turned off. A head
+      object only advertises properties and cannot be used directly to change
+      them.
+
+      A head has some read-only properties: modes, name, description and
+      physical_size. These cannot be changed by clients.
+
+      Other properties can be updated via a wlr_output_configuration object.
+
+      Properties sent via this interface are applied atomically via the
+      wlr_output_manager.done event. No guarantees are made regarding the order
+      in which properties are sent.
+    </description>
+
+    <event name="name">
+      <description summary="head name">
+        This event describes the head name.
+
+        The naming convention is compositor defined, but limited to alphanumeric
+        characters and dashes (-). Each name is unique among all wlr_output_head
+        objects, but if a wlr_output_head object is destroyed the same name may
+        be reused later. The names will also remain consistent across sessions
+        with the same hardware and software configuration.
+
+        Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do
+        not assume that the name is a reflection of an underlying DRM
+        connector, X11 connection, etc.
+
+        If this head matches a wl_output, the wl_output.name event must report
+        the same name.
+
+        The name event is sent after a wlr_output_head object is created. This
+        event is only sent once per object, and the name does not change over
+        the lifetime of the wlr_output_head object.
+      </description>
+      <arg name="name" type="string"/>
+    </event>
+
+    <event name="description">
+      <description summary="head description">
+        This event describes a human-readable description of the head.
+
+        The description is a UTF-8 string with no convention defined for its
+        contents. Examples might include 'Foocorp 11" Display' or 'Virtual X11
+        output via :1'. However, do not assume that the name is a reflection of
+        the make, model, serial of the underlying DRM connector or the display
+        name of the underlying X11 connection, etc.
+
+        If this head matches a wl_output, the wl_output.description event must
+        report the same name.
+
+        The description event is sent after a wlr_output_head object is created.
+        This event is only sent once per object, and the description does not
+        change over the lifetime of the wlr_output_head object.
+      </description>
+      <arg name="description" type="string"/>
+    </event>
+
+    <event name="physical_size">
+      <description summary="head physical size">
+        This event describes the physical size of the head. This event is only
+        sent if the head has a physical size (e.g. is not a projector or a
+        virtual device).
+
+        The physical size event is sent after a wlr_output_head object is created. This
+        event is only sent once per object, and the physical size does not change over
+        the lifetime of the wlr_output_head object.
+      </description>
+      <arg name="width" type="int" summary="width in millimeters of the output"/>
+      <arg name="height" type="int" summary="height in millimeters of the output"/>
+    </event>
+
+    <event name="mode">
+      <description summary="introduce a mode">
+        This event introduces a mode for this head. It is sent once per
+        supported mode.
+      </description>
+      <arg name="mode" type="new_id" interface="zwlr_output_mode_v1"/>
+    </event>
+
+    <event name="enabled">
+      <description summary="head is enabled or disabled">
+        This event describes whether the head is enabled. A disabled head is not
+        mapped to a region of the global compositor space.
+
+        When a head is disabled, some properties (current_mode, position,
+        transform and scale) are irrelevant.
+      </description>
+      <arg name="enabled" type="int" summary="zero if disabled, non-zero if enabled"/>
+    </event>
+
+    <event name="current_mode">
+      <description summary="current mode">
+        This event describes the mode currently in use for this head. It is only
+        sent if the output is enabled.
+      </description>
+      <arg name="mode" type="object" interface="zwlr_output_mode_v1"/>
+    </event>
+
+    <event name="position">
+      <description summary="current position">
+        This events describes the position of the head in the global compositor
+        space. It is only sent if the output is enabled.
+      </description>
+      <arg name="x" type="int"
+        summary="x position within the global compositor space"/>
+      <arg name="y" type="int"
+        summary="y position within the global compositor space"/>
+    </event>
+
+    <event name="transform">
+      <description summary="current transformation">
+        This event describes the transformation currently applied to the head.
+        It is only sent if the output is enabled.
+      </description>
+      <arg name="transform" type="int" enum="wl_output.transform"/>
+    </event>
+
+    <event name="scale">
+      <description summary="current scale">
+        This events describes the scale of the head in the global compositor
+        space. It is only sent if the output is enabled.
+      </description>
+      <arg name="scale" type="fixed"/>
+    </event>
+
+    <event name="finished">
+      <description summary="the head has disappeared">
+        This event indicates that the head is no longer available. The head
+        object becomes inert. Clients should send a destroy request and release
+        any resources associated with it.
+      </description>
+    </event>
+
+    <!-- Version 2 additions -->
+
+    <event name="make" since="2">
+      <description summary="head manufacturer">
+        This event describes the manufacturer of the head.
+
+        Together with the model and serial_number events the purpose is to
+        allow clients to recognize heads from previous sessions and for example
+        load head-specific configurations back.
+
+        It is not guaranteed this event will be ever sent. A reason for that
+        can be that the compositor does not have information about the make of
+        the head or the definition of a make is not sensible in the current
+        setup, for example in a virtual session. Clients can still try to
+        identify the head by available information from other events but should
+        be aware that there is an increased risk of false positives.
+
+        If sent, the make event is sent after a wlr_output_head object is
+        created and only sent once per object. The make does not change over
+        the lifetime of the wlr_output_head object.
+
+        It is not recommended to display the make string in UI to users. For
+        that the string provided by the description event should be preferred.
+      </description>
+      <arg name="make" type="string"/>
+    </event>
+
+    <event name="model" since="2">
+      <description summary="head model">
+        This event describes the model of the head.
+
+        Together with the make and serial_number events the purpose is to
+        allow clients to recognize heads from previous sessions and for example
+        load head-specific configurations back.
+
+        It is not guaranteed this event will be ever sent. A reason for that
+        can be that the compositor does not have information about the model of
+        the head or the definition of a model is not sensible in the current
+        setup, for example in a virtual session. Clients can still try to
+        identify the head by available information from other events but should
+        be aware that there is an increased risk of false positives.
+
+        If sent, the model event is sent after a wlr_output_head object is
+        created and only sent once per object. The model does not change over
+        the lifetime of the wlr_output_head object.
+
+        It is not recommended to display the model string in UI to users. For
+        that the string provided by the description event should be preferred.
+      </description>
+      <arg name="model" type="string"/>
+    </event>
+
+    <event name="serial_number" since="2">
+      <description summary="head serial number">
+        This event describes the serial number of the head.
+
+        Together with the make and model events the purpose is to allow clients
+        to recognize heads from previous sessions and for example load head-
+        specific configurations back.
+
+        It is not guaranteed this event will be ever sent. A reason for that
+        can be that the compositor does not have information about the serial
+        number of the head or the definition of a serial number is not sensible
+        in the current setup. Clients can still try to identify the head by
+        available information from other events but should be aware that there
+        is an increased risk of false positives.
+
+        If sent, the serial number event is sent after a wlr_output_head object
+        is created and only sent once per object. The serial number does not
+        change over the lifetime of the wlr_output_head object.
+
+        It is not recommended to display the serial_number string in UI to
+        users. For that the string provided by the description event should be
+        preferred.
+      </description>
+      <arg name="serial_number" type="string"/>
+    </event>
+
+    <!-- Version 3 additions -->
+
+    <request name="release" type="destructor" since="3">
+      <description summary="destroy the head object">
+        This request indicates that the client will no longer use this head
+        object.
+      </description>
+    </request>
+
+    <!-- Version 4 additions -->
+
+    <enum name="adaptive_sync_state" since="4">
+      <entry name="disabled" value="0" summary="adaptive sync is disabled"/>
+      <entry name="enabled" value="1" summary="adaptive sync is enabled"/>
+    </enum>
+
+    <event name="adaptive_sync" since="4">
+      <description summary="current adaptive sync state">
+        This event describes whether adaptive sync is currently enabled for
+        the head or not. Adaptive sync is also known as Variable Refresh
+        Rate or VRR.
+      </description>
+      <arg name="state" type="uint" enum="adaptive_sync_state"/>
+    </event>
+  </interface>
+
+  <interface name="zwlr_output_mode_v1" version="3">
+    <description summary="output mode">
+      This object describes an output mode.
+
+      Some heads don't support output modes, in which case modes won't be
+      advertised.
+
+      Properties sent via this interface are applied atomically via the
+      wlr_output_manager.done event. No guarantees are made regarding the order
+      in which properties are sent.
+    </description>
+
+    <event name="size">
+      <description summary="mode size">
+        This event describes the mode size. The size is given in physical
+        hardware units of the output device. This is not necessarily the same as
+        the output size in the global compositor space. For instance, the output
+        may be scaled or transformed.
+      </description>
+      <arg name="width" type="int" summary="width of the mode in hardware units"/>
+      <arg name="height" type="int" summary="height of the mode in hardware units"/>
+    </event>
+
+    <event name="refresh">
+      <description summary="mode refresh rate">
+        This event describes the mode's fixed vertical refresh rate. It is only
+        sent if the mode has a fixed refresh rate.
+      </description>
+      <arg name="refresh" type="int" summary="vertical refresh rate in mHz"/>
+    </event>
+
+    <event name="preferred">
+      <description summary="mode is preferred">
+        This event advertises this mode as preferred.
+      </description>
+    </event>
+
+    <event name="finished">
+      <description summary="the mode has disappeared">
+        This event indicates that the mode is no longer available. The mode
+        object becomes inert. Clients should send a destroy request and release
+        any resources associated with it.
+      </description>
+    </event>
+
+    <!-- Version 3 additions -->
+
+    <request name="release" type="destructor" since="3">
+      <description summary="destroy the mode object">
+        This request indicates that the client will no longer use this mode
+        object.
+      </description>
+    </request>
+  </interface>
+
+  <interface name="zwlr_output_configuration_v1" version="4">
+    <description summary="output configuration">
+      This object is used by the client to describe a full output configuration.
+
+      First, the client needs to setup the output configuration. Each head can
+      be either enabled (and configured) or disabled. It is a protocol error to
+      send two enable_head or disable_head requests with the same head. It is a
+      protocol error to omit a head in a configuration.
+
+      Then, the client can apply or test the configuration. The compositor will
+      then reply with a succeeded, failed or cancelled event. Finally the client
+      should destroy the configuration object.
+    </description>
+
+    <enum name="error">
+      <entry name="already_configured_head" value="1"
+        summary="head has been configured twice"/>
+      <entry name="unconfigured_head" value="2"
+        summary="head has not been configured"/>
+      <entry name="already_used" value="3"
+        summary="request sent after configuration has been applied or tested"/>
+    </enum>
+
+    <request name="enable_head">
+      <description summary="enable and configure a head">
+        Enable a head. This request creates a head configuration object that can
+        be used to change the head's properties.
+      </description>
+      <arg name="id" type="new_id" interface="zwlr_output_configuration_head_v1"
+        summary="a new object to configure the head"/>
+      <arg name="head" type="object" interface="zwlr_output_head_v1"
+        summary="the head to be enabled"/>
+    </request>
+
+    <request name="disable_head">
+      <description summary="disable a head">
+        Disable a head.
+      </description>
+      <arg name="head" type="object" interface="zwlr_output_head_v1"
+        summary="the head to be disabled"/>
+    </request>
+
+    <request name="apply">
+      <description summary="apply the configuration">
+        Apply the new output configuration.
+
+        In case the configuration is successfully applied, there is no guarantee
+        that the new output state matches completely the requested
+        configuration. For instance, a compositor might round the scale if it
+        doesn't support fractional scaling.
+
+        After this request has been sent, the compositor must respond with an
+        succeeded, failed or cancelled event. Sending a request that isn't the
+        destructor is a protocol error.
+      </description>
+    </request>
+
+    <request name="test">
+      <description summary="test the configuration">
+        Test the new output configuration. The configuration won't be applied,
+        but will only be validated.
+
+        Even if the compositor succeeds to test a configuration, applying it may
+        fail.
+
+        After this request has been sent, the compositor must respond with an
+        succeeded, failed or cancelled event. Sending a request that isn't the
+        destructor is a protocol error.
+      </description>
+    </request>
+
+    <event name="succeeded">
+      <description summary="configuration changes succeeded">
+        Sent after the compositor has successfully applied the changes or
+        tested them.
+
+        Upon receiving this event, the client should destroy this object.
+
+        If the current configuration has changed, events to describe the changes
+        will be sent followed by a wlr_output_manager.done event.
+      </description>
+    </event>
+
+    <event name="failed">
+      <description summary="configuration changes failed">
+        Sent if the compositor rejects the changes or failed to apply them. The
+        compositor should revert any changes made by the apply request that
+        triggered this event.
+
+        Upon receiving this event, the client should destroy this object.
+      </description>
+    </event>
+
+    <event name="cancelled">
+      <description summary="configuration has been cancelled">
+        Sent if the compositor cancels the configuration because the state of an
+        output changed and the client has outdated information (e.g. after an
+        output has been hotplugged).
+
+        The client can create a new configuration with a newer serial and try
+        again.
+
+        Upon receiving this event, the client should destroy this object.
+      </description>
+    </event>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the output configuration">
+        Using this request a client can tell the compositor that it is not going
+        to use the configuration object anymore. Any changes to the outputs
+        that have not been applied will be discarded.
+
+        This request also destroys wlr_output_configuration_head objects created
+        via this object.
+      </description>
+    </request>
+  </interface>
+
+  <interface name="zwlr_output_configuration_head_v1" version="4">
+    <description summary="head configuration">
+      This object is used by the client to update a single head's configuration.
+
+      It is a protocol error to set the same property twice.
+    </description>
+
+    <enum name="error">
+      <entry name="already_set" value="1" summary="property has already been set"/>
+      <entry name="invalid_mode" value="2" summary="mode doesn't belong to head"/>
+      <entry name="invalid_custom_mode" value="3" summary="mode is invalid"/>
+      <entry name="invalid_transform" value="4" summary="transform value outside enum"/>
+      <entry name="invalid_scale" value="5" summary="scale negative or zero"/>
+      <entry name="invalid_adaptive_sync_state" value="6" since="4"
+        summary="invalid enum value used in the set_adaptive_sync request"/>
+    </enum>
+
+    <request name="set_mode">
+      <description summary="set the mode">
+        This request sets the head's mode.
+      </description>
+      <arg name="mode" type="object" interface="zwlr_output_mode_v1"/>
+    </request>
+
+    <request name="set_custom_mode">
+      <description summary="set a custom mode">
+        This request assigns a custom mode to the head. The size is given in
+        physical hardware units of the output device. If set to zero, the
+        refresh rate is unspecified.
+
+        It is a protocol error to set both a mode and a custom mode.
+      </description>
+      <arg name="width" type="int" summary="width of the mode in hardware units"/>
+      <arg name="height" type="int" summary="height of the mode in hardware units"/>
+      <arg name="refresh" type="int" summary="vertical refresh rate in mHz or zero"/>
+    </request>
+
+    <request name="set_position">
+      <description summary="set the position">
+        This request sets the head's position in the global compositor space.
+      </description>
+      <arg name="x" type="int" summary="x position in the global compositor space"/>
+      <arg name="y" type="int" summary="y position in the global compositor space"/>
+    </request>
+
+    <request name="set_transform">
+      <description summary="set the transform">
+        This request sets the head's transform.
+      </description>
+      <arg name="transform" type="int" enum="wl_output.transform"/>
+    </request>
+
+    <request name="set_scale">
+      <description summary="set the scale">
+        This request sets the head's scale.
+      </description>
+      <arg name="scale" type="fixed"/>
+    </request>
+
+    <!-- Version 4 additions -->
+
+    <request name="set_adaptive_sync" since="4">
+      <description summary="enable/disable adaptive sync">
+        This request enables/disables adaptive sync. Adaptive sync is also
+        known as Variable Refresh Rate or VRR.
+      </description>
+      <arg name="state" type="uint" enum="zwlr_output_head_v1.adaptive_sync_state"/>
+    </request>
+  </interface>
+</protocol>
diff --git a/src/borders.rs b/src/borders.rs
index d251912..188e79d 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -15,15 +15,6 @@ pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
     (val as u32) << 24
 }
 
-/// Write "#RRGGBB" for a given depth into a String.
-pub fn cascade_hex_color(br: u32, bg: u32, bb: u32, depth: i32) -> String {
-    let depth_factor = 0.80_f64;
-    let r = interp_channel(br, depth_factor, depth) >> 24;
-    let g = interp_channel(bg, depth_factor, depth) >> 24;
-    let b = interp_channel(bb, depth_factor, depth) >> 24;
-    format!("#{:02x}{:02x}{:02x}", r, g, b)
-}
-
 /// Normal border color in fixed-point format: dark gray (#3E3E3E)
 pub const BORDER_COLOR_NORMAL_R: u32 = 0x3E000000;
 pub const BORDER_COLOR_NORMAL_G: u32 = 0x3E000000;
@@ -49,8 +40,7 @@ pub struct WindowBorders {
 }
 
 /// Compute border colors for all visible windows.
-/// Returns a list of WindowBorders and the background color string for swaybg.
-pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Option<String>) {
+pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
     let mut results = Vec::new();
     let all_edges = 0b1111u32; // all edges
 
@@ -62,10 +52,7 @@ pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Opti
         }
     }
 
-    let mut max_cascade_depth = 0i32;
-    let mut bg_color: Option<String> = None;
-
-    // Second pass: assign border colors
+    // Assign border colors
     for (idx, win) in state.windows.iter().enumerate() {
         if (win.tags & state.active_tags) == 0 {
             continue;
@@ -93,12 +80,11 @@ pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Opti
                 window_idx: idx,
                 edges: all_edges,
                 width: state.layout.border_width,
-                r, g, b, a: CASCADE_ALPHA,
+                r,
+                g,
+                b,
+                a: CASCADE_ALPHA,
             });
-
-            if depth > max_cascade_depth {
-                max_cascade_depth = depth;
-            }
         } else {
             results.push(WindowBorders {
                 window_idx: idx,
@@ -112,17 +98,7 @@ pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Opti
         }
     }
 
-    // Set desktop background to the darkest cascade color
-    if n_cascade > 0 {
-        bg_color = Some(cascade_hex_color(
-            state.layout.border_r,
-            state.layout.border_g,
-            state.layout.border_b,
-            max_cascade_depth,
-        ));
-    }
-
-    (results, bg_color)
+    results
 }
 
 #[cfg(test)]
@@ -142,10 +118,4 @@ mod tests {
         let val = result >> 24;
         assert_eq!(val, ((0x90 as f64 * 0.80) as u8) as u32);
     }
-
-    #[test]
-    fn test_cascade_hex_color() {
-        let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 0);
-        assert_eq!(color, "#5c9060");
-    }
 }
diff --git a/src/config.rs b/src/config.rs
index 952331c..10dd086 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -18,7 +18,9 @@ pub struct Config {
     #[serde(default)]
     pub repeat: RepeatConfig,
     #[serde(default)]
-    pub startup: StartupConfig,
+    pub startup: Vec<StartupEntryConfig>,
+    #[serde(default)]
+    pub env: HashMap<String, String>,
     #[serde(default)]
     pub keybind: Vec<KeybindConfig>,
     #[serde(default)]
@@ -74,7 +76,7 @@ fn default_border_color() -> String {
 #[derive(Debug, Deserialize, Default)]
 pub struct OutputConfig {
     #[serde(default)]
-    pub scale: i64,
+    pub scale: f64,
 }
 
 #[derive(Debug, Deserialize, Default)]
@@ -85,14 +87,11 @@ pub struct RepeatConfig {
     pub delay: i64,
 }
 
-#[derive(Debug, Deserialize, Default)]
-pub struct StartupConfig {
-    #[serde(default)]
-    pub apps: Vec<String>,
-    #[serde(default)]
-    pub cold_start_only: Vec<String>,
+#[derive(Debug, Deserialize)]
+pub struct StartupEntryConfig {
+    pub exec: String,
     #[serde(default)]
-    pub env: HashMap<String, String>,
+    pub once: bool,
 }
 
 #[derive(Debug, Deserialize)]
@@ -126,7 +125,7 @@ pub struct TagLayoutConfig {
 }
 
 /// Parse the TOML config file and apply it to the WindowManager state.
-/// `cold_start` controls whether cold_start_only apps are spawned.
+/// `cold_start` controls whether `once = true` startup entries are spawned.
 pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
     let content = match fs::read_to_string(path) {
         Ok(c) => c,
@@ -145,6 +144,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
     };
 
     // [layout] section
+    eprintln!("[config] applying layout section...");
     state.layout.gap = config.layout.gap as i32;
     state.layout.offset = config.layout.offset as i32;
     state.layout.bar_height = config.layout.bar_height as i32;
@@ -157,6 +157,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
     }
 
     // [[keybind]] array
+    eprintln!("[config] processing {} keybinds...", config.keybind.len());
     for kb in &config.keybind {
         let mods = parse_modifiers(&kb.mods);
         let keysym = parse_keysym(&kb.key);
@@ -211,45 +212,52 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
         }
     }
 
-    // [startup] section — spawn apps
-    for app in &config.startup.apps {
-        // Extract program name for skip-if-running check
-        let name = extract_program_name(app);
-        if process_running(&name) {
+    // [[startup]] array — queue apps for spawning inside the render callback.
+    // Spawning between blocking_dispatch calls corrupts the Wayland connection
+    // because the fork inherits the socket fd, so we defer to render time.
+    state.pending_startup_apps.clear();
+    eprintln!(
+        "[config] processing {} startup entries (cold_start={})...",
+        config.startup.len(),
+        cold_start
+    );
+    for (i, entry) in config.startup.iter().enumerate() {
+        let name = extract_program_name(&entry.exec);
+        if entry.once && !cold_start {
+            eprintln!(
+                "[config] startup[{}]: exec=\"{}\" once=true → skipped (not cold start)",
+                i, entry.exec
+            );
             continue;
         }
-        // If waybar, kill existing before launching
-        // NOTE: pkill + sleep is too slow for nested mode where River's
-        // 3-second unresponsive timer is ticking. Just launch waybar
-        // directly — if an existing waybar is running, the new one will
-        // replace it (or the old one can be killed manually).
-        // if name == "waybar" {
-        //     let _ = std::process::Command::new("pkill")
-        //         .arg("waybar")
-        //         .output();
-        //     std::thread::sleep(std::time::Duration::from_millis(100));
-        // }
-        spawn_command_bg(app);
-    }
-
-    // Cold-start-only apps
-    if cold_start {
-        for app in &config.startup.cold_start_only {
-            spawn_command_bg(app);
+        let running = process_running(&name);
+        if running {
+            eprintln!("[config] startup[{}]: exec=\"{}\" once={} → skipped (already running, pgrep -x {})", i, entry.exec, entry.once, name);
+        } else {
+            eprintln!(
+                "[config] startup[{}]: exec=\"{}\" once={} → queued for spawn",
+                i, entry.exec, entry.once
+            );
+            state.pending_startup_apps.push(entry.exec.clone());
         }
     }
 
-    // [startup.env] — set environment variables
-    for (key, value) in &config.startup.env {
+    // [env] — set environment variables
+    for (key, value) in &config.env {
         std::env::set_var(key, value);
         state.env_vars.insert(key.clone(), value.clone());
     }
 
-    // [output] scale — handled at startup
-    if config.output.scale > 0 && cold_start {
-        // Scale is applied via wlr-randr; we just store the value
-        // The actual wlr-randr call would happen in the Wayland integration layer
-    }
+    // [output] scale — applied via wlr-output-management protocol.
+    // After storing the scale, set pending_scale_apply so that the next
+    // output_manager done event triggers the configuration. This handles
+    // both initial startup and VT-switch-back (where wlroots resets scale to 1).
+    state.output_scale = if config.output.scale > 0.0 {
+        state.pending_scale_apply = true;
+        config.output.scale
+    } else {
+        0.0
+    };
 
     // Signal config-done
     state.config_done = true;
@@ -258,10 +266,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
 /// Extract the program name (first word, basename) from a command string
 fn extract_program_name(cmd: &str) -> String {
     let cmd = cmd.trim_start();
-    let first_word: String = cmd
-        .chars()
-        .take_while(|c| !c.is_whitespace())
-        .collect();
+    let first_word: String = cmd.chars().take_while(|c| !c.is_whitespace()).collect();
     if let Some(slash) = first_word.rfind('/') {
         first_word[slash + 1..].to_string()
     } else {
@@ -281,26 +286,37 @@ pub fn parse_keysym(key_str: &str) -> u32 {
     xkbcommon::xkb::keysym_from_name(name, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into()
 }
 
-/// Spawn a command in the background (double-fork style)
+/// Spawn a command in the background.
+///
+/// Closes all inherited FDs > 2 in the child via pre_exec so that
+/// spawned Wayland clients (fuzzel, foot, etc.) never accidentally
+/// read from clearwm's Wayland socket fd. Also redirects stdout/stderr
+/// to /dev/null so child output doesn't pollute clearwm's log, and
+/// calls setsid() to detach from clearwm's process group.
 pub fn spawn_command_bg(cmd: &str) {
     use std::os::unix::process::CommandExt;
     let cmd = cmd.to_string();
-    // Double-fork: first fork setsid, second fork execs
-    // Safety: pre_exec is unsafe because it runs between fork and exec.
-    // We only call setsid() which is async-signal-safe.
     let _ = unsafe {
         std::process::Command::new("sh")
             .arg("-c")
             .arg(&cmd)
+            .env_remove("WAYLAND_DEBUG")
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
             .pre_exec(|| {
+                // Close all inherited FDs > 2 to prevent the child from
+                // accidentally reading clearwm's Wayland socket or status
+                // socket FDs. close() and setsid() are async-signal-safe.
+                let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
+                for fd in 3..max_fd {
+                    libc::close(fd);
+                }
                 libc::setsid();
                 Ok(())
             })
             .spawn()
     };
 }
-
-/// Check if a process with the given name is already running
 pub fn process_running(name: &str) -> bool {
     match std::process::Command::new("pgrep")
         .arg("-x")
@@ -337,3 +353,30 @@ mod tests {
         assert_eq!(lc.border_color, "#3e3e3e");
     }
 }
+
+#[cfg(test)]
+mod startup_format_tests {
+    use super::*;
+
+    #[test]
+    fn test_startup_entry_format() {
+        let toml_str = r#"
+[env]
+XDG_CURRENT_DESKTOP = "river"
+
+[[startup]]
+exec = "waybar"
+
+[[startup]]
+exec = "fuzzel"
+once = true
+"#;
+        let config: Config = toml::from_str(toml_str).expect("TOML parse failed");
+        assert_eq!(config.startup.len(), 2);
+        assert_eq!(config.startup[0].exec, "waybar");
+        assert!(!config.startup[0].once);
+        assert_eq!(config.startup[1].exec, "fuzzel");
+        assert!(config.startup[1].once);
+        assert_eq!(config.env.get("XDG_CURRENT_DESKTOP").unwrap(), "river");
+    }
+}
diff --git a/src/ipc.rs b/src/ipc.rs
index 9833a6c..9892e21 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -28,16 +28,43 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
             }
         }
         "close" => {
-            // Will be handled by the WM layer - mark that close was requested
-            // for the focused window
+            // Close the focused window
             if let Some(seat) = state.seats.first() {
-                if seat.focused_window_id.is_some() {
-                    // The actual river_window_v1_close() call happens in wm.rs
+                if let Some(focused_id) = seat.focused_window_id {
+                    if let Some(window) = state.get_window_mut(focused_id) {
+                        window.closed = true;
+                    }
                 }
             }
+            state.needs_render = true;
         }
         "focus-next" => {
-            // Will be handled by the WM layer
+            // Focus the next visible window (wrapping) and move it to the
+            // front of the cascade stack (end of windows vector).
+            if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+                let focused_id = seat.focused_window_id;
+                let active_tags = state.active_tags;
+                let visible_ids: Vec<u64> = state
+                    .windows
+                    .iter()
+                    .filter(|w| (w.tags & active_tags) != 0 && !w.closed)
+                    .map(|w| w.id)
+                    .collect();
+                if visible_ids.len() > 1 {
+                    if let Some(fid) = focused_id {
+                        if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
+                            let next_idx = (idx + 1) % visible_ids.len();
+                            let next_id = visible_ids[next_idx];
+                            seat.focused_window_id = Some(next_id);
+                            // Move newly focused window to front of cascade stack
+                            state.move_window_to_end(next_id);
+                        }
+                    }
+                }
+            }
+            state.needs_render = true;
+            state.needs_focus = true;
+            state.needs_status_update = true;
         }
         "exit" => {
             // Signal exit request
@@ -53,6 +80,18 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
             if let Some(tag) = tag {
                 if tag >= 1 && tag <= NUM_TAGS as i32 {
                     state.active_tags = 1 << (tag - 1);
+
+                    // Reassign focus to a visible window on the new tag
+                    if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+                        let visible_ids: Vec<u64> = state
+                            .windows
+                            .iter()
+                            .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed)
+                            .map(|w| w.id)
+                            .collect();
+                        seat.focused_window_id = visible_ids.last().copied();
+                    }
+                    state.needs_focus = true;
                 }
             }
         }
@@ -61,6 +100,30 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
             if let Some(tag) = tag {
                 if tag >= 1 && tag <= NUM_TAGS as i32 {
                     state.active_tags ^= 1 << (tag - 1);
+
+                    // If the focused window is no longer visible, reassign focus
+                    let focused_id = state
+                        .seats
+                        .iter()
+                        .find(|s| !s.removed)
+                        .and_then(|s| s.focused_window_id);
+                    let focused_still_visible = focused_id.map_or(false, |fid| {
+                        state
+                            .get_window(fid)
+                            .map_or(false, |w| (w.tags & state.active_tags) != 0 && !w.closed)
+                    });
+                    if !focused_still_visible {
+                        let visible_ids: Vec<u64> = state
+                            .windows
+                            .iter()
+                            .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed)
+                            .map(|w| w.id)
+                            .collect();
+                        if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+                            seat.focused_window_id = visible_ids.last().copied();
+                        }
+                        state.needs_focus = true;
+                    }
                 }
             }
         }
@@ -236,7 +299,11 @@ fn handle_bind_command(rest: &str, state: &mut WindowManager) {
     };
 
     let action = parse_action(action_str);
-    let command = if action == Action::Spawn { command } else { None };
+    let command = if action == Action::Spawn {
+        command
+    } else {
+        None
+    };
 
     state.pending_bindings.push(PendingXkbBinding {
         mods,
@@ -287,8 +354,35 @@ fn handle_set_tag_command(rest: &str, state: &mut WindowManager) {
     let tag_str = rest.trim();
     if let Ok(tag) = tag_str.parse::<i32>() {
         if tag >= 1 && tag <= NUM_TAGS as i32 {
-            if let Some(window) = state.focused_window_mut() {
-                window.tags = 1 << (tag - 1);
+            // Read focused_id before any mutable borrow
+            let focused_id = state
+                .seats
+                .iter()
+                .find(|s| !s.removed)
+                .and_then(|s| s.focused_window_id);
+            if let Some(focused_id) = focused_id {
+                // Set the window's tag
+                let active_tags = state.active_tags;
+                let window_left_active_tag = state
+                    .get_window_mut(focused_id)
+                    .map_or(false, |window| {
+                        window.tags = 1 << (tag - 1);
+                        (window.tags & active_tags) == 0
+                    });
+
+                // If the window is no longer on an active tag, shift focus
+                if window_left_active_tag {
+                    let visible_ids: Vec<u64> = state
+                        .windows
+                        .iter()
+                        .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed)
+                        .map(|w| w.id)
+                        .collect();
+                    if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+                        seat.focused_window_id = visible_ids.last().copied();
+                    }
+                    state.needs_focus = true;
+                }
             }
         }
     }
diff --git a/src/lib.rs b/src/lib.rs
index d69dfc7..16a5f31 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,6 +7,7 @@ pub mod tiling;
 pub mod ipc;
 pub mod borders;
 pub mod status;
+pub mod status_server;
 pub mod restart;
 pub mod wm;
 #[allow(unreachable_patterns)] // wayland event match arms use _ => {} for forward-compat
diff --git a/src/main.rs b/src/main.rs
index d7cd851..9f41779 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,21 +1,20 @@
 // clearwm — Wayland window manager for river
 
 use clearwm::config::parse_config;
-use clearwm::ipc::handle_ipc_command;
 use clearwm::restart;
-use clearwm::status::update_status_files;
+use clearwm::status_server;
 use clearwm::wayland::wayland_init;
 use std::env;
 use std::fs;
-use std::io::Read;
-use std::os::unix::io::AsRawFd;
-use std::os::unix::net::{UnixListener, UnixStream};
 
 const SOCKET_PATH: &str = "/tmp/clearwm.sock";
 
 fn main() {
     eprintln!("clearwm starting...");
 
+    // Start the status socket server thread (for waybar integration)
+    let status_sender = status_server::spawn_status_server();
+
     // Set up SIGCHLD handler to reap child processes
     let sa = nix::sys::signal::SigAction::new(
         nix::sys::signal::SigHandler::Handler(sigchld_handler),
@@ -27,14 +26,8 @@ fn main() {
             .expect("failed to set SIGCHLD handler");
     }
 
-    // Create IPC socket (non-blocking for polling)
-    let ipc_listener = create_ipc_socket();
-    if let Some(ref listener) = ipc_listener {
-        listener.set_nonblocking(true).ok();
-    }
-
     // Connect to Wayland display and get initial state.
-    let (conn, mut event_queue, mut state) = match wayland_init() {
+    let (_conn, mut event_queue, mut state) = match wayland_init() {
         Ok(c) => c,
         Err(e) => {
             eprintln!("fatal: {}", e);
@@ -42,14 +35,8 @@ fn main() {
         }
     };
 
-    // Dispatch any events buffered during init
-    let _ = event_queue.dispatch_pending(&mut state);
-
-    // Flush and trigger manage cycle
-    let _ = conn.flush();
-    if let Some(ref wm) = state.window_manager {
-        wm.manage_dirty();
-    }
+    // Store the status sender in the app state so RenderStart can push updates
+    state.status_sender = Some(status_sender);
 
     // Check if this is a restart
     let cold_start = if env::var("CLEARWM_RESTARTING").as_deref() == Ok("1") {
@@ -59,28 +46,43 @@ fn main() {
         true
     };
 
-    let mut need_config_load = true;
     env::remove_var("WAYLAND_DEBUG");
 
-    // Main event loop.
-    // Uses blocking_dispatch() which is the Rust equivalent of the C version's
-    // wl_display_dispatch() — it blocks until events are available, then reads
-    // and dispatches them. This is the simplest and most reliable pattern.
+    // Load config immediately, before entering the event loop.
+    // This ensures bindings are registered before any render_start arrives.
+    // Following the tinyrwm pattern: do setup, then simple blocking_dispatch loop.
+    eprintln!(
+        "[init] about to load config, render_count={}",
+        state.render_count
+    );
+    let config_start = std::time::Instant::now();
+    if let Ok(home) = env::var("HOME") {
+        let config_path = format!("{}/.config/clearwm/config.toml", home);
+        if fs::metadata(&config_path).is_ok() {
+            parse_config(&config_path, cold_start, &mut state.wm);
+        }
+    }
+    eprintln!(
+        "[init] config loaded in {:?}, render_count={}",
+        config_start.elapsed(),
+        state.render_count
+    );
 
-    eprintln!("[DEBUG] entering main loop");
+    // Apply output scale immediately if heads were discovered during init roundtrips.
+    if state.wm.pending_scale_apply && state.wm.output_scale > 0.0 && !state.output_heads.is_empty()
+    {
+        let qh = event_queue.handle();
+        clearwm::wayland::apply_output_scale(&mut state, &qh);
+    }
 
-    loop {
-        // Flush outgoing Wayland requests
-        if let Err(e) = conn.flush() {
-            eprintln!("wayland flush error: {:?}", e);
-            break;
-        }
+    // Flush any queued requests from config loading (bindings, etc.)
+    eprintln!("[init] flushed, entering main loop");
 
-        eprintln!("[DEBUG] calling blocking_dispatch...");
+    // Main loop — tinyrwm pattern: just blocking_dispatch in a loop.
+    // All work (including spawning) happens inside Dispatch callbacks.
+    loop {
         match event_queue.blocking_dispatch(&mut state) {
-            Ok(n) => {
-                eprintln!("[DEBUG] blocking_dispatch returned Ok({})", n);
-            }
+            Ok(_) => {}
             Err(e) => {
                 eprintln!("wayland dispatch error: {:?}", e);
                 if !state.wm.exit_requested {
@@ -89,60 +91,6 @@ fn main() {
                 break;
             }
         }
-        update_status_files(&state.wm);
-
-        // Handle IPC connections (non-blocking)
-        if let Some(ref listener) = ipc_listener {
-            while let Ok((stream, _)) = listener.accept() {
-                handle_ipc_connection(stream, &mut state.wm);
-            }
-            update_status_files(&state.wm);
-        }
-
-        // Deferred config loading — must happen AFTER we've handled at least
-        // one render_start/render_finish cycle. But we also need to keep
-        // handling render cycles DURING config loading, because River's
-        // 3-second timer expects continuous responsiveness.
-        if need_config_load {
-            eprintln!("[DEBUG] loading config...");
-            if let Ok(home) = env::var("HOME") {
-                let config_path = format!("{}/.config/clearwm/config.toml", home);
-                if fs::metadata(&config_path).is_ok() {
-                    parse_config(&config_path, cold_start, &mut state.wm);
-                }
-            }
-
-            // After config loading, we MUST flush and dispatch before
-            // continuing — River may have sent render_start while we
-            // were busy. Do a non-blocking flush+read+dispatch cycle.
-            let _ = conn.flush();
-            if let Some(guard) = event_queue.prepare_read() {
-                // Non-blocking read using libc::recv with MSG_DONTWAIT
-                let fd = guard.connection_fd().as_raw_fd();
-                let mut buf = [0u8; 4096];
-                let _ = unsafe {
-                    libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), libc::MSG_DONTWAIT)
-                };
-                let _ = guard.read();
-            }
-            let pending = event_queue.dispatch_pending(&mut state).unwrap_or(0);
-            if pending > 0 {
-                eprintln!("[DEBUG] dispatched {} events after config", pending);
-                update_status_files(&state.wm);
-            }
-
-            // Trigger manage cycle for bindings
-            if state.wm.config_done {
-                if let Some(ref wm) = state.window_manager {
-                    wm.manage_dirty();
-                }
-                let _ = conn.flush();
-            }
-
-            need_config_load = false;
-            eprintln!("[DEBUG] config loaded, continuing loop");
-        }
-
         if state.exit_requested || state.wm.exit_requested {
             break;
         }
@@ -154,34 +102,6 @@ fn main() {
     eprintln!("main loop exited");
 }
 
-fn create_ipc_socket() -> Option<UnixListener> {
-    let _ = fs::remove_file(SOCKET_PATH);
-    match UnixListener::bind(SOCKET_PATH) {
-        Ok(listener) => {
-            use std::os::unix::fs::PermissionsExt;
-            let _ = fs::set_permissions(SOCKET_PATH, fs::Permissions::from_mode(0o600));
-            Some(listener)
-        }
-        Err(e) => {
-            eprintln!("failed to create IPC socket: {}", e);
-            let _ = fs::remove_file(SOCKET_PATH);
-            UnixListener::bind(SOCKET_PATH).ok()
-        }
-    }
-}
-
-fn handle_ipc_connection(mut stream: UnixStream, state: &mut clearwm::types::WindowManager) {
-    let mut buf = [0u8; 4096];
-    match stream.read(&mut buf) {
-        Ok(n) if n > 0 => {
-            let cmd = String::from_utf8_lossy(&buf[..n]);
-            handle_ipc_command(cmd.trim(), state);
-            state.needs_render = true;
-        }
-        _ => {}
-    }
-}
-
 extern "C" fn sigchld_handler(_sig: nix::libc::c_int) {
     while nix::sys::wait::waitpid(
         nix::unistd::Pid::from_raw(-1),
diff --git a/src/protocol.rs b/src/protocol.rs
index b8573aa..de21dea 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -29,16 +29,26 @@ pub mod river_window_management {
 }
 
 pub mod river_xkb_bindings {
-    river_protocol!("protocol/river-xkb-bindings-v1.xml",
-        [crate::protocol::river_window_management::generated]);
+    river_protocol!(
+        "protocol/river-xkb-bindings-v1.xml",
+        [crate::protocol::river_window_management::generated]
+    );
 }
 
 pub mod river_layer_shell {
-    river_protocol!("protocol/river-layer-shell-v1.xml",
-        [crate::protocol::river_window_management::generated]);
+    river_protocol!(
+        "protocol/river-layer-shell-v1.xml",
+        [crate::protocol::river_window_management::generated]
+    );
 }
 
 pub mod river_input_management {
-    river_protocol!("protocol/river-input-management-v1.xml",
-        [crate::protocol::river_window_management::generated]);
+    river_protocol!(
+        "protocol/river-input-management-v1.xml",
+        [crate::protocol::river_window_management::generated]
+    );
+}
+
+pub mod wlr_output_management {
+    river_protocol!("protocol/wlr-output-management-unstable-v1.xml", []);
 }
diff --git a/src/restart.rs b/src/restart.rs
index d2284ca..733e4dd 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -5,11 +5,12 @@ use crate::types::WindowManager;
 
 /// Restart the window manager process.
 ///
-/// This uses execl() to replace the current process with a fresh instance.
+/// This forks a child process that waits briefly for the parent to die
+/// (so River cleans up the old Wayland connection), then execs a fresh
+/// clearwm instance. The parent (current process) exits immediately.
+///
 /// The CLEARWM_RESTARTING environment variable signals that this is a restart
 /// (not a cold start), so the new process skips cold_start_only apps.
-///
-/// Ported from C wm_restart() with throttle logic.
 pub fn wm_restart() {
     use std::time::Instant;
 
@@ -41,9 +42,29 @@ pub fn wm_restart() {
     let _ = std::fs::remove_file("/tmp/clearwm.sock");
 
     // Get the current executable path
-    if let Ok(exe_path) = std::env::current_exe() {
-        let path_str = exe_path.to_string_lossy().to_string();
-        // Use execl via libc to replace the current process
+    let Ok(exe_path) = std::env::current_exe() else {
+        std::process::exit(1);
+    };
+    let path_str = exe_path.to_string_lossy().to_string();
+
+    // Fork: child waits for parent to die, then execs fresh clearwm.
+    // Parent exits so River tears down the old Wayland connection.
+    let pid = unsafe { libc::fork() };
+    if pid < 0 {
+        // fork failed, just exit
+        std::process::exit(1);
+    } else if pid == 0 {
+        // Child: wait for parent to exit so River cleans up the old
+        // Wayland connection before we try to connect fresh.
+        std::thread::sleep(std::time::Duration::from_millis(500));
+
+        // Close inherited Wayland FDs so we don't confuse River
+        // (close everything except stdin/stdout/stderr)
+        let max_fd = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) } as i32;
+        for fd in 3..max_fd {
+            unsafe { libc::close(fd); }
+        }
+
         let ret = unsafe {
             libc::execl(
                 path_str.as_ptr() as *const i8,
@@ -52,12 +73,13 @@ pub fn wm_restart() {
             )
         };
         if ret < 0 {
-            eprintln!("wm_restart: execl failed");
+            // If execl fails, just exit silently
+            std::process::exit(1);
         }
+    } else {
+        // Parent: exit immediately so River sees the connection drop
+        std::process::exit(0);
     }
-
-    // If execl failed, exit
-    std::process::exit(1);
 }
 
 /// Reload the configuration file.
@@ -75,6 +97,9 @@ pub fn wm_reload(state: &mut WindowManager) {
     // Clear per-tag layout defaults
     state.has_tag_layout = [false; crate::types::NUM_TAGS];
 
+    // Mark status for update after reload
+    state.needs_status_update = true;
+
     // Clear mode rules
     state.mode_rules.clear();
 
diff --git a/src/status.rs b/src/status.rs
index e00ff61..71f1866 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -6,12 +6,18 @@ use std::process::Command;
 
 pub const NUM_TAGS: u32 = 4;
 
-/// Write all status files and signal waybar
-pub fn update_status_files(state: &WindowManager) {
+/// Write status files only (no pkill signaling). Safe to call inside
+/// Dispatch callbacks — no fork, no blocking, just file I/O.
+pub fn write_status_files(state: &WindowManager) {
+    use std::io::Write;
+
     // /tmp/clearwm-tags: active_tags focused_tags num_tags
     if let Ok(mut f) = fs::File::create("/tmp/clearwm-tags") {
-        use std::io::Write;
-        let _ = writeln!(f, "{} {} {}", state.active_tags, state.focused_tags, NUM_TAGS);
+        let _ = writeln!(
+            f,
+            "{} {} {}",
+            state.active_tags, state.focused_tags, NUM_TAGS
+        );
     }
 
     // /tmp/clearwm-layout: focused window's tiling mode
@@ -21,13 +27,11 @@ pub fn update_status_files(state: &WindowManager) {
         .unwrap_or("none");
 
     if let Ok(mut f) = fs::File::create("/tmp/clearwm-layout") {
-        use std::io::Write;
         let _ = writeln!(f, "{}", mode_str);
     }
 
     // /tmp/clearwm-windows: one line per window
     if let Ok(mut f) = fs::File::create("/tmp/clearwm-windows") {
-        use std::io::Write;
         let focused_title = state.focused_window().map(|w| w.title.clone());
 
         for win in &state.windows {
@@ -59,11 +63,17 @@ pub fn update_status_files(state: &WindowManager) {
         // /tmp/clearwm-title
         if let Some(title) = focused_title {
             if let Ok(mut tf) = fs::File::create("/tmp/clearwm-title") {
-                use std::io::Write;
                 let _ = writeln!(tf, "{}", title.as_deref().unwrap_or("(null)"));
             }
         }
     }
+}
+
+/// Write all status files and signal waybar.
+/// WARNING: The pkill calls block the event loop. Do NOT call this
+/// inside a Dispatch callback. Use write_status_files() instead.
+pub fn update_status_files(state: &WindowManager) {
+    write_status_files(state);
 
     // Signal waybar
     let _ = Command::new("pkill").args(["-RTMIN+8", "waybar"]).output();
diff --git a/src/status_server.rs b/src/status_server.rs
new file mode 100644
index 0000000..9473cac
--- /dev/null
+++ b/src/status_server.rs
@@ -0,0 +1,335 @@
+// Status socket server for waybar integration
+//
+// Runs in a dedicated thread. Waybar custom module scripts connect to
+// /tmp/clearwm-status.sock, send a subscription line ("tags", "layout",
+// or "title"), and receive JSON lines whenever the status changes.
+//
+// The main loop sends updates through an mpsc channel — no blocking,
+// no fork, no pkill. The server thread owns the socket and handles
+// all I/O independently of the Wayland event loop.
+
+use std::io::{BufRead, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::mpsc;
+
+/// The socket path for the status server.
+pub const STATUS_SOCKET_PATH: &str = "/tmp/clearwm-status.sock";
+
+/// A status update sent from the main loop to the server thread.
+#[derive(Debug, Clone)]
+pub struct StatusUpdate {
+    /// JSON string for tags module subscribers
+    pub tags_json: String,
+    /// Plain text for layout module subscribers
+    pub layout_text: String,
+    /// Plain text for title module subscribers
+    pub title_text: String,
+}
+
+/// Subscription types that waybar scripts can request.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Subscription {
+    Tags,
+    Layout,
+    Title,
+    Unknown,
+}
+
+impl Subscription {
+    fn from_str(s: &str) -> Self {
+        match s.trim() {
+            "tags" => Subscription::Tags,
+            "layout" => Subscription::Layout,
+            "title" => Subscription::Title,
+            _ => Subscription::Unknown,
+        }
+    }
+}
+
+/// A connected client with a known subscription.
+struct Client {
+    subscription: Subscription,
+    stream: UnixStream,
+}
+
+/// Handle to the status server for sending updates from the main loop.
+#[derive(Debug)]
+pub struct StatusSender {
+    tx: mpsc::Sender<StatusUpdate>,
+}
+
+impl StatusSender {
+    pub fn send(&self, update: StatusUpdate) {
+        // If the channel is full or the receiver is gone, just drop it.
+        // Status updates are frequent; missing one is fine.
+        let _ = self.tx.send(update);
+    }
+}
+
+/// Spawn the status server thread. Returns a StatusSender for the main loop.
+pub fn spawn_status_server() -> StatusSender {
+    let (tx, rx) = mpsc::channel::<StatusUpdate>();
+
+    std::thread::Builder::new()
+        .name("clearwm-status".into())
+        .spawn(move || {
+            status_server_main(rx);
+        })
+        .expect("failed to spawn status server thread");
+
+    StatusSender { tx }
+}
+
+fn status_server_main(rx: mpsc::Receiver<StatusUpdate>) {
+    // Remove stale socket
+    let _ = std::fs::remove_file(STATUS_SOCKET_PATH);
+
+    let listener = match UnixListener::bind(STATUS_SOCKET_PATH) {
+        Ok(l) => l,
+        Err(e) => {
+            eprintln!("[status] failed to bind {}: {}", STATUS_SOCKET_PATH, e);
+            return;
+        }
+    };
+
+    // Set non-blocking so accept() doesn't hang the thread
+    if let Err(e) = listener.set_nonblocking(true) {
+        eprintln!("[status] failed to set non-blocking: {}", e);
+        return;
+    }
+
+    eprintln!("[status] listening on {}", STATUS_SOCKET_PATH);
+
+    let mut clients: Vec<Client> = Vec::new();
+    let mut latest: Option<StatusUpdate> = None;
+
+    loop {
+        // Accept new connections (non-blocking)
+        for _ in 0..5 {
+            match listener.accept() {
+                Ok((mut stream, _addr)) => {
+                    if let Err(e) = stream.set_nonblocking(true) {
+                        eprintln!("[status] failed to set non-blocking on client: {}", e);
+                        continue;
+                    }
+                    // Read the subscription line
+                    let subscription = read_subscription(&stream);
+                    if subscription == Subscription::Unknown {
+                        eprintln!("[status] client sent unknown subscription, dropping");
+                        continue;
+                    }
+                    eprintln!("[status] new client subscribed: {:?}", subscription);
+
+                    // Send current state immediately so waybar shows data on startup
+                    if let Some(ref update) = latest {
+                        let msg = format_for_subscription(subscription, update);
+                        let _ = stream.write_all(msg.as_bytes());
+                        let _ = stream.write_all(b"\n");
+                    }
+
+                    clients.push(Client {
+                        subscription,
+                        stream,
+                    });
+                }
+                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+                    break; // No more pending connections
+                }
+                Err(e) => {
+                    eprintln!("[status] accept error: {}", e);
+                    break;
+                }
+            }
+        }
+
+        // Receive status updates from the main loop
+        // Use try_recv in a loop to drain all pending updates (only the latest matters)
+        loop {
+            match rx.try_recv() {
+                Ok(update) => {
+                    latest = Some(update);
+                }
+                Err(mpsc::TryRecvError::Empty) => break,
+                Err(mpsc::TryRecvError::Disconnected) => {
+                    eprintln!("[status] channel disconnected, exiting");
+                    let _ = std::fs::remove_file(STATUS_SOCKET_PATH);
+                    return;
+                }
+            }
+        }
+
+        // If we got an update, push it to all clients
+        if let Some(ref update) = latest {
+            let mut dead_clients = Vec::new();
+
+            for (i, client) in clients.iter_mut().enumerate() {
+                let msg = format_for_subscription(client.subscription, update);
+                match client
+                    .stream
+                    .write_all(msg.as_bytes())
+                    .and_then(|_| client.stream.write_all(b"\n"))
+                {
+                    Ok(_) => {}
+                    Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+                        // Client not ready to receive — skip for now
+                    }
+                    Err(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
+                        eprintln!(
+                            "[status] client {:?} disconnected (broken pipe)",
+                            client.subscription
+                        );
+                        dead_clients.push(i);
+                    }
+                    Err(e) => {
+                        eprintln!(
+                            "[status] write error to client {:?}: {}",
+                            client.subscription, e
+                        );
+                        dead_clients.push(i);
+                    }
+                }
+            }
+
+            // Remove dead clients (iterate in reverse to preserve indices)
+            for i in dead_clients.into_iter().rev() {
+                clients.remove(i);
+            }
+        }
+
+        // Small sleep to avoid busy-looping when nothing is happening
+        std::thread::sleep(std::time::Duration::from_millis(50));
+    }
+}
+
+/// Read the subscription line from a newly connected client.
+/// The client sends one line: "tags", "layout", or "title".
+fn read_subscription(stream: &UnixStream) -> Subscription {
+    use std::io::BufReader;
+    let mut reader = BufReader::new(stream);
+    let mut line = String::new();
+    // Try to read with a small timeout
+    stream
+        .set_read_timeout(Some(std::time::Duration::from_millis(100)))
+        .ok();
+    match reader.read_line(&mut line) {
+        Ok(_) => Subscription::from_str(&line),
+        Err(e) => {
+            eprintln!("[status] failed to read subscription: {}", e);
+            Subscription::Unknown
+        }
+    }
+}
+
+/// Format the relevant part of a StatusUpdate for a given subscription.
+fn format_for_subscription(sub: Subscription, update: &StatusUpdate) -> String {
+    match sub {
+        Subscription::Tags => update.tags_json.clone(),
+        Subscription::Layout => update.layout_text.clone(),
+        Subscription::Title => update.title_text.clone(),
+        Subscription::Unknown => String::new(),
+    }
+}
+
+/// Build a StatusUpdate from the current WindowManager state.
+/// This is the same logic that write_status_files() uses, but produces
+/// the data for the socket instead of writing to files.
+pub fn build_status_update(wm: &crate::types::WindowManager) -> StatusUpdate {
+    // Tags: generate the same pango-marked JSON that clearwm-tags.sh produces
+    let tags_json = render_tags_json(
+        wm.active_tags,
+        wm.focused_tags,
+        crate::types::NUM_TAGS as u32,
+    );
+
+    // Layout: focused window's tiling mode
+    let layout_text = wm
+        .focused_window()
+        .map(|w| w.tiling_mode.as_str().to_string())
+        .unwrap_or_else(|| "none".to_string());
+
+    // Title: focused window's title
+    let title_text = wm
+        .focused_window()
+        .and_then(|w| w.title.clone())
+        .unwrap_or_else(|| "(none)".to_string());
+
+    StatusUpdate {
+        tags_json,
+        layout_text,
+        title_text,
+    }
+}
+
+/// Render tag state as a JSON string with pango markup, matching the format
+/// produced by the old clearwm-tags.sh script.
+///
+/// Colors:
+/// - Active + Focused: bright (#a8c0d8)
+/// - Focused only: dim (#666666)
+/// - Active only: medium (#888888)
+/// - Neither: dark (#444444)
+fn render_tags_json(active: u32, focused: u32, num_tags: u32) -> String {
+    let mut text = String::new();
+    for i in 0..num_tags {
+        let bit = 1u32 << i;
+        let label = i + 1;
+
+        let is_active = (active & bit) != 0;
+        let is_focused = (focused & bit) != 0;
+
+        let color = if is_focused && is_active {
+            "#a8c0d8"
+        } else if is_focused {
+            "#666666"
+        } else if is_active {
+            "#888888"
+        } else {
+            "#444444"
+        };
+
+        text.push_str(&format!("<span color='{}'>{}</span>", color, label));
+    }
+
+    // waybar expects JSON: {"text": "...", "tooltip": "Tags"}
+    // Need to escape the pango markup for JSON
+    let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
+    format!("{{\"text\": \"{}\", \"tooltip\": \"Tags\"}}", escaped)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_render_tags_json_single_tag() {
+        let json = render_tags_json(1, 1, 4);
+        // Tag 1 should be active+focused (#a8c0d8), tags 2-4 should be dark (#444444)
+        assert!(json.contains("#a8c0d8"), "tag 1 should be bright: {}", json);
+        assert!(
+            json.contains("#444444"),
+            "inactive tags should be dark: {}",
+            json
+        );
+        assert!(json.starts_with("{\"text\":"));
+    }
+
+    #[test]
+    fn test_render_tags_json_no_focus() {
+        let json = render_tags_json(1, 0, 4);
+        // Tag 1 is active but not focused → #888888
+        assert!(
+            json.contains("#888888"),
+            "active unfocused should be medium: {}",
+            json
+        );
+    }
+
+    #[test]
+    fn test_subscription_from_str() {
+        assert_eq!(Subscription::from_str("tags"), Subscription::Tags);
+        assert_eq!(Subscription::from_str("layout"), Subscription::Layout);
+        assert_eq!(Subscription::from_str("title"), Subscription::Title);
+        assert_eq!(Subscription::from_str("foo"), Subscription::Unknown);
+        assert_eq!(Subscription::from_str("tags\n"), Subscription::Tags);
+    }
+}
diff --git a/src/tiling.rs b/src/tiling.rs
index 88f7af8..ef0b3d1 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -72,6 +72,62 @@ pub fn tile_grid(
     (x, y, width, height)
 }
 
+/// Tile a window in vsplit mode (vertical splits — windows side by side).
+///
+/// Each window gets an equal share of the horizontal space.
+///
+///   n = total windows in vsplit
+///   width  = (screen_w - (n + 1) * gap) / n - 2 * bw
+///   height = screen_h - (gap + bw) * 2 - bar_height
+///   x = gap + bw + idx * (width + 2 * bw + gap)
+///   y = bar_height + gap + bw
+pub fn tile_vsplit(
+    screen_w: i32,
+    screen_h: i32,
+    gap: i32,
+    bw: i32,
+    bar_height: i32,
+    n_vsplit: i32,
+    idx: i32,
+) -> (i32, i32, i32, i32) {
+    let n = if n_vsplit < 1 { 1 } else { n_vsplit };
+    let width = (screen_w - (n + 1) * gap) / n - 2 * bw;
+    let height = screen_h - (gap + bw) * 2 - bar_height;
+    let width = if width < 1 { 1 } else { width };
+    let height = if height < 1 { 1 } else { height };
+    let x = gap + bw + idx * (width + 2 * bw + gap);
+    let y = bar_height + gap + bw;
+    (x, y, width, height)
+}
+
+/// Tile a window in hsplit mode (horizontal splits — windows stacked vertically).
+///
+/// Each window gets an equal share of the vertical space.
+///
+///   n = total windows in hsplit
+///   width  = screen_w - (gap + bw) * 2
+///   height = (screen_h - bar_height - (n + 1) * gap) / n - 2 * bw
+///   x = gap + bw
+///   y = bar_height + gap + bw + idx * (height + 2 * bw + gap)
+pub fn tile_hsplit(
+    screen_w: i32,
+    screen_h: i32,
+    gap: i32,
+    bw: i32,
+    bar_height: i32,
+    n_hsplit: i32,
+    idx: i32,
+) -> (i32, i32, i32, i32) {
+    let n = if n_hsplit < 1 { 1 } else { n_hsplit };
+    let width = screen_w - (gap + bw) * 2;
+    let height = (screen_h - bar_height - (n + 1) * gap) / n - 2 * bw;
+    let width = if width < 1 { 1 } else { width };
+    let height = if height < 1 { 1 } else { height };
+    let x = gap + bw;
+    let y = bar_height + gap + bw + idx * (height + 2 * bw + gap);
+    (x, y, width, height)
+}
+
 /// Interpolate a fixed-point channel (0xRR000000) by factor^depth.
 ///
 /// depth 0 returns the base color unchanged; each step darkens by factor.
@@ -112,7 +168,7 @@ mod tests {
         let (x, y, w, h) = tile_cascade(1920, 1080, 18, 18, 32, 28, 1, 0);
         assert_eq!(x, 36); // gap + bw
         assert_eq!(y, 64); // bar_height + gap + bw
-        // w = 1920 - (18+18)*2 - 32*0 = 1920 - 72 = 1848
+                           // w = 1920 - (18+18)*2 - 32*0 = 1920 - 72 = 1848
         assert_eq!(w, 1848);
         // h = 1080 - (18+18)*2 - 32*0 = 1080 - 72 = 1008
         assert_eq!(h, 1008);
@@ -212,4 +268,78 @@ mod tests {
         let color = cascade_hex_color(0x3E000000, 0x3E000000, 0x3E000000, 0);
         assert_eq!(color, "#3e3e3e");
     }
+
+    #[test]
+    fn test_tile_vsplit_single() {
+        // Single vsplit window fills screen minus gaps/borders/bar
+        let (x, y, w, h) = tile_vsplit(1920, 1080, 18, 18, 28, 1, 0);
+        assert_eq!(x, 36); // gap + bw
+        assert_eq!(y, 64); // bar_height + gap + bw
+                           // w = (1920 - 2*18) / 1 - 2*18 = 1884 - 36 = 1848
+        assert_eq!(w, 1848);
+        // h = 1080 - (18+18)*2 - 28 = 1080 - 72 - 28 = 980
+        assert_eq!(h, 980);
+    }
+
+    #[test]
+    fn test_tile_vsplit_two() {
+        // Two vsplit windows side by side
+        let (x0, y0, w0, h0) = tile_vsplit(1920, 1080, 18, 18, 28, 2, 0);
+        let (x1, y1, w1, h1) = tile_vsplit(1920, 1080, 18, 18, 28, 2, 1);
+
+        // Same dimensions
+        assert_eq!(w0, w1);
+        assert_eq!(h0, h1);
+        // Same y (same row)
+        assert_eq!(y0, y1);
+        // Window 1 is to the right
+        assert!(x1 > x0);
+
+        // w = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+        assert_eq!(w0, 897);
+    }
+
+    #[test]
+    fn test_tile_vsplit_minimum_size() {
+        let (_, _, w, h) = tile_vsplit(100, 100, 18, 18, 28, 10, 5);
+        assert!(w >= 1);
+        assert!(h >= 1);
+    }
+
+    #[test]
+    fn test_tile_hsplit_single() {
+        // Single hsplit window fills screen minus gaps/borders/bar
+        let (x, y, w, h) = tile_hsplit(1920, 1080, 18, 18, 28, 1, 0);
+        assert_eq!(x, 36); // gap + bw
+        assert_eq!(y, 64); // bar_height + gap + bw
+                           // w = 1920 - (18+18)*2 = 1920 - 72 = 1848
+        assert_eq!(w, 1848);
+        // h = (1080 - 28 - 2*18) / 1 - 2*18 = 1016 - 36 = 980
+        assert_eq!(h, 980);
+    }
+
+    #[test]
+    fn test_tile_hsplit_two() {
+        // Two hsplit windows stacked vertically
+        let (x0, y0, w0, h0) = tile_hsplit(1920, 1080, 18, 18, 28, 2, 0);
+        let (x1, y1, w1, h1) = tile_hsplit(1920, 1080, 18, 18, 28, 2, 1);
+
+        // Same dimensions
+        assert_eq!(w0, w1);
+        assert_eq!(h0, h1);
+        // Same x (same column)
+        assert_eq!(x0, x1);
+        // Window 1 is below window 0
+        assert!(y1 > y0);
+
+        // h = (1080 - 28 - 3*18) / 2 - 2*18 = (1080-28-54)/2 - 36 = 499 - 36 = 463
+        assert_eq!(h0, 463);
+    }
+
+    #[test]
+    fn test_tile_hsplit_minimum_size() {
+        let (_, _, w, h) = tile_hsplit(100, 100, 18, 18, 28, 10, 5);
+        assert!(w >= 1);
+        assert!(h >= 1);
+    }
 }
diff --git a/src/types.rs b/src/types.rs
index f668380..2f40721 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -115,6 +115,7 @@ pub struct PendingPointerBinding {
 /// to execute when the binding is triggered.
 #[derive(Debug, Clone)]
 pub struct BindingUserData {
+    pub seat_id: u64,
     pub action: Action,
     pub command: Option<String>,
 }
@@ -193,7 +194,7 @@ impl Default for Window {
             title: None,
             identifier: None,
             parent_id: None,
-            decoration_hint: 3, // no_preference
+            decoration_hint: 3,   // no_preference
             presentation_hint: 0, // vsync
             tiling_mode: TilingMode::Floating,
             mode_locked: false,
@@ -210,6 +211,8 @@ pub struct Seat {
     pub focused_window_id: Option<u64>,
     pub hovered_window_id: Option<u64>,
     pub interacted_window_id: Option<u64>,
+    pub pending_action: Action,
+    pub pending_command: Option<String>,
 }
 
 impl Default for Seat {
@@ -221,6 +224,8 @@ impl Default for Seat {
             focused_window_id: None,
             hovered_window_id: None,
             interacted_window_id: None,
+            pending_action: Action::None,
+            pending_command: None,
         }
     }
 }
@@ -240,13 +245,21 @@ pub struct WindowManager {
     pub config_done: bool,
     pub in_manage_sequence: bool,
     pub needs_render: bool,
+    pub needs_focus: bool,
+    pub needs_status_update: bool,
     pub exit_requested: bool,
     pub global_layout: TilingMode,
     pub tag_layouts: [TilingMode; NUM_TAGS],
     pub has_tag_layout: [bool; NUM_TAGS],
     pub input_devices: Vec<InputDevice>,
-    pub last_bg_color: String,
     pub env_vars: HashMap<String, String>,
+    pub pending_startup_apps: Vec<String>,
+    pub startup_spawned: bool,
+    pub output_scale: f64,
+    /// When true, apply configured output_scale via wlr-output-management
+    /// on the next output_manager done event. Set by config load and
+    /// by VT-switch-back (where wlroots resets scale to 1).
+    pub pending_scale_apply: bool,
 }
 
 impl Default for WindowManager {
@@ -263,14 +276,19 @@ impl Default for WindowManager {
             focused_tags: 0,
             config_done: false,
             in_manage_sequence: false,
-            needs_render: true, // render on first frame
+            needs_render: true,        // render on first frame
+            needs_focus: true,         // focus on first frame
+            needs_status_update: true, // update status files on first cycle
             exit_requested: false,
             global_layout: TilingMode::Cascade,
             tag_layouts: [TilingMode::Cascade; NUM_TAGS],
             has_tag_layout: [false; NUM_TAGS],
             input_devices: Vec::new(),
-            last_bg_color: String::new(),
             env_vars: HashMap::new(),
+            pending_startup_apps: Vec::new(),
+            startup_spawned: false,
+            output_scale: 0.0,
+            pending_scale_apply: false,
         }
     }
 }
@@ -310,6 +328,24 @@ impl WindowManager {
         };
         self.get_window_mut(wid)
     }
+
+    /// Move a window to the end of the windows vector.
+    /// This makes it the last cascade window (front of visual stack,
+    /// rightmost/bottommost position, brightest border).
+    /// Returns true if the window was moved, false if not found or already last.
+    pub fn move_window_to_end(&mut self, id: u64) -> bool {
+        let idx = match self.windows.iter().position(|w| w.id == id) {
+            Some(i) => i,
+            None => return false,
+        };
+        // Already last?
+        if idx == self.windows.len() - 1 {
+            return false;
+        }
+        let win = self.windows.remove(idx);
+        self.windows.push(win);
+        true
+    }
 }
 
 /// Parse a hex color string like "#RRGGBB" or "#RRGGBBAA" into
@@ -364,11 +400,16 @@ pub fn parse_action(s: &str) -> Action {
         Action::Restart
     } else if s == "fullscreen" {
         Action::Fullscreen
-    } else if s.starts_with("spawn") && (s.len() == 5 || s.as_bytes()[5] == b' ' || s.as_bytes()[5] == b'-') {
+    } else if s.starts_with("spawn")
+        && (s.len() == 5 || s.as_bytes()[5] == b' ' || s.as_bytes()[5] == b'-')
+    {
         Action::Spawn
     } else if s.starts_with("view") {
         let rest = &s[4..];
-        let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+        let tag_str = rest
+            .strip_prefix('-')
+            .or_else(|| rest.strip_prefix(' '))
+            .unwrap_or(rest);
         if let Ok(tag) = tag_str.parse::<i32>() {
             if tag >= 1 && tag <= NUM_TAGS as i32 {
                 return match tag {
@@ -383,7 +424,10 @@ pub fn parse_action(s: &str) -> Action {
         Action::None
     } else if s.starts_with("toggle") {
         let rest = &s[6..];
-        let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+        let tag_str = rest
+            .strip_prefix('-')
+            .or_else(|| rest.strip_prefix(' '))
+            .unwrap_or(rest);
         if let Ok(tag) = tag_str.parse::<i32>() {
             if tag >= 1 && tag <= NUM_TAGS as i32 {
                 return match tag {
@@ -398,7 +442,10 @@ pub fn parse_action(s: &str) -> Action {
         Action::None
     } else if s.starts_with("set-tag") {
         let rest = &s[7..];
-        let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+        let tag_str = rest
+            .strip_prefix('-')
+            .or_else(|| rest.strip_prefix(' '))
+            .unwrap_or(rest);
         if let Ok(tag) = tag_str.parse::<i32>() {
             if tag >= 1 && tag <= NUM_TAGS as i32 {
                 return match tag {
diff --git a/src/wayland.rs b/src/wayland.rs
index b8e114c..1c08015 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -1,7 +1,8 @@
 // Wayland display connection, registry, and event dispatch for clearwm
 
 use wayland_client::{
-    event_created_child, protocol::wl_registry, Connection, Dispatch, EventQueue, Proxy, QueueHandle,
+    event_created_child, protocol::wl_registry, Connection, Dispatch, EventQueue, Proxy,
+    QueueHandle,
 };
 
 use crate::protocol::river_input_management::client::{
@@ -25,14 +26,22 @@ use crate::protocol::river_xkb_bindings::client::{
     river_xkb_bindings_seat_v1::{self, RiverXkbBindingsSeatV1},
     river_xkb_bindings_v1::{self, RiverXkbBindingsV1},
 };
+use crate::protocol::wlr_output_management::client::{
+    zwlr_output_configuration_head_v1::{self, ZwlrOutputConfigurationHeadV1},
+    zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1},
+    zwlr_output_head_v1::{self, ZwlrOutputHeadV1},
+    zwlr_output_manager_v1::{self, ZwlrOutputManagerV1},
+    zwlr_output_mode_v1::{self, ZwlrOutputModeV1},
+};
 
-use crate::types::{BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
+use crate::types::{Action, BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
 
 // Interface name constants (from river protocol XML)
 const IFACE_WINDOW_MANAGER: &str = "river_window_manager_v1";
 const IFACE_XKB_BINDINGS: &str = "river_xkb_bindings_v1";
 const IFACE_LAYER_SHELL: &str = "river_layer_shell_v1";
 const IFACE_INPUT_MANAGER: &str = "river_input_manager_v1";
+const IFACE_WLR_OUTPUT_MANAGER: &str = "zwlr_output_manager_v1";
 
 /// Wayland proxy objects stored alongside each Window, so we can
 /// call protocol methods (set_position, propose_dimensions, etc.) on it.
@@ -74,11 +83,42 @@ pub struct AppState {
     // River node proxies for window positioning (created via get_node request)
     pub window_nodes: Vec<(u64, RiverNodeV1)>,
 
+    // Active binding proxies — must be held alive for bindings to remain registered.
+    // On reload, these are cleared (which destroys the old protocol objects) before
+    // new bindings are created.
+    pub xkb_binding_proxies: Vec<RiverXkbBindingV1>,
+    pub pointer_binding_proxies: Vec<RiverPointerBindingV1>,
+
     // Next ID counter for new windows/outputs/seats
     pub next_id: u64,
 
     // Exit flag
     pub exit_requested: bool,
+
+    // Debug: render cycle counter
+    pub render_count: u32,
+
+    // --- wlr-output-management protocol state ---
+    pub output_manager: Option<ZwlrOutputManagerV1>,
+    /// Latest serial from the output manager's done event.
+    /// Required to create a valid configuration.
+    pub output_serial: u32,
+    /// Discovered output heads: (head_proxy, name, enabled, current_scale)
+    /// The head proxy must be kept alive to reference it in configurations.
+    pub output_heads: Vec<OutputHeadInfo>,
+    /// Pending configuration (alive until succeeded/failed/cancelled)
+    pub output_config: Option<ZwlrOutputConfigurationV1>,
+
+    // --- Status socket sender for waybar ---
+    pub status_sender: Option<crate::status_server::StatusSender>,
+}
+
+/// Tracked info for a wlr-output-management head.
+pub struct OutputHeadInfo {
+    pub proxy: ZwlrOutputHeadV1,
+    pub name: String,
+    pub enabled: bool,
+    pub scale: f64,
 }
 
 impl AppState {
@@ -95,8 +135,16 @@ impl AppState {
             seat_proxies: Vec::new(),
             output_proxies: Vec::new(),
             window_nodes: Vec::new(),
+            xkb_binding_proxies: Vec::new(),
+            pointer_binding_proxies: Vec::new(),
             next_id: 1,
             exit_requested: false,
+            render_count: 0,
+            output_manager: None,
+            output_serial: 0,
+            output_heads: Vec::new(),
+            output_config: None,
+            status_sender: None,
         }
     }
 
@@ -197,6 +245,11 @@ impl Dispatch<wl_registry::WlRegistry, RegistryData> for AppState {
                     let im: RiverInputManagerV1 =
                         registry.bind::<RiverInputManagerV1, _, _>(name, 1, qhandle, ());
                     state.input_manager = Some(im);
+                } else if interface == IFACE_WLR_OUTPUT_MANAGER {
+                    eprintln!("registry: binding {}", IFACE_WLR_OUTPUT_MANAGER);
+                    let om: ZwlrOutputManagerV1 =
+                        registry.bind::<ZwlrOutputManagerV1, _, _>(name, 4, qhandle, ());
+                    state.output_manager = Some(om);
                 }
             }
             wl_registry::Event::GlobalRemove { name: _ } => {}
@@ -219,7 +272,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
         wm_proxy: &RiverWindowManagerV1,
         event: river_window_manager_v1::Event,
         _data: &(),
-        _conn: &Connection,
+        conn: &Connection,
         qhandle: &QueueHandle<Self>,
     ) {
         match event {
@@ -240,7 +293,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
             }
 
             river_window_manager_v1::Event::ManageStart => {
-                eprintln!("manage sequence start");
+                let ms_start = std::time::Instant::now();
                 state.wm.in_manage_sequence = true;
                 state.wm.focused_tags = 0;
                 state.wm.needs_render = true;
@@ -276,20 +329,169 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                     }
                 }
 
+                // Assign tiling modes to windows based on mode_rules / tag_layouts / global_layout.
+                // Must happen before manage_windows so tiling computation uses the correct modes.
+                crate::wm::assign_window_modes(&mut state.wm);
+
+                // Window management: set position + propose dimensions.
+                // These modify window management state and can ONLY be called
+                // during a manage sequence (per River protocol spec).
+                crate::wm::manage_windows(state, qhandle);
+
+                // Focus management: focus the focused window on each seat.
+                // focus_window() modifies window management state.
+                // Only call when needs_focus is set to avoid stealing focus from
+                // layer-shell clients (like fuzzel) that trigger manage sequences.
+                if state.wm.needs_focus {
+                    for seat in &state.wm.seats {
+                        if seat.removed {
+                            continue;
+                        }
+                        if let Some(focused_id) = seat.focused_window_id {
+                            if let Some((_id, sp)) =
+                                state.seat_proxies.iter().find(|(id, _)| *id == seat.id)
+                            {
+                                if let Some(wp) = state.get_window_proxy(focused_id) {
+                                    eprintln!("[focus] calling focus_window for id={}", focused_id);
+                                    sp.river_seat.focus_window(&wp.river_window);
+                                }
+                            }
+                        } else {
+                            // No focused window — clear focus
+                            if let Some((_id, sp)) =
+                                state.seat_proxies.iter().find(|(id, _)| *id == seat.id)
+                            {
+                                eprintln!("[focus] calling clear_focus");
+                                sp.river_seat.clear_focus();
+                            }
+                        }
+                    }
+                    state.wm.needs_focus = false;
+                }
+
+                // Execute pending actions from key/pointer bindings (tinyrwm pattern).
+                // Like tinyrwm, we defer action execution to ManageStart so all
+                // state mutations happen during the manage sequence.
+                // Collect pending actions first to avoid borrow checker issues
+                // (execute_action borrows state mutably).
+                let pending: Vec<(Action, Option<String>)> = state
+                    .wm
+                    .seats
+                    .iter_mut()
+                    .filter_map(|seat| {
+                        if seat.pending_action != Action::None {
+                            let action = seat.pending_action;
+                            let command = seat.pending_command.take();
+                            seat.pending_action = Action::None;
+                            Some((action, command))
+                        } else {
+                            None
+                        }
+                    })
+                    .collect();
+                for (action, command) in pending {
+                    execute_action(state, &action, command.as_deref());
+                }
+
                 wm_proxy.manage_finish();
                 state.wm.in_manage_sequence = false;
-                crate::status::update_status_files(&state.wm);
+                eprintln!("[manage] ManageStart done in {:?}", ms_start.elapsed());
+                // NOTE: Do NOT call update_status_files() here — it calls
+                // pkill with .output() which blocks the event loop.
             }
 
             river_window_manager_v1::Event::RenderStart => {
-                eprintln!("EVENT: RenderStart (needs_render={})", state.wm.needs_render);
+                state.render_count += 1;
+                eprintln!(
+                    "[render] RenderStart #{} needs_render={}",
+                    state.render_count, state.wm.needs_render
+                );
+
+                // Show/hide windows based on tag visibility.
+                // This is rendering state and must happen during a render sequence.
+                let active_tags = state.wm.active_tags;
+                for window in &state.wm.windows {
+                    if window.closed {
+                        continue;
+                    }
+                    let visible = (window.tags & active_tags) != 0;
+                    if let Some(wp) = state.get_window_proxy(window.id) {
+                        if visible {
+                            wp.river_window.show();
+                        } else {
+                            wp.river_window.hide();
+                        }
+                    }
+                }
+
                 if state.wm.needs_render {
-                    crate::wm::render_windows(state, qhandle);
+                    // Only do rendering state here: borders, place_top/place_bottom.
+                    // set_position and propose_dimensions are window management state
+                    // and must happen during ManageStart.
+                    crate::wm::render_borders(state);
+
+                    // Raise the focused window to the top of the visual stack.
+                    // place_top() modifies rendering state and must be called
+                    // during a render sequence.
+                    if let Some(seat) = state.wm.seats.iter().find(|s| !s.removed) {
+                        if let Some(focused_id) = seat.focused_window_id {
+                            if let Some(node) = state
+                                .window_nodes
+                                .iter()
+                                .find(|(id, _)| *id == focused_id)
+                                .map(|(_, n)| n)
+                            {
+                                eprintln!(
+                                    "[render] place_top for focused window id={}",
+                                    focused_id
+                                );
+                                node.place_top();
+                            }
+                        }
+                    }
+
                     state.wm.needs_render = false;
                 }
 
                 wm_proxy.render_finish();
-                eprintln!("EVENT: RenderFinish sent");
+                eprintln!("[render] render_finish #{} queued", state.render_count);
+
+                // Spawn startup apps inside the callback, like tinyrwm does.
+                // Spawning between blocking_dispatch calls corrupts the Wayland
+                // connection state because the fork inherits the socket fd.
+                if !state.wm.startup_spawned {
+                    let apps: Vec<String> = state.wm.pending_startup_apps.drain(..).collect();
+                    for cmd in &apps {
+                        eprintln!("[init] spawning startup app: {}", cmd);
+                        match std::process::Command::new("sh")
+                            .arg("-c")
+                            .arg(cmd)
+                            .env_remove("WAYLAND_DEBUG")
+                            .spawn()
+                        {
+                            Ok(_) => eprintln!("[init] spawned ok: {}", cmd),
+                            Err(e) => eprintln!("[init] failed to spawn '{}': {e}", cmd),
+                        }
+                    }
+                    state.wm.startup_spawned = true;
+                }
+
+                // Write status files after all manage+render state is settled.
+                // Uses the deferred flag pattern: handlers set needs_status_update,
+                // we check it here inside the Dispatch callback.
+                // write_status_files() does only file I/O — no fork, no pkill.
+                if state.wm.needs_status_update {
+                    crate::status::write_status_files(&state.wm);
+
+                    // Push the same data through the status socket so waybar
+                    // gets updates in real-time without needing signal-based pkill.
+                    if let Some(ref sender) = state.status_sender {
+                        let update = crate::status_server::build_status_update(&state.wm);
+                        sender.send(update);
+                    }
+
+                    state.wm.needs_status_update = false;
+                }
             }
 
             // Window event: field is `id` (the new RiverWindowV1 proxy)
@@ -483,16 +685,30 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
                 }
             }
 
-            river_seat_v1::Event::WindowInteraction { window: river_window } => {
+            river_seat_v1::Event::WindowInteraction {
+                window: river_window,
+            } => {
                 if let Some(wid) = state.window_id_for_proxy(&river_window) {
                     if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+                        eprintln!(
+                            "[focus] WindowInteraction: seat={} focused_window_id={} -> {}",
+                            sid,
+                            seat.focused_window_id.unwrap_or(0),
+                            wid
+                        );
                         seat.focused_window_id = Some(wid);
+                        // Move clicked window to front of cascade stack
+                        state.wm.move_window_to_end(wid);
                         state.wm.needs_render = true;
+                        state.wm.needs_focus = true;
+                        state.wm.needs_status_update = true;
                     }
                 }
             }
 
-            river_seat_v1::Event::PointerEnter { window: river_window } => {
+            river_seat_v1::Event::PointerEnter {
+                window: river_window,
+            } => {
                 if let Some(wid) = state.window_id_for_proxy(&river_window) {
                     if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
                         seat.hovered_window_id = Some(wid);
@@ -577,8 +793,10 @@ impl Dispatch<RiverLayerShellOutputV1, ()> for AppState {
                 height,
             } => {
                 if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.id == oid) {
-                    if output.usable_width != width || output.usable_height != height
-                        || output.usable_x != x || output.usable_y != y
+                    if output.usable_width != width
+                        || output.usable_height != height
+                        || output.usable_x != x
+                        || output.usable_y != y
                     {
                         output.usable_x = x;
                         output.usable_y = y;
@@ -640,7 +858,10 @@ impl Dispatch<RiverInputDeviceV1, ()> for AppState {
             river_input_device_v1::Event::Type { _type: dev_type } => {
                 if let Some(dev) = state.wm.input_devices.last_mut() {
                     // dev_type is WEnum<Type>; check for Keyboard variant
-                    dev.is_keyboard = matches!(dev_type, wayland_client::WEnum::Value(river_input_device_v1::Type::Keyboard));
+                    dev.is_keyboard = matches!(
+                        dev_type,
+                        wayland_client::WEnum::Value(river_input_device_v1::Type::Keyboard)
+                    );
                 }
             }
             river_input_device_v1::Event::Name { name } => {
@@ -665,7 +886,18 @@ impl Dispatch<RiverPointerBindingV1, BindingUserData> for AppState {
     ) {
         match event {
             river_pointer_binding_v1::Event::Pressed => {
-                execute_action(state, &data.action, data.command.as_deref());
+                eprintln!(
+                    "[binding] pointer pressed: action={:?} seat_id={}",
+                    data.action, data.seat_id
+                );
+                if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == data.seat_id) {
+                    seat.pending_action = data.action.clone();
+                    seat.pending_command = data.command.clone();
+                }
+                // Trigger a manage sequence so the pending action is processed
+                if let Some(ref wm) = state.window_manager {
+                    wm.manage_dirty();
+                }
             }
             _ => {}
         }
@@ -697,7 +929,18 @@ impl Dispatch<RiverXkbBindingV1, BindingUserData> for AppState {
     ) {
         match event {
             river_xkb_binding_v1::Event::Pressed => {
-                execute_action(state, &data.action, data.command.as_deref());
+                eprintln!(
+                    "[binding] xkb pressed: action={:?} command={:?} seat_id={}",
+                    data.action, data.command, data.seat_id
+                );
+                if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == data.seat_id) {
+                    seat.pending_action = data.action.clone();
+                    seat.pending_command = data.command.clone();
+                }
+                // Trigger a manage sequence so the pending action is processed
+                if let Some(ref wm) = state.window_manager {
+                    wm.manage_dirty();
+                }
             }
             river_xkb_binding_v1::Event::Released => {}
             river_xkb_binding_v1::Event::StopRepeat => {}
@@ -743,40 +986,64 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
         Action::Spawn => {
             if let Some(cmd) = command {
                 eprintln!("spawn: {}", cmd);
-                unsafe {
-                    match nix::unistd::fork() {
-                        Ok(nix::unistd::ForkResult::Child) => {
-                            nix::unistd::setsid().ok();
-                            let cmd_c = std::ffi::CString::new(cmd).unwrap();
-                            nix::unistd::execvp(
-                                &std::ffi::CString::new("/bin/sh").unwrap(),
-                                &[
-                                    std::ffi::CString::new("sh").unwrap(),
-                                    std::ffi::CString::new("-c").unwrap(),
-                                    cmd_c,
-                                ],
-                            )
-                            .ok();
-                            libc::_exit(127);
-                        }
-                        Ok(nix::unistd::ForkResult::Parent { .. }) => {}
-                        Err(_) => {}
-                    }
-                }
+                // Close inherited FDs > 2 in the child so that spawned
+                // Wayland clients (fuzzel, etc.) never accidentally read
+                // from clearwm's Wayland socket fd. This prevents protocol
+                // corruption and the CPU spin loop that results from it.
+                use std::os::unix::process::CommandExt;
+                let _ = unsafe {
+                    std::process::Command::new("sh")
+                        .arg("-c")
+                        .arg(cmd)
+                        .env_remove("WAYLAND_DEBUG")
+                        .stdout(std::process::Stdio::null())
+                        .stderr(std::process::Stdio::null())
+                        .pre_exec(|| {
+                            let max_fd =
+                                libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
+                            for fd in 3..max_fd {
+                                libc::close(fd);
+                            }
+                            libc::setsid();
+                            Ok(())
+                        })
+                        .spawn()
+                };
             }
         }
         Action::Close => {
-            // Close the focused window
+            // Mark the focused window for closing. The actual close() call
+            // happens during the ManageStart sequence, since close()
+            // modifies window management state and can only be called during
+            // a manage sequence.
             if let Some(seat) = state.wm.seats.first() {
                 if let Some(focused_id) = seat.focused_window_id {
-                    if let Some(wp) = state.get_window_proxy(focused_id) {
-                        wp.river_window.close();
+                    if let Some(window) = state.wm.get_window_mut(focused_id) {
+                        window.closed = true;
+                    }
+                    // Shift focus to the next visible window (excluding the one we just closed)
+                    let visible_ids: Vec<u64> = state
+                        .wm
+                        .windows
+                        .iter()
+                        .filter(|w| {
+                            (w.tags & state.wm.active_tags) != 0 && !w.closed && w.id != focused_id
+                        })
+                        .map(|w| w.id)
+                        .collect();
+                    let next_id = visible_ids.last().copied();
+                    if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+                        seat.focused_window_id = next_id;
                     }
                 }
             }
+            state.wm.needs_render = true;
+            state.wm.needs_focus = true;
+            state.wm.needs_status_update = true;
         }
         Action::FocusNext => {
-            // Focus the next visible window (wrapping)
+            // Focus the next visible window (wrapping) and move it to the
+            // front of the cascade stack (end of windows vector).
             if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
                 let focused_id = seat.focused_window_id;
                 let visible_ids: Vec<u64> = state
@@ -789,11 +1056,19 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 if let Some(fid) = focused_id {
                     if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
                         let next_idx = (idx + 1) % visible_ids.len();
-                        seat.focused_window_id = Some(visible_ids[next_idx]);
+                        let next_id = visible_ids[next_idx];
+                        seat.focused_window_id = Some(next_id);
+                        // Move the newly focused window to the end of the
+                        // windows vector so it gets the front cascade position
+                        // (rightmost/bottommost) and brightest border.
+                        state.wm.move_window_to_end(next_id);
                         state.wm.needs_render = true;
+                        state.wm.needs_focus = true;
+                        state.wm.needs_status_update = true;
                     }
                 }
             }
+            // Trigger a manage sequence so focus_window() is called
         }
         Action::Move => {
             // TODO: pointer move
@@ -811,23 +1086,66 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
         Action::Fullscreen => {
             if let Some(seat) = state.wm.seats.first() {
                 if let Some(focused_id) = seat.focused_window_id {
+                    // Read current state and compute target mode before mutable borrow
+                    let is_fullscreen = state
+                        .wm
+                        .get_window(focused_id)
+                        .map(|w| w.tiling_mode == TilingMode::Fullscreen)
+                        .unwrap_or(false);
+
+                    let resolved_mode = if is_fullscreen {
+                        // Exiting fullscreen: snapshot the window info we need for mode resolution
+                        let (app_id, title, tags, mode_locked) = state
+                            .wm
+                            .get_window(focused_id)
+                            .map(|w| (w.app_id.clone(), w.title.clone(), w.tags, w.mode_locked))
+                            .unwrap_or((None, None, 1, false));
+                        // Build a temporary Window for mode resolution
+                        let temp_win = Window {
+                            id: focused_id,
+                            is_new: false,
+                            closed: false,
+                            tags,
+                            x: 0,
+                            y: 0,
+                            width: 0,
+                            height: 0,
+                            app_id,
+                            title,
+                            identifier: None,
+                            parent_id: None,
+                            decoration_hint: 3,
+                            presentation_hint: 0,
+                            tiling_mode: TilingMode::Fullscreen,
+                            mode_locked,
+                        };
+                        crate::wm::get_mode_for_window(&state.wm, &temp_win)
+                            .unwrap_or(state.wm.global_layout)
+                    } else {
+                        TilingMode::Fullscreen
+                    };
+
                     if let Some(window) = state.wm.get_window_mut(focused_id) {
-                        if window.tiling_mode == TilingMode::Fullscreen {
-                            window.tiling_mode = TilingMode::Cascade; // TODO: get_mode_for_window
+                        if is_fullscreen {
+                            window.tiling_mode = resolved_mode;
+                            window.mode_locked = false;
                         } else {
                             window.tiling_mode = TilingMode::Fullscreen;
+                            window.mode_locked = true;
                         }
-                        window.mode_locked = true;
                         state.wm.needs_render = true;
+                        state.wm.needs_status_update = true;
                     }
                 }
             }
-            if let Some(ref wm) = state.window_manager {
-                wm.manage_dirty();
-            }
         }
         Action::LayoutNext => {
-            let cycle = [TilingMode::Cascade, TilingMode::Grid, TilingMode::Vsplit, TilingMode::Hsplit];
+            let cycle = [
+                TilingMode::Cascade,
+                TilingMode::Grid,
+                TilingMode::Vsplit,
+                TilingMode::Hsplit,
+            ];
             let current = state.wm.global_layout;
             let next = cycle
                 .iter()
@@ -836,10 +1154,15 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 .unwrap_or(TilingMode::Cascade);
             state.wm.global_layout = next;
             eprintln!("layout-next: global layout is now {}", next.as_str());
+
+            // Unlock windows that got their mode from the layout (not from mode_rules
+            // or manual set-mode) so assign_window_modes will reassign them.
+            // Windows with mode_locked=true were explicitly set by the user and stay.
+            // Windows matched by mode_rules will get reassigned to the same rule mode.
+            // Only windows that fell through to global_layout will change.
+
             state.wm.needs_render = true;
-            if let Some(ref wm) = state.window_manager {
-                wm.manage_dirty();
-            }
+            state.wm.needs_status_update = true;
         }
         Action::Reload => {
             // TODO: implement reload (re-run config)
@@ -857,10 +1180,22 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 _ => return,
             };
             state.wm.active_tags = 1 << (tag - 1);
-            state.wm.needs_render = true;
-            if let Some(ref wm) = state.window_manager {
-                wm.manage_dirty();
+
+            // Reassign focus to a visible window on the new tag
+            let visible_ids: Vec<u64> = state
+                .wm
+                .windows
+                .iter()
+                .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+                .map(|w| w.id)
+                .collect();
+            if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+                seat.focused_window_id = visible_ids.last().copied();
             }
+
+            state.wm.needs_render = true;
+            state.wm.needs_focus = true;
+            state.wm.needs_status_update = true;
         }
         Action::Toggle1 | Action::Toggle2 | Action::Toggle3 | Action::Toggle4 => {
             let tag = match action {
@@ -871,10 +1206,36 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 _ => return,
             };
             state.wm.active_tags ^= 1 << (tag - 1);
-            state.wm.needs_render = true;
-            if let Some(ref wm) = state.window_manager {
-                wm.manage_dirty();
+
+            // If the focused window is no longer visible, reassign focus
+            let focused_id = state
+                .wm
+                .seats
+                .iter()
+                .find(|s| !s.removed)
+                .and_then(|s| s.focused_window_id);
+            let focused_still_visible = focused_id.map_or(false, |fid| {
+                state
+                    .wm
+                    .get_window(fid)
+                    .map_or(false, |w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+            });
+            if !focused_still_visible {
+                let visible_ids: Vec<u64> = state
+                    .wm
+                    .windows
+                    .iter()
+                    .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+                    .map(|w| w.id)
+                    .collect();
+                if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+                    seat.focused_window_id = visible_ids.last().copied();
+                }
+                state.wm.needs_focus = true;
             }
+
+            state.wm.needs_render = true;
+            state.wm.needs_status_update = true;
         }
         Action::SetTag1 | Action::SetTag2 | Action::SetTag3 | Action::SetTag4 => {
             let tag = match action {
@@ -884,16 +1245,41 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 Action::SetTag4 => 4,
                 _ => return,
             };
-            if let Some(seat) = state.wm.seats.first() {
-                if let Some(focused_id) = seat.focused_window_id {
-                    if let Some(window) = state.wm.get_window_mut(focused_id) {
+            // Read focused_id before any mutable borrow
+            let focused_id = state
+                .wm
+                .seats
+                .iter()
+                .find(|s| !s.removed)
+                .and_then(|s| s.focused_window_id);
+            if let Some(focused_id) = focused_id {
+                // Set the window's tag
+                let active_tags = state.wm.active_tags;
+                let window_left_active_tag = state
+                    .wm
+                    .get_window_mut(focused_id)
+                    .map_or(false, |window| {
                         window.tags = 1 << (tag - 1);
-                        state.wm.needs_render = true;
+                        (window.tags & active_tags) == 0
+                    });
+
+                // If the window is no longer on an active tag, shift focus
+                if window_left_active_tag {
+                    let visible_ids: Vec<u64> = state
+                        .wm
+                        .windows
+                        .iter()
+                        .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+                        .map(|w| w.id)
+                        .collect();
+                    if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+                        seat.focused_window_id = visible_ids.last().copied();
                     }
+                    state.wm.needs_focus = true;
                 }
-            }
-            if let Some(ref wm) = state.window_manager {
-                wm.manage_dirty();
+
+                state.wm.needs_render = true;
+                state.wm.needs_status_update = true;
             }
         }
     }
@@ -936,6 +1322,10 @@ fn u32_to_modifiers(mods: u32) -> Modifiers {
 }
 
 fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+    // Clear old binding proxies (destroying them unregisters the bindings)
+    state.xkb_binding_proxies.clear();
+    state.pointer_binding_proxies.clear();
+
     // Create xkb binding seats for any seats that don't have one yet
     if state.xkb_bindings.is_some() {
         for (_sid, sp) in &mut state.seat_proxies {
@@ -949,18 +1339,31 @@ fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>)
 
     // Apply xkb bindings to each seat
     let bindings: Vec<_> = state.wm.pending_bindings.drain(..).collect();
+    eprintln!(
+        "[bindings] applying {} xkb bindings to {} seats",
+        bindings.len(),
+        state.seat_proxies.len()
+    );
     for pb in &bindings {
-        for (_sid, sp) in &state.seat_proxies {
+        for (sid, sp) in &state.seat_proxies {
             if let Some(ref xb) = state.xkb_bindings {
                 if let Some(ref _xbs) = sp.xkb_bindings_seat {
                     let modifiers = u32_to_modifiers(pb.mods);
                     let binding_data = BindingUserData {
+                        seat_id: *sid,
                         action: pb.action.clone(),
                         command: pb.command.clone(),
                     };
-                    let binding =
-                        xb.get_xkb_binding(&sp.river_seat, pb.keysym, modifiers, qhandle, binding_data);
+                    let binding = xb.get_xkb_binding(
+                        &sp.river_seat,
+                        pb.keysym,
+                        modifiers,
+                        qhandle,
+                        binding_data,
+                    );
                     binding.enable();
+                    // Store the proxy so it stays alive (binding would be unregistered on drop)
+                    state.xkb_binding_proxies.push(binding);
                 }
             }
         }
@@ -969,19 +1372,354 @@ fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>)
     // Apply pointer bindings to each seat
     let ptr_bindings: Vec<_> = state.wm.pending_pointer_bindings.drain(..).collect();
     for ppb in &ptr_bindings {
-        for (_sid, sp) in &state.seat_proxies {
+        for (sid, sp) in &state.seat_proxies {
             let modifiers = u32_to_modifiers(ppb.mods);
             let binding_data = BindingUserData {
+                seat_id: *sid,
                 action: ppb.action.clone(),
                 command: None,
             };
-            let _pb = sp
-                .river_seat
-                .get_pointer_binding(ppb.button, modifiers, qhandle, binding_data);
+            let pb =
+                sp.river_seat
+                    .get_pointer_binding(ppb.button, modifiers, qhandle, binding_data);
+            // Store the proxy so it stays alive
+            state.pointer_binding_proxies.push(pb);
         }
     }
 }
 
+// --- wlr-output-management dispatch implementations ---
+
+/// Helper: convert an f64 scale value to the wl_fixed_t format
+/// used by the Wayland protocol set_scale method.
+/// wayland-scanner already converts wl_fixed -> f64 for events,
+/// but set_scale takes f64 directly (the scanner handles the conversion).
+fn _f64_to_wl_fixed(v: f64) -> i32 {
+    (v * 256.0) as i32
+}
+
+impl Dispatch<ZwlrOutputManagerV1, ()> for AppState {
+    event_created_child!(AppState, ZwlrOutputManagerV1, [
+        zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()),
+    ]);
+
+    fn event(
+        state: &mut Self,
+        _proxy: &ZwlrOutputManagerV1,
+        event: zwlr_output_manager_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            zwlr_output_manager_v1::Event::Head { head: _ } => {
+                eprintln!("[output-mgmt] Head event (new head advertised)");
+            }
+            zwlr_output_manager_v1::Event::Done { serial } => {
+                eprintln!(
+                    "[output-mgmt] Done serial={} pending_scale_apply={} output_scale={} heads={}",
+                    serial,
+                    state.wm.pending_scale_apply,
+                    state.wm.output_scale,
+                    state.output_heads.len()
+                );
+                for h in &state.output_heads {
+                    eprintln!(
+                        "[output-mgmt]   head {} enabled={} scale={}",
+                        h.name, h.enabled, h.scale
+                    );
+                }
+                state.output_serial = serial;
+
+                // If config requested scale application and we have heads, apply it
+                if state.wm.pending_scale_apply && state.wm.output_scale > 0.0 {
+                    apply_output_scale(state, qhandle);
+                }
+            }
+            zwlr_output_manager_v1::Event::Finished => {
+                eprintln!(
+                    "[output-mgmt] manager finished, heads before clear: {}",
+                    state.output_heads.len()
+                );
+                state.output_manager = None;
+            }
+            _ => {}
+        }
+    }
+}
+
+impl Dispatch<ZwlrOutputHeadV1, ()> for AppState {
+    event_created_child!(AppState, ZwlrOutputHeadV1, [
+        zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()),
+    ]);
+
+    fn event(
+        state: &mut Self,
+        proxy: &ZwlrOutputHeadV1,
+        event: zwlr_output_head_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        // Find or create the head entry by proxy ID
+        let pid = proxy.id().protocol_id();
+
+        match event {
+            zwlr_output_head_v1::Event::Name { name } => {
+                // Check if we already track this head
+                if let Some(head) = state
+                    .output_heads
+                    .iter_mut()
+                    .find(|h| h.proxy.id().protocol_id() == pid)
+                {
+                    head.name = name.clone();
+                } else {
+                    state.output_heads.push(OutputHeadInfo {
+                        proxy: proxy.clone(),
+                        name,
+                        enabled: false,
+                        scale: 1.0,
+                    });
+                }
+                eprintln!(
+                    "[output-mgmt] head name={}",
+                    state
+                        .output_heads
+                        .iter()
+                        .find(|h| h.proxy.id().protocol_id() == pid)
+                        .map(|h| h.name.as_str())
+                        .unwrap_or("?")
+                );
+            }
+            zwlr_output_head_v1::Event::Description { description } => {
+                let _ = description; // not needed for scale config
+            }
+            zwlr_output_head_v1::Event::PhysicalSize { width, height } => {
+                let _ = (width, height);
+            }
+            zwlr_output_head_v1::Event::Enabled { enabled } => {
+                let head_name = state
+                    .output_heads
+                    .iter()
+                    .find(|h| h.proxy.id().protocol_id() == pid)
+                    .map(|h| h.name.as_str())
+                    .unwrap_or("?");
+                eprintln!(
+                    "[output-mgmt] head {} Enabled enabled={} pending_scale_apply={}",
+                    head_name, enabled, state.wm.pending_scale_apply
+                );
+                if let Some(head) = state
+                    .output_heads
+                    .iter_mut()
+                    .find(|h| h.proxy.id().protocol_id() == pid)
+                {
+                    head.enabled = enabled != 0;
+                }
+            }
+            zwlr_output_head_v1::Event::Scale { scale } => {
+                let head_name = state
+                    .output_heads
+                    .iter()
+                    .find(|h| h.proxy.id().protocol_id() == pid)
+                    .map(|h| h.name.as_str())
+                    .unwrap_or("?");
+                eprintln!(
+                    "[output-mgmt] head {} Scale={} output_scale={} pending_scale_apply={}",
+                    head_name, scale, state.wm.output_scale, state.wm.pending_scale_apply
+                );
+                // wayland-scanner converts wl_fixed to f64 automatically
+                if let Some(head) = state
+                    .output_heads
+                    .iter_mut()
+                    .find(|h| h.proxy.id().protocol_id() == pid)
+                {
+                    // Detect VT-switch-back: compositor reports a scale that
+                    // differs from our configured output_scale. This covers
+                    // both: (a) the old head had scale=X and compositor sends 1.0,
+                    // and (b) the head was re-created after Finished (so its
+                    // internal scale was reset to 1.0) and compositor sends 1.0
+                    // instead of the configured scale.
+                    if state.wm.output_scale > 0.0
+                        && head.enabled
+                        && (scale - state.wm.output_scale).abs() > 0.01
+                    {
+                        eprintln!(
+                            "[output-mgmt] scale mismatch (got {}, configured {}), will re-apply",
+                            scale, state.wm.output_scale
+                        );
+                        state.wm.pending_scale_apply = true;
+                    }
+                    head.scale = scale;
+                }
+            }
+            zwlr_output_head_v1::Event::Finished => {
+                let head_name = state
+                    .output_heads
+                    .iter()
+                    .find(|h| h.proxy.id().protocol_id() == pid)
+                    .map(|h| h.name.as_str())
+                    .unwrap_or("?");
+                eprintln!(
+                    "[output-mgmt] head {} Finished (removing from heads)",
+                    head_name
+                );
+                state
+                    .output_heads
+                    .retain(|h| h.proxy.id().protocol_id() != pid);
+            }
+            zwlr_output_head_v1::Event::CurrentMode { mode: _ } => {}
+            zwlr_output_head_v1::Event::Position { x, y } => {
+                let _ = (x, y);
+            }
+            zwlr_output_head_v1::Event::Transform { transform } => {
+                let _ = transform;
+            }
+            zwlr_output_head_v1::Event::Make { make } => {
+                let _ = make;
+            }
+            zwlr_output_head_v1::Event::Model { model } => {
+                let _ = model;
+            }
+            zwlr_output_head_v1::Event::SerialNumber { serial_number } => {
+                let _ = serial_number;
+            }
+            zwlr_output_head_v1::Event::AdaptiveSync { state: _ } => {}
+            _ => {}
+        }
+    }
+}
+
+impl Dispatch<ZwlrOutputModeV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &ZwlrOutputModeV1,
+        event: zwlr_output_mode_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            zwlr_output_mode_v1::Event::Size { width, height } => {
+                let _ = (width, height);
+            }
+            zwlr_output_mode_v1::Event::Refresh { refresh } => {
+                let _ = refresh;
+            }
+            zwlr_output_mode_v1::Event::Preferred => {}
+            zwlr_output_mode_v1::Event::Finished => {}
+            _ => {}
+        }
+    }
+}
+
+impl Dispatch<ZwlrOutputConfigurationV1, ()> for AppState {
+    event_created_child!(AppState, ZwlrOutputConfigurationV1, [
+        zwlr_output_configuration_v1::REQ_ENABLE_HEAD_OPCODE => (ZwlrOutputConfigurationHeadV1, ()),
+    ]);
+
+    fn event(
+        state: &mut Self,
+        _proxy: &ZwlrOutputConfigurationV1,
+        event: zwlr_output_configuration_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            zwlr_output_configuration_v1::Event::Succeeded => {
+                eprintln!("[output-mgmt] configuration succeeded");
+                state.output_config = None;
+            }
+            zwlr_output_configuration_v1::Event::Failed => {
+                eprintln!("[output-mgmt] configuration failed");
+                state.output_config = None;
+            }
+            zwlr_output_configuration_v1::Event::Cancelled => {
+                eprintln!("[output-mgmt] configuration cancelled (stale serial, will retry)");
+                state.output_config = None;
+                // Re-queue: the done event will fire again with a fresh serial
+                state.wm.pending_scale_apply = true;
+            }
+            _ => {}
+        }
+    }
+}
+
+impl Dispatch<ZwlrOutputConfigurationHeadV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &ZwlrOutputConfigurationHeadV1,
+        _event: zwlr_output_configuration_head_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        // zwlr_output_configuration_head_v1 has no events — it's request-only
+    }
+}
+
+/// Apply the configured output scale via the wlr-output-management protocol.
+/// Called when a `done` event arrives and `pending_scale_apply` is true.
+pub fn apply_output_scale(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+    let Some(ref om) = state.output_manager else {
+        eprintln!("[output-mgmt] no output_manager, cannot apply scale");
+        return;
+    };
+
+    if state.output_heads.is_empty() {
+        eprintln!("[output-mgmt] no heads discovered yet, deferring scale apply");
+        return;
+    }
+
+    // Check if any enabled head actually needs a scale change
+    let target_scale = state.wm.output_scale;
+    let needs_change = state
+        .output_heads
+        .iter()
+        .any(|h| h.enabled && (h.scale - target_scale).abs() > 0.01);
+    if !needs_change {
+        eprintln!(
+            "[output-mgmt] all heads already at target scale {}",
+            target_scale
+        );
+        state.wm.pending_scale_apply = false;
+        return;
+    }
+
+    eprintln!(
+        "[output-mgmt] applying scale {} to {} head(s)",
+        target_scale,
+        state.output_heads.len()
+    );
+
+    let serial = state.output_serial;
+
+    // Destroy any existing pending configuration
+    if let Some(ref config) = state.output_config {
+        config.destroy();
+    }
+    state.output_config = None;
+
+    // Create a new configuration object
+    let config = om.create_configuration(serial, qhandle, ());
+
+    // For each head: enable it and set the scale
+    for head in &state.output_heads {
+        if head.enabled {
+            let config_head = config.enable_head(&head.proxy, qhandle, ());
+            config_head.set_scale(target_scale);
+        } else {
+            config.disable_head(&head.proxy);
+        }
+    }
+
+    config.apply();
+
+    // Store the config proxy so it stays alive until succeeded/failed/cancelled
+    state.output_config = Some(config);
+    state.wm.pending_scale_apply = false;
+}
+
 /// Connect to the Wayland display and set up the event queue.
 /// Returns the Connection, EventQueue, and AppState after the initial registry roundtrip.
 pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), String> {
@@ -995,9 +1733,16 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
 
     // Do initial roundtrip to receive global events and bind protocols
     let mut state = AppState::new();
+    eprintln!("[init] first roundtrip starting...");
+    let rt1 = std::time::Instant::now();
     event_queue
         .roundtrip(&mut state)
         .map_err(|e| format!("initial roundtrip failed: {:?}", e))?;
+    eprintln!(
+        "[init] first roundtrip done in {:?}, render_count={}",
+        rt1.elapsed(),
+        state.render_count
+    );
 
     // Check we got the required protocols
     if !state.has_window_manager {
@@ -1008,9 +1753,16 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
     }
 
     // Second roundtrip for input device events
+    eprintln!("[init] second roundtrip starting...");
+    let rt2 = std::time::Instant::now();
     event_queue
         .roundtrip(&mut state)
         .map_err(|e| format!("second roundtrip failed: {:?}", e))?;
+    eprintln!(
+        "[init] second roundtrip done in {:?}, render_count={}",
+        rt2.elapsed(),
+        state.render_count
+    );
 
     eprintln!("clearwm: Wayland connection established");
 
diff --git a/src/wm.rs b/src/wm.rs
index 436437b..d7de94d 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -4,16 +4,81 @@
 // the same work as the C version's wm_handle_render_start():
 //   1. Tile visible windows (propose dimensions + set position)
 //   2. Set border colors (cascade depth gradient or normal gray)
-//   3. Update desktop background via swaybg
 
 use crate::borders::compute_border_colors;
 use crate::protocol::river_window_management::client::river_node_v1::RiverNodeV1;
 use crate::protocol::river_window_management::client::river_window_v1::Edges;
 use crate::tiling;
-use crate::types::{TilingMode, WindowManager};
+use crate::types::{TilingMode, Window, WindowManager, NUM_TAGS};
 use crate::wayland::AppState;
 use wayland_client::QueueHandle;
 
+/// Determine the tiling mode for a window based on mode_rules, tag_layouts,
+/// and global_layout (in priority order).
+///
+/// Returns the resolved TilingMode, or None if the window's mode is locked
+/// (i.e., the user manually set it and it should not be overridden).
+pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMode> {
+    // If the user manually locked the mode (via set-mode, fullscreen toggle, etc.),
+    // don't override it.
+    if win.mode_locked {
+        return None;
+    }
+
+    // 1. Check mode_rules for a match on app_id/title
+    for rule in &wm.mode_rules {
+        let match_app = rule.app_id_pattern == "*"
+            || win
+                .app_id
+                .as_deref()
+                .map_or(false, |aid| aid.contains(&rule.app_id_pattern));
+        let match_title = rule.title_pattern.as_deref() == Some("*")
+            || rule.title_pattern.is_none()
+            || win.title.as_deref().map_or(false, |t| {
+                t.contains(rule.title_pattern.as_deref().unwrap_or(""))
+            });
+
+        if match_app && match_title {
+            return Some(rule.mode);
+        }
+    }
+
+    // 2. Check tag_layouts for the window's active tags
+    for tag_bit in 0..NUM_TAGS {
+        let tag_mask = 1u32 << tag_bit;
+        if (win.tags & tag_mask) != 0 && wm.has_tag_layout[tag_bit] {
+            return Some(wm.tag_layouts[tag_bit]);
+        }
+    }
+
+    // 3. Fall back to global_layout
+    Some(wm.global_layout)
+}
+
+/// Assign tiling modes to all windows that aren't mode_locked.
+/// Should be called during ManageStart before compute_tiling.
+pub fn assign_window_modes(wm: &mut WindowManager) {
+    // Collect assignments first (borrow checker: can't borrow wm mutably while iterating mode_rules)
+    let assignments: Vec<(u64, TilingMode)> = wm
+        .windows
+        .iter()
+        .filter(|w| !w.closed && !w.mode_locked)
+        .filter_map(|win| get_mode_for_window(wm, win).map(|mode| (win.id, mode)))
+        .collect();
+
+    for (wid, mode) in assignments {
+        if let Some(win) = wm.get_window_mut(wid) {
+            if win.tiling_mode != mode {
+                eprintln!(
+                    "[mode] window {} (app_id={:?}): {:?} -> {:?}",
+                    wid, win.app_id, win.tiling_mode, mode
+                );
+                win.tiling_mode = mode;
+            }
+        }
+    }
+}
+
 /// A tiling result for a single window
 struct TileResult {
     wid: u64,
@@ -23,24 +88,36 @@ struct TileResult {
     h: i32,
 }
 
-/// Perform a full render cycle: tile windows and set borders.
-///
-/// This is called from the `render_start` handler only when `needs_render` is true.
-/// After this, the caller always calls `render_finish()`.
-pub fn render_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+/// Perform window management during a manage sequence.
+/// This calls set_position and propose_dimensions on visible windows.
+/// These modify window management state and can ONLY be called during
+/// a manage sequence (between ManageStart and ManageFinish).
+pub fn manage_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
     let screen_dims = get_screen_dimensions(&state.wm);
     let (screen_w, screen_h) = screen_dims;
 
-    // Ensure each window that needs positioning has a river_node_v1
+    eprintln!(
+        "[manage] windows={} outputs={} screen={}x{}",
+        state.wm.windows.len(),
+        state.wm.outputs.len(),
+        screen_w,
+        screen_h
+    );
+
+    // Ensure each window has a river_node_v1 proxy for positioning
     ensure_window_nodes(state, qhandle);
 
-    // Compute tiling (read-only pass over windows)
+    // Compute tiling
     let tile_results = compute_tiling(&state.wm, screen_w, screen_h);
 
-    // Apply tiling results (mutations)
+    // Apply: set_position + propose_dimensions + update internal state
     apply_tiling(state, &tile_results);
+}
 
-    // Set border colors
+/// Set border colors on all visible windows.
+/// This modifies rendering state and is called during RenderStart.
+/// Border colors are applied with the next render_finish.
+pub fn render_borders(state: &mut AppState) {
     set_borders(state);
 }
 
@@ -106,6 +183,8 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
     // Count windows per tiling mode
     let mut n_cascade = 0i32;
     let mut n_grid = 0i32;
+    let mut n_vsplit = 0i32;
+    let mut n_hsplit = 0i32;
     for win in &wm.windows {
         if (win.tags & wm.active_tags) == 0 || win.closed {
             continue;
@@ -113,6 +192,8 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
         match win.tiling_mode {
             TilingMode::Cascade => n_cascade += 1,
             TilingMode::Grid => n_grid += 1,
+            TilingMode::Vsplit => n_vsplit += 1,
+            TilingMode::Hsplit => n_hsplit += 1,
             _ => {}
         }
     }
@@ -122,9 +203,7 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
         .windows
         .iter()
         .find(|w| {
-            (w.tags & wm.active_tags) != 0
-                && !w.closed
-                && w.tiling_mode == TilingMode::Fullscreen
+            (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
         })
         .map(|w| w.id);
 
@@ -132,6 +211,8 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
     let mut results = Vec::new();
     let mut idx_cascade = 0i32;
     let mut idx_grid = 0i32;
+    let mut idx_vsplit = 0i32;
+    let mut idx_hsplit = 0i32;
 
     for win in &wm.windows {
         if (win.tags & wm.active_tags) == 0 || win.closed {
@@ -169,12 +250,48 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
                 idx_grid += 1;
                 (x, y, w, h)
             }
-            TilingMode::Vsplit | TilingMode::Hsplit => {
-                // TODO: implement vsplit/hsplit
-                continue;
+            TilingMode::Vsplit => {
+                let (x, y, w, h) = tiling::tile_vsplit(
+                    screen_w, screen_h, gap, bw, bar_height, n_vsplit, idx_vsplit,
+                );
+                idx_vsplit += 1;
+                (x, y, w, h)
+            }
+            TilingMode::Hsplit => {
+                let (x, y, w, h) = tiling::tile_hsplit(
+                    screen_w, screen_h, gap, bw, bar_height, n_hsplit, idx_hsplit,
+                );
+                idx_hsplit += 1;
+                (x, y, w, h)
             }
             TilingMode::Floating => {
-                continue;
+                // Floating windows: don't tile them, but still propose
+                // dimensions so they don't end up at w=0 h=0 (which
+                // triggers River's unresponsive-client detection).
+                // Use the window's existing dimensions, or a reasonable
+                // default if unset.
+                let fw = if win.width > 0 {
+                    win.width
+                } else {
+                    screen_w * 2 / 3
+                };
+                let fh = if win.height > 0 {
+                    win.height
+                } else {
+                    screen_h * 2 / 3
+                };
+                let fx = if win.x != 0 || win.y != 0 {
+                    win.x
+                } else {
+                    gap + bw + offset * idx_cascade
+                };
+                let fy = if win.x != 0 || win.y != 0 {
+                    win.y
+                } else {
+                    gap + bw + bar_height + offset * idx_cascade
+                };
+                idx_cascade += 1;
+                (fx, fy, fw, fh)
             }
         };
 
@@ -214,7 +331,7 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
 
 /// Set border colors on all visible windows.
 fn set_borders(state: &mut AppState) {
-    let (border_colors, bg_color) = compute_border_colors(&state.wm);
+    let border_colors = compute_border_colors(&state.wm);
 
     for bc in &border_colors {
         let wid = match state.wm.windows.get(bc.window_idx) {
@@ -228,39 +345,4 @@ fn set_borders(state: &mut AppState) {
                 .set_borders(edges, bc.width, bc.r, bc.g, bc.b, bc.a);
         }
     }
-
-    // Update desktop background if cascade color changed
-    if let Some(ref color) = bg_color {
-        if *color != state.wm.last_bg_color {
-            state.wm.last_bg_color = color.clone();
-            spawn_swaybg(color);
-        }
-    }
-}
-
-/// Spawn swaybg with the given color (fire-and-forget).
-fn spawn_swaybg(color: &str) {
-    let cmd = format!("pkill -f swaybg 2>/dev/null; swaybg -c '{}'", color);
-    unsafe {
-        match nix::unistd::fork() {
-            Ok(nix::unistd::ForkResult::Child) => {
-                nix::unistd::close(nix::libc::STDIN_FILENO).ok();
-                nix::unistd::close(nix::libc::STDOUT_FILENO).ok();
-                nix::unistd::close(nix::libc::STDERR_FILENO).ok();
-                nix::unistd::execvp(
-                    &std::ffi::CString::new("/bin/sh").unwrap(),
-                    &[
-                        std::ffi::CString::new("sh").unwrap(),
-                        std::ffi::CString::new("-c").unwrap(),
-                        std::ffi::CString::new(cmd).unwrap(),
-                    ],
-                )
-                .ok();
-                // If exec fails, exit child
-                libc::_exit(127);
-            }
-            Ok(nix::unistd::ForkResult::Parent { .. }) => {}
-            Err(_) => {}
-        }
-    }
 }
diff --git a/start-river.sh b/start-river.sh
index a2cf546..4edb36f 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -5,6 +5,14 @@
 export XDG_RUNTIME_DIR=/run/user/$(id -u)
 export WAYLAND_DISPLAY=wayland-1
 
+# Create the River init executable (clearwm launch script)
+# This must exist before River starts, and /tmp is cleared on reboot.
+cat > /tmp/clearwm-rs-launch.sh << 'LAUNCH_EOF'
+#!/bin/sh
+exec /home/lsgalante/.local/bin/clearwm 2>/tmp/clearwm-test.log
+LAUNCH_EOF
+chmod +x /tmp/clearwm-rs-launch.sh
+
 echo "Starting river with clearwm-rs..."
 echo "Log will be at /tmp/river-clearwm.log"