summaryrefslogtreecommitdiffhomepage
path: root/.rules
diff options
context:
space:
mode:
Diffstat (limited to '.rules')
-rw-r--r--.rules/plan/README.md50
-rw-r--r--.rules/plan/phase-00-skeleton.md72
-rw-r--r--.rules/plan/phase-01-raylib-window.md19
-rw-r--r--.rules/plan/phase-02-window-manager.md93
-rw-r--r--.rules/plan/phase-03-compositing.md106
-rw-r--r--.rules/plan/phase-04-input.md50
-rw-r--r--.rules/plan/phase-05-lifecycle.md45
-rw-r--r--.rules/plan/phase-06-effects.md44
-rw-r--r--.rules/plan/phase-07-ewmh.md46
-rw-r--r--.rules/plan/phase-08-zero-copy.md39
-rw-r--r--.rules/plan/phase-09-cursor.md30
-rw-r--r--.rules/plan/phase-10-robustness.md48
12 files changed, 642 insertions, 0 deletions
diff --git a/.rules/plan/README.md b/.rules/plan/README.md
new file mode 100644
index 0000000..a50dabf
--- /dev/null
+++ b/.rules/plan/README.md
@@ -0,0 +1,50 @@
+# winman-raylib — Implementation Plan
+
+A compositing X11 window manager powered by raylib, written in C.
+
+Each step is atomic: it produces something you can build, run, and visually
+verify before moving to the next step. Each phase is documented in its own
+file below.
+
+---
+
+## Phases
+
+| Phase | File | Summary |
+|-------|------|---------|
+| 0 | [phase-00-skeleton.md](plan/phase-00-skeleton.md) | Project skeleton & dev environment |
+| 1 | [phase-01-raylib-window.md](plan/phase-01-raylib-window.md) | Raylib window inside Xephyr |
+| 2 | [phase-02-window-manager.md](plan/phase-02-window-manager.md) | Become a window manager |
+| 3 | [phase-03-compositing.md](plan/phase-03-compositing.md) | Redirect & capture window contents |
+| 4 | [phase-04-input.md](plan/phase-04-input.md) | Input & focus |
+| 5 | [phase-05-lifecycle.md](plan/phase-05-lifecycle.md) | Window lifecycle polish |
+| 6 | [phase-06-effects.md](plan/phase-06-effects.md) | Visual effects |
+| 7 | [phase-07-ewmh.md](plan/phase-07-ewmh.md) | EWMH basics |
+| 8 | [phase-08-zero-copy.md](plan/phase-08-zero-copy.md) | GLX texture-from-pixmap (zero-copy) |
+| 9 | [phase-09-cursor.md](plan/phase-09-cursor.md) | Cursor rendering |
+| 10 | [phase-10-robustness.md](plan/phase-10-robustness.md) | Robustness & cleanup |
+
+---
+
+## Future Phases (not yet planned in detail)
+
+- **Phase 11** — Multi-monitor support (XRandR)
+- **Phase 12** — Virtual desktops / workspaces
+- **Phase 13** — Window animations (open, close, minimize)
+- **Phase 14** — Blur and advanced shader effects
+- **Phase 15** — RmlUi integration for HTML/CSS-driven decorations/panels
+- **Phase 16** — Configuration system (keybinds, rules, themes)
+- **Phase 17** — Composite Overlay Window (COW) for production use outside Xephyr
+- **Phase 18** — Tiling layout modes
+
+---
+
+## Architecture Decisions
+
+- **Language:** C
+- **Build system:** Make
+- **Rendering:** raylib (creates the GL context and window)
+- **X11 plumbing:** raw Xlib + GLX alongside raylib in the same process
+- **Dev environment:** Xephyr nested X server on `:1`
+- **Compositing surface:** raylib's own window (not the COW), suitable for Xephyr dev; COW migration in Phase 17
+- **Initial texture path:** CPU copy via `XGetImage` (Phase 3), upgraded to zero-copy `GLX_EXT_texture_from_pixmap` in Phase 8
diff --git a/.rules/plan/phase-00-skeleton.md b/.rules/plan/phase-00-skeleton.md
new file mode 100644
index 0000000..f6bac05
--- /dev/null
+++ b/.rules/plan/phase-00-skeleton.md
@@ -0,0 +1,72 @@
+# Phase 0 — Project Skeleton & Dev Environment
+
+---
+
+## Step 0.1 — Makefile and empty main
+
+Create the basic project structure:
+
+```
+src/
+ main.c — int main() { return 0; }
+bin/
+ build.sh — runs make
+ run.sh — builds then launches the binary
+Makefile — compiles src/*.c → build/winman-raylib
+```
+
+**Makefile** compiles with `cc`, links nothing yet. Uses `pkg-config` for
+flags. Output goes to `build/`.
+
+**Verify:** `./bin/build.sh` succeeds, `./build/winman-raylib` runs and
+exits silently with code 0.
+
+---
+
+## Step 0.2 — Install dependencies and verify Xephyr
+
+Create `bin/setup-deps.sh` that installs all needed packages (Arch
+`pacman` commands):
+
+- **Build:** `base-devel`, `raylib`
+- **X11 libs:** `libx11`, `libxcomposite`, `libxdamage`, `libxfixes`,
+ `libxrender`, `libxext`, `libxrandr`
+- **GL:** `mesa`, `glew`
+- **Dev tools:** `xorg-server-xephyr`, `xorg-xinit`, `xorg-xauth`
+- **Test clients:** `xorg-xeyes`, `xorg-xclock`, `xterm`,
+ `xorg-xwininfo`, `xorg-xprop`, `xorg-xdpyinfo`
+
+**Verify:** `./bin/setup-deps.sh` runs without errors. `Xephyr -help`
+prints usage. `pkg-config --libs raylib x11 xcomposite xdamage xfixes`
+prints flags.
+
+---
+
+## Step 0.3 — Xephyr launch/kill scripts
+
+Create two scripts:
+
+- `bin/xephyr-start.sh` — Launches Xephyr on `:1` with
+ `-br -ac -noreset -resizeable -no-host-grab -screen 1280x800`.
+ Prints the PID. Exits immediately (Xephyr runs in background).
+- `bin/xephyr-stop.sh` — Kills the Xephyr process cleanly.
+
+**Verify:** `./bin/xephyr-start.sh` opens a black Xephyr window.
+`DISPLAY=:1 xeyes &` shows xeyes inside it. `./bin/xephyr-stop.sh`
+closes everything.
+
+---
+
+## Step 0.4 — `bin/run.sh` — full dev loop script
+
+A single script that:
+
+1. Builds the project (`make`).
+2. Starts Xephyr on `:1` if not already running.
+3. Launches `build/winman-raylib` with `DISPLAY=:1`.
+
+Accepts an optional `--clients` flag that also spawns `xeyes`, `xclock`,
+and `xterm` into `:1` after a short delay.
+
+**Verify:** `./bin/run.sh --clients` opens Xephyr, launches the (still
+empty) binary, and spawns the test clients into the Xephyr window.
diff --git a/.rules/plan/phase-01-raylib-window.md b/.rules/plan/phase-01-raylib-window.md
new file mode 100644
index 0000000..d40959b
--- /dev/null
+++ b/.rules/plan/phase-01-raylib-window.md
@@ -0,0 +1,19 @@
+# Phase 1 — Raylib Window Inside Xephyr
+
+---
+
+## Step 1.1 — Open a raylib window
+
+Update `main.c` to:
+
+1. Call `InitWindow(1280, 800, "winman-raylib")`.
+2. Enter a loop: `BeginDrawing()`, clear to dark blue, `DrawText("winman-raylib", ...)`,
+ `EndDrawing()`.
+3. `CloseWindow()` on exit.
+
+Update the Makefile to link raylib (`pkg-config --libs raylib`), plus
+`-lGL -lm -lpthread -ldl`.
+
+**Verify:** `./bin/run.sh` opens Xephyr and a raylib window appears inside
+it showing "winman-raylib" text on a dark blue background. The window
+responds to the close button / Escape key.
diff --git a/.rules/plan/phase-02-window-manager.md b/.rules/plan/phase-02-window-manager.md
new file mode 100644
index 0000000..2627a57
--- /dev/null
+++ b/.rules/plan/phase-02-window-manager.md
@@ -0,0 +1,93 @@
+# Phase 2 — Become a Window Manager
+
+---
+
+## Step 2.1 — Open an Xlib display connection alongside raylib
+
+Before `InitWindow()`, call `XOpenDisplay(NULL)` to get a `Display*`.
+Store it globally. Get the root window with `DefaultRootWindow()`. After
+the raylib loop exits, `XCloseDisplay()`.
+
+Print the display name and root window ID to confirm.
+
+**Verify:** The output prints something like `Display: :1, Root: 0x...`.
+Raylib window still works as before.
+
+---
+
+## Step 2.2 — Register as the window manager (SubstructureRedirect)
+
+After opening the display, call:
+
+```c
+XSelectInput(dpy, root,
+ SubstructureRedirectMask |
+ SubstructureNotifyMask |
+ StructureNotifyMask);
+XSync(dpy, False);
+```
+
+Set a custom X error handler that catches `BadAccess` (another WM is
+already running) and prints a clear message.
+
+**Verify:** Running in Xephyr succeeds (no other WM). Running a second
+instance prints "Another window manager is already running" and exits.
+
+---
+
+## Step 2.3 — Process X events in the raylib loop
+
+Inside the raylib `while (!WindowShouldClose())` loop, add a non-blocking
+X event drain:
+
+```c
+while (XPending(dpy) > 0) {
+ XEvent ev;
+ XNextEvent(dpy, &ev);
+ printf("Event: %d\n", ev.type);
+}
+```
+
+**Verify:** `./bin/run.sh --clients` — launching `xeyes` or `xterm` into
+`:1` prints `MapRequest`, `ConfigureRequest`, etc. to the terminal. The
+clients don't appear yet (because we don't handle `MapRequest`).
+
+---
+
+## Step 2.4 — Handle MapRequest and ConfigureRequest (basic)
+
+Add a simple event handler:
+
+- **`MapRequest`** → call `XMapWindow(dpy, ev.xmaprequest.window)`.
+- **`ConfigureRequest`** → honor the request by calling
+ `XConfigureWindow()` with the requested values.
+
+**Verify:** `./bin/run.sh --clients` — `xeyes`, `xclock`, and `xterm` now
+appear inside the Xephyr window. They're drawn by X directly (not
+composited yet), floating on top of/beside the raylib window. You can type
+in `xterm`.
+
+---
+
+## Step 2.5 — Track managed windows in a list
+
+Create a simple dynamic array (or linked list) of `WmWindow` structs:
+
+```c
+typedef struct {
+ Window xwin;
+ int x, y;
+ unsigned int width, height;
+ bool mapped;
+} WmWindow;
+```
+
+- On `MapRequest`: add the window to the list, then `XMapWindow`.
+- On `UnmapNotify`: mark `mapped = false`.
+- On `DestroyNotify`: remove from the list.
+- On `ConfigureRequest`: update stored geometry, then `XConfigureWindow`.
+
+Print the window list count on each change.
+
+**Verify:** Spawn/close clients in `:1`. The log shows windows being added
+and removed. Count matches what's visible.
diff --git a/.rules/plan/phase-03-compositing.md b/.rules/plan/phase-03-compositing.md
new file mode 100644
index 0000000..a85d12b
--- /dev/null
+++ b/.rules/plan/phase-03-compositing.md
@@ -0,0 +1,106 @@
+# Phase 3 — Compositing: Redirect & Capture Window Contents
+
+---
+
+## Step 3.1 — Query and enable the Composite extension
+
+At startup (after becoming WM), call:
+
+```c
+XCompositeQueryExtension(dpy, &composite_event, &composite_error);
+XCompositeQueryVersion(dpy, &major, &minor);
+XCompositeRedirectSubwindows(dpy, root, CompositeRedirectManual);
+```
+
+Print the extension version to confirm.
+
+**Verify:** Prints "Composite extension: 0.4" (or similar). Clients
+launched into `:1` are now **invisible** (redirected to off-screen pixmaps
+but nobody is painting them). This is expected — the WM is responsible for
+compositing now.
+
+---
+
+## Step 3.2 — Name window pixmaps
+
+When a window is mapped (in the `MapRequest` handler or after
+`XMapWindow`), call:
+
+```c
+Pixmap pixmap = XCompositeNameWindowPixmap(dpy, win->xwin);
+```
+
+Store the `Pixmap` in the `WmWindow` struct. On `ConfigureNotify` (resize),
+free the old pixmap and re-obtain it.
+
+**Verify:** No visual change yet (windows are still invisible), but no X
+errors are printed. Confirm pixmap handles are non-zero via log output.
+
+---
+
+## Step 3.3 — Read pixmap into a raylib Texture (CPU copy path)
+
+For each mapped window with a valid pixmap:
+
+1. Call `XGetImage(dpy, pixmap, 0, 0, width, height, AllPlanes, ZPixmap)`
+ to read the pixel data.
+2. Convert the BGRA data (X11 format) to RGBA.
+3. Create a raylib `Image` from the buffer, then `LoadTextureFromImage()`.
+4. Store the `Texture2D` in the `WmWindow` struct.
+
+In the raylib draw loop, draw each managed window's texture at its
+position using `DrawTexture()`.
+
+**Verify:** `./bin/run.sh --clients` — `xeyes`, `xclock`, and `xterm`
+appear as textured quads rendered by raylib on the dark blue background.
+They are static snapshots (no live updates yet). The raylib window is the
+compositor.
+
+---
+
+## Step 3.4 — Enable the Damage extension for live updates
+
+At startup, query and enable Damage:
+
+```c
+XDamageQueryExtension(dpy, &damage_event, &damage_error);
+```
+
+For each managed window, create a damage object:
+
+```c
+win->damage = XDamageCreate(dpy, win->xwin, XDamageReportNonEmpty);
+```
+
+In the event loop, handle `DamageNotify`:
+
+```c
+if (ev.type == damage_event + XDamageNotify) {
+ // mark the window dirty
+ win->dirty = true;
+}
+```
+
+When a window is dirty, before drawing, re-read the pixmap via
+`XGetImage`, update the texture with `UpdateTexture()`, then call
+`XDamageSubtract(dpy, win->damage, None, None)`.
+
+**Verify:** `xeyes` follows your mouse cursor in real-time. `xclock`
+ticks. Typing in `xterm` is visible immediately. This is a working (CPU-
+copied) compositor.
+
+---
+
+## Step 3.5 — Handle window stacking order
+
+Draw windows in the correct stacking order (bottom to top). Maintain the
+list order to match X's stacking order. Use `XQueryTree()` at startup to
+get the initial order, and update on `ConfigureNotify` with
+`Above`/`Below` sibling fields, or re-query when the stack changes.
+
+Also handle `XRaiseWindow` / `XLowerWindow` requests via
+`ConfigureRequest`.
+
+**Verify:** Overlapping windows draw in the correct order. Clicking
+`xterm` (if focus-follows-click is wired) or manually calling
+`DISPLAY=:1 xdotool windowraise <id>` changes the draw order.
diff --git a/.rules/plan/phase-04-input.md b/.rules/plan/phase-04-input.md
new file mode 100644
index 0000000..439d215
--- /dev/null
+++ b/.rules/plan/phase-04-input.md
@@ -0,0 +1,50 @@
+# Phase 4 — Input & Focus
+
+---
+
+## Step 4.1 — Click-to-focus and raise
+
+On `ButtonPress` events on managed windows, set focus with
+`XSetInputFocus(dpy, win->xwin, RevertToParent, CurrentTime)` and raise
+the window with `XRaiseWindow()`.
+
+Register for `ButtonPressMask` on all managed windows using a passive
+grab:
+
+```c
+XGrabButton(dpy, AnyButton, AnyModifier, win->xwin, True,
+ ButtonPressMask, GrabModeSync, GrabModeAsync, None, None);
+```
+
+On `ButtonPress`, set focus, raise, then `XAllowEvents(dpy, ReplayPointer,
+CurrentTime)` to pass the click through to the client.
+
+**Verify:** Click on `xterm` — it receives focus and comes to front.
+Type in it. Click `xeyes` — it comes to front. Focus switching works.
+
+---
+
+## Step 4.2 — Window dragging with Mod+Click
+
+Grab `Mod4 + Button1` (Super+Click) on the root window. On press, start
+tracking a drag. On motion, `XMoveWindow()` the client and update the
+stored position. On release, stop.
+
+```c
+XGrabButton(dpy, Button1, Mod4Mask, root, True,
+ ButtonPressMask | ButtonReleaseMask | PointerMotionMask,
+ GrabModeAsync, GrabModeAsync, None, None);
+```
+
+**Verify:** Super+Click and drag moves windows around inside Xephyr.
+Release drops them. The composited view updates in real-time.
+
+---
+
+## Step 4.3 — Window resizing with Mod+RightClick
+
+Same pattern as dragging but with `Mod4 + Button3`. On motion, call
+`XResizeWindow()`. Re-obtain the pixmap and texture after resize.
+
+**Verify:** Super+RightClick and drag resizes windows. The texture
+updates to show the new content.
diff --git a/.rules/plan/phase-05-lifecycle.md b/.rules/plan/phase-05-lifecycle.md
new file mode 100644
index 0000000..89f6a45
--- /dev/null
+++ b/.rules/plan/phase-05-lifecycle.md
@@ -0,0 +1,45 @@
+# Phase 5 — Window Lifecycle Polish
+
+---
+
+## Step 5.1 — Graceful close (WM_DELETE_WINDOW)
+
+When the user presses a keybind (e.g., `Mod4+Q`), check if the focused
+window supports `WM_DELETE_WINDOW` in its `WM_PROTOCOLS` property. If yes,
+send a `ClientMessage`. If no, call `XKillClient()`.
+
+Register the keybind with `XGrabKey()`.
+
+**Verify:** `Mod4+Q` closes `xterm` gracefully (it gets a chance to
+confirm). Force-kills uncooperative clients.
+
+---
+
+## Step 5.2 — Handle UnmapNotify and DestroyNotify properly
+
+On `UnmapNotify`:
+- Free the pixmap and texture.
+- Mark window as unmapped.
+- Destroy the damage object.
+
+On `DestroyNotify`:
+- Remove the window from the managed list entirely.
+- Clean up all resources.
+
+Handle re-mapping (a window that was unmapped then mapped again) by
+re-creating everything.
+
+**Verify:** Close and reopen clients repeatedly. No memory leaks (watch
+with `valgrind` or check texture count). No X errors. No stale windows
+in the compositor view.
+
+---
+
+## Step 5.3 — Manage pre-existing windows on startup
+
+After becoming the WM, call `XQueryTree()` to get all existing children
+of root. For each that has `map_state == IsViewable`, treat it as if we
+received a `MapRequest`: add to the list, set up pixmap, damage, texture.
+
+**Verify:** Start some clients in `:1` *before* the WM. Then start the
+WM. Pre-existing windows appear immediately in the composited view.
diff --git a/.rules/plan/phase-06-effects.md b/.rules/plan/phase-06-effects.md
new file mode 100644
index 0000000..6a21f72
--- /dev/null
+++ b/.rules/plan/phase-06-effects.md
@@ -0,0 +1,44 @@
+# Phase 6 — Visual Effects (Compositor Features)
+
+---
+
+## Step 6.1 — Window decorations (title bars)
+
+For each managed window, draw a simple title bar above it:
+- A colored rectangle.
+- The window title (from `WM_NAME` or `_NET_WM_NAME` property).
+- A close button (clickable, sends `WM_DELETE_WINDOW`).
+
+Use raylib's `DrawRectangle()` and `DrawText()`.
+
+**Verify:** Each window has a title bar showing its name. Clicking the
+close button closes the window.
+
+---
+
+## Step 6.2 — Drop shadows
+
+Draw a soft shadow behind each window. Approach:
+- Precompute a shadow texture (gaussian blur of a solid rect) at startup.
+- Draw it as a 9-slice scaled quad behind each window, offset down and
+ right by a few pixels.
+
+Or use raylib's shader system with a blur shader on an expanded quad.
+
+**Verify:** Each window has a soft drop shadow. Overlapping windows show
+correct shadow layering.
+
+---
+
+## Step 6.3 — Window transparency / opacity
+
+Read `_NET_WM_WINDOW_OPACITY` from each window's properties. Apply it
+when drawing the texture (raylib's `DrawTexturePro` with tint alpha, or
+a custom shader uniform).
+
+**Verify:** Run:
+```bash
+DISPLAY=:1 xprop -f _NET_WM_WINDOW_OPACITY 32c \
+ -set _NET_WM_WINDOW_OPACITY 0x7FFFFFFF -id <wid>
+```
+to set 50% opacity on a window. It becomes translucent in the compositor.
diff --git a/.rules/plan/phase-07-ewmh.md b/.rules/plan/phase-07-ewmh.md
new file mode 100644
index 0000000..9c300e2
--- /dev/null
+++ b/.rules/plan/phase-07-ewmh.md
@@ -0,0 +1,46 @@
+# Phase 7 — EWMH Basics
+
+---
+
+## Step 7.1 — Set root window EWMH properties
+
+At startup, set on the root window:
+- `_NET_SUPPORTED` — list of atoms we support.
+- `_NET_SUPPORTING_WM_CHECK` — create a child window, set `_NET_WM_NAME`
+ on it to `"winman-raylib"`.
+- `_NET_CLIENT_LIST` — list of managed windows.
+- `_NET_ACTIVE_WINDOW` — currently focused window.
+- `_NET_NUMBER_OF_DESKTOPS` — `1` (for now).
+- `_NET_CURRENT_DESKTOP` — `0`.
+
+Update `_NET_CLIENT_LIST` and `_NET_ACTIVE_WINDOW` as windows are
+added/removed/focused.
+
+**Verify:** `DISPLAY=:1 wmctrl -m` shows the WM name. `wmctrl -l` lists
+managed windows. `xprop -root _NET_SUPPORTED` shows the atom list.
+
+---
+
+## Step 7.2 — Handle _NET_WM_WINDOW_TYPE
+
+Read `_NET_WM_WINDOW_TYPE` from each window on map. Handle at minimum:
+- `_NET_WM_WINDOW_TYPE_NORMAL` — standard management.
+- `_NET_WM_WINDOW_TYPE_DIALOG` — don't decorate differently (for now),
+ but keep above parent if `WM_TRANSIENT_FOR` is set.
+- `_NET_WM_WINDOW_TYPE_DOCK` — don't manage (panels); reserve screen
+ space per `_NET_WM_STRUT` / `_NET_WM_STRUT_PARTIAL`.
+
+**Verify:** A dock-type window (e.g., `DISPLAY=:1 tint2` or a custom
+test client) is not given a title bar and stays above other windows.
+
+---
+
+## Step 7.3 — Handle _NET_WM_STATE (fullscreen)
+
+Listen for `_NET_WM_STATE` `ClientMessage` requests. Implement at
+minimum:
+- `_NET_WM_STATE_FULLSCREEN` — resize window to fill screen, remove
+ decorations, draw above all others.
+
+**Verify:** `DISPLAY=:1 wmctrl -r :ACTIVE: -b toggle,fullscreen` toggles
+the focused window to fullscreen and back.
diff --git a/.rules/plan/phase-08-zero-copy.md b/.rules/plan/phase-08-zero-copy.md
new file mode 100644
index 0000000..56f99c3
--- /dev/null
+++ b/.rules/plan/phase-08-zero-copy.md
@@ -0,0 +1,39 @@
+# Phase 8 — GLX Texture-from-Pixmap (Zero-Copy Upgrade)
+
+---
+
+## Step 8.1 — Replace XGetImage with GLX_EXT_texture_from_pixmap
+
+This is the performance upgrade. Instead of reading pixels through the
+CPU, bind the X pixmap directly as an OpenGL texture:
+
+1. Find an FBConfig that supports `GLX_BIND_TO_TEXTURE_RGBA_EXT`.
+2. Create a `GLXPixmap` from the X `Pixmap`.
+3. Call `glXBindTexImageEXT()` to bind it as a GL texture.
+4. Wrap the GL texture ID in a raylib `Texture2D` struct (set `.id`
+ directly, since raylib textures are just GL texture handles with
+ width/height/format metadata).
+
+On damage: `glXReleaseTexImageEXT` then `glXBindTexImageEXT` to refresh.
+On resize: destroy and recreate the GLX pixmap.
+
+Handle `GLX_Y_INVERTED_EXT` — if the pixmap is Y-inverted, flip the
+texture coordinates when drawing (use `DrawTexturePro` with a negative
+source height, or flip in a shader).
+
+**Verify:** Same visual result as Step 3.4 but with dramatically less
+CPU usage. Confirm with `top` or `perf` that `XGetImage` is no longer
+called. `xeyes` still tracks the mouse smoothly. Resizing windows
+works without artifacts.
+
+**Note:** This step may not work inside Xephyr (which uses software
+rendering). If `GLX_EXT_texture_from_pixmap` is not available in Xephyr,
+keep the CPU-copy path as a fallback and test zero-copy on a real X
+session or VM with GPU passthrough. Implement runtime detection:
+
+```c
+const char *glx_exts = glXQueryExtensionsString(dpy, screen);
+bool has_tfp = strstr(glx_exts, "GLX_EXT_texture_from_pixmap") != NULL;
+```
+
+Use `has_tfp` to pick the code path.
diff --git a/.rules/plan/phase-09-cursor.md b/.rules/plan/phase-09-cursor.md
new file mode 100644
index 0000000..11d301f
--- /dev/null
+++ b/.rules/plan/phase-09-cursor.md
@@ -0,0 +1,30 @@
+# Phase 9 — Cursor Rendering
+
+---
+
+## Step 9.1 — Render the cursor in the compositor
+
+Since compositing may obscure the hardware cursor in certain
+configurations, render it ourselves:
+
+1. Call `XFixesGetCursorImage(dpy)` to get the current cursor bitmap
+ (ARGB pixel data, width, height, hotspot).
+2. Convert to a raylib `Texture2D`.
+3. Draw it at the pointer position each frame (query with
+ `XQueryPointer()`).
+4. Optionally call `XFixesHideCursor()` on the root and draw our own
+ cursor exclusively.
+
+Subscribe to cursor change notifications:
+
+```c
+XFixesSelectCursorInput(dpy, root, XFixesDisplayCursorNotifyMask);
+```
+
+Cache the cursor texture and only update it when an
+`XFixesCursorNotify` event fires (cursor shape changed — e.g., from
+arrow to text beam when hovering over `xterm`).
+
+**Verify:** The cursor is visible and correct when moving over composited
+windows. Cursor changes (e.g., text cursor in xterm, resize arrows at
+window edges) are reflected. No flickering or offset.
diff --git a/.rules/plan/phase-10-robustness.md b/.rules/plan/phase-10-robustness.md
new file mode 100644
index 0000000..39c719d
--- /dev/null
+++ b/.rules/plan/phase-10-robustness.md
@@ -0,0 +1,48 @@
+# Phase 10 — Robustness & Cleanup
+
+---
+
+## Step 10.1 — Proper error handling
+
+- Set `XSetErrorHandler()` to a handler that logs but doesn't crash on
+ `BadWindow`, `BadPixmap`, `BadDrawable` (windows can vanish between
+ operations).
+- Wrap all X calls that reference a window in the handler's
+ error-checked scope.
+- On fatal errors (`XSetIOErrorHandler`), clean up gracefully.
+
+**Verify:** Rapidly spawn and kill clients (a stress loop script). No
+crashes, no X error floods. Clean log output.
+
+---
+
+## Step 10.2 — Clean shutdown
+
+On `SIGINT`/`SIGTERM` or raylib `WindowShouldClose()`:
+
+1. Unredirect all windows (`XCompositeUnredirectSubwindows`).
+2. Free all pixmaps, textures, damage objects.
+3. Release the overlay (if used later).
+4. `XCloseDisplay()`.
+5. `CloseWindow()` (raylib).
+
+**Verify:** Killing the WM with Ctrl+C cleanly restores windows in
+Xephyr (they become visible via X's normal rendering again). No
+orphaned resources.
+
+---
+
+## Step 10.3 — Stress test script
+
+Create `bin/stress-test.sh` that:
+
+1. Starts Xephyr.
+2. Starts the WM.
+3. Rapidly spawns 20 `xterm` instances.
+4. Randomly moves, resizes, and closes them via `xdotool`.
+5. Checks that `wmctrl -l` count matches expectations.
+6. Screenshots the result.
+7. Tears everything down.
+
+**Verify:** No crashes, no hangs, no leaked windows. Screenshot looks
+sane.