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

commit6b52feb7ef8b5b087349266542d3d5e83824a7fd
parentb31a1a0519
authorLucas Galante <[email protected]>
date2026-05-19 23:36
add state persistence, xprop parent detection, libinput tap-to-click, per-tag layouts, and window management improvements

- Add state persistence (~/.cache/clearwm_state) for restart recovery of tags, layouts, active tags
- Implement XWayland parent detection via async xprop (handles dialog/utility windows)
- Add libinput tap-to-click support with RiverLibinputConfigV1 protocol
- Per-tag layout support: layout-next cycles layout per active tag, not just global
- Add ModeNext action to cycle individual window tiling modes
- Auto-focus new windows on current tags after manage_windows
- Flush connection after manage_finish/render_finish to prevent River timeout (3s)
- Use protocol close() instead of just marking window.closed=true
- Fullscreen mode respects gaps, per-mode border widths, and bar height
- Focus-based fullscreen detection (prefer focused window for fullscreen)
- SSD (use_ssd) enforcement for consistent window decorations
- Track window metadata: pid, dimensions_hint, parent_id, fullscreen/maximize/minimize requests
- Clean up proxy destruction on window/output/seat removal
- Retry state restore across ManageStart cycles for delayed metadata arrival
- Add river-libinput-config-v1.xml protocol spec

 protocol/river-libinput-config-v1.xml | 891 ++++++++++++++++++++++++++++++++++
 src/borders.rs                        | 151 +++---
 src/clearctl.rs                       |   2 +-
 src/config.rs                         | 222 ++++++++-
 src/ipc.rs                            | 127 ++++-
 src/lib.rs                            |  13 +-
 src/main.rs                           | 122 ++++-
 src/protocol.rs                       |   7 +
 src/restart.rs                        | 141 +++++-
 src/state.rs                          | 456 +++++++++++++++++
 src/status.rs                         |   3 +-
 src/tiling.rs                         | 339 +++++++++----
 src/types.rs                          | 114 ++++-
 src/wayland.rs                        | 769 +++++++++++++++++++++++++++--
 src/wm.rs                             |  69 ++-
 start-river.sh                        |  28 +-
 test_proto                            | Bin 0 -> 439040 bytes
 17 files changed, 3169 insertions(+), 285 deletions(-)

diff --git a/protocol/river-libinput-config-v1.xml b/protocol/river-libinput-config-v1.xml
new file mode 100644
index 0000000..5e38b08
--- /dev/null
+++ b/protocol/river-libinput-config-v1.xml
@@ -0,0 +1,891 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="river_libinput_config_v1">
+  <copyright>
+    SPDX-FileCopyrightText: © 2025 Isaac Freund
+    SPDX-License-Identifier: MIT
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to
+    deal in the Software without restriction, including without limitation the
+    rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+    sell copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+    IN THE SOFTWARE.
+  </copyright>
+
+  <description summary="configure libinput devices">
+    This protocol exposes libinput device configuration APIs. The libinput
+    documentation should be referred to for detailed information on libinput's
+    behavior.
+
+    Note that the compositor will not be able to expose libinput devices through
+    this protocol when it does not have access to the hardware, for example when
+    running nested in another Wayland compositor or X11 session.
+
+    This protocol is designed so that (hopefully) any backwards compatible
+    change to libinput's API can be matched with a backwards compatible change
+    to this protocol.
+
+    Note: the libinput API uses floating point types (float and double in C)
+    which are not (yet?) natively supported by the Wayland protocol. However,
+    the Wayland protocol does support sending arbitrary bytes through the array
+    argument type. This protocol uses e.g. type="array" summary="double" to
+    indicate a native-endian IEEE-754 64-bit double value.
+
+    The key words "must", "must not", "required", "shall", "shall not",
+    "should", "should not", "recommended", "may", and "optional" in this
+    document are to be interpreted as described in IETF RFC 2119.
+  </description>
+
+  <interface name="river_libinput_config_v1" version="1">
+    <description summary="libinput config global interface">
+      Global interface for configuring libinput devices. This global should
+      only be advertised if river_input_manager_v1 is advertised as well.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_arg" value="0"
+        summary="invalid enum value or similar"/>
+      <entry name="invalid_destroy" value="1"/>
+    </enum>
+
+    <request name="stop">
+      <description summary="stop sending events">
+        This request indicates that the client no longer wishes to receive
+        events on this object.
+
+        The Wayland protocol is asynchronous, which means the server may send
+        further events until the stop request is processed. The client must wait
+        for a river_libinput_config_v1.finished event before destroying this
+        object.
+      </description>
+    </request>
+
+    <event name="finished">
+      <description summary="the server has finished with the object">
+        This event indicates that the server will send no further events on this
+        object. The client should destroy the object. See
+        river_libinput_config_v1.destroy for more information.
+      </description>
+    </event>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the river_libinput_config_v1 object">
+        This request should be called after the finished event has been received
+        to complete destruction of the object.
+
+        It is a protocol error to make this request before the finished event
+        has been received.
+
+        If a client wishes to destroy this object it should send a
+        river_libinput_config_v1.stop request and wait for a
+        river_libinput_config_v1.finished event. Once the finished event is
+        received it is safe to destroy this object and any other objects created
+        through this interface.
+      </description>
+    </request>
+
+    <event name="libinput_device">
+      <description summary="new libinput device">
+        A new libinput device has been created. Not every river_input_device_v1
+        is necessarily a libinput device as well.
+      </description>
+      <arg name="id" type="new_id" interface="river_libinput_device_v1"/>
+    </event>
+
+    <request name="create_accel_config">
+      <description summary="create a acceleration config">
+        Create a acceleration config which can be applied
+        with river_libinput_device_v1.apply_accel_config.
+      </description>
+      <arg name="id" type="new_id"
+        interface="river_libinput_accel_config_v1"/>
+      <arg name="profile" type="uint"
+        enum="river_libinput_device_v1.accel_profile"/>
+    </request>
+  </interface>
+
+  <interface name="river_libinput_device_v1" version="1">
+    <description summary="a libinput device">
+      In general, *_support events will be sent exactly once directly after the
+      river_libinput_device_v1 is created. *_default events will be sent after
+      *_support events if the config option is supported, and *_current events
+      willl be sent after the *_default events and again whenever the config
+      option is changed.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_arg" value="0"
+        summary="invalid enum value or similar"/>
+    </enum>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the libinput device object">
+        This request indicates that the client will no longer use the input
+        device object and that it may be safely destroyed.
+      </description>
+    </request>
+
+    <event name="removed">
+      <description summary="the libinput device is removed">
+        This event indicates that the libinput device has been removed.
+
+        The server will send no further events on this object and ignore any
+        request (other than river_libinput_device_v1.destroy) made after this
+        event is sent. The client should destroy this object with the
+        river_libinput_device_v1.destroy request to free up resources.
+      </description>
+    </event>
+
+    <event name="input_device">
+      <description summary="corresponding river input device">
+        The river_input_device_v1 corresponding to this libinput device.
+        This event will always be the first event sent on the
+        river_libinput_device_v1 object, and it will be sent exactly once.
+      </description>
+      <arg name="device" type="object" interface="river_input_device_v1"/>
+    </event>
+
+    <enum name="send_events_modes" bitfield="true">
+      <entry name="enabled" value="0"/>
+      <entry name="disabled" value="1"/>
+      <entry name="disabled_on_external_mouse" value="2"/>
+    </enum>
+
+    <event name="send_events_support">
+      <description summary="supported send events modes">
+        Supported send events modes.
+      </description>
+      <arg name="modes" type="uint" enum="send_events_modes"/>
+    </event>
+
+    <event name="send_events_default">
+      <description summary="default send events mode">
+        Default send events mode.
+      </description>
+      <arg name="mode" type="uint" enum="send_events_modes"/>
+    </event>
+
+    <event name="send_events_current">
+      <description summary="current send events mode">
+        Current send events mode.
+      </description>
+      <arg name="mode" type="uint" enum="send_events_modes"/>
+    </event>
+
+    <request name="set_send_events">
+      <description summary="set send events mode">
+        Set the send events mode for the device.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="mode" type="uint" enum="send_events_modes"/>
+    </request>
+
+    <enum name="tap_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="tap_support">
+      <description summary="tap-to-click/drag support">
+        The number of fingers supported for tap-to-click/drag.
+        If finger_count is 0, tap-to-click and drag are unsupported.
+      </description>
+      <arg name="finger_count" type="int"/>
+    </event>
+
+    <event name="tap_default">
+      <description summary="default tap-to-click state">
+        Default tap-to-click state.
+      </description>
+      <arg name="state" type="uint" enum="tap_state"/>
+    </event>
+
+    <event name="tap_current">
+      <description summary="current tap-to-click state">
+        Current tap-to-click state.
+      </description>
+      <arg name="state" type="uint" enum="tap_state"/>
+    </event>
+
+    <request name="set_tap">
+      <description summary="enable/disable tap-to-click">
+        Configure tap-to-click on this device, with a default mapping of
+        1, 2, 3 finger tap mapping to left, right, middle click, respectively.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="tap_state"/>
+    </request>
+
+    <enum name="tap_button_map">
+      <entry name="lrm" value="0"
+        summary="1/2/3 finger tap maps to left/right/middle"/>
+      <entry name="lmr" value="1"
+        summary="1/2/3 finger tap maps to left/middle/right"/>
+    </enum>
+
+    <event name="tap_button_map_default">
+      <description summary="default tap-to-click button map">
+        Default tap-to-click button map.
+      </description>
+      <arg name="button_map" type="uint" enum="tap_button_map"/>
+    </event>
+
+    <event name="tap_button_map_current">
+      <description summary="current tap-to-click button map">
+        Current tap-to-click button map.
+      </description>
+      <arg name="button_map" type="uint" enum="tap_button_map"/>
+    </event>
+
+    <request name="set_tap_button_map">
+      <description summary="set tap-to-click button map">
+        Set the finger number to button number mapping for tap-to-click. The
+        default mapping on most devices is to have a 1, 2 and 3 finger tap to
+        map to the left, right and middle button, respectively.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="button_map" type="uint" enum="tap_button_map"/>
+    </request>
+
+    <enum name="drag_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="drag_default">
+      <description summary="default tap-and-drag state">
+        Default tap-and-drag state.
+      </description>
+      <arg name="state" type="uint" enum="drag_state"/>
+    </event>
+
+    <event name="drag_current">
+      <description summary="current tap-and-drag state">
+        Current tap-and-drag state.
+      </description>
+      <arg name="state" type="uint" enum="drag_state"/>
+    </event>
+
+    <request name="set_drag">
+      <description summary="set tap-and-drag state">
+        Configure tap-and-drag functionality on the device.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="drag_state"/>
+    </request>
+
+    <enum name="drag_lock_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled_timeout" value="1"/>
+      <entry name="enabled_sticky" value="2"/>
+    </enum>
+
+    <event name="drag_lock_default">
+      <description summary="default drag lock state">
+        Default drag lock state.
+      </description>
+      <arg name="state" type="uint" enum="drag_lock_state"/>
+    </event>
+
+    <event name="drag_lock_current">
+      <description summary="current drag lock state">
+        Current drag lock state.
+      </description>
+      <arg name="state" type="uint" enum="drag_lock_state"/>
+    </event>
+
+    <request name="set_drag_lock">
+      <description summary="set drag lock state">
+        Configure drag-lock during tapping on this device. When enabled, a
+        finger may be lifted and put back on the touchpad and the drag process
+        continues. A timeout for lifting the finger is optional. When disabled,
+        lifting the finger during a tap-and-drag will immediately stop the drag.
+        See the libinput documentation for more details.
+       </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="drag_lock_state"/>
+    </request>
+
+    <event name="three_finger_drag_support">
+      <description summary="three finger drag support">
+        The number of fingers supported for three/four finger drag.
+        If finger_count is less than 3, three finger drag is unsupported.
+      </description>
+      <arg name="finger_count" type="int"/>
+    </event>
+
+    <enum name="three_finger_drag_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled_3fg" value="1"/>
+      <entry name="enabled_4fg" value="2"/>
+    </enum>
+
+    <event name="three_finger_drag_default">
+      <description summary="default three finger drag state">
+        Default three finger drag state.
+      </description>
+      <arg name="state" type="uint" enum="three_finger_drag_state"/>
+    </event>
+
+    <event name="three_finger_drag_current">
+      <description summary="current three finger drag state">
+        Current three finger drag state.
+      </description>
+      <arg name="state" type="uint" enum="three_finger_drag_state"/>
+    </event>
+
+    <request name="set_three_finger_drag">
+      <description summary="set three finger drag state">
+        Configure three finger drag functionality for the device.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="three_finger_drag_state"/>
+    </request>
+
+    <event name="calibration_matrix_support">
+      <description summary="support for a calibration matrix">
+        A calibration matrix is supported if the supported argument is non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <event name="calibration_matrix_default">
+      <description summary="default calibration matrix">
+        Default calibration matrix.
+      </description>
+      <arg name="matrix" type="array" summary="array of 6 floats"/>
+    </event>
+
+    <event name="calibration_matrix_current">
+      <description summary="current calibration matrix">
+        Current calibration matrix.
+      </description>
+      <arg name="matrix" type="array" summary="array of 6 floats"/>
+    </event>
+
+    <request name="set_calibration_matrix">
+      <description summary="set calibration matrix">
+        Set calibration matrix.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="matrix" type="array" summary="array of 6 floats"/>
+    </request>
+
+    <enum name="accel_profile">
+      <entry name="none" value="0"/>
+      <entry name="flat" value="1"/>
+      <entry name="adaptive" value="2"/>
+      <entry name="custom" value="4"/>
+    </enum>
+
+    <enum name="accel_profiles" bitfield="true">
+      <entry name="none" value="0"/>
+      <entry name="flat" value="1"/>
+      <entry name="adaptive" value="2"/>
+      <entry name="custom" value="4"/>
+    </enum>
+
+    <event name="accel_profiles_support">
+      <description summary="supported acceleration profiles">
+        Supported acceleration profiles.
+      </description>
+      <arg name="profiles" type="uint" enum="accel_profiles"/>
+    </event>
+
+    <event name="accel_profile_default">
+      <description summary="default acceleration profile">
+        Default acceleration profile.
+      </description>
+      <arg name="profile" type="uint" enum="accel_profile"/>
+    </event>
+
+    <event name="accel_profile_current">
+      <description summary="current send events mode">
+        Current acceleration profile.
+      </description>
+      <arg name="profile" type="uint" enum="accel_profile"/>
+    </event>
+
+    <request name="set_accel_profile">
+      <description summary="set send events mode">
+        Set the acceleration profile.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="profile" type="uint" enum="accel_profile"/>
+    </request>
+
+    <event name="accel_speed_default">
+      <description summary="default acceleration speed">
+        Default acceleration speed.
+      </description>
+      <arg name="speed" type="array" summary="double"/>
+    </event>
+
+    <event name="accel_speed_current">
+      <description summary="current acceleration speed">
+        Current acceleration speed.
+      </description>
+      <arg name="speed" type="array" summary="double"/>
+    </event>
+
+    <request name="set_accel_speed">
+      <description summary="set acceleration speed">
+        Set the acceleration speed within a range of [-1, 1], where 0 is
+        the default acceleration for this device, -1 is the slowest acceleration
+        and 1 is the maximum acceleration available on this device.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="speed" type="array" summary="double"/>
+    </request>
+
+    <request name="apply_accel_config">
+      <description summary="apply acceleration config">
+        Apply a pointer accleration config.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="config" type="object" interface="river_libinput_accel_config_v1"/>
+    </request>
+
+    <event name="natural_scroll_support">
+      <description summary="support for natural scroll">
+        Natural scroll is supported if the supported argument is non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <enum name="natural_scroll_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="natural_scroll_default">
+      <description summary="default natural scroll">
+        Default natural scroll.
+      </description>
+      <arg name="state" type="uint" enum="natural_scroll_state"/>
+    </event>
+
+    <event name="natural_scroll_current">
+      <description summary="current natural scroll state">
+        Current natural scroll.
+      </description>
+      <arg name="state" type="uint" enum="natural_scroll_state"/>
+    </event>
+
+    <request name="set_natural_scroll">
+      <description summary="set natural scroll state">
+        Set natural scroll state.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="natural_scroll_state"/>
+    </request>
+
+    <event name="left_handed_support">
+      <description summary="support for left-handed mode">
+        Left-handed mode is supported if the supported argument is non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <enum name="left_handed_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="left_handed_default">
+      <description summary="default left-handed mode">
+        Default left-handed mode.
+      </description>
+      <arg name="state" type="uint" enum="left_handed_state"/>
+    </event>
+
+    <event name="left_handed_current">
+      <description summary="current left-handed mode state">
+        Current left-handed mode.
+      </description>
+      <arg name="state" type="uint" enum="left_handed_state"/>
+    </event>
+
+    <request name="set_left_handed">
+      <description summary="set left-handed mode state">
+        Set left-handed mode state.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="left_handed_state"/>
+    </request>
+
+    <enum name="click_method">
+      <entry name="none" value="0"/>
+      <entry name="button_areas" value="1"/>
+      <entry name="clickfinger" value="2"/>
+    </enum>
+
+    <enum name="click_methods" bitfield="true">
+      <entry name="none" value="0"/>
+      <entry name="button_areas" value="1"/>
+      <entry name="clickfinger" value="2"/>
+    </enum>
+
+    <event name="click_method_support">
+      <description summary="supported click methods">
+        The click methods supported by the device.
+      </description>
+      <arg name="methods" type="uint" enum="click_methods"/>
+    </event>
+
+    <event name="click_method_default">
+      <description summary="default click method">
+        Default click method.
+      </description>
+      <arg name="method" type="uint" enum="click_method"/>
+    </event>
+
+    <event name="click_method_current">
+      <description summary="current click method">
+        Current click method.
+      </description>
+      <arg name="method" type="uint" enum="click_method"/>
+    </event>
+
+    <request name="set_click_method">
+      <description summary="set click method">
+        Set click method.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="method" type="uint" enum="click_method"/>
+    </request>
+
+    <enum name="clickfinger_button_map">
+      <entry name="lrm" value="0"/>
+      <entry name="lmr" value="1"/>
+    </enum>
+
+    <event name="clickfinger_button_map_default">
+      <description summary="default clickfinger button map">
+        Default clickfinger button map.
+        Supported if click_methods.clickfinger is supported.
+      </description>
+      <arg name="button_map" type="uint" enum="clickfinger_button_map"/>
+    </event>
+
+    <event name="clickfinger_button_map_current">
+      <description summary="current clickfinger button map">
+        Current clickfinger button map.
+        Supported if click_methods.clickfinger is supported.
+      </description>
+      <arg name="button_map" type="uint" enum="clickfinger_button_map"/>
+    </event>
+
+    <request name="set_clickfinger_button_map">
+      <description summary="set clickfinger button map">
+        Set clickfinger button map.
+        Supported if click_methods.clickfinger is supported.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="button_map" type="uint" enum="clickfinger_button_map"/>
+    </request>
+
+    <event name="middle_emulation_support">
+      <description summary="support for middle mouse button emulation">
+        Middle mouse button emulation is supported if the supported argument is
+        non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <enum name="middle_emulation_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="middle_emulation_default">
+      <description summary="default middle mouse button emulation">
+        Default middle mouse button emulation.
+      </description>
+      <arg name="state" type="uint" enum="middle_emulation_state"/>
+    </event>
+
+    <event name="middle_emulation_current">
+      <description summary="current middle mouse button emulation state">
+        Current middle mouse button emulation.
+      </description>
+      <arg name="state" type="uint" enum="middle_emulation_state"/>
+    </event>
+
+    <request name="set_middle_emulation">
+      <description summary="set middle mouse button emulation state">
+        Set middle mouse button emulation state.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="middle_emulation_state"/>
+    </request>
+
+    <enum name="scroll_method">
+      <entry name="no_scroll" value="0"/>
+      <entry name="two_finger" value="1"/>
+      <entry name="edge" value="2"/>
+      <entry name="on_button_down" value="4"/>
+    </enum>
+
+    <enum name="scroll_methods" bitfield="true">
+      <entry name="no_scroll" value="0"/>
+      <entry name="two_finger" value="1"/>
+      <entry name="edge" value="2"/>
+      <entry name="on_button_down" value="4"/>
+    </enum>
+
+    <event name="scroll_method_support">
+      <description summary="supported scroll methods">
+        The scroll methods supported by the device.
+      </description>
+      <arg name="methods" type="uint" enum="scroll_methods"/>
+    </event>
+
+    <event name="scroll_method_default">
+      <description summary="default scroll method">
+        Default scroll method.
+      </description>
+      <arg name="method" type="uint" enum="scroll_method"/>
+    </event>
+
+    <event name="scroll_method_current">
+      <description summary="current scroll method">
+        Current scroll method.
+      </description>
+      <arg name="method" type="uint" enum="scroll_method"/>
+    </event>
+
+    <request name="set_scroll_method">
+      <description summary="set scroll method">
+        Set scroll method.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="method" type="uint" enum="scroll_method"/>
+    </request>
+
+    <event name="scroll_button_default">
+      <description summary="default scroll button">
+        Default scroll button.
+        Supported if scroll_methods.on_button_down is supported.
+      </description>
+      <arg name="button" type="uint"/>
+    </event>
+
+    <event name="scroll_button_current">
+      <description summary="current scroll button">
+        Current scroll button.
+        Supported if scroll_methods.on_button_down is supported.
+      </description>
+      <arg name="button" type="uint"/>
+    </event>
+
+    <request name="set_scroll_button">
+      <description summary="set scroll button">
+        Set scroll button.
+        Supported if scroll_methods.on_button_down is supported.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="button" type="uint"/>
+    </request>
+
+    <enum name="scroll_button_lock_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="scroll_button_lock_default">
+      <description summary="default scroll button lock state">
+        Default scroll button lock state.
+        Supported if scroll_methods.on_button_down is supported.
+      </description>
+      <arg name="state" type="uint" enum="scroll_button_lock_state"/>
+    </event>
+
+    <event name="scroll_button_lock_current">
+      <description summary="current scroll button lock state">
+        Current scroll button lock state.
+        Supported if scroll_methods.on_button_down is supported.
+      </description>
+      <arg name="state" type="uint" enum="scroll_button_lock_state"/>
+    </event>
+
+    <request name="set_scroll_button_lock">
+      <description summary="set scroll button lock state">
+        Set scroll button lock state.
+        Supported if scroll_methods.on_button_down is supported.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="scroll_button_lock_state"/>
+    </request>
+
+    <event name="dwt_support">
+      <description summary="support for disable-while-typing">
+        Disable-while-typing is supported if the supported argument is
+        non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <enum name="dwt_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="dwt_default">
+      <description summary="default disable-while-typing state">
+        Default disable-while-typing state.
+      </description>
+      <arg name="state" type="uint" enum="dwt_state"/>
+    </event>
+
+    <event name="dwt_current">
+      <description summary="current disable-while-typing state">
+        Current disable-while-typing state.
+      </description>
+      <arg name="state" type="uint" enum="dwt_state"/>
+    </event>
+
+    <request name="set_dwt">
+      <description summary="set disable-while-typing state">
+        Set disable-while-typing state.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="dwt_state"/>
+    </request>
+
+    <event name="dwtp_support">
+      <description summary="support for disable-while-trackpointing">
+        Disable-while-trackpointing is supported if the supported argument is
+        non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <enum name="dwtp_state">
+      <entry name="disabled" value="0"/>
+      <entry name="enabled" value="1"/>
+    </enum>
+
+    <event name="dwtp_default">
+      <description summary="default disable-while-trackpointing state">
+        Default disable-while-trackpointing state.
+      </description>
+      <arg name="state" type="uint" enum="dwtp_state"/>
+    </event>
+
+    <event name="dwtp_current">
+      <description summary="current disable-while-trackpointing state">
+        Current disable-while-trackpointing state.
+      </description>
+      <arg name="state" type="uint" enum="dwtp_state"/>
+    </event>
+
+    <request name="set_dwtp">
+      <description summary="set disable-while-trackpointing state">
+        Set disable-while-trackpointing state.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="state" type="uint" enum="dwtp_state"/>
+    </request>
+
+    <event name="rotation_support">
+      <description summary="support for rotation">
+        Rotation is supported if the supported argument is non-zero.
+      </description>
+      <arg name="supported" type="int" summary="boolean"/>
+    </event>
+
+    <event name="rotation_default">
+      <description summary="default rotation angle">
+        Default rotation angle.
+      </description>
+      <arg name="angle" type="uint"/>
+    </event>
+
+    <event name="rotation_current">
+      <description summary="current rotation angle">
+        Current rotation angle.
+      </description>
+      <arg name="angle" type="uint"/>
+    </event>
+
+    <request name="set_rotation">
+      <description summary="set rotation angle">
+        Set rotation angle in degrees clockwise off the logical neutral
+        position. Angle must be in the range [0-360).
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="angle" type="uint"/>
+    </request>
+  </interface>
+
+  <interface name="river_libinput_accel_config_v1" version="1">
+    <description summary="acceleration config">
+      The result returned by libinput on setting configuration for a device.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_arg" value="0"
+        summary="invalid enum value or similar"/>
+    </enum>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the accel object">
+        This request indicates that the client will no longer use the accel
+        config object and that it may be safely destroyed.
+      </description>
+    </request>
+
+    <enum name="accel_type">
+      <entry name="fallback" value="0"/>
+      <entry name="motion" value="1"/>
+      <entry name="scroll" value="2"/>
+    </enum>
+
+    <request name="set_points">
+      <description summary="define custom acceleration function">
+        Defines the acceleration function for a given movement type
+        in an acceleration configuration with custom accel profile.
+      </description>
+      <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+      <arg name="type" type="uint" enum="accel_type"/>
+      <arg name="step" type="array" summary="double"/>
+      <arg name="points" type="array" summary="array of doubles"/>
+    </request>
+  </interface>
+
+  <interface name="river_libinput_result_v1" version="1">
+    <description summary="config application result">
+      The result returned by libinput on setting configuration for a device.
+    </description>
+
+    <event name="success" type="destructor">
+      <description summary="config success">
+        The configuration was successfully applied to the device.
+      </description>
+    </event>
+
+    <event name="unsupported" type="destructor">
+      <description summary="config unsupported">
+        The configuration is unsupported by the device and was ignored.
+      </description>
+    </event>
+
+    <event name="invalid" type="destructor">
+      <description summary="config invalid">
+        The configuration is invalid and was ignored.
+      </description>
+    </event>
+  </interface>
+</protocol>
diff --git a/src/borders.rs b/src/borders.rs
index 188e79d..6647ada 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -3,29 +3,35 @@
 use crate::types::{TilingMode, WindowManager};
 
 /// Interpolate a single 8-bit channel toward black by (factor ^ depth).
-/// fp_channel is in fixed-point format (0xRR000000).
-/// Returns the value in 32-bit fixed-point (channel in high byte).
+/// Returns a 32-bit value with the byte replicated across all 4 bytes,
+/// which is the format River expects (it divides by maxInt(u32) to get a float).
 pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
-    let base = (fp_channel >> 24) as u8;
+    let base = (fp_channel & 0xFF) as u8;
     let mut f = 1.0_f64;
     for _ in 0..depth {
         f *= factor;
     }
     let val = (base as f64 * f) as u8;
-    (val as u32) << 24
+    // Replicate byte across all 4 bytes: 0xVV -> 0xVVVVVVVV
+    val as u32 * 0x01010101
 }
 
-/// 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;
-pub const BORDER_COLOR_NORMAL_B: u32 = 0x3E000000;
-pub const BORDER_COLOR_NORMAL_A: u32 = 0x000000FF;
+/// Blend between two byte-replicated 32-bit channel values.
+/// factor=0.0 → pure bg, factor=1.0 → pure fg.
+fn blend_channel(bg_channel: u32, fg_channel: u32, factor: f64) -> u32 {
+    let bg = (bg_channel & 0xFF) as u8 as f64;
+    let fg = (fg_channel & 0xFF) as u8 as f64;
+    let val = (bg + (fg - bg) * factor) as u8;
+    val as u32 * 0x01010101
+}
 
-/// Cascade alpha: full alpha in low byte
-pub const CASCADE_ALPHA: u32 = 0x000000FF;
+/// Alpha for borders: fully opaque, byte-replicated
+pub const ALPHA: u32 = 0xFFFFFFFF;
 
-/// Cascade depth darkening factor
-pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
+/// Unfocused depth factor: each step away from the focused window reduces
+/// the blend factor by this multiplier, making the border color approach
+/// the background color.
+pub const UNFOCUSED_DEPTH_FACTOR: f64 = 0.70;
 
 /// Result of border color computation for a single window
 #[derive(Debug, Clone)]
@@ -40,62 +46,77 @@ pub struct WindowBorders {
 }
 
 /// Compute border colors for all visible windows.
+/// The focused window gets the configured border_color.
+/// Unfocused windows get a color interpolated between the desktop
+/// background_color and border_color based on stack depth.
 pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
     let mut results = Vec::new();
-    let all_edges = 0b1111u32; // all edges
-
-    // Count cascade windows
-    let mut n_cascade = 0usize;
-    for win in &state.windows {
-        if (win.tags & state.active_tags) != 0 && win.tiling_mode == TilingMode::Cascade {
-            n_cascade += 1;
-        }
-    }
+    let all_edges = 0b1111u32;
+
+    // Find the focused window ID from the first non-removed seat
+    let focused_id = state
+        .seats
+        .iter()
+        .find(|s| !s.removed)
+        .and_then(|s| s.focused_window_id);
+
+    // Collect visible window indices (ordered from bottom to top of stack)
+    let visible: Vec<usize> = state
+        .windows
+        .iter()
+        .enumerate()
+        .filter(|(_, w)| (w.tags & state.active_tags) != 0 && !w.closed)
+        .map(|(i, _)| i)
+        .collect();
+    let n_visible = visible.len();
 
-    // Assign border colors
     for (idx, win) in state.windows.iter().enumerate() {
         if (win.tags & state.active_tags) == 0 {
             continue;
         }
 
-        if win.tiling_mode == TilingMode::Cascade && n_cascade > 0 {
-            // Compute cascade depth: count how many cascade windows come before this one
-            let mut cascade_idx = 0usize;
-            for (i, w) in state.windows.iter().enumerate() {
-                if i >= idx {
-                    break;
-                }
-                if (w.tags & state.active_tags) != 0 && w.tiling_mode == TilingMode::Cascade {
-                    cascade_idx += 1;
-                }
-            }
-            // depth: 0 for front (focused/last), n_cascade-1 for back
-            let depth = (n_cascade - 1 - cascade_idx) as i32;
-
-            let r = interp_channel(state.layout.border_r, CASCADE_DEPTH_FACTOR, depth);
-            let g = interp_channel(state.layout.border_g, CASCADE_DEPTH_FACTOR, depth);
-            let b = interp_channel(state.layout.border_b, CASCADE_DEPTH_FACTOR, depth);
-
-            results.push(WindowBorders {
-                window_idx: idx,
-                edges: all_edges,
-                width: state.layout.border_width,
-                r,
-                g,
-                b,
-                a: CASCADE_ALPHA,
-            });
+        let is_focused = focused_id.map_or(false, |fid| win.id == fid);
+
+        let (r, g, b, a) = if is_focused {
+            // Focused window: pure border color
+            (
+                state.layout.border_r,
+                state.layout.border_g,
+                state.layout.border_b,
+                state.layout.border_a,
+            )
         } else {
-            results.push(WindowBorders {
-                window_idx: idx,
-                edges: all_edges,
-                width: state.layout.border_width,
-                r: BORDER_COLOR_NORMAL_R,
-                g: BORDER_COLOR_NORMAL_G,
-                b: BORDER_COLOR_NORMAL_B,
-                a: BORDER_COLOR_NORMAL_A,
-            });
-        }
+            // Unfocused window: blend background → border based on depth
+            let pos = visible.iter().position(|&i| i == idx).unwrap_or(0);
+            let depth = (n_visible - 1 - pos) as i32;
+            let mut factor = 1.0_f64;
+            for _ in 0..depth {
+                factor *= UNFOCUSED_DEPTH_FACTOR;
+            }
+            let r = blend_channel(state.layout.background_r, state.layout.border_r, factor);
+            let g = blend_channel(state.layout.background_g, state.layout.border_g, factor);
+            let b = blend_channel(state.layout.background_b, state.layout.border_b, factor);
+            (r, g, b, ALPHA)
+        };
+
+        let width = match win.tiling_mode {
+            TilingMode::Cascade => state.layout.cascade_border_width,
+            TilingMode::Fullscreen => state.layout.fullscreen_border_width,
+            TilingMode::Grid => state.layout.grid_border_width,
+            TilingMode::Vsplit => state.layout.vsplit_border_width,
+            TilingMode::Hsplit => state.layout.hsplit_border_width,
+            TilingMode::Floating => state.layout.floating_border_width,
+        };
+
+        results.push(WindowBorders {
+            window_idx: idx,
+            edges: all_edges,
+            width,
+            r,
+            g,
+            b,
+            a,
+        });
     }
 
     results
@@ -108,14 +129,14 @@ mod tests {
     #[test]
     fn test_interp_channel_depth0() {
         // Depth 0 should return the base color unchanged
-        let result = interp_channel(0x5C000000, 0.80, 0);
-        assert_eq!(result >> 24, 0x5C);
+        let result = interp_channel(0x5C5C5C5C, 0.80, 0);
+        assert_eq!(result, 0x5C5C5C5C);
     }
 
     #[test]
     fn test_interp_channel_depth1() {
-        let result = interp_channel(0x90000000, 0.80, 1);
-        let val = result >> 24;
-        assert_eq!(val, ((0x90 as f64 * 0.80) as u8) as u32);
+        let result = interp_channel(0x90909090, 0.80, 1);
+        let expected = ((0x90 as f64 * 0.80) as u8) as u32 * 0x01010101;
+        assert_eq!(result, expected);
     }
 }
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 21732c3..3af57e1 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -12,7 +12,7 @@ fn usage(name: &str) {
     eprintln!("usage: {} <command> [args...]", name);
     eprintln!();
     eprintln!("commands:");
-    eprintln!("  layout <gap|offset|bar_height|border_width|border_color> <value>");
+    eprintln!("  layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
     eprintln!("  view <1-4>");
     eprintln!("  toggle <1-4>");
     eprintln!("  close");
diff --git a/src/config.rs b/src/config.rs
index 10dd086..77f8494 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,6 +16,8 @@ pub struct Config {
     #[serde(default)]
     pub output: OutputConfig,
     #[serde(default)]
+    pub input: InputConfig,
+    #[serde(default)]
     pub repeat: RepeatConfig,
     #[serde(default)]
     pub startup: Vec<StartupEntryConfig>,
@@ -35,24 +37,57 @@ pub struct Config {
 pub struct LayoutConfig {
     #[serde(default = "default_gap")]
     pub gap: i64,
-    #[serde(default = "default_offset")]
-    pub offset: i64,
+    #[serde(default = "default_gap_top")]
+    pub gap_top: i64,
+    #[serde(default = "default_gap_left")]
+    pub gap_left: i64,
+    #[serde(default = "default_gap_right")]
+    pub gap_right: i64,
+    #[serde(default = "default_gap_bottom")]
+    pub gap_bottom: i64,
+    #[serde(default = "default_cascade_offset")]
+    pub cascade_offset: i64,
     #[serde(default = "default_bar_height")]
     pub bar_height: i64,
     #[serde(default = "default_border_width")]
     pub border_width: i64,
+    #[serde(default = "default_fullscreen_border_width")]
+    pub fullscreen_border_width: i64,
+    #[serde(default = "default_cascade_border_width")]
+    pub cascade_border_width: i64,
+    #[serde(default = "default_grid_border_width")]
+    pub grid_border_width: i64,
+    #[serde(default = "default_vsplit_border_width")]
+    pub vsplit_border_width: i64,
+    #[serde(default = "default_hsplit_border_width")]
+    pub hsplit_border_width: i64,
+    #[serde(default = "default_floating_border_width")]
+    pub floating_border_width: i64,
     #[serde(default = "default_border_color")]
     pub border_color: String,
+    #[serde(default = "default_background_color")]
+    pub background_color: String,
 }
 
 impl Default for LayoutConfig {
     fn default() -> Self {
         LayoutConfig {
             gap: default_gap(),
-            offset: default_offset(),
+            gap_top: default_gap_top(),
+            gap_left: default_gap_left(),
+            gap_right: default_gap_right(),
+            gap_bottom: default_gap_bottom(),
+            cascade_offset: default_cascade_offset(),
             bar_height: default_bar_height(),
             border_width: default_border_width(),
+            fullscreen_border_width: default_fullscreen_border_width(),
+            cascade_border_width: default_cascade_border_width(),
+            grid_border_width: default_grid_border_width(),
+            vsplit_border_width: default_vsplit_border_width(),
+            hsplit_border_width: default_hsplit_border_width(),
+            floating_border_width: default_floating_border_width(),
             border_color: default_border_color(),
+            background_color: default_background_color(),
         }
     }
 }
@@ -60,7 +95,19 @@ impl Default for LayoutConfig {
 fn default_gap() -> i64 {
     48
 }
-fn default_offset() -> i64 {
+fn default_gap_top() -> i64 {
+    48
+}
+fn default_gap_left() -> i64 {
+    48
+}
+fn default_gap_right() -> i64 {
+    48
+}
+fn default_gap_bottom() -> i64 {
+    48
+}
+fn default_cascade_offset() -> i64 {
     20
 }
 fn default_bar_height() -> i64 {
@@ -69,9 +116,30 @@ fn default_bar_height() -> i64 {
 fn default_border_width() -> i64 {
     6
 }
+fn default_fullscreen_border_width() -> i64 {
+    0
+}
+fn default_cascade_border_width() -> i64 {
+    6
+}
+fn default_grid_border_width() -> i64 {
+    6
+}
+fn default_vsplit_border_width() -> i64 {
+    6
+}
+fn default_hsplit_border_width() -> i64 {
+    6
+}
+fn default_floating_border_width() -> i64 {
+    6
+}
 fn default_border_color() -> String {
     "#3e3e3e".to_string()
 }
+fn default_background_color() -> String {
+    "#0a1a0e".to_string()
+}
 
 #[derive(Debug, Deserialize, Default)]
 pub struct OutputConfig {
@@ -87,6 +155,12 @@ pub struct RepeatConfig {
     pub delay: i64,
 }
 
+#[derive(Debug, Deserialize, Default)]
+pub struct InputConfig {
+    #[serde(default)]
+    pub tap_to_click: bool,
+}
+
 #[derive(Debug, Deserialize)]
 pub struct StartupEntryConfig {
     pub exec: String,
@@ -146,15 +220,31 @@ 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.gap_top = config.layout.gap_top as i32;
+    state.layout.gap_left = config.layout.gap_left as i32;
+    state.layout.gap_right = config.layout.gap_right as i32;
+    state.layout.gap_bottom = config.layout.gap_bottom as i32;
+    state.layout.cascade_offset = config.layout.cascade_offset as i32;
     state.layout.bar_height = config.layout.bar_height as i32;
     state.layout.border_width = config.layout.border_width as i32;
+    state.layout.fullscreen_border_width = config.layout.fullscreen_border_width as i32;
+    state.layout.cascade_border_width = config.layout.cascade_border_width as i32;
+    state.layout.grid_border_width = config.layout.grid_border_width as i32;
+    state.layout.vsplit_border_width = config.layout.vsplit_border_width as i32;
+    state.layout.hsplit_border_width = config.layout.hsplit_border_width as i32;
+    state.layout.floating_border_width = config.layout.floating_border_width as i32;
     if let Some((r, g, b, a)) = parse_hex_color(&config.layout.border_color) {
         state.layout.border_r = r;
         state.layout.border_g = g;
         state.layout.border_b = b;
         state.layout.border_a = a;
     }
+    if let Some((r, g, b, a)) = parse_hex_color(&config.layout.background_color) {
+        state.layout.background_r = r;
+        state.layout.background_g = g;
+        state.layout.background_b = b;
+        state.layout.background_a = a;
+    }
 
     // [[keybind]] array
     eprintln!("[config] processing {} keybinds...", config.keybind.len());
@@ -212,6 +302,15 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
         }
     }
 
+    // [env] — set environment variables BEFORE startup apps are processed,
+    // so that PATH and other vars are available when spawn_command_bg runs.
+    // Supports $VAR and ${VAR} expansion using the current environment.
+    for (key, value) in &config.env {
+        let expanded = expand_env_vars(value);
+        std::env::set_var(key, &expanded);
+        state.env_vars.insert(key.clone(), expanded);
+    }
+
     // [[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.
@@ -242,12 +341,6 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
         }
     }
 
-    // [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 — 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
@@ -259,10 +352,59 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
         0.0
     };
 
+    // [input] tap_to_click — applied via river-libinput-config protocol.
+    // The tap config will be applied when libinput devices are discovered
+    // (in the RiverLibinputDeviceV1 TapSupport event handler).
+    state.tap_to_click = config.input.tap_to_click;
+
     // Signal config-done
     state.config_done = true;
 }
 
+/// Expand $VAR and ${VAR} references in a string using the current environment.
+/// Unset variables expand to empty strings. $$ is not expanded (not a shell).
+fn expand_env_vars(s: &str) -> String {
+    let mut result = String::with_capacity(s.len());
+    let chars: Vec<char> = s.chars().collect();
+    let mut i = 0;
+    while i < chars.len() {
+        if chars[i] == '$' && i + 1 < chars.len() {
+            if chars[i + 1] == '{' {
+                // ${VAR} form
+                if let Some(end) = chars[i + 2..].iter().position(|c| *c == '}') {
+                    let var_name: String = chars[i + 2..i + 2 + end].iter().collect();
+                    let val = std::env::var(&var_name).unwrap_or_default();
+                    result.push_str(&val);
+                    i = i + 2 + end + 1; // skip ${VAR}
+                } else {
+                    result.push(chars[i]);
+                    i += 1;
+                }
+            } else if chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_' {
+                // $VAR form — name is [A-Za-z_][A-Za-z0-9_]*
+                let start = i + 1;
+                let mut end = start;
+                while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
+                {
+                    end += 1;
+                }
+                let var_name: String = chars[start..end].iter().collect();
+                let val = std::env::var(&var_name).unwrap_or_default();
+                result.push_str(&val);
+                i = end;
+            } else {
+                // $ followed by non-identifier char (e.g. $$, $:, $@) — keep as-is
+                result.push(chars[i]);
+                i += 1;
+            }
+        } else {
+            result.push(chars[i]);
+            i += 1;
+        }
+    }
+    result
+}
+
 /// Extract the program name (first word, basename) from a command string
 fn extract_program_name(cmd: &str) -> String {
     let cmd = cmd.trim_start();
@@ -347,11 +489,67 @@ mod tests {
     fn test_config_layout_defaults() {
         let lc = LayoutConfig::default();
         assert_eq!(lc.gap, 48);
-        assert_eq!(lc.offset, 20);
+        assert_eq!(lc.gap_top, 48);
+        assert_eq!(lc.gap_left, 48);
+        assert_eq!(lc.gap_right, 48);
+        assert_eq!(lc.gap_bottom, 48);
+        assert_eq!(lc.cascade_offset, 20);
         assert_eq!(lc.bar_height, 24);
         assert_eq!(lc.border_width, 6);
+        assert_eq!(lc.fullscreen_border_width, 0);
         assert_eq!(lc.border_color, "#3e3e3e");
     }
+
+    #[test]
+    fn test_expand_env_vars_simple() {
+        std::env::set_var("CLEARWM_TEST_VAR", "hello");
+        assert_eq!(expand_env_vars("$CLEARWM_TEST_VAR"), "hello");
+        std::env::remove_var("CLEARWM_TEST_VAR");
+    }
+
+    #[test]
+    fn test_expand_env_vars_braces() {
+        std::env::set_var("CLEARWM_TEST_VAR", "world");
+        assert_eq!(expand_env_vars("${CLEARWM_TEST_VAR}!"), "world!");
+        std::env::remove_var("CLEARWM_TEST_VAR");
+    }
+
+    #[test]
+    fn test_expand_env_vars_mid_string() {
+        std::env::set_var("CLEARWM_TEST_HOME", "/home/user");
+        assert_eq!(
+            expand_env_vars("$CLEARWM_TEST_HOME/.local/bin:$CLEARWM_TEST_HOME/bin"),
+            "/home/user/.local/bin:/home/user/bin"
+        );
+        std::env::remove_var("CLEARWM_TEST_HOME");
+    }
+
+    #[test]
+    fn test_expand_env_vars_unset() {
+        assert_eq!(expand_env_vars("$CLEARWM_NONEXISTENT_VAR"), "");
+    }
+
+    #[test]
+    fn test_expand_env_vars_no_vars() {
+        assert_eq!(expand_env_vars("plain string"), "plain string");
+    }
+
+    #[test]
+    fn test_expand_env_vars_dollar_non_identifier() {
+        // $$ and $: should be kept as-is (not a shell)
+        assert_eq!(expand_env_vars("$$"), "$$");
+        assert_eq!(expand_env_vars("$:"), "$:");
+    }
+
+    #[test]
+    fn test_expand_env_vars_path_append() {
+        // Simulates the real use case: PATH = "$HOME/.local/bin:$PATH"
+        let orig_path = std::env::var("PATH").unwrap_or_default();
+        let home = std::env::var("HOME").unwrap_or_default();
+        let expanded = expand_env_vars("$HOME/.local/bin:$PATH");
+        assert!(expanded.starts_with(&format!("{}/.local/bin:", home)));
+        assert!(expanded.contains(&orig_path));
+    }
 }
 
 #[cfg(test)]
diff --git a/src/ipc.rs b/src/ipc.rs
index 9892e21..d9d210c 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -158,6 +158,9 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
         "repeat" => {
             handle_repeat_command(rest, state);
         }
+        "input" => {
+            handle_input_command(rest, state);
+        }
         _ => {
             // Unknown command, ignore
         }
@@ -194,9 +197,29 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
                 state.layout.gap = value;
             }
         }
-        "offset" => {
+        "gap_top" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.gap_top = value;
+            }
+        }
+        "gap_left" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.gap_left = value;
+            }
+        }
+        "gap_right" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.gap_right = value;
+            }
+        }
+        "gap_bottom" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.gap_bottom = value;
+            }
+        }
+        "cascade_offset" => {
             if let Ok(value) = value_str.parse::<i32>() {
-                state.layout.offset = value;
+                state.layout.cascade_offset = value;
             }
         }
         "bar_height" => {
@@ -209,6 +232,36 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
                 state.layout.border_width = value;
             }
         }
+        "fullscreen_border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.fullscreen_border_width = value;
+            }
+        }
+        "cascade_border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.cascade_border_width = value;
+            }
+        }
+        "grid_border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.grid_border_width = value;
+            }
+        }
+        "vsplit_border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.vsplit_border_width = value;
+            }
+        }
+        "hsplit_border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.hsplit_border_width = value;
+            }
+        }
+        "floating_border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.floating_border_width = value;
+            }
+        }
         "border_color" => {
             if let Some((r, g, b, a)) = parse_hex_color(value_str) {
                 state.layout.border_r = r;
@@ -217,6 +270,14 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
                 state.layout.border_a = a;
             }
         }
+        "background_color" => {
+            if let Some((r, g, b, a)) = parse_hex_color(value_str) {
+                state.layout.background_r = r;
+                state.layout.background_g = g;
+                state.layout.background_b = b;
+                state.layout.background_a = a;
+            }
+        }
         _ => {}
     }
 }
@@ -363,9 +424,8 @@ fn handle_set_tag_command(rest: &str, state: &mut WindowManager) {
             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| {
+                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
                     });
@@ -396,6 +456,33 @@ fn handle_repeat_command(_rest: &str, _state: &mut WindowManager) {
     // river_input_device_v1_set_repeat_info()
 }
 
+fn handle_input_command(rest: &str, state: &mut WindowManager) {
+    let tokens: Vec<&str> = rest.splitn(2, ' ').collect();
+    let param = tokens[0];
+    let value_str = if tokens.len() > 1 { tokens[1] } else { "" };
+
+    match param {
+        "tap-to-click" | "tap_to_click" => {
+            match value_str {
+                "true" | "1" | "enabled" => {
+                    state.tap_to_click = true;
+                    state.tap_config_applied = false; // re-apply
+                }
+                "false" | "0" | "disabled" => {
+                    state.tap_to_click = false;
+                    state.tap_config_applied = false; // re-apply
+                }
+                "toggle" => {
+                    state.tap_to_click = !state.tap_to_click;
+                    state.tap_config_applied = false; // re-apply
+                }
+                _ => {}
+            }
+        }
+        _ => {}
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -443,13 +530,31 @@ mod tests {
         assert_eq!(state.layout.gap, 18);
     }
 
+    #[test]
+    fn test_ipc_layout_gap_sides() {
+        let mut state = WindowManager::default();
+        assert_eq!(state.layout.gap_top, 48);
+        assert_eq!(state.layout.gap_left, 48);
+        assert_eq!(state.layout.gap_right, 48);
+        assert_eq!(state.layout.gap_bottom, 48);
+
+        handle_ipc_command("layout gap_top 10", &mut state);
+        handle_ipc_command("layout gap_left 20", &mut state);
+        handle_ipc_command("layout gap_right 30", &mut state);
+        handle_ipc_command("layout gap_bottom 40", &mut state);
+        assert_eq!(state.layout.gap_top, 10);
+        assert_eq!(state.layout.gap_left, 20);
+        assert_eq!(state.layout.gap_right, 30);
+        assert_eq!(state.layout.gap_bottom, 40);
+    }
+
     #[test]
     fn test_ipc_layout_border_color() {
         let mut state = WindowManager::default();
         handle_ipc_command("layout border_color #5c9060", &mut state);
-        assert_eq!(state.layout.border_r, 0x5C000000);
-        assert_eq!(state.layout.border_g, 0x90000000);
-        assert_eq!(state.layout.border_b, 0x60000000);
+        assert_eq!(state.layout.border_r, 0x5C5C5C5C);
+        assert_eq!(state.layout.border_g, 0x90909090);
+        assert_eq!(state.layout.border_b, 0x60606060);
     }
 
     #[test]
@@ -532,10 +637,10 @@ mod tests {
     }
 
     #[test]
-    fn test_ipc_layout_offset() {
+    fn test_ipc_layout_cascade_offset() {
         let mut state = WindowManager::default();
-        handle_ipc_command("layout offset 32", &mut state);
-        assert_eq!(state.layout.offset, 32);
+        handle_ipc_command("layout cascade_offset 32", &mut state);
+        assert_eq!(state.layout.cascade_offset, 32);
     }
 
     #[test]
diff --git a/src/lib.rs b/src/lib.rs
index 16a5f31..6f49b62 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,14 +1,15 @@
 // clearwm — Wayland window manager for river, written in Rust
 
-pub mod protocol;
-pub mod types;
+pub mod borders;
 pub mod config;
-pub mod tiling;
 pub mod ipc;
-pub mod borders;
+pub mod protocol;
+pub mod restart;
+pub mod state;
 pub mod status;
 pub mod status_server;
-pub mod restart;
-pub mod wm;
+pub mod tiling;
+pub mod types;
 #[allow(unreachable_patterns)] // wayland event match arms use _ => {} for forward-compat
 pub mod wayland;
+pub mod wm;
diff --git a/src/main.rs b/src/main.rs
index 9f41779..2f667b9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,12 +9,49 @@ use std::fs;
 
 const SOCKET_PATH: &str = "/tmp/clearwm.sock";
 
+/// Write a crash/exit trace to /tmp/clearwm-death.log so we can diagnose
+/// why clearwm dies even when the normal log gets overwritten on restart.
+fn log_death(msg: &str) {
+    use std::io::Write;
+    if let Ok(mut f) = std::fs::OpenOptions::new()
+        .create(true)
+        .append(true)
+        .open("/tmp/clearwm-death.log")
+    {
+        let _ = writeln!(f, "{}", msg);
+    }
+    eprintln!("{}", msg);
+}
+
 fn main() {
+    // Install a panic hook that writes to a separate log file before aborting.
+    std::panic::set_hook(Box::new(|info| {
+        let msg = format!("[PANIC] {}", info);
+        eprintln!("{}", msg);
+        use std::io::Write;
+        if let Ok(mut f) = std::fs::OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open("/tmp/clearwm-death.log")
+        {
+            let _ = writeln!(f, "{}", msg);
+        }
+    }));
+
     eprintln!("clearwm starting...");
 
     // Start the status socket server thread (for waybar integration)
     let status_sender = status_server::spawn_status_server();
 
+    // Ignore SIGPIPE — when the Wayland compositor closes the connection
+    // (protocol error, unresponsive timeout, etc.), writes to the socket
+    // generate SIGPIPE. Without ignoring it, the process is killed before
+    // conn.flush() or blocking_dispatch() can return an error, masking the
+    // real cause of the disconnect.
+    unsafe {
+        nix::libc::signal(nix::libc::SIGPIPE, nix::libc::SIG_IGN);
+    }
+
     // Set up SIGCHLD handler to reap child processes
     let sa = nix::sys::signal::SigAction::new(
         nix::sys::signal::SigHandler::Handler(sigchld_handler),
@@ -27,10 +64,23 @@ fn main() {
     }
 
     // Connect to Wayland display and get initial state.
+    // SIGUSR2 handler: dump backtrace to /tmp/clearwm-bt.txt for debugging busy loops
+    unsafe {
+        nix::sys::signal::sigaction(
+            nix::sys::signal::SIGUSR2,
+            &nix::sys::signal::SigAction::new(
+                nix::sys::signal::SigHandler::Handler(dump_backtrace),
+                nix::sys::signal::SaFlags::empty(),
+                nix::sys::signal::SigSet::empty(),
+            ),
+        )
+        .expect("failed to set SIGUSR2 handler");
+    }
+
     let (_conn, mut event_queue, mut state) = match wayland_init() {
         Ok(c) => c,
         Err(e) => {
-            eprintln!("fatal: {}", e);
+            log_death(&format!("fatal: {}", e));
             std::process::exit(1);
         }
     };
@@ -46,7 +96,8 @@ fn main() {
         true
     };
 
-    env::remove_var("WAYLAND_DEBUG");
+    // Don't remove WAYLAND_DEBUG — we need protocol debug logging to diagnose crashes
+    // env::remove_var("WAYLAND_DEBUG");
 
     // Load config immediately, before entering the event loop.
     // This ensures bindings are registered before any render_start arrives.
@@ -80,11 +131,40 @@ fn main() {
 
     // Main loop — tinyrwm pattern: just blocking_dispatch in a loop.
     // All work (including spawning) happens inside Dispatch callbacks.
+    let mut loop_count: u64 = 0;
     loop {
+        loop_count += 1;
+        if loop_count % 10000 == 0 {
+            eprintln!("[main] loop iteration {}", loop_count);
+        }
         match event_queue.blocking_dispatch(&mut state) {
-            Ok(_) => {}
+            Ok(n) => {
+                eprintln!(
+                    "[main] blocking_dispatch returned Ok({}), about to flush",
+                    n
+                );
+                // Explicitly flush after every dispatch cycle.
+                // blocking_dispatch only flushes when dispatched==0 (before blocking read),
+                // so if events were dispatched, pending requests like manage_finish
+                // and render_finish stay in the buffer until the next cycle.
+                // Flushing here ensures River receives our responses promptly.
+                if let Err(e) = event_queue.flush() {
+                    log_death(&format!("flush error after dispatch: {:?}", e));
+                    let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
+                    if !state.wm.exit_requested {
+                        restart::wm_restart();
+                    }
+                    break;
+                }
+                eprintln!("[main] flush ok, looping");
+            }
             Err(e) => {
-                eprintln!("wayland dispatch error: {:?}", e);
+                log_death(&format!(
+                    "wayland dispatch error: {:?}\n  exit_requested={} wm.exit_requested={}",
+                    e, state.exit_requested, state.wm.exit_requested
+                ));
+                // Save log before restart overwrites it
+                let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
                 if !state.wm.exit_requested {
                     restart::wm_restart();
                 }
@@ -92,21 +172,45 @@ fn main() {
             }
         }
         if state.exit_requested || state.wm.exit_requested {
+            log_death(&format!(
+                "main loop exit: exit_requested={} wm.exit_requested={}",
+                state.exit_requested, state.wm.exit_requested
+            ));
             break;
         }
     }
     let _ = fs::remove_file(SOCKET_PATH);
     if !state.wm.exit_requested {
+        // Save log before restart overwrites it
+        let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
         restart::wm_restart();
     }
-    eprintln!("main loop exited");
+    log_death("main loop exited cleanly");
 }
 
 extern "C" fn sigchld_handler(_sig: nix::libc::c_int) {
-    while nix::sys::wait::waitpid(
+    use nix::sys::wait::WaitStatus;
+    while let Ok(status) = nix::sys::wait::waitpid(
         nix::unistd::Pid::from_raw(-1),
         Some(nix::sys::wait::WaitPidFlag::WNOHANG),
-    )
-    .is_ok()
-    {}
+    ) {
+        match status {
+            WaitStatus::StillAlive => break, // no more children to reap
+            _ => continue,                   // reaped a child, check for more
+        }
+    }
+}
+
+extern "C" fn dump_backtrace(_sig: nix::libc::c_int) {
+    use std::io::Write;
+    let bt = std::backtrace::Backtrace::capture();
+    if let Ok(mut f) = std::fs::OpenOptions::new()
+        .create(true)
+        .write(true)
+        .truncate(true)
+        .open("/tmp/clearwm-bt.txt")
+    {
+        let _ = writeln!(f, "SIGUSR2 backtrace:\n{:?}", bt);
+        let _ = f.sync_all();
+    }
 }
diff --git a/src/protocol.rs b/src/protocol.rs
index de21dea..6049a23 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -49,6 +49,13 @@ pub mod river_input_management {
     );
 }
 
+pub mod river_libinput_config {
+    river_protocol!(
+        "protocol/river-libinput-config-v1.xml",
+        [crate::protocol::river_input_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 733e4dd..dba8c56 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -11,9 +11,29 @@ use crate::types::WindowManager;
 ///
 /// The CLEARWM_RESTARTING environment variable signals that this is a restart
 /// (not a cold start), so the new process skips cold_start_only apps.
+///
+/// ## Why setsid() is required
+///
+/// clearwm is typically a session leader (PID == SID, started by River's
+/// `-c` launch script). When a session leader exits, the kernel sends SIGHUP
+/// to all processes in that session — including the forked child. Without
+/// `setsid()`, the child dies from SIGHUP before it can exec, and clearwm
+/// never comes back.
 pub fn wm_restart() {
     use std::time::Instant;
 
+    // Write to death log before fork — this is the last chance to capture
+    // why we're restarting, since fork+process::exit(0) silently kills the parent.
+    if let Ok(mut f) = std::fs::OpenOptions::new()
+        .create(true)
+        .append(true)
+        .open("/tmp/clearwm-death.log")
+    {
+        use std::io::Write;
+        let _ = writeln!(f, "wm_restart() called — about to fork+exit");
+        let _ = f.sync_all(); // ensure it hits disk before process::exit(0)
+    }
+
     static mut LAST_RESTART: Option<Instant> = None;
 
     // Throttle restarts (minimum 2 seconds between restarts)
@@ -38,14 +58,40 @@ pub fn wm_restart() {
     // Signal that this is a restart, not a cold start
     std::env::set_var("CLEARWM_RESTARTING", "1");
 
+    // Persist state to ~/.cache/clearwm_state for the new instance to restore.
+    // Note: we can't pass &WindowManager here since wm_restart() has no access
+    // to it. The state file is kept fresh by RenderStart's needs_status_update
+    // path, so it should be reasonably up-to-date already.
+
+    // Save the current log before River's launch script truncates it on restart.
+    // This preserves the crash/reason for the restart.
+    let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
+
     // Remove the IPC socket
     let _ = std::fs::remove_file("/tmp/clearwm.sock");
 
-    // Get the current executable path
-    let Ok(exe_path) = std::env::current_exe() else {
-        std::process::exit(1);
+    // Get the current executable path.
+    // CString is required because execl() needs a null-terminated C string;
+    // Rust's String::as_ptr() is NOT guaranteed to be null-terminated.
+    //
+    // We prefer the symlink path (~/.local/bin/clearwm) over current_exe()
+    // because current_exe() resolves through /proc/self/exe to the real path,
+    // which may be on a sync filesystem (Dropbox) that temporarily moves files.
+    // The symlink is on the root filesystem and always available.
+    let home = std::env::var("HOME").unwrap_or_default();
+    let exe_path = if !home.is_empty() {
+        let symlink = format!("{}/.local/bin/clearwm", home);
+        if std::path::Path::new(&symlink).exists() {
+            std::path::PathBuf::from(symlink)
+        } else {
+            std::env::current_exe().unwrap_or_else(|_| std::process::exit(1))
+        }
+    } else {
+        std::env::current_exe().unwrap_or_else(|_| std::process::exit(1))
     };
-    let path_str = exe_path.to_string_lossy().to_string();
+    eprintln!("wm_restart: exe_path={}", exe_path.display());
+    let path_cstr = std::ffi::CString::new(exe_path.to_string_lossy().into_owned())
+        .unwrap_or_else(|_| std::process::exit(1));
 
     // Fork: child waits for parent to die, then execs fresh clearwm.
     // Parent exits so River tears down the old Wayland connection.
@@ -54,30 +100,87 @@ pub fn wm_restart() {
         // 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.
+        // ── Child process ──
+
+        // Create a new session IMMEDIATELY. Without this, the child stays
+        // in the parent's session. When the parent (session leader) calls
+        // process::exit(0), the kernel sends SIGHUP to every process in that
+        // session — killing the child before it can exec.
+        unsafe {
+            libc::setsid();
+            libc::signal(libc::SIGHUP, libc::SIG_IGN);
+        }
+
+        // Wait for parent to exit so River cleans up the old Wayland
+        // connection before we try to connect fresh.
+        let parent_pid = unsafe { libc::getppid() };
         std::thread::sleep(std::time::Duration::from_millis(500));
 
-        // Close inherited Wayland FDs so we don't confuse River
+        // If parent is somehow still alive, wait a bit more
+        if unsafe { libc::kill(parent_pid, 0) == 0 } {
+            std::thread::sleep(std::time::Duration::from_millis(500));
+        }
+
+        // Close inherited Wayland FDs so the new clearwm instance doesn't
+        // confuse River with stale connections.
         // (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); }
+            unsafe {
+                libc::close(fd);
+            }
         }
 
-        let ret = unsafe {
-            libc::execl(
-                path_str.as_ptr() as *const i8,
-                path_str.as_ptr() as *const i8,
-                std::ptr::null::<i8>(),
-            )
-        };
-        if ret < 0 {
-            // If execl fails, just exit silently
-            std::process::exit(1);
+        // Exec the same binary — replaces this process with a fresh clearwm.
+        // Retry up to 3 times with a short delay — the binary may be temporarily
+        // unavailable if cargo build is replacing it mid-write (atomic rename).
+        for attempt in 0..3 {
+            let ret = unsafe {
+                libc::execl(
+                    path_cstr.as_ptr(),
+                    path_cstr.as_ptr(),
+                    std::ptr::null::<i8>(),
+                )
+            };
+            let errno = unsafe { *libc::__errno_location() };
+            if attempt < 2 {
+                eprintln!(
+                    "wm_restart: execl attempt {} failed (errno={} {}), retrying in 500ms...",
+                    attempt + 1,
+                    errno,
+                    std::io::Error::from_raw_os_error(errno)
+                );
+                std::thread::sleep(std::time::Duration::from_millis(500));
+                let _ = ret; // suppress unused
+            } else {
+                // Final attempt failed — log and exit
+                eprintln!(
+                    "wm_restart: execl failed after 3 attempts! errno={} ({})",
+                    errno,
+                    std::io::Error::from_raw_os_error(errno)
+                );
+                if let Ok(mut f) = std::fs::OpenOptions::new()
+                    .create(true)
+                    .append(true)
+                    .open("/tmp/clearwm-death.log")
+                {
+                    use std::io::Write;
+                    let _ = writeln!(
+                        f,
+                        "child: execl failed after 3 attempts! errno={} ({}) path={}",
+                        errno,
+                        std::io::Error::from_raw_os_error(errno),
+                        exe_path.display()
+                    );
+                }
+                let _ = ret; // suppress unused
+                std::process::exit(1);
+            }
         }
     } else {
-        // Parent: exit immediately so River sees the connection drop
+        // ── Parent process ──
+        // Exit immediately so River sees the Wayland connection drop
+        // and tears down the old WM binding.
         std::process::exit(0);
     }
 }
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..e8ccf2a
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,456 @@
+// Persistent state file for clearwm restart recovery.
+//
+// Writes window tag assignments and global tag/layout state to
+// ~/.cache/clearwm_state so that it survives restarts. On startup,
+// the state file is read and applied to re-advertised windows
+// matched by their River identifier (stable across WM restarts)
+// or app_id+title as a fallback.
+//
+// What is persisted:
+//   - active_tags bitmask
+//   - Per-tag layout overrides (tag_layouts + has_tag_layout)
+//   - Per-window: tags, tiling_mode (only if mode_locked), identifier, app_id, title
+//
+// What is NOT persisted:
+//   - Focus (seat.focused_window_id)
+//   - Window positions/dimensions (recomputed on startup)
+//   - Transient flags (is_new, needs_*, etc.)
+
+use crate::types::{TilingMode, WindowManager, NUM_TAGS};
+use std::fs;
+use std::io::{BufRead, Write};
+use std::path::PathBuf;
+
+/// Get the state file path: ~/.cache/clearwm_state
+fn state_file_path() -> PathBuf {
+    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
+    let mut path = PathBuf::from(home);
+    path.push(".cache");
+    path.push("clearwm_state");
+    path
+}
+
+/// Persistent state snapshot (plain-text, line-oriented).
+///
+/// Format:
+///   active_tags=<u32>
+///   tag_layout=<tag_index> <mode_str> <has_layout_bool>
+///   window <identifier> <app_id> <title> <tags> <mode_str> <mode_locked>
+///
+/// Fields are tab-separated. App_id and title use URL-style percent-encoding
+/// for spaces, tabs, and newlines so they never break the line format.
+pub struct PersistentState {
+    pub active_tags: u32,
+    pub tag_layouts: Vec<(usize, TilingMode, bool)>,
+    pub windows: Vec<PersistentWindow>,
+}
+
+pub struct PersistentWindow {
+    pub identifier: Option<String>,
+    pub app_id: Option<String>,
+    pub title: Option<String>,
+    pub tags: u32,
+    pub tiling_mode: TilingMode,
+    pub mode_locked: bool,
+}
+
+/// Percent-encode spaces, tabs, newlines, and percent signs in a string.
+fn pct_encode(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            ' ' => out.push_str("%20"),
+            '\t' => out.push_str("%09"),
+            '\n' => out.push_str("%0A"),
+            '\r' => out.push_str("%0D"),
+            '%' => out.push_str("%25"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+/// Decode a percent-encoded string.
+fn pct_decode(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    let mut chars = s.chars();
+    while let Some(c) = chars.next() {
+        if c == '%' {
+            let hex: String = chars.by_ref().take(2).collect();
+            if let Ok(byte) = u8::from_str_radix(&hex, 16) {
+                out.push(byte as char);
+            } else {
+                out.push('%');
+                out.push_str(&hex);
+            }
+        } else {
+            out.push(c);
+        }
+    }
+    out
+}
+
+/// Write the current WM state to the state file.
+/// Safe to call inside Dispatch callbacks — just file I/O, no fork.
+pub fn write_state(wm: &WindowManager) {
+    let path = state_file_path();
+
+    // Ensure ~/.cache/ exists
+    if let Some(parent) = path.parent() {
+        let _ = fs::create_dir_all(parent);
+    }
+
+    if let Ok(mut f) = fs::File::create(&path) {
+        let _ = writeln!(f, "active_tags={}", wm.active_tags);
+
+        for tag_bit in 0..NUM_TAGS {
+            let mode_str = wm.tag_layouts[tag_bit].as_str();
+            let has = wm.has_tag_layout[tag_bit];
+            let _ = writeln!(f, "tag_layout\t{}\t{}\t{}", tag_bit, mode_str, has);
+        }
+
+        for win in &wm.windows {
+            if win.closed {
+                continue;
+            }
+            let ident = win
+                .identifier
+                .as_deref()
+                .map(pct_encode)
+                .unwrap_or_else(|| "-".to_string());
+            let app_id = win
+                .app_id
+                .as_deref()
+                .map(pct_encode)
+                .unwrap_or_else(|| "-".to_string());
+            let title = win
+                .title
+                .as_deref()
+                .map(pct_encode)
+                .unwrap_or_else(|| "-".to_string());
+            let mode_str = win.tiling_mode.as_str();
+            let _ = writeln!(
+                f,
+                "window\t{}\t{}\t{}\t{}\t{}\t{}",
+                ident, app_id, title, win.tags, mode_str, win.mode_locked
+            );
+        }
+
+        let _ = f.sync_all();
+    }
+}
+
+/// Read the persisted state from disk. Returns None if the file doesn't
+/// exist or can't be parsed.
+pub fn read_state() -> Option<PersistentState> {
+    let path = state_file_path();
+    let file = fs::File::open(&path).ok()?;
+    let reader = std::io::BufReader::new(file);
+
+    let mut active_tags: Option<u32> = None;
+    let mut tag_layouts = Vec::new();
+    let mut windows = Vec::new();
+
+    for line in reader.lines() {
+        let line = line.ok()?;
+        let line = line.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+
+        if let Some(rest) = line.strip_prefix("active_tags=") {
+            active_tags = rest.parse::<u32>().ok();
+            continue;
+        }
+
+        if let Some(rest) = line.strip_prefix("tag_layout") {
+            let rest = rest.trim_start();
+            let parts: Vec<&str> = rest.split('\t').collect();
+            if parts.len() >= 3 {
+                if let Ok(idx) = parts[0].parse::<usize>() {
+                    let mode = parse_tiling_mode_str(parts[1]);
+                    let has = parts[2] == "true";
+                    tag_layouts.push((idx, mode, has));
+                }
+            }
+            continue;
+        }
+
+        if let Some(rest) = line.strip_prefix("window") {
+            let rest = rest.trim_start();
+            let parts: Vec<&str> = rest.split('\t').collect();
+            if parts.len() >= 6 {
+                let identifier = if parts[0] == "-" {
+                    None
+                } else {
+                    Some(pct_decode(parts[0]))
+                };
+                let app_id = if parts[1] == "-" {
+                    None
+                } else {
+                    Some(pct_decode(parts[1]))
+                };
+                let title = if parts[2] == "-" {
+                    None
+                } else {
+                    Some(pct_decode(parts[2]))
+                };
+                let tags = parts[3].parse::<u32>().unwrap_or(1);
+                let tiling_mode = parse_tiling_mode_str(parts[4]);
+                let mode_locked = parts[5] == "true";
+
+                windows.push(PersistentWindow {
+                    identifier,
+                    app_id,
+                    title,
+                    tags,
+                    tiling_mode,
+                    mode_locked,
+                });
+            }
+            continue;
+        }
+    }
+
+    Some(PersistentState {
+        active_tags: active_tags.unwrap_or(1),
+        tag_layouts,
+        windows,
+    })
+}
+
+/// Parse a tiling mode string (case-insensitive).
+fn parse_tiling_mode_str(s: &str) -> TilingMode {
+    match s {
+        "Cascade" => TilingMode::Cascade,
+        "Grid" => TilingMode::Grid,
+        "Vsplit" => TilingMode::Vsplit,
+        "Hsplit" => TilingMode::Hsplit,
+        "Fullscreen" => TilingMode::Fullscreen,
+        "Floating" => TilingMode::Floating,
+        _ => TilingMode::Cascade,
+    }
+}
+
+/// Apply persisted state to the window manager.
+/// Called on startup after config is loaded but before the main loop.
+///
+/// - Restores active_tags (so the correct tag view is shown).
+/// - Restores per-tag layout overrides.
+/// - For each live window, looks up its persisted entry by identifier
+///   (preferred) or app_id+title (fallback) and restores:
+///     - tags (which tag the window is on)
+///     - tiling_mode + mode_locked (if the mode was user-set)
+pub fn apply_state(wm: &mut WindowManager, state: &PersistentState) {
+    // Restore active_tags
+    wm.active_tags = state.active_tags;
+
+    // Restore per-tag layouts
+    for (idx, mode, has) in &state.tag_layouts {
+        if *idx < NUM_TAGS {
+            wm.tag_layouts[*idx] = *mode;
+            wm.has_tag_layout[*idx] = *has;
+        }
+    }
+
+    // Apply per-window state from persisted entries.
+    // Build a lookup: identifier -> PersistentWindow, and (app_id, title) -> PersistentWindow.
+    // Identifier is the primary key (stable across restarts).
+    // App_id+title is a fallback for windows without identifiers.
+    let mut by_identifier: Vec<&PersistentWindow> = Vec::new();
+    let mut by_app_id_title: Vec<&PersistentWindow> = Vec::new();
+
+    for pw in &state.windows {
+        if pw.identifier.is_some() {
+            by_identifier.push(pw);
+        }
+        if pw.app_id.is_some() {
+            by_app_id_title.push(pw);
+        }
+    }
+
+    for win in &mut wm.windows {
+        if win.closed {
+            continue;
+        }
+
+        // Try identifier match first
+        let matched = if let Some(ref ident) = win.identifier {
+            by_identifier.iter().find(|pw| {
+                pw.identifier.as_deref() == Some(ident.as_str())
+            })
+        } else {
+            None
+        };
+
+        // Fallback: app_id + title match
+        let matched = matched.or_else(|| {
+            if let Some(ref aid) = win.app_id {
+                by_app_id_title.iter().find(|pw| {
+                    pw.app_id.as_deref() == Some(aid.as_str())
+                        && pw.title == win.title
+                })
+            } else {
+                None
+            }
+        });
+
+        if let Some(pw) = matched {
+            // Restore tag assignment
+            win.tags = pw.tags;
+
+            // Restore locked tiling mode (user explicitly set this)
+            if pw.mode_locked {
+                win.tiling_mode = pw.tiling_mode;
+                win.mode_locked = true;
+            }
+        }
+    }
+
+    eprintln!(
+        "[state] restored: active_tags={}, tag_layouts={:?}, window_entries={}",
+        state.active_tags,
+        state
+            .tag_layouts
+            .iter()
+            .filter(|(_, _, has)| *has)
+            .count(),
+        state.windows.len()
+    );
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_pct_encode_decode_roundtrip() {
+        let cases = vec![
+            "",
+            "hello",
+            "hello world",
+            "foo\tbar\nbaz",
+            "100%",
+            "org.qutebrowser.qutebrowser",
+            "zed-industries/awesome-gpui: Awesome projects!",
+        ];
+        for case in cases {
+            assert_eq!(pct_decode(&pct_encode(case)), case);
+        }
+    }
+
+    #[test]
+    fn test_write_read_roundtrip() {
+        let dir = std::env::temp_dir().join("clearwm_state_test");
+        let _ = fs::create_dir_all(&dir);
+        let path = dir.join("clearwm_state");
+
+        let mut wm = WindowManager::default();
+        wm.active_tags = 0b1010; // tags 2 and 4
+        wm.tag_layouts[1] = TilingMode::Grid;
+        wm.has_tag_layout[1] = true;
+
+        // Add a window
+        let mut win = crate::types::Window::default();
+        win.id = 42;
+        win.identifier = Some("river-window-123".to_string());
+        win.app_id = Some("org.qutebrowser.qutebrowser".to_string());
+        win.title = Some("Test Page - qutebrowser".to_string());
+        win.tags = 0b100; // tag 3
+        win.tiling_mode = TilingMode::Fullscreen;
+        win.mode_locked = true;
+        wm.windows.push(win);
+
+        // Write
+        if let Some(parent) = path.parent() {
+            let _ = fs::create_dir_all(parent);
+        }
+        if let Ok(mut f) = fs::File::create(&path) {
+            // Manually write using the same format as write_state
+            let _ = writeln!(f, "active_tags={}", wm.active_tags);
+            for tag_bit in 0..NUM_TAGS {
+                let _ = writeln!(
+                    f,
+                    "tag_layout\t{}\t{}\t{}",
+                    tag_bit,
+                    wm.tag_layouts[tag_bit].as_str(),
+                    wm.has_tag_layout[tag_bit]
+                );
+            }
+            for w in &wm.windows {
+                let ident = w.identifier.as_deref().map(pct_encode).unwrap_or("-".to_string());
+                let app_id = w.app_id.as_deref().map(pct_encode).unwrap_or("-".to_string());
+                let title = w.title.as_deref().map(pct_encode).unwrap_or("-".to_string());
+                let _ = writeln!(
+                    f,
+                    "window\t{}\t{}\t{}\t{}\t{}\t{}",
+                    ident, app_id, title, w.tags, w.tiling_mode.as_str(), w.mode_locked
+                );
+            }
+        }
+
+        // Read and verify via the public read_state() path.
+        // We set HOME to our temp dir so state_file_path() finds the test file.
+        // Instead, use a direct parse approach:
+        let file = fs::File::open(&path).unwrap();
+        let reader = std::io::BufReader::new(file);
+        let mut active_tags: Option<u32> = None;
+        let mut tag_layouts = Vec::new();
+        let mut windows = Vec::new();
+        for line_r in reader.lines() {
+            let line = line_r.unwrap();
+            let line = line.trim();
+            if line.is_empty() { continue; }
+            if let Some(rest) = line.strip_prefix("active_tags=") {
+                active_tags = rest.parse::<u32>().ok();
+            } else if let Some(rest) = line.strip_prefix("tag_layout") {
+                let rest = rest.trim_start();
+                let parts: Vec<&str> = rest.split('\t').collect();
+                if parts.len() >= 3 {
+                    if let Ok(idx) = parts[0].parse::<usize>() {
+                        let mode = parse_tiling_mode_str(parts[1]);
+                        let has = parts[2] == "true";
+                        tag_layouts.push((idx, mode, has));
+                    }
+                }
+            } else if let Some(rest) = line.strip_prefix("window") {
+                let rest = rest.trim_start();
+                let parts: Vec<&str> = rest.split('\t').collect();
+                if parts.len() >= 6 {
+                    let identifier = if parts[0] == "-" { None } else { Some(pct_decode(parts[0])) };
+                    let app_id = if parts[1] == "-" { None } else { Some(pct_decode(parts[1])) };
+                    let title = if parts[2] == "-" { None } else { Some(pct_decode(parts[2])) };
+                    let tags = parts[3].parse::<u32>().unwrap_or(1);
+                    let tiling_mode = parse_tiling_mode_str(parts[4]);
+                    let mode_locked = parts[5] == "true";
+                    windows.push(PersistentWindow { identifier, app_id, title, tags, tiling_mode, mode_locked });
+                }
+            }
+        }
+        let state = PersistentState {
+            active_tags: active_tags.unwrap_or(1),
+            tag_layouts,
+            windows,
+        };
+        assert_eq!(state.active_tags, 0b1010);
+        assert_eq!(state.tag_layouts.len(), NUM_TAGS);
+        // Tag layout at index 1 should be Grid + has=true
+        let tl1 = state.tag_layouts.iter().find(|(idx, _, _)| *idx == 1);
+        assert!(tl1.is_some());
+        let (_, mode, has) = tl1.unwrap();
+        assert_eq!(*mode, TilingMode::Grid);
+        assert!(has);
+
+        assert_eq!(state.windows.len(), 1);
+        let pw = &state.windows[0];
+        assert_eq!(pw.identifier.as_deref(), Some("river-window-123"));
+        assert_eq!(pw.app_id.as_deref(), Some("org.qutebrowser.qutebrowser"));
+        assert_eq!(pw.title.as_deref(), Some("Test Page - qutebrowser"));
+        assert_eq!(pw.tags, 0b100);
+        assert_eq!(pw.tiling_mode, TilingMode::Fullscreen);
+        assert!(pw.mode_locked);
+
+        // Clean up
+        let _ = fs::remove_file(&path);
+    }
+}
diff --git a/src/status.rs b/src/status.rs
index 71f1866..914b1a9 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -50,13 +50,14 @@ pub fn write_status_files(state: &WindowManager) {
             };
             let _ = writeln!(
                 f,
-                "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={}",
+                "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={} has_parent={}",
                 win.app_id.as_deref().unwrap_or("(null)"),
                 win.title.as_deref().unwrap_or("(null)"),
                 mode_str,
                 decoration_str,
                 presentation_str,
                 win.tags, win.x, win.y, win.width, win.height,
+                win.has_parent,
             );
         }
 
diff --git a/src/tiling.rs b/src/tiling.rs
index ef0b3d1..df4cfee 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -3,8 +3,8 @@
 /// Cascade depth factor: each depth step multiplies channels by this
 pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
 
-/// Full alpha in high-byte-first fixed-point
-pub const CASCADE_ALPHA: u32 = 0x000000FFu32;
+/// Full alpha, byte-replicated for River's color format
+pub const CASCADE_ALPHA: u32 = 0xFFFFFFFFu32;
 
 /// Cascade base green (focused window) in fixed-point format
 /// These come from the layout.border_r/g/b values, not hardcoded constants.
@@ -15,27 +15,31 @@ pub const CASCADE_ALPHA: u32 = 0x000000FFu32;
 ///
 /// Returns (x, y, width, height) for the window at the given cascade index.
 ///
-/// The cascade formula from C:
-///   width  = screen_w - (gap + bw)*2 - offset*(n_cascade - 1)
-///   height = screen_h - (gap + bw)*2 - offset*(n_cascade - 1)
-///   x = gap + bw + idx * offset
-///   y = bar_height + gap + bw + idx * offset
+/// Screen edges use per-side gaps; inter-window offset is cascade_offset.
+///   width  = screen_w - gap_left - gap_right - bw*2 - cascade_offset*(n_cascade - 1)
+///   height = screen_h - gap_top - gap_bottom - bw*2 - cascade_offset*(n_cascade - 1)
+///   x = gap_left + bw + idx * cascade_offset
+///   y = bar_height + gap_top + bw + idx * cascade_offset
 pub fn tile_cascade(
     screen_w: i32,
     screen_h: i32,
-    gap: i32,
+    _gap: i32,
+    gap_top: i32,
+    gap_left: i32,
+    gap_right: i32,
+    gap_bottom: i32,
     bw: i32,
-    offset: i32,
+    cascade_offset: i32,
     bar_height: i32,
     n_cascade: i32,
     idx: i32,
 ) -> (i32, i32, i32, i32) {
-    let width = screen_w - (gap + bw) * 2 - offset * (n_cascade - 1);
-    let height = screen_h - (gap + bw) * 2 - offset * (n_cascade - 1);
+    let width = screen_w - gap_left - gap_right - bw * 2 - cascade_offset * (n_cascade - 1);
+    let height = screen_h - gap_top - gap_bottom - bw * 2 - cascade_offset * (n_cascade - 1);
     let width = if width < 1 { 1 } else { width };
     let height = if height < 1 { 1 } else { height };
-    let x = gap + bw + idx * offset;
-    let y = bar_height + gap + bw + idx * offset;
+    let x = gap_left + bw + idx * cascade_offset;
+    let y = bar_height + gap_top + bw + idx * cascade_offset;
     (x, y, width, height)
 }
 
@@ -43,17 +47,21 @@ pub fn tile_cascade(
 ///
 /// Uses a 2-column grid layout.
 ///
-/// The grid formula from C:
+/// Screen edges use per-side gaps; inter-window spacing uses `gap`.
 ///   cols = 2
 ///   rows = (n_grid + cols - 1) / cols
-///   width  = (screen_w - (cols + 1) * gap) / cols - 2 * bw
-///   height = (screen_h - (rows + 1) * gap) / rows - 2 * bw
-///   x = gap + bw + col * (width + 2 * bw + gap)
-///   y = bar_height + gap + bw + row * (height + 2 * bw + gap)
+///   width  = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw
+///   height = (screen_h - gap_top - gap_bottom - (rows - 1) * gap) / rows - 2 * bw
+///   x = gap_left + bw + col * (width + 2 * bw + gap)
+///   y = bar_height + gap_top + bw + row * (height + 2 * bw + gap)
 pub fn tile_grid(
     screen_w: i32,
     screen_h: i32,
     gap: i32,
+    gap_top: i32,
+    gap_left: i32,
+    gap_right: i32,
+    gap_bottom: i32,
     bw: i32,
     bar_height: i32,
     n_grid: i32,
@@ -63,12 +71,12 @@ pub fn tile_grid(
     let row = idx / cols;
     let col = idx % cols;
     let rows = (n_grid + cols - 1) / cols;
-    let width = (screen_w - (cols + 1) * gap) / cols - 2 * bw;
-    let height = (screen_h - (rows + 1) * gap) / rows - 2 * bw;
+    let width = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw;
+    let height = (screen_h - gap_top - gap_bottom - (rows - 1) * gap) / rows - 2 * bw;
     let width = if width < 1 { 1 } else { width };
     let height = if height < 1 { 1 } else { height };
-    let x = gap + bw + col * (width + 2 * bw + gap);
-    let y = bar_height + gap + bw + row * (height + 2 * bw + gap);
+    let x = gap_left + bw + col * (width + 2 * bw + gap);
+    let y = bar_height + gap_top + bw + row * (height + 2 * bw + gap);
     (x, y, width, height)
 }
 
@@ -76,27 +84,32 @@ pub fn tile_grid(
 ///
 /// Each window gets an equal share of the horizontal space.
 ///
+/// Screen edges use per-side gaps; inter-window spacing uses `gap`.
 ///   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
+///   width  = (screen_w - gap_left - gap_right - (n - 1) * gap) / n - 2 * bw
+///   height = screen_h - gap_top - gap_bottom - bw * 2 - bar_height
+///   x = gap_left + bw + idx * (width + 2 * bw + gap)
+///   y = bar_height + gap_top + bw
 pub fn tile_vsplit(
     screen_w: i32,
     screen_h: i32,
     gap: i32,
+    gap_top: i32,
+    gap_left: i32,
+    gap_right: i32,
+    gap_bottom: 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 = (screen_w - gap_left - gap_right - (n - 1) * gap) / n - 2 * bw;
+    let height = screen_h - gap_top - gap_bottom - 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;
+    let x = gap_left + bw + idx * (width + 2 * bw + gap);
+    let y = bar_height + gap_top + bw;
     (x, y, width, height)
 }
 
@@ -104,46 +117,77 @@ pub fn tile_vsplit(
 ///
 /// Each window gets an equal share of the vertical space.
 ///
+/// Screen edges use per-side gaps; inter-window spacing uses `gap`.
 ///   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)
+///   width  = screen_w - gap_left - gap_right - bw * 2
+///   height = (screen_h - bar_height - gap_top - gap_bottom - (n - 1) * gap) / n - 2 * bw
+///   x = gap_left + bw
+///   y = bar_height + gap_top + bw + idx * (height + 2 * bw + gap)
 pub fn tile_hsplit(
     screen_w: i32,
     screen_h: i32,
     gap: i32,
+    gap_top: i32,
+    gap_left: i32,
+    gap_right: i32,
+    gap_bottom: 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 = screen_w - gap_left - gap_right - bw * 2;
+    let height = (screen_h - bar_height - gap_top - gap_bottom - (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);
+    let x = gap_left + bw;
+    let y = bar_height + gap_top + bw + idx * (height + 2 * bw + gap);
     (x, y, width, height)
 }
 
-/// Interpolate a fixed-point channel (0xRR000000) by factor^depth.
+/// Tile a window in fullscreen mode — fills the screen minus gaps, bar, and borders.
+///
+/// Only the focused window is visible; other fullscreen windows are skipped.
+///   width  = screen_w - gap_left - gap_right - bw * 2
+///   height = screen_h - bar_height - gap_top - gap_bottom - bw * 2
+///   x = gap_left + bw
+///   y = bar_height + gap_top + bw
+pub fn tile_fullscreen(
+    screen_w: i32,
+    screen_h: i32,
+    gap_top: i32,
+    gap_left: i32,
+    gap_right: i32,
+    gap_bottom: i32,
+    bw: i32,
+    bar_height: i32,
+) -> (i32, i32, i32, i32) {
+    let width = screen_w - gap_left - gap_right - bw * 2;
+    let height = screen_h - bar_height - gap_top - gap_bottom - bw * 2;
+    let width = if width < 1 { 1 } else { width };
+    let height = if height < 1 { 1 } else { height };
+    let x = gap_left + bw;
+    let y = bar_height + gap_top + bw;
+    (x, y, width, height)
+}
+
+/// Interpolate a byte-replicated 32-bit channel (0xVVVVVVVV) by factor^depth.
 ///
 /// depth 0 returns the base color unchanged; each step darkens by factor.
-/// Returns the value in 32-bit fixed-point (channel in high byte).
+/// Returns byte-replicated 32-bit value for River's color format.
 ///
 /// Ported from C: interp_channel()
 pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
-    let base = (fp_channel >> 24) as u8;
+    let base = (fp_channel & 0xFF) as u8;
     let f = factor.powi(depth);
     let val = ((base as f64) * f) as u8;
-    (val as u32) << 24
+    val as u32 * 0x01010101
 }
 
 /// Compute a "#RRGGBB" hex color string for a given cascade depth.
 ///
-/// Takes the base border color channels in fixed-point format and
+/// Takes the base border color channels in byte-replicated format and
 /// interpolates them toward black by CASCADE_DEPTH_FACTOR^depth.
 ///
 /// Ported from C: cascade_hex_color()
@@ -151,10 +195,10 @@ pub fn cascade_hex_color(r: u32, g: u32, b: u32, depth: i32) -> String {
     let ri = interp_channel(r, CASCADE_DEPTH_FACTOR, depth);
     let gi = interp_channel(g, CASCADE_DEPTH_FACTOR, depth);
     let bi = interp_channel(b, CASCADE_DEPTH_FACTOR, depth);
-    // Extract the high byte for display
-    let rv = ri >> 24;
-    let gv = gi >> 24;
-    let bv = bi >> 24;
+    // Extract the low byte for display
+    let rv = ri & 0xFF;
+    let gv = gi & 0xFF;
+    let bv = bi & 0xFF;
     format!("#{:02x}{:02x}{:02x}", rv, gv, bv)
 }
 
@@ -165,35 +209,47 @@ mod tests {
     #[test]
     fn test_tile_cascade_single() {
         // Single cascade window should fill the screen minus gaps/borders
-        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
+        let (x, y, w, h) = tile_cascade(1920, 1080, 18, 18, 18, 18, 18, 18, 32, 28, 1, 0);
+        assert_eq!(x, 36); // gap_left + bw
+        assert_eq!(y, 64); // bar_height + gap_top + bw
+                           // w = 1920 - 18 - 18 - 18*2 - 32*0 = 1920 - 72 = 1848
         assert_eq!(w, 1848);
-        // h = 1080 - (18+18)*2 - 32*0 = 1080 - 72 = 1008
+        // h = 1080 - 18 - 18 - 18*2 - 32*0 = 1080 - 72 = 1008
         assert_eq!(h, 1008);
     }
 
     #[test]
     fn test_tile_cascade_multiple() {
         // 3 cascade windows, idx=2 (the back one)
-        let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 32, 28, 3, 2);
-        assert_eq!(x, 36 + 2 * 32); // gap + bw + idx * offset
-        assert_eq!(y, 64 + 2 * 32); // bar + gap + bw + idx * offset
+        let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 18, 18, 18, 18, 32, 28, 3, 2);
+        assert_eq!(x, 36 + 2 * 32); // gap_left + bw + idx * offset
+        assert_eq!(y, 64 + 2 * 32); // bar + gap_top + bw + idx * offset
     }
 
     #[test]
     fn test_tile_cascade_minimum_size() {
         // Very small screen, many windows — should clamp to 1
-        let (_, _, w, h) = tile_cascade(100, 100, 18, 18, 32, 28, 10, 5);
+        let (_, _, w, h) = tile_cascade(100, 100, 18, 18, 18, 18, 18, 18, 32, 28, 10, 5);
         assert!(w >= 1);
         assert!(h >= 1);
     }
 
+    #[test]
+    fn test_tile_cascade_asymmetric_gaps() {
+        // Asymmetric screen gaps: top=10, left=20, right=30, bottom=40
+        let (x, y, w, h) = tile_cascade(1920, 1080, 12, 10, 20, 30, 40, 6, 24, 28, 1, 0);
+        assert_eq!(x, 20 + 6);        // gap_left + bw = 26
+        assert_eq!(y, 28 + 10 + 6);   // bar_height + gap_top + bw = 44
+                                       // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
+        assert_eq!(w, 1858);
+        // h = 1080 - 10 - 40 - 6*2 = 1080 - 62 = 1018
+        assert_eq!(h, 1018);
+    }
+
     #[test]
     fn test_tile_grid_two_windows() {
-        let (x0, y0, w0, h0) = tile_grid(1920, 1080, 18, 18, 28, 2, 0);
-        let (x1, y1, w1, h1) = tile_grid(1920, 1080, 18, 18, 28, 2, 1);
+        let (x0, y0, w0, h0) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 0);
+        let (x1, y1, w1, h1) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 1);
 
         // Both windows should have same dimensions
         assert_eq!(w0, w1);
@@ -203,20 +259,19 @@ mod tests {
         assert!(x1 > x0);
         assert_eq!(y0, y1); // Same row
 
-        // Width = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+        // Width = (1920 - 18 - 18 - 1*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
         assert_eq!(w0, 897);
-        // Height = (1080 - 2*18) / 1 - 2*18 = 1044 - 36 = 1008
-        // Wait: rows = (2+2-1)/2 = 1, so height = (1080 - 2*18)/1 - 36 = 1044 - 36 = 1008
+        // rows=1, Height = (1080 - 18 - 18 - 0*18) / 1 - 2*18 = 1044 - 36 = 1008
         assert_eq!(h0, 1008);
     }
 
     #[test]
     fn test_tile_grid_four_windows() {
         // 4 windows in 2x2 grid
-        let (x0, y0, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 0);
-        let (x1, y1, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 1);
-        let (x2, y2, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 2);
-        let (x3, y3, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 3);
+        let (x0, y0, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 0);
+        let (x1, y1, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 1);
+        let (x2, y2, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 2);
+        let (x3, y3, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 3);
 
         // Row 0: windows 0,1
         assert!(y0 == y1);
@@ -228,36 +283,57 @@ mod tests {
         assert!(y2 > y0);
     }
 
+    #[test]
+    fn test_tile_grid_asymmetric_gaps() {
+        // Asymmetric screen gaps with 2 windows in 1 row
+        let (x0, y0, w0, h0) = tile_grid(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 0);
+        let (x1, _y1, w1, _h1) = tile_grid(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 1);
+
+        // Width = (1920 - 20 - 30 - 1*12) / 2 - 2*6 = (1920-62)/2 - 12 = 929 - 12 = 917
+        assert_eq!(w0, 917);
+        assert_eq!(w0, w1);
+
+        // x0 = gap_left + bw = 20 + 6 = 26
+        assert_eq!(x0, 26);
+        // y0 = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+        assert_eq!(y0, 44);
+        // rows=1, Height = (1080 - 10 - 40 - 0*12) / 1 - 2*6 = 1030 - 12 = 1018
+        assert_eq!(h0, 1018);
+
+        // x1 = gap_left + bw + 1*(917 + 2*6 + 12) = 26 + 941 = 967
+        assert_eq!(x1, 967);
+    }
+
     #[test]
     fn test_interp_channel_depth_zero() {
         // depth 0 should return the base color unchanged
-        let result = interp_channel(0x5C000000, CASCADE_DEPTH_FACTOR, 0);
-        assert_eq!(result >> 24, 0x5C);
+        let result = interp_channel(0x5C5C5C5C, CASCADE_DEPTH_FACTOR, 0);
+        assert_eq!(result, 0x5C5C5C5C);
     }
 
     #[test]
     fn test_interp_channel_depth_one() {
-        let result = interp_channel(0x90000000, CASCADE_DEPTH_FACTOR, 1);
-        // 0x90 * 0.80 = 0x90 * 0.80 = 144 * 0.80 = 115.2 → 115 = 0x73
-        assert_eq!(result >> 24, 0x73);
+        let result = interp_channel(0x90909090, CASCADE_DEPTH_FACTOR, 1);
+        // 0x90 * 0.80 = 144 * 0.80 = 115.2 → 115 = 0x73
+        assert_eq!(result & 0xFF, 0x73);
     }
 
     #[test]
     fn test_interp_channel_depth_two() {
-        let result = interp_channel(0x60000000, CASCADE_DEPTH_FACTOR, 2);
+        let result = interp_channel(0x60606060, CASCADE_DEPTH_FACTOR, 2);
         // 0x60 * 0.80^2 = 96 * 0.64 = 61.44 → 61 = 0x3D
-        assert_eq!(result >> 24, 0x3D);
+        assert_eq!(result & 0xFF, 0x3D);
     }
 
     #[test]
     fn test_cascade_hex_color_depth_zero() {
-        let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 0);
+        let color = cascade_hex_color(0x5C5C5C5C, 0x90909090, 0x60606060, 0);
         assert_eq!(color, "#5c9060");
     }
 
     #[test]
     fn test_cascade_hex_color_depth_one() {
-        let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 1);
+        let color = cascade_hex_color(0x5C5C5C5C, 0x90909090, 0x60606060, 1);
         // 0x5C*0.80=0x49, 0x90*0.80=0x73, 0x60*0.80=0x4C
         assert_eq!(color, "#49734c");
     }
@@ -265,27 +341,27 @@ mod tests {
     #[test]
     fn test_cascade_hex_color_dark_gray() {
         // Using #3e3e3e as base (normal border color)
-        let color = cascade_hex_color(0x3E000000, 0x3E000000, 0x3E000000, 0);
+        let color = cascade_hex_color(0x3E3E3E3E, 0x3E3E3E3E, 0x3E3E3E3E, 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
+        let (x, y, w, h) = tile_vsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 1, 0);
+        assert_eq!(x, 36); // gap_left + bw
+        assert_eq!(y, 64); // bar_height + gap_top + bw
+                           // w = (1920 - 18 - 18 - 0*18) / 1 - 2*18 = 1884 - 36 = 1848
         assert_eq!(w, 1848);
-        // h = 1080 - (18+18)*2 - 28 = 1080 - 72 - 28 = 980
+        // h = 1080 - 18 - 18 - 18*2 - 28 = 1080 - 100 = 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);
+        let (x0, y0, w0, h0) = tile_vsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 0);
+        let (x1, y1, w1, h1) = tile_vsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 1);
 
         // Same dimensions
         assert_eq!(w0, w1);
@@ -295,34 +371,55 @@ mod tests {
         // Window 1 is to the right
         assert!(x1 > x0);
 
-        // w = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+        // w = (1920 - 18 - 18 - 1*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);
+        let (_, _, w, h) = tile_vsplit(100, 100, 18, 18, 18, 18, 18, 18, 28, 10, 5);
         assert!(w >= 1);
         assert!(h >= 1);
     }
 
+    #[test]
+    fn test_tile_vsplit_asymmetric_gaps() {
+        // Asymmetric screen gaps with 2 vsplit windows
+        let (x0, y0, w0, h0) = tile_vsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 0);
+        let (x1, _y1, w1, _h1) = tile_vsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 1);
+
+        // w = (1920 - 20 - 30 - 1*12) / 2 - 2*6 = (1920-62)/2 - 12 = 929 - 12 = 917
+        assert_eq!(w0, 917);
+        assert_eq!(w0, w1);
+
+        // x0 = gap_left + bw = 20 + 6 = 26
+        assert_eq!(x0, 26);
+        // y0 = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+        assert_eq!(y0, 44);
+        // h = 1080 - 10 - 40 - 6*2 - 28 = 1080 - 90 = 990
+        assert_eq!(h0, 990);
+
+        // x1 = gap_left + bw + 1*(917 + 2*6 + 12) = 26 + 941 = 967
+        assert_eq!(x1, 967);
+    }
+
     #[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
+        let (x, y, w, h) = tile_hsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 1, 0);
+        assert_eq!(x, 36); // gap_left + bw
+        assert_eq!(y, 64); // bar_height + gap_top + bw
+                           // w = 1920 - 18 - 18 - 18*2 = 1920 - 72 = 1848
         assert_eq!(w, 1848);
-        // h = (1080 - 28 - 2*18) / 1 - 2*18 = 1016 - 36 = 980
+        // h = (1080 - 28 - 18 - 18 - 0*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);
+        let (x0, y0, w0, h0) = tile_hsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 0);
+        let (x1, y1, w1, h1) = tile_hsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 1);
 
         // Same dimensions
         assert_eq!(w0, w1);
@@ -332,13 +429,69 @@ mod tests {
         // 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
+        // h = (1080 - 28 - 18 - 18 - 1*18) / 2 - 2*18 = (1080-82)/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);
+        let (_, _, w, h) = tile_hsplit(100, 100, 18, 18, 18, 18, 18, 18, 28, 10, 5);
+        assert!(w >= 1);
+        assert!(h >= 1);
+    }
+
+    #[test]
+    fn test_tile_hsplit_asymmetric_gaps() {
+        // Asymmetric screen gaps with 2 hsplit windows
+        let (x0, y0, w0, h0) = tile_hsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 0);
+        let (_x1, y1, w1, h1) = tile_hsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 1);
+
+        // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
+        assert_eq!(w0, 1858);
+        assert_eq!(w0, w1);
+
+        // x0 = gap_left + bw = 20 + 6 = 26
+        assert_eq!(x0, 26);
+        // y0 = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+        assert_eq!(y0, 44);
+
+        // h = (1080 - 28 - 10 - 40 - 1*12) / 2 - 2*6 = (1080-90)/2 - 12 = 495 - 12 = 483
+        assert_eq!(h0, 483);
+        assert_eq!(h0, h1);
+
+        // y1 = bar_height + gap_top + bw + 1*(483 + 2*6 + 12) = 44 + 507 = 551
+        assert_eq!(y1, 551);
+    }
+
+    #[test]
+    fn test_tile_fullscreen_basic() {
+        let (x, y, w, h) = tile_fullscreen(1920, 1080, 18, 18, 18, 18, 6, 28);
+        // x = gap_left + bw = 18 + 6 = 24
+        assert_eq!(x, 24);
+        // y = bar_height + gap_top + bw = 28 + 18 + 6 = 52
+        assert_eq!(y, 52);
+        // w = 1920 - 18 - 18 - 6*2 = 1920 - 48 = 1872
+        assert_eq!(w, 1872);
+        // h = 1080 - 28 - 18 - 18 - 6*2 = 1080 - 76 = 1004
+        assert_eq!(h, 1004);
+    }
+
+    #[test]
+    fn test_tile_fullscreen_asymmetric_gaps() {
+        let (x, y, w, h) = tile_fullscreen(1920, 1080, 10, 20, 30, 40, 6, 28);
+        // x = gap_left + bw = 20 + 6 = 26
+        assert_eq!(x, 26);
+        // y = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+        assert_eq!(y, 44);
+        // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
+        assert_eq!(w, 1858);
+        // h = 1080 - 28 - 10 - 40 - 6*2 = 1080 - 90 = 990
+        assert_eq!(h, 990);
+    }
+
+    #[test]
+    fn test_tile_fullscreen_minimum_size() {
+        let (_, _, w, h) = tile_fullscreen(50, 50, 18, 18, 18, 18, 6, 28);
         assert!(w >= 1);
         assert!(h >= 1);
     }
diff --git a/src/types.rs b/src/types.rs
index 2f40721..bdcea97 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -40,6 +40,7 @@ pub enum Action {
     Exit,
     Fullscreen,
     LayoutNext,
+    ModeNext,
     Reload,
     Restart,
     View1,
@@ -59,27 +60,55 @@ pub enum Action {
 /// Layout parameters
 #[derive(Debug, Clone)]
 pub struct Layout {
-    pub gap: i32,
-    pub offset: i32,
+    pub gap: i32,           // inter-window spacing
+    pub gap_top: i32,       // screen edge inset, top (above bar)
+    pub gap_left: i32,      // screen edge inset, left
+    pub gap_right: i32,     // screen edge inset, right
+    pub gap_bottom: i32,    // screen edge inset, bottom
+    pub cascade_offset: i32,
     pub bar_height: i32,
     pub border_width: i32,
+    pub fullscreen_border_width: i32,
+    pub cascade_border_width: i32,
+    pub grid_border_width: i32,
+    pub vsplit_border_width: i32,
+    pub hsplit_border_width: i32,
+    pub floating_border_width: i32,
     pub border_r: u32,
     pub border_g: u32,
     pub border_b: u32,
     pub border_a: u32,
+    pub background_r: u32,
+    pub background_g: u32,
+    pub background_b: u32,
+    pub background_a: u32,
 }
 
 impl Default for Layout {
     fn default() -> Self {
         Layout {
             gap: 48,
-            offset: 20,
+            gap_top: 48,
+            gap_left: 48,
+            gap_right: 48,
+            gap_bottom: 48,
+            cascade_offset: 20,
             bar_height: 24,
             border_width: 6,
-            border_r: 0x3E000000u32,
-            border_g: 0x3E000000u32,
-            border_b: 0x3E000000u32,
-            border_a: 0x000000FFu32,
+            fullscreen_border_width: 0,
+            cascade_border_width: 6,
+            grid_border_width: 6,
+            vsplit_border_width: 6,
+            hsplit_border_width: 6,
+            floating_border_width: 6,
+            border_r: 0x3E3E3E3Eu32,
+            border_g: 0x3E3E3E3Eu32,
+            border_b: 0x3E3E3E3Eu32,
+            border_a: 0xFFFFFFFFu32,
+            background_r: 0x0A0A0A0Au32,
+            background_g: 0x1A1A1A1Au32,
+            background_b: 0x0E0E0E0Eu32,
+            background_a: 0xFFFFFFFFu32,
         }
     }
 }
@@ -173,10 +202,25 @@ pub struct Window {
     pub title: Option<String>,
     pub identifier: Option<String>,
     pub parent_id: Option<u64>,
+    pub has_parent: bool,
+    pub pid: u32,
+    pub hint_min_width: i32,
+    pub hint_min_height: i32,
+    pub hint_max_width: i32,
+    pub hint_max_height: i32,
     pub decoration_hint: u32,
     pub presentation_hint: u32,
+    pub fullscreen_requested: bool,
+    pub maximize_requested: bool,
+    pub minimize_requested: bool,
     pub tiling_mode: TilingMode,
     pub mode_locked: bool,
+    /// Whether we've queued an xprop check for XWayland parent detection.
+    /// River doesn't forward WM_TRANSIENT_FOR for XWayland windows, so we
+    /// check via xprop as a fallback.
+    pub needs_xprop_check: bool,
+    /// How many ManageStart cycles we've waited for the xprop result file.
+    pub xprop_check_attempts: u8,
 }
 
 impl Default for Window {
@@ -194,10 +238,21 @@ impl Default for Window {
             title: None,
             identifier: None,
             parent_id: None,
+            has_parent: false,
+            pid: 0,
+            hint_min_width: 0,
+            hint_min_height: 0,
+            hint_max_width: 0,
+            hint_max_height: 0,
             decoration_hint: 3,   // no_preference
             presentation_hint: 0, // vsync
+            fullscreen_requested: false,
+            maximize_requested: false,
+            minimize_requested: false,
             tiling_mode: TilingMode::Floating,
             mode_locked: false,
+            needs_xprop_check: false,
+            xprop_check_attempts: 0,
         }
     }
 }
@@ -260,6 +315,17 @@ pub struct WindowManager {
     /// 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,
+    /// When true, apply persisted state from ~/.cache/clearwm_state on the
+    /// next ManageStart cycle (after windows have been re-advertised).
+    /// Set to true on startup/restart, consumed after application.
+    pub needs_state_restore: bool,
+    /// Counter for how many ManageStart cycles we've waited for window metadata
+    /// before applying persisted state. Reset to 0 after state is applied.
+    pub state_restore_attempts: u8,
+    /// Whether tap-to-click is enabled on touchpad devices
+    pub tap_to_click: bool,
+    /// Whether tap-to-click config has been applied to libinput devices yet
+    pub tap_config_applied: bool,
 }
 
 impl Default for WindowManager {
@@ -289,6 +355,10 @@ impl Default for WindowManager {
             startup_spawned: false,
             output_scale: 0.0,
             pending_scale_apply: false,
+            needs_state_restore: true,
+            state_restore_attempts: 0,
+            tap_to_click: false,
+            tap_config_applied: false,
         }
     }
 }
@@ -349,7 +419,9 @@ impl WindowManager {
 }
 
 /// Parse a hex color string like "#RRGGBB" or "#RRGGBBAA" into
-/// fixed-point 32-bit channel values (0xRR000000 format)
+/// byte-replicated 32-bit channel values for River's color format.
+/// River divides each u32 by maxInt(u32) to get a float, so each byte
+/// must be replicated: 0xVV -> 0xVVVVVVVV.
 pub fn parse_hex_color(s: &str) -> Option<(u32, u32, u32, u32)> {
     let s = s.strip_prefix('#')?;
     if s.len() != 6 && s.len() != 8 {
@@ -363,8 +435,8 @@ pub fn parse_hex_color(s: &str) -> Option<(u32, u32, u32, u32)> {
     } else {
         0xFFu32
     };
-    // Convert to high-byte-first fixed-point: 0xRR000000
-    Some((r << 24, g << 24, b << 24, a << 24))
+    // Byte-replicate: 0xVV -> 0xVVVVVVVV
+    Some((r * 0x01010101, g * 0x01010101, b * 0x01010101, a * 0x01010101))
 }
 
 /// Parse a tiling mode string
@@ -394,6 +466,8 @@ pub fn parse_action(s: &str) -> Action {
         Action::Resize
     } else if s == "layout-next" {
         Action::LayoutNext
+    } else if s == "mode-next" {
+        Action::ModeNext
     } else if s == "reload" {
         Action::Reload
     } else if s == "restart" {
@@ -498,19 +572,19 @@ mod tests {
     #[test]
     fn test_parse_hex_color_rgb() {
         let (r, g, b, a) = parse_hex_color("#3e3e3e").unwrap();
-        assert_eq!(r, 0x3E000000);
-        assert_eq!(g, 0x3E000000);
-        assert_eq!(b, 0x3E000000);
-        assert_eq!(a, 0xFF000000);
+        assert_eq!(r, 0x3E3E3E3E);
+        assert_eq!(g, 0x3E3E3E3E);
+        assert_eq!(b, 0x3E3E3E3E);
+        assert_eq!(a, 0xFFFFFFFF);
     }
 
     #[test]
     fn test_parse_hex_color_rgba() {
         let (r, g, b, a) = parse_hex_color("#5c9060ff").unwrap();
-        assert_eq!(r, 0x5C000000);
-        assert_eq!(g, 0x90000000);
-        assert_eq!(b, 0x60000000);
-        assert_eq!(a, 0xFF000000);
+        assert_eq!(r, 0x5C5C5C5C);
+        assert_eq!(g, 0x90909090);
+        assert_eq!(b, 0x60606060);
+        assert_eq!(a, 0xFFFFFFFF);
     }
 
     #[test]
@@ -574,5 +648,9 @@ mod tests {
         assert_eq!(wm.global_layout, TilingMode::Cascade);
         assert!(!wm.config_done);
         assert_eq!(wm.layout.gap, 48);
+        assert_eq!(wm.layout.gap_top, 48);
+        assert_eq!(wm.layout.gap_left, 48);
+        assert_eq!(wm.layout.gap_right, 48);
+        assert_eq!(wm.layout.gap_bottom, 48);
     }
 }
diff --git a/src/wayland.rs b/src/wayland.rs
index 987dad0..18e7628 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -9,6 +9,11 @@ use crate::protocol::river_input_management::client::{
     river_input_device_v1::{self, RiverInputDeviceV1},
     river_input_manager_v1::{self, RiverInputManagerV1},
 };
+use crate::protocol::river_libinput_config::client::{
+    river_libinput_config_v1::{self, RiverLibinputConfigV1},
+    river_libinput_device_v1::{self, RiverLibinputDeviceV1},
+    river_libinput_result_v1::{self, RiverLibinputResultV1},
+};
 use crate::protocol::river_layer_shell::client::{
     river_layer_shell_output_v1::{self, RiverLayerShellOutputV1},
     river_layer_shell_v1::{self, RiverLayerShellV1},
@@ -41,6 +46,7 @@ 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_LIBINPUT_CONFIG: &str = "river_libinput_config_v1";
 const IFACE_WLR_OUTPUT_MANAGER: &str = "zwlr_output_manager_v1";
 
 /// Wayland proxy objects stored alongside each Window, so we can
@@ -111,6 +117,22 @@ pub struct AppState {
 
     // --- Status socket sender for waybar ---
     pub status_sender: Option<crate::status_server::StatusSender>,
+
+    // --- Libinput config protocol state ---
+    pub libinput_config: Option<RiverLibinputConfigV1>,
+    /// Tracked libinput devices with their tap state
+    pub libinput_devices: Vec<LibinputDeviceInfo>,
+}
+
+/// Info tracked for each libinput device discovered via river_libinput_config_v1
+pub struct LibinputDeviceInfo {
+    pub device: RiverLibinputDeviceV1,
+    /// Device name (from the river_input_device_v1 name event)
+    pub name: String,
+    /// Number of fingers supported for tap (0 = unsupported)
+    pub tap_finger_count: i32,
+    /// Whether we've received enough events to apply tap config
+    pub tap_info_received: bool,
 }
 
 /// Tracked info for a wlr-output-management head.
@@ -145,6 +167,8 @@ impl AppState {
             output_heads: Vec::new(),
             output_config: None,
             status_sender: None,
+            libinput_config: None,
+            libinput_devices: Vec::new(),
         }
     }
 
@@ -245,6 +269,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_LIBINPUT_CONFIG {
+                    eprintln!("registry: binding {}", IFACE_LIBINPUT_CONFIG);
+                    let lc: RiverLibinputConfigV1 =
+                        registry.bind::<RiverLibinputConfigV1, _, _>(name, 1, qhandle, ());
+                    state.libinput_config = Some(lc);
                 } else if interface == IFACE_WLR_OUTPUT_MANAGER {
                     eprintln!("registry: binding {}", IFACE_WLR_OUTPUT_MANAGER);
                     let om: ZwlrOutputManagerV1 =
@@ -288,6 +317,21 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                     state.exit_requested = true;
                 } else {
                     eprintln!("river sent finished unexpectedly, restarting");
+                    // Write to death log before fork+exit loses all traces
+                    if let Ok(mut f) = std::fs::OpenOptions::new()
+                        .create(true)
+                        .append(true)
+                        .open("/tmp/clearwm-death.log")
+                    {
+                        use std::io::Write;
+                        let _ = writeln!(
+                            f,
+                            "river sent Finished event — protocol error likely, in_manage={}, render_count={}",
+                            state.wm.in_manage_sequence,
+                            state.render_count
+                        );
+                        let _ = f.sync_all();
+                    }
                     crate::restart::wm_restart();
                 }
             }
@@ -298,11 +342,115 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                 state.wm.focused_tags = 0;
                 state.wm.needs_render = true;
 
-                // Remove closed windows
+                // Remove closed windows and their proxy objects.
+                // Collect IDs first, then clean up proxies, then remove from windows vector.
+                // This matches tinyrwm's remove_windows() pattern.
+                let closed_ids: Vec<(u64, Option<String>)> = state
+                    .wm
+                    .windows
+                    .iter()
+                    .filter(|w| w.closed)
+                    .map(|w| (w.id, w.app_id.clone()))
+                    .collect();
+                // Destroy proxy objects for closed windows.
+                // Also clean up seat state (focused_window_id, hovered_window_id,
+                // interacted_window_id) that references closed windows, matching
+                // tinyrwm's remove_windows() pattern which cleans up seat ops
+                // referencing closed window proxies.
+                for (closed_id, closed_app_id) in &closed_ids {
+                    state.window_proxies.retain(|(id, wp)| {
+                        if id == closed_id {
+                            wp.river_window.destroy();
+                            false
+                        } else {
+                            true
+                        }
+                    });
+                    state.window_nodes.retain(|(id, node)| {
+                        if id == closed_id {
+                            node.destroy();
+                            false
+                        } else {
+                            true
+                        }
+                    });
+                    // Clear seat references to this closed window.
+                    // If the focused window closed, reassign focus to another
+                    // visible window (matching tinyrwm's focus_top pattern).
+                    let active_tags = state.wm.active_tags;
+                    for seat in &mut state.wm.seats {
+                        if seat.focused_window_id == Some(*closed_id) {
+                            // Pick the last visible window (cascade front)
+                            let visible_ids: Vec<u64> = state
+                                .wm
+                                .windows
+                                .iter()
+                                .filter(|w| {
+                                    !w.closed && (w.tags & active_tags) != 0 && w.id != *closed_id
+                                })
+                                .map(|w| w.id)
+                                .collect();
+                            seat.focused_window_id = visible_ids.last().copied();
+                            if seat.focused_window_id.is_some() {
+                                state.wm.needs_focus = true;
+                                state.wm.needs_status_update = true;
+                            }
+                            eprintln!(
+                                "[manage] focused window {} (app_id={:?}) closed, reassigned to {:?}",
+                                closed_id, closed_app_id, seat.focused_window_id
+                            );
+                        }
+                        if seat.hovered_window_id == Some(*closed_id) {
+                            seat.hovered_window_id = None;
+                        }
+                        if seat.interacted_window_id == Some(*closed_id) {
+                            seat.interacted_window_id = None;
+                        }
+                    }
+                }
                 state.wm.windows.retain(|w| !w.closed);
-                // Remove removed outputs
+
+                // Remove removed outputs — destroy protocol proxies first
+                // (matching tinyrwm's remove_outputs pattern).
+                let removed_output_ids: Vec<u64> = state
+                    .wm
+                    .outputs
+                    .iter()
+                    .filter(|o| o.removed)
+                    .map(|o| o.id)
+                    .collect();
+                for removed_id in &removed_output_ids {
+                    state.output_proxies.retain(|(id, op)| {
+                        if id == removed_id {
+                            op.river_output.destroy();
+                            false
+                        } else {
+                            true
+                        }
+                    });
+                }
                 state.wm.outputs.retain(|o| !o.removed);
-                // Remove removed seats
+
+                // Remove removed seats — destroy protocol proxies first
+                // (matching tinyrwm's remove_seats pattern which destroys
+                // seat proxy and all binding proxies).
+                let removed_seat_ids: Vec<u64> = state
+                    .wm
+                    .seats
+                    .iter()
+                    .filter(|s| s.removed)
+                    .map(|s| s.id)
+                    .collect();
+                for removed_id in &removed_seat_ids {
+                    state.seat_proxies.retain(|(id, sp)| {
+                        if id == removed_id {
+                            sp.river_seat.destroy();
+                            false
+                        } else {
+                            true
+                        }
+                    });
+                }
                 state.wm.seats.retain(|s| !s.removed);
 
                 // Set default layer shell on first output
@@ -329,6 +477,116 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                     }
                 }
 
+                // Check xprop results for XWayland parent detection.
+                // River doesn't send the Parent event for XWayland windows,
+                // so we detect WM_TRANSIENT_FOR / _NET_WM_WINDOW_TYPE_DIALOG|UTILITY
+                // via an async xprop check spawned in the UnreliablePid handler.
+                // This must run before assign_window_modes so has_parent is set
+                // before mode assignment decides Floating vs tiled.
+                {
+                    let mut xprop_parent_changed = false;
+                    let max_attempts: u8 = 10;
+                    for window in &mut state.wm.windows {
+                        if !window.needs_xprop_check || window.has_parent || window.closed {
+                            continue;
+                        }
+                        window.xprop_check_attempts += 1;
+                        let path = format!("/tmp/clearwm-xprop-{}", window.id);
+                        if let Ok(content) = std::fs::read_to_string(&path) {
+                            let _ = std::fs::remove_file(&path);
+                            // Each line: title|window_type_line
+                            // Match by title against our window's title.
+                            let my_title = window.title.as_deref().unwrap_or("");
+                            for line in content.lines() {
+                                let parts: Vec<&str> = line.splitn(2, '|').collect();
+                                if parts.len() < 2 {
+                                    continue;
+                                }
+                                let x11_title = parts[0];
+                                let wtype = parts[1];
+                                if x11_title != my_title {
+                                    continue;
+                                }
+                                let is_dialog =
+                                    wtype.contains("DIALOG") || wtype.contains("UTILITY");
+                                // Only use the window type hint, not WM_TRANSIENT_FOR alone.
+                                // Transient-for is unreliable: some apps (e.g. Houdini)
+                                // set WM_TRANSIENT_FOR on their main window (transient to the
+                                // splash screen). The window type hint is the reliable signal
+                                // that a window is actually a dialog/utility child.
+                                if is_dialog {
+                                    window.has_parent = true;
+                                    xprop_parent_changed = true;
+                                    eprintln!(
+                                        "[xprop] id={} (app_id={:?}) detected XWayland dialog/utility window",
+                                        window.id, window.app_id
+                                    );
+                                }
+                            }
+                            window.needs_xprop_check = false;
+                        } else if window.xprop_check_attempts >= max_attempts {
+                            // Give up — xprop file never appeared (native Wayland window?)
+                            window.needs_xprop_check = false;
+                            eprintln!(
+                                "[xprop] id={} (app_id={:?}) giving up after {} attempts",
+                                window.id, window.app_id, max_attempts
+                            );
+                        }
+                    }
+                    // If any window gained has_parent, trigger a re-manage so
+                    // assign_window_modes picks it up on the next cycle.
+                    if xprop_parent_changed {
+                        if let Some(ref wm) = state.window_manager {
+                            wm.manage_dirty();
+                        }
+                    }
+                }
+
+                // Apply persisted state from ~/.cache/clearwm_state on first ManageStart.
+                // This restores window tag assignments, active_tags, tag_layouts, and
+                // locked tiling modes from the previous session. Must run before
+                // assign_window_modes so restored tags/modes take effect.
+                //
+                // We retry across multiple ManageStart cycles because window metadata
+                // (identifier, app_id, title) arrives as separate events AFTER the
+                // Window creation event. On the first ManageStart, these fields may
+                // still be None, so matching would fail. We keep needs_state_restore
+                // true until we've had at least one window with metadata (or after
+                // 5 cycles, giving up gracefully).
+                if state.wm.needs_state_restore {
+                    if let Some(pstate) = crate::state::read_state() {
+                        // Check if any window has metadata yet
+                        let has_metadata = state.wm.windows.iter().any(|w| {
+                            w.identifier.is_some() || w.app_id.is_some()
+                        });
+                        if has_metadata {
+                            crate::state::apply_state(&mut state.wm, &pstate);
+                            state.wm.needs_state_restore = false;
+                            eprintln!("[state] restored state on ManageStart (windows have metadata)");
+                        } else {
+                            // Increment a counter; give up after 5 cycles
+                            state.wm.state_restore_attempts += 1;
+                            if state.wm.state_restore_attempts >= 5 {
+                                // No windows with metadata yet — probably a fresh start
+                                // with no existing windows. Apply global state only
+                                // (active_tags, tag_layouts) and stop retrying.
+                                state.wm.active_tags = pstate.active_tags;
+                                for (idx, mode, has) in &pstate.tag_layouts {
+                                    if *idx < crate::types::NUM_TAGS {
+                                        state.wm.tag_layouts[*idx] = *mode;
+                                        state.wm.has_tag_layout[*idx] = *has;
+                                    }
+                                }
+                                state.wm.needs_state_restore = false;
+                                eprintln!("[state] no windows with metadata after 5 cycles, applied global state only");
+                            }
+                        }
+                    } else {
+                        // No state file — fresh start
+                        state.wm.needs_state_restore = false;
+                    }
+                }
+
                 // 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);
@@ -338,6 +596,47 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                 // during a manage sequence (per River protocol spec).
                 crate::wm::manage_windows(state, qhandle);
 
+                // Auto-focus: new windows on the current tags get keyboard focus.
+                // This runs after manage_windows so the new window is tiled and
+                // has a valid position/proxy, but before focus application so
+                // needs_focus will trigger the actual focus_window() call.
+                {
+                    let active_tags = state.wm.active_tags;
+                    // Find the last new window on active tags (cascade-front).
+                    // Multiple new windows can appear in one manage cycle (e.g.
+                    // spawning several apps at once); focusing the last one is
+                    // consistent with FocusNext and WindowInteraction which also
+                    // pick visible_ids.last().
+                    let new_focused_id = state
+                        .wm
+                        .windows
+                        .iter()
+                        .filter(|w| w.is_new && !w.closed && (w.tags & active_tags) != 0)
+                        .map(|w| w.id)
+                        .last();
+                    if let Some(new_id) = new_focused_id {
+                        for seat in &mut state.wm.seats {
+                            if seat.removed {
+                                continue;
+                            }
+                            seat.focused_window_id = Some(new_id);
+                        }
+                        // Move to cascade-front position (last in windows vec)
+                        state.wm.move_window_to_end(new_id);
+                        state.wm.needs_focus = true;
+                        state.wm.needs_status_update = true;
+                        let app_id = state.wm.get_window(new_id).and_then(|w| w.app_id.clone());
+                        eprintln!(
+                            "[focus] auto-focusing new window id={} (app_id={:?})",
+                            new_id, app_id
+                        );
+                    }
+                    // Clear is_new on all windows (only matters once)
+                    for window in &mut state.wm.windows {
+                        window.is_new = false;
+                    }
+                }
+
                 // 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
@@ -352,7 +651,14 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                                 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);
+                                    let app_id = state
+                                        .wm
+                                        .get_window(focused_id)
+                                        .and_then(|w| w.app_id.clone());
+                                    eprintln!(
+                                        "[focus] calling focus_window for id={} (app_id={:?})",
+                                        focused_id, app_id
+                                    );
                                     sp.river_seat.focus_window(&wp.river_window);
                                 }
                             }
@@ -394,6 +700,11 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                 }
 
                 wm_proxy.manage_finish();
+                // Flush immediately so River can start the configure/render cycle
+                // without waiting for our blocking_dispatch to complete.
+                if let Err(e) = conn.flush() {
+                    eprintln!("[manage] FATAL: flush after manage_finish failed: {:?}", e);
+                }
                 state.wm.in_manage_sequence = false;
                 eprintln!("[manage] ManageStart done in {:?}", ms_start.elapsed());
                 // NOTE: Do NOT call update_status_files() here — it calls
@@ -441,9 +752,13 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                                 .find(|(id, _)| *id == focused_id)
                                 .map(|(_, n)| n)
                             {
+                                let app_id = state
+                                    .wm
+                                    .get_window(focused_id)
+                                    .and_then(|w| w.app_id.clone());
                                 eprintln!(
-                                    "[render] place_top for focused window id={}",
-                                    focused_id
+                                    "[render] place_top for focused window id={} (app_id={:?})",
+                                    focused_id, app_id
                                 );
                                 node.place_top();
                             }
@@ -454,8 +769,16 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                 }
 
                 wm_proxy.render_finish();
-                eprintln!("[render] render_finish #{} queued", state.render_count);
-
+                // Flush immediately so River receives render_finish without waiting
+                // for blocking_dispatch to complete. River has a 3-second unresponsive
+                // timeout, and if we don't flush promptly, River will kill us.
+                if let Err(e) = conn.flush() {
+                    eprintln!(
+                        "[render] FATAL: flush after render_finish #{} failed: {:?}",
+                        state.render_count, e
+                    );
+                }
+                eprintln!("[render] render_finish #{} flushed", 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.
@@ -475,6 +798,10 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                 if state.wm.needs_status_update {
                     crate::status::write_status_files(&state.wm);
 
+                    // Persist state to ~/.cache/clearwm_state for restart recovery.
+                    // Safe: just file I/O, no fork, no blocking.
+                    crate::state::write_state(&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 {
@@ -484,6 +811,11 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
 
                     state.wm.needs_status_update = false;
                 }
+
+                // Re-apply tap-to-click config if it was changed via IPC
+                if !state.wm.tap_config_applied && !state.libinput_devices.is_empty() {
+                    crate::wayland::apply_tap_config(state, qhandle);
+                }
             }
 
             // Window event: field is `id` (the new RiverWindowV1 proxy)
@@ -506,7 +838,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                 state
                     .window_proxies
                     .push((id, WindowProxy { river_window }));
-                eprintln!("wm_handle_window: new window id={}", id);
+                eprintln!("[window] new window id={} (app_id pending)", id);
             }
 
             // Output event: field is `id` (the new RiverOutputV1 proxy)
@@ -593,7 +925,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
             river_window_v1::Event::Closed => {
                 if let Some(window) = state.wm.get_window_mut(wid) {
                     window.closed = true;
-                    eprintln!("window id={} closed", wid);
+                    eprintln!("window id={} (app_id={:?}) closed", wid, window.app_id);
                 }
             }
 
@@ -605,10 +937,21 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
             }
 
             river_window_v1::Event::AppId { app_id } => {
+                let mut re_eval = false;
                 if let Some(window) = state.wm.get_window_mut(wid) {
                     if window.app_id != app_id {
+                        eprintln!(
+                            "[window] id={} app_id: {:?} -> {:?}",
+                            wid, window.app_id, app_id
+                        );
                         window.app_id = app_id;
-                        state.wm.needs_render = true;
+                        re_eval = !window.mode_locked;
+                    }
+                }
+                if re_eval {
+                    state.wm.needs_render = true;
+                    if let Some(ref wm) = state.window_manager {
+                        wm.manage_dirty();
                     }
                 }
             }
@@ -641,6 +984,146 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
                 }
             }
 
+            river_window_v1::Event::UnreliablePid { unreliable_pid } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.pid = unreliable_pid as u32;
+                    // Spawn an async xprop check for XWayland parent detection.
+                    // River doesn't forward WM_TRANSIENT_FOR for XWayland windows,
+                    // so we check via xdotool + xprop as a fallback.
+                    // The script writes results to /tmp/clearwm-xprop-{wid} which
+                    // is read on the next ManageStart cycle.
+                    if !window.has_parent && window.pid > 0 {
+                        let pid = window.pid;
+                        let id = window.id;
+                        let cmd = format!(
+                            "for xid in $(xdotool search --pid {pid} 2>/dev/null); do \
+                             t=$(xdotool getwindowname $xid 2>/dev/null); \
+                             wt=$(xprop -id $xid _NET_WM_WINDOW_TYPE 2>/dev/null); \
+                             printf '%s|%s\\n' \"$t\" \"$wt\"; \
+                             done > /tmp/clearwm-xprop-{id}",
+                            pid = pid,
+                            id = id
+                        );
+                        crate::config::spawn_command_bg(&cmd);
+                        window.needs_xprop_check = true;
+                        eprintln!(
+                            "[window] id={} (app_id={:?}) spawned xprop check for pid={}",
+                            wid, window.app_id, pid
+                        );
+                    }
+                }
+            }
+
+            river_window_v1::Event::DimensionsHint {
+                min_width,
+                min_height,
+                max_width,
+                max_height,
+            } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.hint_min_width = min_width;
+                    window.hint_min_height = min_height;
+                    window.hint_max_width = max_width;
+                    window.hint_max_height = max_height;
+                    eprintln!(
+                        "[window] id={} (app_id={:?}) dimensions_hint: min={}x{} max={}x{}",
+                        wid, window.app_id, min_width, min_height, max_width, max_height
+                    );
+                }
+            }
+
+            river_window_v1::Event::Parent { parent } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    let had_parent = window.has_parent;
+                    match &parent {
+                        Some(parent_proxy) => {
+                            window.has_parent = true;
+                            // Look up our internal ID for the parent proxy
+                            let parent_id = state
+                                .window_proxies
+                                .iter()
+                                .find(|(_, wp)| {
+                                    wp.river_window.id().protocol_id()
+                                        == parent_proxy.id().protocol_id()
+                                })
+                                .map(|(id, _)| *id);
+                            window.parent_id = parent_id;
+                            eprintln!(
+                                "[window] id={} (app_id={:?}) has parent (internal_id={:?})",
+                                wid, window.app_id, parent_id
+                            );
+                        }
+                        None => {
+                            window.has_parent = false;
+                            window.parent_id = None;
+                        }
+                    }
+                    // Parent status changed: re-assign mode (child windows float)
+                    if window.has_parent != had_parent && !window.mode_locked {
+                        if let Some(ref wm) = state.window_manager {
+                            wm.manage_dirty();
+                        }
+                    }
+                }
+            }
+
+            river_window_v1::Event::FullscreenRequested { .. } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.fullscreen_requested = true;
+                    eprintln!(
+                        "[window] id={} (app_id={:?}) requested fullscreen",
+                        wid, window.app_id
+                    );
+                }
+            }
+
+            river_window_v1::Event::ExitFullscreenRequested { .. } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.fullscreen_requested = false;
+                    eprintln!(
+                        "[window] id={} (app_id={:?}) requested exit fullscreen",
+                        wid, window.app_id
+                    );
+                }
+            }
+
+            river_window_v1::Event::MaximizeRequested { .. } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.maximize_requested = true;
+                    eprintln!(
+                        "[window] id={} (app_id={:?}) requested maximize",
+                        wid, window.app_id
+                    );
+                }
+            }
+
+            river_window_v1::Event::UnmaximizeRequested { .. } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.maximize_requested = false;
+                    eprintln!(
+                        "[window] id={} (app_id={:?}) requested unmaximize",
+                        wid, window.app_id
+                    );
+                }
+            }
+
+            river_window_v1::Event::MinimizeRequested { .. } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.minimize_requested = true;
+                    eprintln!(
+                        "[window] id={} (app_id={:?}) requested minimize",
+                        wid, window.app_id
+                    );
+                }
+            }
+
+            river_window_v1::Event::ShowWindowMenuRequested { x, y } => {
+                eprintln!(
+                    "[window] id={} show_window_menu_requested at ({}, {}) — ignored",
+                    wid, x, y
+                );
+            }
+
             river_window_v1::Event::PointerMoveRequested { .. } => {
                 // Will handle pointer ops later
             }
@@ -681,12 +1164,14 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
                 window: river_window,
             } => {
                 if let Some(wid) = state.window_id_for_proxy(&river_window) {
+                    let target_app_id = state.wm.get_window(wid).and_then(|w| w.app_id.clone());
                     if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
                         eprintln!(
-                            "[focus] WindowInteraction: seat={} focused_window_id={} -> {}",
+                            "[focus] WindowInteraction: seat={} focused_window_id={} -> {} (app_id={:?})",
                             sid,
                             seat.focused_window_id.unwrap_or(0),
-                            wid
+                            wid,
+                            target_app_id
                         );
                         seat.focused_window_id = Some(wid);
                         // Move clicked window to front of cascade stack
@@ -991,8 +1476,7 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                         .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;
+                            let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
                             for fd in 3..max_fd {
                                 libc::close(fd);
                             }
@@ -1004,16 +1488,25 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
             }
         }
         Action::Close => {
-            // 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.
+            // Ask the compositor to close the focused window by calling
+            // close() on its River protocol proxy. This matches tinyrwm's
+            // approach: close() sends a request to River, which asks the
+            // client to close. When the client actually closes, River sends
+            // Event::Closed, which sets window.closed = true. Then on the
+            // next ManageStart, remove_windows() drops it from the vector.
+            //
+            // close() modifies window management state and can only be called
+            // during a manage sequence — which it is, since execute_action
+            // runs inside ManageStart.
             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) {
-                        window.closed = true;
+                    // Send the close request to the compositor
+                    if let Some(wp) = state.get_window_proxy(focused_id) {
+                        wp.river_window.close();
                     }
-                    // Shift focus to the next visible window (excluding the one we just closed)
+                    // Shift focus to the next visible window (excluding the one we just closed).
+                    // The window isn't closed=true yet (that happens when River sends Event::Closed),
+                    // so we exclude it by ID instead.
                     let visible_ids: Vec<u64> = state
                         .wm
                         .windows
@@ -1106,10 +1599,21 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                             title,
                             identifier: None,
                             parent_id: None,
+                            has_parent: false,
+                            pid: 0,
+                            hint_min_width: 0,
+                            hint_min_height: 0,
+                            hint_max_width: 0,
+                            hint_max_height: 0,
                             decoration_hint: 3,
                             presentation_hint: 0,
+                            fullscreen_requested: false,
+                            maximize_requested: false,
+                            minimize_requested: false,
                             tiling_mode: TilingMode::Fullscreen,
                             mode_locked,
+                            needs_xprop_check: false,
+                            xprop_check_attempts: 0,
                         };
                         crate::wm::get_mode_for_window(&state.wm, &temp_win)
                             .unwrap_or(state.wm.global_layout)
@@ -1137,25 +1641,86 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 TilingMode::Grid,
                 TilingMode::Vsplit,
                 TilingMode::Hsplit,
+                TilingMode::Fullscreen,
             ];
-            let current = state.wm.global_layout;
+            // Cycle the layout for the currently active tag(s) only.
+            // Determine the "current" mode from the first active tag's layout
+            // (or global_layout if no tag_layout is set for it), then advance.
+            let active_tags = state.wm.active_tags;
+            let first_tag_bit = (0..crate::types::NUM_TAGS).find(|b| (active_tags & (1u32 << b)) != 0);
+            let current = if let Some(bit) = first_tag_bit {
+                if state.wm.has_tag_layout[bit] {
+                    state.wm.tag_layouts[bit]
+                } else {
+                    state.wm.global_layout
+                }
+            } else {
+                state.wm.global_layout
+            };
             let next = cycle
                 .iter()
                 .position(|m| *m == current)
                 .map(|i| cycle[(i + 1) % cycle.len()])
                 .unwrap_or(TilingMode::Cascade);
-            state.wm.global_layout = next;
-            eprintln!("layout-next: global layout is now {}", next.as_str());
+
+            // Set the layout for every currently active tag.
+            for tag_bit in 0..crate::types::NUM_TAGS {
+                if (active_tags & (1u32 << tag_bit)) != 0 {
+                    state.wm.tag_layouts[tag_bit] = next;
+                    state.wm.has_tag_layout[tag_bit] = true;
+                }
+            }
+            eprintln!(
+                "layout-next: tag layout set to {} for active_tags=0b{:b}",
+                next.as_str(),
+                active_tags
+            );
 
             // 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.
+            // Only windows that fell through to tag_layouts/global_layout will change.
 
             state.wm.needs_render = true;
             state.wm.needs_status_update = true;
         }
+        Action::ModeNext => {
+            let cycle = [
+                TilingMode::Cascade,
+                TilingMode::Grid,
+                TilingMode::Vsplit,
+                TilingMode::Hsplit,
+                TilingMode::Fullscreen,
+                TilingMode::Floating,
+            ];
+            let focused_id = state
+                .wm
+                .seats
+                .iter()
+                .find(|s| !s.removed)
+                .and_then(|s| s.focused_window_id);
+            if let Some(fid) = focused_id {
+                if let Some(win) = state.wm.get_window_mut(fid) {
+                    let next = cycle
+                        .iter()
+                        .position(|m| *m == win.tiling_mode)
+                        .map(|i| cycle[(i + 1) % cycle.len()])
+                        .unwrap_or(TilingMode::Cascade);
+                    eprintln!(
+                        "mode-next: window {} ({:?}) {} -> {}",
+                        fid,
+                        win.app_id,
+                        win.tiling_mode.as_str(),
+                        next.as_str()
+                    );
+                    win.tiling_mode = next;
+                    win.mode_locked = true;
+                    state.wm.needs_render = true;
+                    state.wm.needs_status_update = true;
+                }
+            }
+        }
         Action::Reload => {
             // TODO: implement reload (re-run config)
             eprintln!("reload: not yet implemented");
@@ -1247,10 +1812,8 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
             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| {
+                let window_left_active_tag =
+                    state.wm.get_window_mut(focused_id).map_or(false, |window| {
                         window.tags = 1 << (tag - 1);
                         (window.tags & active_tags) == 0
                     });
@@ -1760,3 +2323,149 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
 
     Ok((conn, event_queue, state))
 }
+
+// --- RiverLibinputConfigV1 events ---
+
+impl Dispatch<RiverLibinputConfigV1, ()> for AppState {
+    event_created_child!(AppState, RiverLibinputConfigV1, [
+        river_libinput_config_v1::EVT_LIBINPUT_DEVICE_OPCODE => (RiverLibinputDeviceV1, ()),
+    ]);
+
+    fn event(
+        state: &mut Self,
+        _proxy: &RiverLibinputConfigV1,
+        event: river_libinput_config_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_libinput_config_v1::Event::LibinputDevice { id: device } => {
+                eprintln!("[libinput] device discovered");
+                state.libinput_devices.push(LibinputDeviceInfo {
+                    device,
+                    name: String::new(),
+                    tap_finger_count: -1, // not yet received
+                    tap_info_received: false,
+                });
+            }
+            river_libinput_config_v1::Event::Finished => {}
+            _ => {}
+        }
+    }
+}
+
+// --- RiverLibinputDeviceV1 events ---
+
+impl Dispatch<RiverLibinputDeviceV1, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        _proxy: &RiverLibinputDeviceV1,
+        event: river_libinput_device_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_libinput_device_v1::Event::TapSupport { finger_count } => {
+                if let Some(dev) = state.libinput_devices.last_mut() {
+                    dev.tap_finger_count = finger_count;
+                    eprintln!("[libinput] tap support: {} fingers", finger_count);
+                }
+                // Don't apply tap config here — devices arrive one at a time.
+                // If we apply after the first device (which may not support tap),
+                // tap_config_applied gets set too early and we miss the touchpad.
+                // Instead, apply in RenderStart after all devices have been discovered.
+            }
+            river_libinput_device_v1::Event::TapDefault { state: tap_state } => {
+                let _ = tap_state;
+                eprintln!("[libinput] tap default received");
+            }
+            river_libinput_device_v1::Event::TapCurrent { state: tap_state } => {
+                let _ = tap_state;
+                eprintln!("[libinput] tap current received");
+            }
+            river_libinput_device_v1::Event::Removed => {
+                eprintln!("[libinput] device removed");
+            }
+            _ => {}
+        }
+    }
+}
+
+// --- RiverLibinputResultV1 events ---
+
+impl Dispatch<RiverLibinputResultV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &RiverLibinputResultV1,
+        event: river_libinput_result_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_libinput_result_v1::Event::Success => {
+                eprintln!("[libinput] config applied successfully");
+            }
+            river_libinput_result_v1::Event::Unsupported => {
+                eprintln!("[libinput] config unsupported by device");
+            }
+            river_libinput_result_v1::Event::Invalid => {
+                eprintln!("[libinput] config invalid");
+            }
+        }
+    }
+}
+
+/// Apply tap-to-click configuration to all libinput devices that support it.
+/// Called after device events arrive and after config changes.
+pub fn apply_tap_config(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+    if state.wm.tap_config_applied {
+        return;
+    }
+
+    // Wait until ALL discovered devices have received their tap_support event.
+    // Devices arrive one at a time; if we mark tap_config_applied after only
+    // the first device (which may not support tap), we'll miss the touchpad.
+    let all_info_received = state.libinput_devices.iter().all(|d| d.tap_finger_count >= 0);
+    if !all_info_received {
+        return;
+    }
+
+    let tap_to_click = state.wm.tap_to_click;
+
+    for dev_info in &mut state.libinput_devices {
+        if dev_info.tap_info_received {
+            continue; // Already applied to this device
+        }
+        if dev_info.tap_finger_count == 0 {
+            // Device doesn't support tap-to-click
+            dev_info.tap_info_received = true;
+            continue;
+        }
+
+        // Device supports tap — apply config
+        let tap_state = if tap_to_click {
+            river_libinput_device_v1::TapState::Enabled
+        } else {
+            river_libinput_device_v1::TapState::Disabled
+        };
+
+        eprintln!(
+            "[libinput] setting tap={} on device ({})",
+            if tap_to_click { "enabled" } else { "disabled" },
+            if dev_info.name.is_empty() { "unnamed" } else { &dev_info.name }
+        );
+
+        dev_info.device.set_tap(tap_state, qhandle, ());
+        dev_info.tap_info_received = true;
+    }
+
+    // Only mark as fully applied once all devices have been configured
+    let all_done = state.libinput_devices.iter().all(|d| d.tap_info_received);
+    if all_done && !state.libinput_devices.is_empty() {
+        state.wm.tap_config_applied = true;
+        eprintln!("[libinput] tap config applied to all devices");
+    }
+}
diff --git a/src/wm.rs b/src/wm.rs
index d7de94d..f9c0fda 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -25,6 +25,11 @@ pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMod
         return None;
     }
 
+    // 0. Windows with a parent (dialogs, file pickers, etc.) always float.
+    if win.has_parent {
+        return Some(TilingMode::Floating);
+    }
+
     // 1. Check mode_rules for a match on app_id/title
     for rule in &wm.mode_rules {
         let match_app = rule.app_id_pattern == "*"
@@ -176,8 +181,11 @@ fn ensure_window_nodes(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
 /// Compute tiling for all visible windows (read-only, returns results).
 fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileResult> {
     let gap = wm.layout.gap;
-    let bw = wm.layout.border_width;
-    let offset = wm.layout.offset;
+    let gap_top = wm.layout.gap_top;
+    let gap_left = wm.layout.gap_left;
+    let gap_right = wm.layout.gap_right;
+    let gap_bottom = wm.layout.gap_bottom;
+    let cascade_offset = wm.layout.cascade_offset;
     let bar_height = wm.layout.bar_height;
 
     // Count windows per tiling mode
@@ -198,14 +206,24 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
         }
     }
 
-    // Check for fullscreen window
+    // Check for fullscreen window — prefer the focused window so FocusNext
+    // cycles visible windows when fullscreen is used as a layout mode.
     let fullscreen_id = wm
-        .windows
+        .seats
         .iter()
-        .find(|w| {
-            (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
+        .find(|s| !s.removed)
+        .and_then(|s| s.focused_window_id)
+        .filter(|&fid| {
+            wm.get_window(fid).map_or(false, |w| {
+                (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
+            })
         })
-        .map(|w| w.id);
+        .or_else(|| {
+            // Fallback: first fullscreen window if no focused window qualifies
+            wm.windows.iter().find(|w| {
+                (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
+            }).map(|w| w.id)
+        });
 
     // Compute tiling
     let mut results = Vec::new();
@@ -225,7 +243,16 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
         let (x, y, w, h) = match mode {
             TilingMode::Fullscreen => {
                 if fullscreen_id == Some(wid) {
-                    (0, 0, screen_w, screen_h)
+                    tiling::tile_fullscreen(
+                        screen_w,
+                        screen_h,
+                        gap_top,
+                        gap_left,
+                        gap_right,
+                        gap_bottom,
+                        wm.layout.fullscreen_border_width,
+                        bar_height,
+                    )
                 } else {
                     continue;
                 }
@@ -235,8 +262,12 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
                     screen_w,
                     screen_h,
                     gap,
-                    bw,
-                    offset,
+                    gap_top,
+                    gap_left,
+                    gap_right,
+                    gap_bottom,
+                    wm.layout.cascade_border_width,
+                    cascade_offset,
                     bar_height,
                     n_cascade,
                     idx_cascade,
@@ -246,20 +277,20 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
             }
             TilingMode::Grid => {
                 let (x, y, w, h) =
-                    tiling::tile_grid(screen_w, screen_h, gap, bw, bar_height, n_grid, idx_grid);
+                    tiling::tile_grid(screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.grid_border_width, bar_height, n_grid, idx_grid);
                 idx_grid += 1;
                 (x, y, w, h)
             }
             TilingMode::Vsplit => {
                 let (x, y, w, h) = tiling::tile_vsplit(
-                    screen_w, screen_h, gap, bw, bar_height, n_vsplit, idx_vsplit,
+                    screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.vsplit_border_width, 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,
+                    screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.hsplit_border_width, bar_height, n_hsplit, idx_hsplit,
                 );
                 idx_hsplit += 1;
                 (x, y, w, h)
@@ -270,6 +301,7 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
                 // triggers River's unresponsive-client detection).
                 // Use the window's existing dimensions, or a reasonable
                 // default if unset.
+                let fbw = wm.layout.floating_border_width;
                 let fw = if win.width > 0 {
                     win.width
                 } else {
@@ -283,12 +315,12 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
                 let fx = if win.x != 0 || win.y != 0 {
                     win.x
                 } else {
-                    gap + bw + offset * idx_cascade
+                    gap_left + fbw + cascade_offset * idx_cascade
                 };
                 let fy = if win.x != 0 || win.y != 0 {
                     win.y
                 } else {
-                    gap + bw + bar_height + offset * idx_cascade
+                    gap_left + fbw + bar_height + gap_top + cascade_offset * idx_cascade
                 };
                 idx_cascade += 1;
                 (fx, fy, fw, fh)
@@ -317,6 +349,13 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
         // Propose dimensions via river_window_v1
         if let Some(wp) = state.get_window_proxy(tr.wid) {
             wp.river_window.propose_dimensions(tr.w, tr.h);
+            // Tell the client to use server-side decoration.
+            // Per the River protocol, use_csd is the default when neither
+            // use_csd nor use_ssd is called. Calling use_ssd here ensures
+            // windows don't draw their own CSD titlebars/borders.
+            // use_ssd has no effect if the client only supports CSD
+            // (decoration_hint == only_supports_csd).
+            wp.river_window.use_ssd();
         }
 
         // Update internal state
diff --git a/start-river.sh b/start-river.sh
index 4edb36f..f21b21f 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -1,19 +1,37 @@
 #!/bin/bash
-# Launch river with clearwm-rs on this TTY
+# Launch river with clearwm on this TTY
 # Usage: Switch to a free TTY, log in, and run this script
 
+LOGGING=false
+for arg in "$@"; do
+    case "$arg" in
+        --logging) LOGGING=true ;;
+    esac
+done
+
 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'
+if [ "$LOGGING" = true ]; then
+    cat > /tmp/clearwm-rs-launch.sh << 'LAUNCH_EOF'
+#!/bin/sh
+exec /home/lsgalante/.local/bin/clearwm 2>/tmp/clearwm.log
+LAUNCH_EOF
+else
+    cat > /tmp/clearwm-rs-launch.sh << 'LAUNCH_EOF'
 #!/bin/sh
-exec /home/lsgalante/.local/bin/clearwm 2>/tmp/clearwm-test.log
+exec /home/lsgalante/.local/bin/clearwm
 LAUNCH_EOF
+fi
 chmod +x /tmp/clearwm-rs-launch.sh
 
-echo "Starting river with clearwm-rs..."
-echo "Log will be at /tmp/river-clearwm.log"
+echo "Starting river with clearwm..."
+if [ "$LOGGING" = true ]; then
+    echo "Logs: /tmp/river-clearwm.log + /tmp/clearwm.log"
+else
+    echo "Logging disabled. Use --logging to enable."
+fi
 
 exec river -c /tmp/clearwm-rs-launch.sh 2>/tmp/river-clearwm.log
diff --git a/test_proto b/test_proto
new file mode 100755
index 0000000..c7eed87
Binary files /dev/null and b/test_proto differ