tawasuyu

tawasuyu/mirada

Sovereign tiling Wayland compositor + window manager in Rust, on Llimphi.

Rust 519Markdown 113TOML 50RON 1312096 Sutichaykuna

feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)

Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay
+ Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se
consume por git desde su repo publicado; las hojas compartidas (format,
auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van
vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el
compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

3dc85ebdcdd5fda0a1be0db5f8fbce5859266f2e · ruwaq Sergio · (4 killa ñawpaq) · kay commitpi sach'ata qhaway

Semántico sayk'uy · 1282

02_ruway/llimphi/widgets/menubar/src/lib.rs

02_ruway/mirada/mirada-app-llimphi/src/main.rs

02_ruway/mirada/mirada-bar-core/src/lib.rs

02_ruway/mirada/mirada-body/examples/headless.rs

02_ruway/mirada/mirada-brain/examples/headless-ctl.rs

02_ruway/mirada/mirada-brain/examples/keymap-default.rs

02_ruway/mirada/mirada-brain/src/config.rs

02_ruway/mirada/mirada-brain/src/ctl.rs

02_ruway/mirada/mirada-brain/src/desktop.rs

02_ruway/mirada/mirada-brain/src/keymap.rs

02_ruway/mirada/mirada-brain/src/lib.rs

02_ruway/mirada/mirada-brain/src/rules.rs

02_ruway/mirada/mirada-brain/src/watch.rs

02_ruway/mirada/mirada-compositor/src/drm_backend.rs

02_ruway/mirada/mirada-compositor/src/main.rs

02_ruway/mirada/mirada-compositor/src/menu.rs

02_ruway/mirada/mirada-compositor/src/text.rs

02_ruway/mirada/mirada-ctl/src/main.rs

02_ruway/mirada/mirada-greeter/src/main.rs

02_ruway/mirada/mirada-greeter/src/rain.rs

02_ruway/mirada/mirada-greeter/src/sessions.rs

02_ruway/mirada/mirada-greeter/src/state.rs

02_ruway/mirada/mirada-launcher/src/main.rs

02_ruway/mirada/mirada-layout/src/geometry.rs

02_ruway/mirada/mirada-layout/src/layout.rs

02_ruway/mirada/mirada-layout/src/lib.rs

02_ruway/mirada/mirada-layout/src/workspace.rs

02_ruway/mirada/mirada-link/src/lib.rs

02_ruway/mirada/mirada-portal/src/main.rs

02_ruway/mirada/mirada-portal/src/theme_facts.rs

shared/app-bus/src/lib.rs

shared/auth/auth-core/examples/auth-probe.rs

shared/auth/auth-core/src/lib.rs

shared/auth/auth-core/src/pam_backend.rs

shared/auth/auth-core/src/ticket.rs

shared/auth/auth-core/src/user.rs

shared/format/src/cable.rs

shared/format/src/constantes.rs

shared/format/src/firma.rs

shared/format/src/grafo.rs

shared/format/src/lib.rs

shared/format/src/pruebas.rs

shared/format/src/tipos.rs

shared/rimay-localize/examples/showcase.rs

shared/rimay-localize/src/lib.rs

shared/wawa-config/src/lib.rs

116 qillqakuna · +31060 −0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/llimphi/widgets/menubar/Cargo.toml b/02_ruway/llimphi/widgets/menubar/Cargo.toml
new file mode 100644
index 0000000..d71f673
--- /dev/null
+++ b/02_ruway/llimphi/widgets/menubar/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "llimphi-widget-menubar"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "llimphi-widget-menubar — barra de menú principal in-window (Archivo/Editar/Ver/Ayuda) que cualquier app Llimphi monta a partir de un app_bus::AppMenu. menubar_view() pinta la fila de títulos; menubar_overlay() el dropdown (vía context-menu) para App::view_overlay. Decoplado del Surface del launcher: sirve dentro de la ventana de cada app."
+
+[dependencies]
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-button = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+app-bus = { path = "../../../../shared/app-bus" }
diff --git a/02_ruway/llimphi/widgets/menubar/src/lib.rs b/02_ruway/llimphi/widgets/menubar/src/lib.rs
new file mode 100644
index 0000000..abd178d
--- /dev/null
+++ b/02_ruway/llimphi/widgets/menubar/src/lib.rs
@@ -0,0 +1,334 @@
+//! `llimphi-widget-menubar` — la barra de menú principal de una app.
+//!
+//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar /
+//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de
+//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la
+//! ventana de la app — para las apps que corren standalone y no bajo el
+//! shell del launcher.
+//!
+//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz
+//! está abierto (`Option<usize>`); el widget aplana el `AppMenu` y emite
+//! `Msg` en cada interacción.
+//!
+//! Dos entradas:
+//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`.
+//! - [`menubar_overlay`] → el dropdown del menú abierto, para
+//!   `App::view_overlay` (devolvé `None` si no hay nada abierto).
+//!
+//! El `command` de cada ítem es el id que la app entiende (convención
+//! `menu.<verbo>`, ver [`app_bus::AppMenu::standard`]); el widget lo
+//! rebota por `on_command`.
+
+#![forbid(unsafe_code)]
+
+use std::sync::Arc;
+
+use app_bus::{AppMenu, Menu};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
+    Rect,
+};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+use llimphi_widget_button::{button_styled, ButtonPalette};
+use llimphi_widget_context_menu::{
+    context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette,
+    ContextMenuSpec,
+};
+
+type MsgFromMenu<Msg> = Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>;
+type MsgFromStr<Msg> = Arc<dyn Fn(&str) -> Msg + Send + Sync>;
+
+/// Todo lo que el render necesita. El host lo arma en cada `view()`.
+pub struct MenuBarSpec<'a, Msg: Clone + 'static> {
+    /// El menú a pintar (típicamente `AppMenu::standard()` + menús propios).
+    pub menu: &'a AppMenu,
+    /// Índice del menú raíz abierto (estado del host). `None` = ninguno.
+    pub open: Option<usize>,
+    pub theme: &'a Theme,
+    /// Tamaño de la ventana — para clampear el dropdown.
+    pub viewport: (f32, f32),
+    /// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón.
+    pub height: f32,
+    /// Abrir/cerrar un menú raíz por índice (`None` = cerrar).
+    pub on_open: MsgFromMenu<Msg>,
+    /// command id → Msg, al elegir un ítem.
+    pub on_command: MsgFromStr<Msg>,
+}
+
+/// Alto recomendado de la barra de menú.
+pub const DEFAULT_HEIGHT: f32 = 30.0;
+
+fn title_palette(theme: &Theme) -> ButtonPalette {
+    ButtonPalette::from_theme(theme)
+}
+
+fn title_palette_active(theme: &Theme) -> ButtonPalette {
+    let base = ButtonPalette::from_theme(theme);
+    ButtonPalette {
+        bg: theme.accent,
+        bg_hover: theme.accent,
+        fg: theme.bg_panel,
+        radius: base.radius,
+    }
+}
+
+/// La fila de títulos (Archivo / Editar / …). Click sobre un título
+/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent.
+/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que,
+/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú
+/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay,
+/// donde los títulos quedan por encima del scrim y son hovereables.
+fn titles_row<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>, hover_switch: bool) -> View<Msg> {
+    let pal = title_palette(spec.theme);
+    let pal_on = title_palette_active(spec.theme);
+
+    let mut titles: Vec<View<Msg>> = Vec::with_capacity(spec.menu.menus.len());
+    for (i, root) in spec.menu.menus.iter().enumerate() {
+        let open = spec.open == Some(i);
+        let target = if open { None } else { Some(i) };
+        let mut title = button_styled(
+            root.label.clone(),
+            title_style(),
+            Alignment::Center,
+            if open { &pal_on } else { &pal },
+            (spec.on_open)(target),
+        );
+        // Con un menú abierto, hover sobre otro título lo abre.
+        if hover_switch && !open {
+            title = title.on_pointer_enter((spec.on_open)(Some(i)));
+        }
+        titles.push(title);
+    }
+
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(spec.height),
+        },
+        flex_shrink: 0.0,
+        flex_direction: FlexDirection::Row,
+        align_items: Some(AlignItems::Center),
+        padding: Rect {
+            left: length(6.0_f32),
+            right: length(6.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        gap: Size {
+            width: length(2.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(spec.theme.bg_panel_alt)
+    .children(titles)
+}
+
+/// La barra de menú principal — primer hijo del column raíz de `view()`.
+pub fn menubar_view<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> View<Msg> {
+    titles_row(spec, false)
+}
+
+/// Aplana un menú raíz al par alineado `(items, commands)` que consume el
+/// context-menu (los separadores `separator_before` se insertan como
+/// filas y llevan `command = None`). Es la única fuente de verdad del
+/// orden de filas — la navegación por teclado y el render comparten esto.
+fn dropdown_items(root: &Menu) -> (Vec<ContextMenuItem>, Vec<Option<String>>) {
+    let mut items: Vec<ContextMenuItem> = Vec::new();
+    let mut commands: Vec<Option<String>> = Vec::new();
+    for (k, src) in root.items.iter().enumerate() {
+        if src.separator_before && k != 0 {
+            items.push(ContextMenuItem::separator());
+            commands.push(None);
+        }
+        let mut cm = ContextMenuItem::action(src.label.clone());
+        if let Some(s) = &src.shortcut {
+            cm = cm.with_shortcut(s.clone());
+        }
+        if let Some(ic) = &src.icon {
+            cm = cm.icon(ic.clone());
+        }
+        if !src.enabled {
+            cm = cm.disabled();
+        }
+        items.push(cm);
+        commands.push(Some(src.command.clone()));
+    }
+    (items, commands)
+}
+
+/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no
+/// hay menú abierto. Hospeda además una copia de la fila de títulos por
+/// encima del scrim: así, con el menú abierto, mover el mouse a otro
+/// título cambia de menú (hover-switch).
+pub fn menubar_overlay<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> Option<View<Msg>> {
+    menubar_overlay_core(spec, usize::MAX, 1.0)
+}
+
+/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado;
+/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil
+/// para que el dropdown se deslice/funda al cambiar de menú por hover o
+/// flechas). La app guarda el `active` y un `Tween` para el `appear`.
+pub fn menubar_overlay_animated<Msg: Clone + 'static>(
+    spec: &MenuBarSpec<Msg>,
+    active: usize,
+    appear: f32,
+) -> Option<View<Msg>> {
+    menubar_overlay_core(spec, active, appear)
+}
+
+fn menubar_overlay_core<Msg: Clone + 'static>(
+    spec: &MenuBarSpec<Msg>,
+    active: usize,
+    appear: f32,
+) -> Option<View<Msg>> {
+    let idx = spec.open?;
+    let root = spec.menu.menus.get(idx)?;
+
+    let mut x = 6.0_f32;
+    for prev in spec.menu.menus.iter().take(idx) {
+        x += approx_title_width(&prev.label);
+    }
+
+    let (items, commands) = dropdown_items(root);
+
+    let on_command = spec.on_command.clone();
+    let on_open = spec.on_open.clone();
+    let commands = Arc::new(commands);
+    let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
+        match commands.get(i).and_then(|c| c.clone()) {
+            Some(cmd) => (on_command)(&cmd),
+            None => (on_open)(None),
+        }
+    });
+
+    let dropdown = context_menu_view_ex(
+        ContextMenuSpec {
+            anchor: (x, spec.height),
+            viewport: spec.viewport,
+            header: Some(root.label.clone()),
+            items,
+            active,
+            on_pick,
+            on_dismiss: (spec.on_open)(None),
+            palette: ContextMenuPalette::from_theme(spec.theme),
+        },
+        ContextMenuExtras {
+            appear,
+            ..ContextMenuExtras::default()
+        },
+    );
+
+    // Fila de títulos por encima del scrim del dropdown: queda hovereable
+    // para cambiar de menú con el mouse. Absoluta al tope para no consumir
+    // el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana
+    // el hit-test.
+    let titles = View::new(Style {
+        position: Position::Absolute,
+        inset: Rect {
+            left: length(0.0_f32),
+            top: length(0.0_f32),
+            right: auto(),
+            bottom: auto(),
+        },
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(spec.height),
+        },
+        ..Default::default()
+    })
+    .children(vec![titles_row(spec, true)]);
+
+    Some(
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .children(vec![dropdown, titles]),
+    )
+}
+
+/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el
+/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el
+/// próximo índice de fila válido (saltea separadores y deshabilitados).
+/// `usize::MAX` si no hay menú abierto o sin filas elegibles.
+pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize {
+    let Some(root) = menu.menus.get(menu_idx) else {
+        return usize::MAX;
+    };
+    let (items, _) = dropdown_items(root);
+    step_active(&items, active, dir)
+}
+
+/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con
+/// Enter). `None` si el índice no es una fila-acción.
+pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option<String> {
+    let root = menu.menus.get(menu_idx)?;
+    let (_, commands) = dropdown_items(root);
+    commands.get(active).cloned().flatten()
+}
+
+fn title_style() -> Style {
+    Style {
+        size: Size {
+            width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
+            height: length(24.0_f32),
+        },
+        flex_shrink: 0.0,
+        padding: Rect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    }
+}
+
+/// Ancho aproximado de un título — mismo criterio que `launcher-llimphi`
+/// para anclar el dropdown sin medir la fuente.
+fn approx_title_width(label: &str) -> f32 {
+    label.chars().count() as f32 * 8.0 + 22.0
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn overlay_none_si_no_hay_abierto() {
+        let menu = AppMenu::standard();
+        let spec = MenuBarSpec {
+            menu: &menu,
+            open: None,
+            theme: &Theme::dark(),
+            viewport: (800.0, 600.0),
+            height: DEFAULT_HEIGHT,
+            on_open: Arc::new(|_| 0u8),
+            on_command: Arc::new(|_| 1u8),
+        };
+        assert!(menubar_overlay(&spec).is_none());
+    }
+
+    #[test]
+    fn overlay_some_si_hay_abierto() {
+        let menu = AppMenu::standard();
+        let spec = MenuBarSpec {
+            menu: &menu,
+            open: Some(0),
+            theme: &Theme::dark(),
+            viewport: (800.0, 600.0),
+            height: DEFAULT_HEIGHT,
+            on_open: Arc::new(|_| 0u8),
+            on_command: Arc::new(|_| 1u8),
+        };
+        assert!(menubar_overlay(&spec).is_some());
+    }
+}
diff --git a/02_ruway/mirada/mirada-app-llimphi/Cargo.toml b/02_ruway/mirada/mirada-app-llimphi/Cargo.toml
new file mode 100644
index 0000000..97eef0c
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "mirada-app-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada (Llimphi) — el Cerebro del compositor: ventana Llimphi que tesela el escritorio sobre mirada-brain y manda la geometría al Cuerpo (smithay) por mirada-link. Reemplazo del `mirada-app` GPUI; sin Cuerpo arranca en simulación."
+
+[[bin]]
+name = "mirada-llimphi"
+path = "src/main.rs"
+
+[dependencies]
+mirada-brain = { path = "../mirada-brain" }
+mirada-link = { path = "../mirada-link" }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
+rimay-localize = { path = "../../../shared/rimay-localize" }
+wawa-config = { path = "../../../shared/wawa-config" }
diff --git a/02_ruway/mirada/mirada-app-llimphi/LEEME.md b/02_ruway/mirada/mirada-app-llimphi/LEEME.md
new file mode 100644
index 0000000..5373fe9
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/LEEME.md
@@ -0,0 +1,9 @@
+# mirada-app-llimphi
+
+> Apps shell del compositor de [mirada](../README.md).
+
+Conjunto de mini-apps que viven adentro del compositor (taskbar, notification center, screenshot tool). Llimphi nativo sobre [`mirada-protocol`](../mirada-protocol/README.md).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`llimphi-ui`](../../llimphi/)
diff --git a/02_ruway/mirada/mirada-app-llimphi/README.md b/02_ruway/mirada/mirada-app-llimphi/README.md
new file mode 100644
index 0000000..aa02c47
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/README.md
@@ -0,0 +1,9 @@
+# mirada-app-llimphi
+
+> Compositor shell apps of [mirada](../README.md).
+
+Set of mini-apps that live inside the compositor (taskbar, notification center, screenshot tool). Llimphi native over [`mirada-protocol`](../mirada-protocol/README.md).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`llimphi-ui`](../../llimphi/)
diff --git a/02_ruway/mirada/mirada-app-llimphi/src/main.rs b/02_ruway/mirada/mirada-app-llimphi/src/main.rs
new file mode 100644
index 0000000..c5dbf9c
--- /dev/null
+++ b/02_ruway/mirada/mirada-app-llimphi/src/main.rs
@@ -0,0 +1,1138 @@
+//! `mirada-llimphi` — la ventana del Cerebro del compositor.
+//!
+//! Es el "Cerebro" de la arquitectura carmen hecho app Llimphi: envuelve
+//! [`mirada_brain::Desktop`] (toda la lógica de teselado y foco) y lo
+//! pinta. La cadena completa:
+//!
+//! ```text
+//!   mirada-layout ─► mirada-protocol ─► mirada-brain ─► [esta ventana]
+//!                                          │
+//!                                    mirada-link ─► mirada-compositor (Cuerpo)
+//! ```
+//!
+//! Con un Cuerpo conectado (variable `MIRADA_SOCKET`) sondea sus
+//! [`BodyEvent`]s y le devuelve [`BrainCommand`]s por el socket. Sin
+//! Cuerpo arranca en **simulación**: las ventanas son sintéticas y el
+//! teclado de esta ventana maneja el escritorio — útil para ver el
+//! motor de teselado sin hardware.
+//!
+//! Teclas (simulación):
+//!
+//! ```text
+//!   n / Shift+n  abre ventana / monitor    tab / espacio  cicla layout
+//!   w            cierra la enfocada        t m g c r d s  layout directo
+//!   f / Shift+f  flota / pantalla completa h / l          área maestra −/+
+//!   j / k        foco siguiente/anterior   , / .          nmaster −/+
+//!   Shift+j / k  mueve la enfocada         1..9           ir a escritorio
+//!   Enter        promueve a maestra        Ctrl+1..9      enviar a escritorio
+//!   o            siguiente monitor         ` / Shift+`    scratchpad ver/guardar
+//! ```
+//!
+//! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
+//! `mirada-ctl` controla el escritorio desde la terminal — ambos pasan
+//! por el mismo `Desktop::apply`.
+
+#![forbid(unsafe_code)]
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use app_bus::{AppMenu, Menu, MenuItem};
+use llimphi_theme::Theme;
+use llimphi_widget_context_menu::{
+    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+    menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+    DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, AlignItems, Dimension, FlexDirection, JustifyContent, Position, Size, Style},
+    Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use mirada_brain::{
+    BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
+    Keymap, KeymapWatch, LayoutMode, Rules, WindowId, WindowPlacement,
+};
+use mirada_link::BrainLink;
+
+/// Pantalla virtual del modo simulación — coincide con el lienzo.
+const SCREEN_W: i32 = 1280;
+const SCREEN_H: i32 = 720;
+/// Período del sondeo del Cuerpo, en ms (~60 Hz).
+const POLL_MS: u64 = 16;
+
+/// Nombres de app ficticios para las ventanas de simulación.
+const APPS: &[&str] = &[
+    "shuma", "pluma_app", "revista", "cosmobiología", "matilda", "pluma_notebook_app", "barra",
+];
+
+struct Model {
+    theme: Theme,
+    desktop: Desktop,
+    /// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
+    placements: Vec<WindowPlacement>,
+    /// Contador de ids para las ventanas sintéticas.
+    next_id: WindowId,
+    /// Cable al Cuerpo; `None` en simulación.
+    link: Option<BrainLink>,
+    /// Última acción, para la barra de estado.
+    note: String,
+    /// Ruta del keymap del usuario, para recargarlo en caliente.
+    keymap_path: Option<PathBuf>,
+    /// Vigía del keymap; `None` en simulación o si no hay archivo.
+    keymap_watch: Option<KeymapWatch>,
+    /// Socket del API de control externo (`mirada-ctl`).
+    ctl: Option<CtlServer>,
+    /// Barra de menú principal: índice del menú raíz abierto (`None`
+    /// cerrado).
+    menu_open: Option<usize>,
+    /// Fila resaltada dentro del dropdown abierto (`usize::MAX` = ninguna).
+    menu_active: usize,
+    /// Animación de aparición/swap del dropdown del menú principal.
+    menu_anim: Tween<f32>,
+    /// Menú contextual sobre la ventana enfocada: ancla `(x, y)` en
+    /// coordenadas de ventana. `None` cerrado. No hay edición de texto,
+    /// así que el contextual sólo ofrece acciones de gestión de ventana.
+    context_menu: Option<(f32, f32)>,
+}
+
+#[derive(Clone)]
+enum Msg {
+    /// Tick periódico: drena el Cuerpo, vigila el keymap, atiende ctl.
+    Tick,
+    /// Tecla recibida desde la ventana de simulación.
+    Key(KeyEvent),
+    /// Click en un pip de escritorio.
+    SwitchWorkspace(usize),
+    /// Click en una ventana del lienzo.
+    FocusWindow(WindowId),
+    /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
+    MenuOpen(Option<usize>),
+    /// Comando elegido en el menú principal — se traduce a acciones reales.
+    MenuCommand(String),
+    /// Navegación por teclado dentro del dropdown: +1 baja, -1 sube.
+    MenuNav(i32),
+    /// Enter sobre la fila resaltada del dropdown.
+    MenuActivate,
+    /// Tick de la animación del menú (sólo re-render).
+    MenuTick,
+    /// Cierra cualquier menú abierto (click-fuera / Esc).
+    CloseMenus,
+    /// Right-click en la raíz → abre el menú contextual anclado en
+    /// `(x, y)` sobre la ventana enfocada. Sin ventana enfocada es no-op.
+    ContextMenuOpen(f32, f32),
+    /// Ejecuta una acción de escritorio (usado por el menú contextual y
+    /// el principal sobre la ventana enfocada).
+    Act(DesktopAction),
+}
+
+struct Mirada;
+
+impl App for Mirada {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "brahman · mirada"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (SCREEN_W as u32, (SCREEN_H + 70) as u32)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        // Keymap del usuario (~/.config/mirada/keymap.ron): define los
+        // atajos que el Cuerpo intercepta y nos devuelve como `Keybind`.
+        let keymap_path = Keymap::default_path();
+        let keymap = match &keymap_path {
+            Some(p) => Keymap::load_or_init(p),
+            None => Keymap::default(),
+        };
+        let link = connect_body();
+        // Vigilar el keymap sólo tiene sentido con un Cuerpo conectado;
+        // en simulación, mirada usa las teclas de su propia ventana.
+        let keymap_watch = if link.is_some() {
+            keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok())
+        } else {
+            None
+        };
+        // API de control: mirada siempre posee el Desktop, así que
+        // siempre abre el socket de `mirada-ctl`.
+        let ctl = match CtlServer::bind(&mirada_brain::ctl::default_socket_path()) {
+            Ok(s) => Some(s),
+            Err(e) => {
+                eprintln!("mirada · sin API de control: {e}");
+                None
+            }
+        };
+
+        let mut desktop = Desktop::with_keymap(keymap);
+        desktop.set_rules(load_user_rules());
+
+        let mut model = Model {
+            theme: Theme::dark(),
+            desktop,
+            placements: Vec::new(),
+            next_id: 1,
+            link,
+            note: rimay_localize::t("success"),
+            keymap_path,
+            keymap_watch,
+            ctl,
+            menu_open: None,
+            menu_active: usize::MAX,
+            menu_anim: Tween::idle(1.0),
+            context_menu: None,
+        };
+        if let Some(link) = model.link.as_mut() {
+            let _ = link.send(&model.desktop.grab_keys());
+            model.note = rimay_localize::t("mirada-status-body-connected");
+        } else {
+            // Simulación: una pantalla virtual y tres ventanas de muestra.
+            feed(&mut model, BodyEvent::OutputAdded {
+                id: 0,
+                width: SCREEN_W,
+                height: SCREEN_H,
+            });
+            for _ in 0..3 {
+                open_window(&mut model);
+            }
+            model.note = rimay_localize::t("mirada-status-simulation");
+        }
+
+        // El sondeo corre siempre: drena el Cuerpo (si lo hay), vigila el
+        // keymap y atiende `mirada-ctl`. Llega como `Msg::Tick` al update.
+        handle.spawn_periodic(Duration::from_millis(POLL_MS), || Msg::Tick);
+
+        model
+    }
+
+    fn on_key(model: &Model, e: &KeyEvent) -> Option<Msg> {
+        if e.state != KeyState::Pressed {
+            return None;
+        }
+        // Con el menú principal abierto las flechas navegan: ←/→ cambian de
+        // menú raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta y
+        // Esc cierra. Consume la tecla.
+        if let Some(mi) = model.menu_open {
+            let n = app_menu(model).menus.len().max(1);
+            return match &e.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+                Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+                Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+                Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+                Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+                _ => None,
+            };
+        }
+        Some(Msg::Key(e.clone()))
+    }
+
+    fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+        let mut m = model;
+        match msg {
+            Msg::Tick => tick(&mut m),
+            Msg::Key(ev) => handle_key(&mut m, &ev),
+            Msg::SwitchWorkspace(i) => act(&mut m, DesktopAction::SwitchWorkspace(i)),
+            Msg::FocusWindow(id) => act(&mut m, DesktopAction::FocusWindow(id)),
+            Msg::MenuOpen(which) => {
+                m.menu_open = which;
+                // Abrir un menú raíz cierra cualquier contextual.
+                m.context_menu = None;
+                m.menu_active = usize::MAX;
+                // Animación de aparición/swap del dropdown.
+                if which.is_some() {
+                    m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                    animate(handle, motion::FAST, || Msg::MenuTick);
+                }
+            }
+            Msg::MenuNav(dir) => {
+                if let Some(mi) = m.menu_open {
+                    let menu = app_menu(&m);
+                    m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir);
+                }
+            }
+            Msg::MenuActivate => {
+                if let Some(mi) = m.menu_open {
+                    let menu = app_menu(&m);
+                    if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) {
+                        m.menu_open = None;
+                        handle_menu_command(&mut m, &cmd);
+                    }
+                }
+            }
+            Msg::MenuTick => {}
+            Msg::CloseMenus => {
+                m.menu_open = None;
+                m.context_menu = None;
+                m.menu_active = usize::MAX;
+            }
+            Msg::ContextMenuOpen(x, y) => {
+                // Sólo tiene sentido con una ventana enfocada.
+                if m.desktop.focused_window().is_some() {
+                    m.menu_open = None;
+                    m.context_menu = Some((x, y));
+                }
+            }
+            Msg::MenuCommand(cmd) => {
+                m.menu_open = None;
+                handle_menu_command(&mut m, &cmd);
+            }
+            Msg::Act(action) => {
+                m.menu_open = None;
+                m.context_menu = None;
+                act(&mut m, action);
+            }
+        }
+        m
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        let theme = &model.theme;
+        // Barra de menú principal — primer hijo del column raíz.
+        let menu = app_menu(model);
+        let menubar = menubar_view(&menubar_spec(&menu, model, theme));
+        // Colores cromáticos heredados del original (HSL → RGB hardcoded).
+        let win_bg = Color::from_rgba8(28, 32, 41, 255);
+        let bar_bg = Color::from_rgba8(19, 22, 30, 255);
+        let canvas_bg = Color::from_rgba8(10, 13, 19, 255);
+        let on_accent = Color::from_rgba8(12, 16, 24, 255);
+
+        let active = model.desktop.active_index();
+        let mode = model.desktop.active_workspace().params().mode;
+        let loads = model.desktop.workspace_loads();
+        let focused = model.desktop.focused_window();
+
+        // --- Barra superior: identidad + escritorios + modo ----------
+        let bar = top_bar(model, theme, mode, &loads, active, focused, on_accent, bar_bg);
+
+        // --- Lienzo: el escritorio teselado, a escala ----------------
+        let canvas = canvas_view(model, theme, on_accent, win_bg, canvas_bg);
+
+        // --- Pie de estado ------------------------------------------
+        let status = View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(26.0_f32),
+            },
+            padding: Rect {
+                left: length(14.0_f32),
+                right: length(14.0_f32),
+                top: length(0.0_f32),
+                bottom: length(0.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            ..Default::default()
+        })
+        .fill(bar_bg)
+        .text_aligned(model.note.clone(), 11.0, theme.fg_placeholder, Alignment::Start);
+
+        // --- Composición ---------------------------------------------
+        let canvas_wrap = View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size {
+                width: percent(1.0_f32),
+                height: Dimension::auto(),
+            },
+            flex_grow: 1.0,
+            align_items: Some(AlignItems::Center),
+            justify_content: Some(JustifyContent::Center),
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        .children(vec![canvas]);
+
+        View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        // El origen de la raíz es (0,0) ⇒ coords locales == coords de
+        // ventana. Right-click abre el contextual sobre la ventana
+        // enfocada.
+        .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+        .children(vec![menubar, bar, canvas_wrap, status])
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        // El menú contextual de la ventana enfocada tiene prioridad.
+        if let Some((x, y)) = model.context_menu {
+            let focused = model.desktop.focused_window();
+            let label = focused
+                .and_then(|id| model.desktop.window_info(id))
+                .map(|i| i.title.clone())
+                .unwrap_or_else(|| rimay_localize::t("mirada-win-label-fallback"));
+            // Acciones reales del Desktop sobre la enfocada. Sin edición
+            // de texto: el contextual es de gestión de ventana.
+            let t = rimay_localize::t;
+            let actions: [(String, DesktopAction); 6] = [
+                (t("mirada-win-promote"), DesktopAction::PromoteToMaster),
+                (t("mirada-win-float"), DesktopAction::ToggleFloat),
+                (t("mirada-win-fullscreen"), DesktopAction::ToggleFullscreen),
+                (t("mirada-win-scratchpad"), DesktopAction::SendToScratchpad),
+                (t("mirada-output-next"), DesktopAction::FocusOutputNext),
+                (t("close"), DesktopAction::CloseFocused),
+            ];
+            // "Cerrar" (último) se marca como destructivo.
+            let last = actions.len() - 1;
+            let items: Vec<ContextMenuItem> = actions
+                .iter()
+                .enumerate()
+                .map(|(i, (l, _))| {
+                    let it = ContextMenuItem::action(l.clone());
+                    if i == last {
+                        it.destructive()
+                    } else {
+                        it
+                    }
+                })
+                .collect();
+            let acts: Vec<DesktopAction> = actions.iter().map(|(_, a)| a.clone()).collect();
+            let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+                Arc::new(move |i: usize| Msg::Act(acts[i].clone()));
+            return Some(context_menu_view(ContextMenuSpec {
+                anchor: (x, y),
+                viewport: viewport_of(model),
+                header: Some(label),
+                items,
+                active: usize::MAX,
+                on_pick,
+                on_dismiss: Msg::CloseMenus,
+                palette: ContextMenuPalette::from_theme(&model.theme),
+            }));
+        }
+        // Si no, el dropdown del menú principal.
+        let menu = app_menu(model);
+        menubar_overlay_animated(
+            &menubar_spec(&menu, model, &model.theme),
+            model.menu_active,
+            model.menu_anim.value(),
+        )
+    }
+}
+
+// ─── Lógica fuera del trait App ─────────────────────────────────────
+
+/// Bucle de poll consolidado: drena Cuerpo, recarga keymap, sirve ctl.
+fn tick(m: &mut Model) {
+    let events: Vec<BodyEvent> = match m.link.as_ref() {
+        Some(link) => link.drain(),
+        None => Vec::new(),
+    };
+    let keymap_changed = m.keymap_watch.as_ref().is_some_and(|w| w.changed());
+    if keymap_changed {
+        reload_keymap(m);
+    }
+    let _ctl_served = poll_ctl(m);
+    for ev in events {
+        feed(m, ev);
+    }
+}
+
+fn open_window(m: &mut Model) {
+    let id = m.next_id;
+    m.next_id += 1;
+    let app = APPS[(id as usize) % APPS.len()];
+    feed(m, BodyEvent::WindowOpened {
+        id,
+        app_id: format!("org.brahman.{app}"),
+        title: format!("{app} · ventana {id}"),
+    });
+    m.note = format!("abierta ventana {id}");
+}
+
+fn feed(m: &mut Model, event: BodyEvent) {
+    let cmds = m.desktop.on_event(event);
+    dispatch(m, cmds);
+}
+
+fn act(m: &mut Model, action: DesktopAction) {
+    let cmds = m.desktop.apply(action);
+    dispatch(m, cmds);
+}
+
+fn reload_keymap(m: &mut Model) {
+    let Some(path) = m.keymap_path.clone() else {
+        return;
+    };
+    match Keymap::load(&path) {
+        Ok(km) => {
+            let cmd = m.desktop.set_keymap(km);
+            dispatch(m, vec![cmd]);
+            m.note = rimay_localize::t("mirada-status-keymap-reloaded");
+        }
+        Err(e) => m.note = format!("{}: {e}", rimay_localize::t("mirada-status-keymap-invalid")),
+    }
+}
+
+fn poll_ctl(m: &mut Model) -> bool {
+    let conns: Vec<CtlConn> = match &m.ctl {
+        Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(),
+        None => return false,
+    };
+    let mut served = false;
+    for mut conn in conns {
+        let reply = match conn.read_request() {
+            Ok(Some(req)) => {
+                served = true;
+                serve_ctl(m, req)
+            }
+            Ok(None) => continue,
+            Err(e) => CtlReply::Error(format!("{e}")),
+        };
+        let _ = conn.reply(&reply);
+    }
+    served
+}
+
+fn serve_ctl(m: &mut Model, req: CtlRequest) -> CtlReply {
+    match req {
+        CtlRequest::Do(action) => {
+            act(m, action);
+            CtlReply::Ok
+        }
+        CtlRequest::ListWindows => CtlReply::Windows(m.desktop.window_lines()),
+        // Las zonas de arrastre son del compositor; esta app de Cerebro no las
+        // gestiona.
+        CtlRequest::CycleZones => CtlReply::Ok,
+    }
+}
+
+fn dispatch(m: &mut Model, cmds: Vec<BrainCommand>) {
+    for cmd in &cmds {
+        if let BrainCommand::Place(p) = cmd {
+            m.placements = p.clone();
+        }
+    }
+    match m.link.as_mut() {
+        Some(link) => {
+            for cmd in &cmds {
+                let _ = link.send(cmd);
+            }
+        }
+        None => {
+            for cmd in cmds {
+                match cmd {
+                    BrainCommand::Close(id) | BrainCommand::Kill(id) => {
+                        feed(m, BodyEvent::WindowClosed { id });
+                    }
+                    _ => {}
+                }
+            }
+        }
+    }
+}
+
+/// Mapea una tecla a una acción de escritorio. La firma cambia respecto
+/// del original GPUI: ahora muta `Model` directamente porque el bucle
+/// Llimphi es Elm puro.
+fn handle_key(m: &mut Model, ev: &KeyEvent) {
+    let shift = ev.modifiers.shift;
+    let ctrl = ev.modifiers.ctrl;
+    let connected = m.link.is_some();
+
+    let key_str: Option<String> = match &ev.key {
+        Key::Named(NamedKey::Tab) => Some("tab".into()),
+        Key::Named(NamedKey::Space) => Some("space".into()),
+        Key::Named(NamedKey::Enter) => Some("enter".into()),
+        Key::Character(s) => Some(s.to_lowercase()),
+        _ => None,
+    };
+    let Some(k) = key_str else { return };
+
+    match k.as_str() {
+        "n" if shift && !connected => {
+            let id = m.desktop.outputs().len() as u32;
+            feed(m, BodyEvent::OutputAdded {
+                id,
+                width: SCREEN_W,
+                height: SCREEN_H,
+            });
+        }
+        "n" if !connected => open_window(m),
+        "w" => act(m, DesktopAction::CloseFocused),
+        "f" if shift => act(m, DesktopAction::ToggleFullscreen),
+        "f" => act(m, DesktopAction::ToggleFloat),
+        "j" if shift => act(m, DesktopAction::MoveForward),
+        "k" if shift => act(m, DesktopAction::MoveBackward),
+        "j" => act(m, DesktopAction::FocusNext),
+        "k" => act(m, DesktopAction::FocusPrev),
+        "tab" | "space" => act(m, DesktopAction::CycleLayout),
+        "t" => act(m, DesktopAction::SetLayout(LayoutMode::MasterStack)),
+        "m" => act(m, DesktopAction::SetLayout(LayoutMode::Monocle)),
+        "g" => act(m, DesktopAction::SetLayout(LayoutMode::Grid)),
+        "c" => act(m, DesktopAction::SetLayout(LayoutMode::Columns)),
+        "r" => act(m, DesktopAction::SetLayout(LayoutMode::Rows)),
+        "d" => act(m, DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+        "s" => act(m, DesktopAction::SetLayout(LayoutMode::Spiral)),
+        "h" => act(m, DesktopAction::ShrinkMaster),
+        "l" => act(m, DesktopAction::GrowMaster),
+        "o" => act(m, DesktopAction::FocusOutputNext),
+        "`" if shift => act(m, DesktopAction::SendToScratchpad),
+        "`" => act(m, DesktopAction::ToggleScratchpad),
+        "enter" => act(m, DesktopAction::PromoteToMaster),
+        "," => act(m, DesktopAction::IncMaster),
+        "." => act(m, DesktopAction::DecMaster),
+        d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
+            let n = (d.as_bytes()[0] - b'1') as usize;
+            if ctrl {
+                act(m, DesktopAction::SendToWorkspace(n));
+            } else {
+                act(m, DesktopAction::SwitchWorkspace(n));
+            }
+        }
+        _ => {}
+    }
+}
+
+fn connect_body() -> Option<BrainLink> {
+    let path = std::env::var("MIRADA_SOCKET").ok()?;
+    BrainLink::connect(&path).ok()
+}
+
+fn load_user_rules() -> Rules {
+    match Rules::default_path() {
+        Some(p) => Rules::load_or_default(&p),
+        None => Rules::default(),
+    }
+}
+
+fn mode_name(m: LayoutMode) -> String {
+    match m {
+        LayoutMode::MasterStack => rimay_localize::t("mirada-layout-master-stack"),
+        LayoutMode::Monocle => rimay_localize::t("mirada-layout-monocle"),
+        LayoutMode::Grid => rimay_localize::t("mirada-layout-grid"),
+        LayoutMode::Columns => rimay_localize::t("mirada-layout-columns"),
+        LayoutMode::Rows => rimay_localize::t("mirada-layout-rows"),
+        LayoutMode::CenteredMaster => rimay_localize::t("mirada-layout-centered"),
+        LayoutMode::Spiral => rimay_localize::t("mirada-layout-spiral"),
+    }
+}
+
+// ─── Menú principal y contextual ────────────────────────────────────
+
+/// Viewport para clampear overlays. El Model no trackea el tamaño de
+/// ventana, así que usamos `initial_size()`.
+fn viewport_of(_model: &Model) -> (f32, f32) {
+    let (w, h) = Mirada::initial_size();
+    (w as f32, h as f32)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport: viewport_of(model),
+        height: MENU_H,
+        on_open: Arc::new(Msg::MenuOpen),
+        on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
+
+/// El menú principal de mirada. Archivo / Ver / Ayuda — sólo comandos que
+/// mapean a acciones reales del `Desktop`. Sin "Editar": no hay campos de
+/// texto editables. Los items que actúan sobre la enfocada se inhabilitan
+/// cuando no hay ventana enfocada. Abrir ventana/monitor sólo tiene
+/// sentido en simulación (sin Cuerpo conectado).
+fn app_menu(model: &Model) -> AppMenu {
+    let has_focus = model.desktop.focused_window().is_some();
+    let sim = model.link.is_none();
+    let mode = model.desktop.active_workspace().params().mode;
+
+    // Etiquetas de UI localizadas: IDs genéricos del catálogo o prefijados
+    // con `mirada-`. Los segundos argumentos de MenuItem (ids de comando)
+    // son estables y NO se localizan.
+    let t = rimay_localize::t;
+
+    let mut abrir = MenuItem::new(t("mirada-menu-open-window"), "file.new_window").shortcut("n");
+    let mut abrir_mon = MenuItem::new(t("mirada-menu-open-output"), "file.new_output").shortcut("Shift+n");
+    if !sim {
+        // Con Cuerpo conectado, las ventanas las crea el compositor real.
+        abrir = abrir.disabled();
+        abrir_mon = abrir_mon.disabled();
+    }
+    let mut cerrar = MenuItem::new(t("mirada-menu-close-focused"), "win.close").shortcut("w").separated();
+    if !has_focus {
+        cerrar = cerrar.disabled();
+    }
+
+    // Submenú de layouts: el modo vigente queda en gris (ya aplicado).
+    let layout_item = |label: String, cmd: &str, m: LayoutMode| {
+        let it = MenuItem::new(label, cmd);
+        if mode == m {
+            it.disabled()
+        } else {
+            it
+        }
+    };
+
+    let mut promover = MenuItem::new(t("mirada-win-promote"), "win.promote").shortcut("Enter");
+    let mut flotar = MenuItem::new(t("mirada-win-float"), "win.float").shortcut("f");
+    let mut fullscreen = MenuItem::new(t("mirada-win-fullscreen"), "win.fullscreen").shortcut("Shift+f");
+    let mut scratch = MenuItem::new(t("mirada-win-scratchpad"), "win.scratchpad").shortcut("Shift+`");
+    if !has_focus {
+        promover = promover.disabled();
+        flotar = flotar.disabled();
+        fullscreen = fullscreen.disabled();
+        scratch = scratch.disabled();
+    }
+
+    // Menú de idioma: autónimos sin traducir (convención del SO). El item
+    // activo lleva ✔. El comando `lang.<code>` lo resuelve `handle_menu_command`.
+    let cur = rimay_localize::current_locale();
+    let lang_item = |label: &str, code: &str| {
+        let mut it = MenuItem::new(label, format!("lang.{code}"));
+        if cur == code {
+            it = it.icon("\u{2714}");
+        }
+        it
+    };
+
+    AppMenu::new()
+        .menu(
+            Menu::new(t("file"))
+                .item(abrir)
+                .item(abrir_mon)
+                .item(cerrar)
+                .item(MenuItem::new(t("exit"), "file.quit").shortcut("Ctrl+Q").separated()),
+        )
+        .menu(
+            Menu::new(t("view"))
+                .item(MenuItem::new(t("mirada-layout-cycle"), "view.cycle").shortcut("Tab"))
+                .item(layout_item(t("mirada-layout-master-stack"), "layout.master", LayoutMode::MasterStack).separated())
+                .item(layout_item(t("mirada-layout-monocle"), "layout.monocle", LayoutMode::Monocle))
+                .item(layout_item(t("mirada-layout-grid"), "layout.grid", LayoutMode::Grid))
+                .item(layout_item(t("mirada-layout-columns"), "layout.columns", LayoutMode::Columns))
+                .item(layout_item(t("mirada-layout-rows"), "layout.rows", LayoutMode::Rows))
+                .item(layout_item(t("mirada-layout-centered"), "layout.centered", LayoutMode::CenteredMaster))
+                .item(layout_item(t("mirada-layout-spiral"), "layout.spiral", LayoutMode::Spiral))
+                .item(MenuItem::new(t("mirada-layout-shrink"), "view.shrink").shortcut("h").separated())
+                .item(MenuItem::new(t("mirada-layout-grow"), "view.grow").shortcut("l"))
+                .item(MenuItem::new(t("mirada-output-next"), "view.output_next").shortcut("o").separated()),
+        )
+        .menu(
+            Menu::new(t("mirada-menu-window"))
+                .item(promover)
+                .item(flotar)
+                .item(fullscreen)
+                .item(scratch),
+        )
+        .menu(
+            Menu::new(t("help"))
+                .item(MenuItem::new(t("about"), "help.about")),
+        )
+        .menu(
+            Menu::new(t("language"))
+                .item(lang_item("Español", "es-PE"))
+                .item(lang_item("English", "en-US"))
+                .item(lang_item("Runasimi", "qu-PE")),
+        )
+}
+
+/// Traduce un command id del menú principal a la acción real del Desktop.
+fn handle_menu_command(m: &mut Model, cmd: &str) {
+    // Cambio de idioma: aplica el locale en caliente y lo persiste en wawa-config.
+    if let Some(code) = cmd.strip_prefix("lang.") {
+        let _ = rimay_localize::set_locale(code);
+        let mut cfg = wawa_config::WawaConfig::load();
+        cfg.lang = code.to_string();
+        let _ = cfg.save();
+        return;
+    }
+    match cmd {
+        "file.new_window" if m.link.is_none() => open_window(m),
+        "file.new_output" if m.link.is_none() => {
+            let id = m.desktop.outputs().len() as u32;
+            feed(m, BodyEvent::OutputAdded {
+                id,
+                width: SCREEN_W,
+                height: SCREEN_H,
+            });
+        }
+        "win.close" => act(m, DesktopAction::CloseFocused),
+        "file.quit" => std::process::exit(0),
+        "view.cycle" => act(m, DesktopAction::CycleLayout),
+        "layout.master" => act(m, DesktopAction::SetLayout(LayoutMode::MasterStack)),
+        "layout.monocle" => act(m, DesktopAction::SetLayout(LayoutMode::Monocle)),
+        "layout.grid" => act(m, DesktopAction::SetLayout(LayoutMode::Grid)),
+        "layout.columns" => act(m, DesktopAction::SetLayout(LayoutMode::Columns)),
+        "layout.rows" => act(m, DesktopAction::SetLayout(LayoutMode::Rows)),
+        "layout.centered" => act(m, DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+        "layout.spiral" => act(m, DesktopAction::SetLayout(LayoutMode::Spiral)),
+        "view.shrink" => act(m, DesktopAction::ShrinkMaster),
+        "view.grow" => act(m, DesktopAction::GrowMaster),
+        "view.output_next" => act(m, DesktopAction::FocusOutputNext),
+        "win.promote" => act(m, DesktopAction::PromoteToMaster),
+        "win.float" => act(m, DesktopAction::ToggleFloat),
+        "win.fullscreen" => act(m, DesktopAction::ToggleFullscreen),
+        "win.scratchpad" => act(m, DesktopAction::SendToScratchpad),
+        // "help.about" y desconocidos: no-op (sin diálogo todavía).
+        _ => {}
+    }
+}
+
+// ─── Subviews ───────────────────────────────────────────────────────
+
+#[allow(clippy::too_many_arguments)]
+fn top_bar(
+    model: &Model,
+    theme: &Theme,
+    mode: LayoutMode,
+    loads: &[usize],
+    active: usize,
+    focused: Option<WindowId>,
+    on_accent: Color,
+    bar_bg: Color,
+) -> View<Msg> {
+    let mut pips: Vec<View<Msg>> = Vec::with_capacity(loads.len());
+    for (i, &load) in loads.iter().enumerate() {
+        let is_active = i == active;
+        let fg = if is_active {
+            on_accent
+        } else if load > 0 {
+            theme.fg_text
+        } else {
+            theme.fg_placeholder
+        };
+        let bg = if is_active {
+            theme.accent
+        } else if load > 0 {
+            theme.bg_row_hover
+        } else {
+            bar_bg
+        };
+        pips.push(
+            View::new(Style {
+                size: Size {
+                    width: length(24.0_f32),
+                    height: length(22.0_f32),
+                },
+                align_items: Some(AlignItems::Center),
+                justify_content: Some(JustifyContent::Center),
+                ..Default::default()
+            })
+            .fill(bg)
+            .radius(4.0)
+            .text_aligned(format!("{}", i + 1), 12.0, fg, Alignment::Start)
+            .on_click(Msg::SwitchWorkspace(i)),
+        );
+    }
+
+    let focus_label = match focused.and_then(|id| model.desktop.window_info(id)) {
+        Some(info) => info.title.clone(),
+        None => "—".to_string(),
+    };
+
+    let pips_row = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: Dimension::auto(),
+            height: length(22.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(4.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(pips);
+
+    let label_node = |text: String, color: Color, size: f32, width: f32| {
+        View::new(Style {
+            size: Size {
+                width: length(width),
+                height: length(22.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            ..Default::default()
+        })
+        .text_aligned(text, size, color, Alignment::Start)
+    };
+
+    let mirada_tag = label_node("mirada".into(), theme.accent, 13.0, 70.0);
+    let sep_a = label_node("·".into(), theme.fg_placeholder, 12.0, 12.0);
+    let sep_b = label_node("·".into(), theme.fg_placeholder, 12.0, 12.0);
+    let layout_label = label_node(
+        format!("{}: {}", rimay_localize::t("mirada-label-layout"), mode_name(mode)),
+        theme.fg_muted,
+        12.0,
+        180.0,
+    );
+    let spacer = View::new(Style {
+        size: Size {
+            width: Dimension::auto(),
+            height: length(22.0_f32),
+        },
+        flex_grow: 1.0,
+        ..Default::default()
+    });
+    let focus_label_node = label_node(
+        format!("{}: {focus_label}", rimay_localize::t("mirada-label-focus")),
+        theme.fg_muted,
+        12.0,
+        320.0,
+    );
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(44.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        padding: Rect {
+            left: length(14.0_f32),
+            right: length(14.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        gap: Size {
+            width: length(12.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(bar_bg)
+    .children(vec![
+        mirada_tag,
+        sep_a,
+        pips_row,
+        sep_b,
+        layout_label,
+        spacer,
+        focus_label_node,
+    ])
+}
+
+fn canvas_view(
+    model: &Model,
+    theme: &Theme,
+    on_accent: Color,
+    win_bg: Color,
+    canvas_bg: Color,
+) -> View<Msg> {
+    let outs = model.desktop.outputs();
+    let (bb_w, bb_h) = if outs.is_empty() {
+        (SCREEN_W as f32, SCREEN_H as f32)
+    } else {
+        let w = outs.iter().map(|o| o.rect.x + o.rect.w).max().unwrap_or(SCREEN_W);
+        let h = outs.iter().map(|o| o.rect.y + o.rect.h).max().unwrap_or(SCREEN_H);
+        (w as f32, h as f32)
+    };
+    let scale = (SCREEN_W as f32 / bb_w)
+        .min(SCREEN_H as f32 / bb_h)
+        .min(1.0);
+
+    let mut children: Vec<View<Msg>> = Vec::new();
+
+    // Marcos de cada salida.
+    for (i, o) in outs.iter().enumerate() {
+        let is_focused_out = i == model.desktop.focused_output();
+        let border = if is_focused_out { theme.accent } else { theme.border };
+        let label = View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(16.0_f32),
+            },
+            padding: Rect {
+                left: length(4.0_f32),
+                right: length(4.0_f32),
+                top: length(2.0_f32),
+                bottom: length(0.0_f32),
+            },
+            ..Default::default()
+        })
+        .text_aligned(
+            format!(
+                "{} {} · {} {}",
+                rimay_localize::t("mirada-label-output"),
+                i + 1,
+                rimay_localize::t("mirada-label-workspace"),
+                o.workspace + 1,
+            ),
+            10.0,
+            theme.fg_placeholder,
+            Alignment::Start,
+        );
+        children.push(
+            View::new(Style {
+                position: Position::Absolute,
+                inset: Rect {
+                    left: length(o.rect.x as f32 * scale),
+                    top: length(o.rect.y as f32 * scale),
+                    right: auto(),
+                    bottom: auto(),
+                },
+                size: Size {
+                    width: length(o.rect.w as f32 * scale),
+                    height: length(o.rect.h as f32 * scale),
+                },
+                ..Default::default()
+            })
+            // Llimphi no tiene "border 1px" como propiedad — emulamos
+            // con un fill del color del border que pinta una franja
+            // alrededor del contenido vía padding (cheap edge).
+            .fill(border)
+            .children(vec![label]),
+        );
+    }
+
+    // Mensaje vacío.
+    let visible = model.placements.iter().filter(|p| p.visible).count();
+    if visible == 0 {
+        children.push(
+            View::new(Style {
+                position: Position::Absolute,
+                inset: Rect {
+                    left: length(0.0_f32),
+                    top: length(0.0_f32),
+                    right: length(0.0_f32),
+                    bottom: length(0.0_f32),
+                },
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: percent(1.0_f32),
+                },
+                align_items: Some(AlignItems::Center),
+                justify_content: Some(JustifyContent::Center),
+                ..Default::default()
+            })
+            .text_aligned(
+                rimay_localize::t("mirada-canvas-empty-hint"),
+                13.0,
+                theme.fg_placeholder,
+                Alignment::Center,
+            ),
+        );
+    }
+
+    // Ventanas.
+    for p in model.placements.iter().filter(|p| p.visible) {
+        let info = model.desktop.window_info(p.id);
+        let title = info
+            .map(|i| i.title.clone())
+            .unwrap_or_else(|| format!("ventana {}", p.id));
+        let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
+        let border = if p.focused { theme.accent } else { theme.border };
+        let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
+        let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
+        let kind_label = if p.fullscreen {
+            rimay_localize::t("mirada-win-kind-fullscreen")
+        } else if p.floating {
+            rimay_localize::t("mirada-win-kind-floating")
+        } else {
+            rimay_localize::t("mirada-win-kind-surface")
+        };
+
+        let titlebar = View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(22.0_f32),
+            },
+            padding: Rect {
+                left: length(8.0_f32),
+                right: length(8.0_f32),
+                top: length(0.0_f32),
+                bottom: length(0.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            ..Default::default()
+        })
+        .fill(tb_bg)
+        .text_aligned(title, 11.0, tb_fg, Alignment::Start);
+
+        let interior = View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: Dimension::auto(),
+            },
+            flex_grow: 1.0,
+            align_items: Some(AlignItems::Center),
+            justify_content: Some(JustifyContent::Center),
+            gap: Size {
+                width: length(0.0_f32),
+                height: length(4.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(win_bg)
+        .children(vec![
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(16.0_f32),
+                },
+                ..Default::default()
+            })
+            .text_aligned(app_id, 11.0, theme.fg_placeholder, Alignment::Center),
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(16.0_f32),
+                },
+                ..Default::default()
+            })
+            .text_aligned(kind_label, 11.0, theme.fg_placeholder, Alignment::Center),
+        ]);
+
+        children.push(
+            View::new(Style {
+                flex_direction: FlexDirection::Column,
+                position: Position::Absolute,
+                inset: Rect {
+                    left: length(p.rect.x as f32 * scale),
+                    top: length(p.rect.y as f32 * scale),
+                    right: auto(),
+                    bottom: auto(),
+                },
+                size: Size {
+                    width: length(p.rect.w as f32 * scale),
+                    height: length(p.rect.h as f32 * scale),
+                },
+                padding: Rect {
+                    left: length(2.0_f32),
+                    right: length(2.0_f32),
+                    top: length(2.0_f32),
+                    bottom: length(2.0_f32),
+                },
+                ..Default::default()
+            })
+            .fill(border)
+            .radius(5.0)
+            .on_click(Msg::FocusWindow(p.id))
+            .children(vec![titlebar, interior]),
+        );
+    }
+
+    View::new(Style {
+        position: Position::Relative,
+        size: Size {
+            width: length(SCREEN_W as f32),
+            height: length(SCREEN_H as f32),
+        },
+        ..Default::default()
+    })
+    .fill(canvas_bg)
+    .children(children)
+}
+
+fn main() {
+    rimay_localize::init();
+    let _ = rimay_localize::set_locale(&wawa_config::WawaConfig::load().lang);
+    llimphi_ui::run::<Mirada>();
+}
diff --git a/02_ruway/mirada/mirada-bar-core/Cargo.toml b/02_ruway/mirada/mirada-bar-core/Cargo.toml
new file mode 100644
index 0000000..6c3505c
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "mirada-bar-core"
+description = "Barra — modelo de taskbar agnóstico: Task + render-to-html + sanitizadores. Sin dependencias web/DOM."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
diff --git a/02_ruway/mirada/mirada-bar-core/LEEME.md b/02_ruway/mirada/mirada-bar-core/LEEME.md
new file mode 100644
index 0000000..22f7133
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/LEEME.md
@@ -0,0 +1,9 @@
+# mirada-bar-core
+
+> Trait de status bar de [mirada](../README.md).
+
+`StatusBar` define qué slots tiene la barra (workspaces, clock, tray, battery, ...). Permite múltiples implementaciones (Llimphi nativa, HTML overlay).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-bar-core/README.md b/02_ruway/mirada/mirada-bar-core/README.md
new file mode 100644
index 0000000..9c1750a
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/README.md
@@ -0,0 +1,9 @@
+# mirada-bar-core
+
+> Status bar trait of [mirada](../README.md).
+
+`StatusBar` defines what slots the bar has (workspaces, clock, tray, battery, ...). Allows multiple implementations (Llimphi native, HTML overlay).
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-bar-core/src/lib.rs b/02_ruway/mirada/mirada-bar-core/src/lib.rs
new file mode 100644
index 0000000..ef4afc7
--- /dev/null
+++ b/02_ruway/mirada/mirada-bar-core/src/lib.rs
@@ -0,0 +1,108 @@
+//! Barra core — modelo agnóstico de taskbar.
+//!
+//! Provee la lista de `Task`, los helpers de sanitización para atributos
+//! HTML, y `render_html` puro. El binding DOM vive en `barra-web`.
+
+/// Una tarea (cajita) en la barra.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Task {
+    pub id: String,
+    pub label: String,
+    pub active: bool,
+}
+
+impl Task {
+    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
+        Self { id: id.into(), label: label.into(), active: false }
+    }
+    pub fn active(mut self) -> Self {
+        self.active = true;
+        self
+    }
+}
+
+/// Renderiza un slice de tareas a markup HTML. Sanitiza IDs y escapa
+/// labels. La salida es la lista de `<li>` que el host inyecta en su `<ul>`.
+pub fn render_html(tasks: &[Task]) -> String {
+    let mut html = String::new();
+    for t in tasks {
+        let id_safe = sanitize_attr(&t.id);
+        let label_safe = escape_text(&t.label);
+        let active_cls = if t.active { " active" } else { "" };
+        html.push_str(&format!(
+            "<li><button class=\"taskbar-item{active_cls}\" data-task=\"{id_safe}\" type=\"button\">\
+             <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label_safe}</button></li>"
+        ));
+    }
+    html
+}
+
+/// Filtra a `[a-zA-Z0-9_-]` para uso seguro en atributos HTML.
+pub fn sanitize_attr(s: &str) -> String {
+    s.chars()
+        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
+        .collect()
+}
+
+/// HTML-escape de texto para insertarlo en posiciones de contenido.
+pub fn escape_text(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            c => out.push(c),
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn task_builder_defaults_inactive() {
+        let t = Task::new("aire", "AIRE");
+        assert!(!t.active);
+        assert!(Task::new("f", "F").active().active);
+    }
+
+    #[test]
+    fn sanitize_attr_strips_unsafe() {
+        assert_eq!(sanitize_attr("aire"), "aire");
+        assert_eq!(sanitize_attr("a-b_c"), "a-b_c");
+        assert_eq!(sanitize_attr("ai<re>"), "aire");
+        assert_eq!(sanitize_attr("a\"b"), "ab");
+    }
+
+    #[test]
+    fn escape_text_escapes_html() {
+        assert_eq!(escape_text("AIRE"), "AIRE");
+        assert_eq!(escape_text("<script>"), "&lt;script&gt;");
+        assert_eq!(escape_text("a & b"), "a &amp; b");
+    }
+
+    #[test]
+    fn render_html_emits_active_class() {
+        let tasks = [
+            Task::new("aire", "AIRE"),
+            Task::new("fuego", "FUEGO").active(),
+        ];
+        let html = render_html(&tasks);
+        assert!(html.contains("data-task=\"aire\""));
+        assert!(html.contains("data-task=\"fuego\""));
+        assert!(html.contains("taskbar-item active"));
+    }
+
+    #[test]
+    fn render_html_escapes_label_and_sanitizes_id() {
+        let tasks = [Task::new("a<b", "x<script>y")];
+        let html = render_html(&tasks);
+        assert!(html.contains("data-task=\"ab\""));
+        assert!(html.contains("x&lt;script&gt;y"));
+        assert!(!html.contains("<script>"));
+    }
+}
diff --git a/02_ruway/mirada/mirada-body/Cargo.toml b/02_ruway/mirada/mirada-body/Cargo.toml
new file mode 100644
index 0000000..d2ba2bc
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "mirada-body"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — estado del Cuerpo del compositor: lleva la cuenta de salidas y superficies, traduce los BrainCommand a operaciones de backend y emite los BodyEvent. Agnóstico de smithay."
+
+[dependencies]
+mirada-protocol = { path = "../mirada-protocol" }
+
+[dev-dependencies]
+mirada-link = { path = "../mirada-link" }
diff --git a/02_ruway/mirada/mirada-body/LEEME.md b/02_ruway/mirada/mirada-body/LEEME.md
new file mode 100644
index 0000000..96ddbc2
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/LEEME.md
@@ -0,0 +1,10 @@
+# mirada-body
+
+> Estado físico del display (monitors, modes) de [mirada](../README.md).
+
+Inventario de outputs (HDMI/DP/eDP/...) y sus modos (resolution + refresh + scale). El operador puede cambiar layout/scale en runtime sin reiniciar el compositor.
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md)
+- `drm`, `gbm` (Linux)
diff --git a/02_ruway/mirada/mirada-body/README.md b/02_ruway/mirada/mirada-body/README.md
new file mode 100644
index 0000000..4c09a5a
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/README.md
@@ -0,0 +1,10 @@
+# mirada-body
+
+> Display physical state (monitors, modes) of [mirada](../README.md).
+
+Inventory of outputs (HDMI/DP/eDP/...) and their modes (resolution + refresh + scale). The operator can change layout/scale at runtime without restarting the compositor.
+
+## Deps
+
+- [`mirada-protocol`](../mirada-protocol/README.md)
+- `drm`, `gbm` (Linux)
diff --git a/02_ruway/mirada/mirada-body/examples/headless.rs b/02_ruway/mirada/mirada-body/examples/headless.rs
new file mode 100644
index 0000000..0fb479d
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/examples/headless.rs
@@ -0,0 +1,121 @@
+//! `headless` — un Cuerpo de carmen sin gráficos, guiado por stdin.
+//!
+//! Es el banco de pruebas del Cerebro: implementa el lado del Cuerpo del
+//! protocolo (escucha en un socket, lleva un [`BodyState`], manda
+//! [`BodyEvent`]s y ejecuta —imprimiéndolas— las [`BodyOp`]s) sin tocar
+//! `smithay` ni el hardware. Así se ejercita el bucle entero
+//! Cerebro↔Cuerpo desde una terminal.
+//!
+//! ```text
+//!   # terminal 1 — el Cuerpo escucha
+//!   cargo run -p mirada-body --example headless -- /tmp/mirada.sock
+//!   # terminal 2 — el Cerebro se conecta
+//!   MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada
+//! ```
+//!
+//! Órdenes de stdin: `output <w> <h>`, `open <app>`, `close <id>`,
+//! `title <id> <texto>`, `key <combo>`, `pointer <id>`, `tick`, `quit`.
+
+use std::io::BufRead;
+use std::time::Duration;
+
+use mirada_body::BodyState;
+use mirada_link::BodyLink;
+
+fn main() {
+    let path = std::env::args()
+        .nth(1)
+        .unwrap_or_else(|| "/tmp/mirada.sock".to_string());
+    let _ = std::fs::remove_file(&path);
+
+    println!("Cuerpo headless · escuchando en {path} — esperando al Cerebro…");
+    let mut link: BodyLink = match BodyLink::listen(&path) {
+        Ok(l) => l,
+        Err(e) => {
+            eprintln!("no se pudo escuchar en {path}: {e}");
+            std::process::exit(1);
+        }
+    };
+    println!("Cerebro conectado. Órdenes: output / open / close / title / key / pointer / tick / quit");
+
+    let mut body = BodyState::new();
+    let mut next_id: u64 = 1;
+    let stdin = std::io::stdin();
+
+    for line in stdin.lock().lines() {
+        let line = match line {
+            Ok(l) => l,
+            Err(_) => break,
+        };
+        let mut parts = line.split_whitespace();
+        let cmd = parts.next().unwrap_or("");
+        let rest: Vec<&str> = parts.collect();
+
+        // Cada orden o bien manda un evento al Cerebro, o no manda nada.
+        let event = match cmd {
+            "output" if rest.len() == 2 => {
+                match (rest[0].parse(), rest[1].parse()) {
+                    (Ok(w), Ok(h)) => Some(body.add_output(0, w, h)),
+                    _ => {
+                        eprintln!("uso: output <ancho> <alto>");
+                        None
+                    }
+                }
+            }
+            "open" if !rest.is_empty() => {
+                let app = rest[0];
+                let id = next_id;
+                next_id += 1;
+                println!("  → ventana {id} ({app})");
+                Some(body.open_surface(id, format!("org.brahman.{app}"), format!("{app} {id}")))
+            }
+            "close" if rest.len() == 1 => match rest[0].parse() {
+                Ok(id) => body.close_surface(id),
+                Err(_) => {
+                    eprintln!("uso: close <id>");
+                    None
+                }
+            },
+            "title" if rest.len() >= 2 => match rest[0].parse() {
+                Ok(id) => body.retitle_surface(id, rest[1..].join(" ")),
+                Err(_) => {
+                    eprintln!("uso: title <id> <texto>");
+                    None
+                }
+            },
+            "key" if rest.len() == 1 => Some(body.keybind(rest[0])),
+            "pointer" if rest.len() == 1 => match rest[0].parse() {
+                Ok(id) => Some(body.pointer_enter(id)),
+                Err(_) => {
+                    eprintln!("uso: pointer <id>");
+                    None
+                }
+            },
+            "tick" => None,
+            "quit" | "exit" => break,
+            "" => None,
+            other => {
+                eprintln!("orden desconocida: {other}");
+                None
+            }
+        };
+
+        if let Some(ev) = event {
+            if link.send(&ev).is_err() {
+                eprintln!("el Cerebro cerró la conexión.");
+                break;
+            }
+        }
+
+        // Deja que el Cerebro responda y ejecuta lo que ordene.
+        std::thread::sleep(Duration::from_millis(40));
+        for command in link.drain() {
+            for op in body.apply(command) {
+                println!("  · op: {op:?}");
+            }
+        }
+    }
+
+    println!("Cuerpo headless · adiós.");
+    let _ = std::fs::remove_file(&path);
+}
diff --git a/02_ruway/mirada/mirada-body/src/lib.rs b/02_ruway/mirada/mirada-body/src/lib.rs
new file mode 100644
index 0000000..d81d5c8
--- /dev/null
+++ b/02_ruway/mirada/mirada-body/src/lib.rs
@@ -0,0 +1,480 @@
+//! `mirada-body` — el estado del Cuerpo del compositor.
+//!
+//! El "Cuerpo" de carmen (`mirada-compositor`, sobre `smithay`) tiene
+//! dos mitades: el *backend*, que habla Wayland y posee el hardware, y
+//! esta *contabilidad* — qué salidas y superficies existen y con qué
+//! geometría. Aislarla deja el backend reducido a "ejecuta estas
+//! [`BodyOp`]" y la hace testeable sin un servidor gráfico.
+//!
+//! El flujo es simétrico al del Cerebro:
+//!
+//! - El backend avisa de cambios de hardware/clientes con los mutadores
+//!   ([`BodyState::open_surface`], [`BodyState::add_output`], …), que
+//!   devuelven el [`BodyEvent`] a mandar al Cerebro.
+//! - El Cerebro responde con [`BrainCommand`]s; [`BodyState::apply`] los
+//!   traduce a [`BodyOp`]s concretas que el backend ejecuta.
+
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use mirada_protocol::{BodyEvent, BrainCommand, Decorations, OutputId, Rect, WindowId};
+
+/// Una superficie Wayland desde la óptica del Cuerpo.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Surface {
+    pub app_id: String,
+    pub title: String,
+    /// Geometría aplicada — `None` hasta la primera [`BodyOp::Configure`].
+    pub geometry: Option<Rect>,
+    pub visible: bool,
+    pub focused: bool,
+    /// `true` si flota: el backend la pinta por encima de las teseladas.
+    pub floating: bool,
+    /// `true` si está en pantalla completa.
+    pub fullscreen: bool,
+}
+
+impl Surface {
+    fn new(app_id: String, title: String) -> Self {
+        Self {
+            app_id,
+            title,
+            geometry: None,
+            visible: false,
+            focused: false,
+            floating: false,
+            fullscreen: false,
+        }
+    }
+}
+
+/// Una orden concreta para el backend (smithay, headless, …).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum BodyOp {
+    /// Recoloca una superficie, la muestra u oculta y dice si flota o
+    /// está en pantalla completa (el backend ajusta el orden de pintado
+    /// y el estado `xdg_toplevel` en consecuencia).
+    Configure {
+        id: WindowId,
+        rect: Rect,
+        visible: bool,
+        floating: bool,
+        fullscreen: bool,
+    },
+    /// Da el foco del teclado a una superficie.
+    Focus(WindowId),
+    /// Quita el foco a todas las superficies.
+    Unfocus,
+    /// Pide el cierre ordenado de un cliente.
+    CloseClient(WindowId),
+    /// Mata a un cliente que no responde.
+    KillClient(WindowId),
+    /// Registra los atajos globales a interceptar.
+    SetGrabs(Vec<String>),
+    /// Cambia el cursor del puntero.
+    SetCursor(String),
+    /// Fija los parámetros de decoración de las ventanas (marco, …).
+    SetDecorations(Decorations),
+    /// Lanza un programa como proceso hijo del compositor.
+    Spawn(String),
+    /// Apaga el compositor y libera el hardware.
+    Shutdown,
+}
+
+/// La contabilidad del Cuerpo: salidas y superficies.
+#[derive(Debug, Default)]
+pub struct BodyState {
+    outputs: Vec<(OutputId, Rect)>,
+    /// `BTreeMap` para que el orden de las `BodyOp` sea determinista.
+    surfaces: BTreeMap<WindowId, Surface>,
+    focused: Option<WindowId>,
+}
+
+impl BodyState {
+    /// Cuerpo recién arrancado: sin salidas ni superficies.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    // --- Traducción de comandos del Cerebro --------------------------
+
+    /// Traduce un comando del Cerebro a las operaciones de backend que lo
+    /// materializan. Sólo emite lo que de verdad cambia: un `Place`
+    /// idéntico al estado actual no produce ninguna `BodyOp`.
+    pub fn apply(&mut self, cmd: BrainCommand) -> Vec<BodyOp> {
+        match cmd {
+            BrainCommand::Place(placements) => {
+                let mut ops = Vec::new();
+                let listed: Vec<WindowId> = placements.iter().map(|p| p.id).collect();
+                let mut new_focus = None;
+
+                // Reconfigura las superficies que aparecen en la lista.
+                for p in &placements {
+                    if p.focused {
+                        new_focus = Some(p.id);
+                    }
+                    if let Some(s) = self.surfaces.get_mut(&p.id) {
+                        if s.geometry != Some(p.rect)
+                            || s.visible != p.visible
+                            || s.floating != p.floating
+                            || s.fullscreen != p.fullscreen
+                        {
+                            s.geometry = Some(p.rect);
+                            s.visible = p.visible;
+                            s.floating = p.floating;
+                            s.fullscreen = p.fullscreen;
+                            ops.push(BodyOp::Configure {
+                                id: p.id,
+                                rect: p.rect,
+                                visible: p.visible,
+                                floating: p.floating,
+                                fullscreen: p.fullscreen,
+                            });
+                        }
+                    }
+                }
+
+                // Oculta lo que el Cerebro ya no coloca.
+                for (id, s) in &mut self.surfaces {
+                    if !listed.contains(id) && s.visible {
+                        s.visible = false;
+                        let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0));
+                        ops.push(BodyOp::Configure {
+                            id: *id,
+                            rect,
+                            visible: false,
+                            floating: s.floating,
+                            fullscreen: s.fullscreen,
+                        });
+                    }
+                }
+
+                // Reasigna el foco sólo si cambió.
+                if new_focus != self.focused {
+                    self.focused = new_focus;
+                    for (id, s) in &mut self.surfaces {
+                        s.focused = Some(*id) == new_focus;
+                    }
+                    ops.push(match new_focus {
+                        Some(id) => BodyOp::Focus(id),
+                        None => BodyOp::Unfocus,
+                    });
+                }
+                ops
+            }
+            BrainCommand::Close(id) => vec![BodyOp::CloseClient(id)],
+            BrainCommand::Kill(id) => vec![BodyOp::KillClient(id)],
+            BrainCommand::GrabKeys(keys) => vec![BodyOp::SetGrabs(keys)],
+            BrainCommand::SetCursor(name) => vec![BodyOp::SetCursor(name)],
+            BrainCommand::SetDecorations(d) => vec![BodyOp::SetDecorations(d)],
+            BrainCommand::Spawn(cmd) => vec![BodyOp::Spawn(cmd)],
+            BrainCommand::Shutdown => vec![BodyOp::Shutdown],
+        }
+    }
+
+    // --- Mutadores del backend → eventos para el Cerebro -------------
+
+    /// Registra una salida recién conectada.
+    pub fn add_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
+        self.outputs.push((id, Rect::new(0, 0, width, height)));
+        BodyEvent::OutputAdded { id, width, height }
+    }
+
+    /// Da de baja una salida desconectada.
+    pub fn remove_output(&mut self, id: OutputId) -> BodyEvent {
+        self.outputs.retain(|(o, _)| *o != id);
+        BodyEvent::OutputRemoved { id }
+    }
+
+    /// Cambia el área útil de una salida sin desconectarla — al
+    /// redimensionar la ventana anfitriona o al reservar/liberar la
+    /// franja del shell. Conserva el escritorio que muestra.
+    pub fn resize_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
+        if let Some((_, rect)) = self.outputs.iter_mut().find(|(o, _)| *o == id) {
+            rect.w = width;
+            rect.h = height;
+        }
+        BodyEvent::OutputResized { id, width, height }
+    }
+
+    /// Reserva —o libera— franjas en los bordes de una salida: las zonas
+    /// exclusivas (px desde cada borde) que el teselado debe esquivar. Las usa
+    /// el marco (`pata`) para acoplar sus barras sin que las ventanas las tapen;
+    /// cero en los cuatro libera la reserva. No toca el tamaño físico, así que
+    /// admite barras en varios bordes a la vez.
+    pub fn reserve_output(
+        &self,
+        id: OutputId,
+        top: i32,
+        bottom: i32,
+        left: i32,
+        right: i32,
+    ) -> BodyEvent {
+        BodyEvent::OutputReserved {
+            id,
+            top,
+            bottom,
+            left,
+            right,
+        }
+    }
+
+    /// Registra una superficie recién creada por un cliente.
+    pub fn open_surface(
+        &mut self,
+        id: WindowId,
+        app_id: impl Into<String>,
+        title: impl Into<String>,
+    ) -> BodyEvent {
+        let app_id = app_id.into();
+        let title = title.into();
+        self.surfaces
+            .insert(id, Surface::new(app_id.clone(), title.clone()));
+        BodyEvent::WindowOpened { id, app_id, title }
+    }
+
+    /// Da de baja una superficie destruida. `None` si no se conocía.
+    pub fn close_surface(&mut self, id: WindowId) -> Option<BodyEvent> {
+        self.surfaces.remove(&id)?;
+        if self.focused == Some(id) {
+            self.focused = None;
+        }
+        Some(BodyEvent::WindowClosed { id })
+    }
+
+    /// Actualiza el título de una superficie. `None` si no se conocía.
+    pub fn retitle_surface(&mut self, id: WindowId, title: impl Into<String>) -> Option<BodyEvent> {
+        let title = title.into();
+        let s = self.surfaces.get_mut(&id)?;
+        s.title = title.clone();
+        Some(BodyEvent::WindowRetitled { id, title })
+    }
+
+    /// Construye un evento de puntero entrando en una superficie.
+    pub fn pointer_enter(&self, id: WindowId) -> BodyEvent {
+        BodyEvent::PointerEntered { id }
+    }
+
+    /// Construye un evento de click (foco-al-click) sobre una superficie.
+    pub fn clicked(&self, id: WindowId) -> BodyEvent {
+        BodyEvent::Clicked { id }
+    }
+
+    /// Construye un evento de arrastre teselado al punto `(x, y)`.
+    pub fn window_dragged(&self, id: WindowId, x: i32, y: i32) -> BodyEvent {
+        BodyEvent::WindowDragged { id, x, y }
+    }
+
+    /// Construye un evento de atajo pulsado.
+    pub fn keybind(&self, combo: impl Into<String>) -> BodyEvent {
+        BodyEvent::Keybind(combo.into())
+    }
+
+    // --- Accesores de sólo lectura -----------------------------------
+
+    /// Las salidas conectadas.
+    pub fn outputs(&self) -> &[(OutputId, Rect)] {
+        &self.outputs
+    }
+
+    /// Una superficie conocida.
+    pub fn surface(&self, id: WindowId) -> Option<&Surface> {
+        self.surfaces.get(&id)
+    }
+
+    /// Número de superficies registradas.
+    pub fn surface_count(&self) -> usize {
+        self.surfaces.len()
+    }
+
+    /// Las superficies visibles, en orden de id.
+    pub fn visible(&self) -> impl Iterator<Item = (WindowId, &Surface)> {
+        self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s))
+    }
+
+    /// La superficie enfocada.
+    pub fn focused(&self) -> Option<WindowId> {
+        self.focused
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use mirada_protocol::WindowPlacement;
+
+    fn placement(id: WindowId, visible: bool, focused: bool) -> WindowPlacement {
+        WindowPlacement {
+            id,
+            rect: Rect::new(0, 0, 800, 600),
+            visible,
+            focused,
+            floating: false,
+            fullscreen: false,
+        }
+    }
+
+    /// Cuerpo con dos superficies abiertas.
+    fn body_with_two() -> BodyState {
+        let mut b = BodyState::new();
+        b.add_output(0, 1920, 1080);
+        b.open_surface(1, "app1", "uno");
+        b.open_surface(2, "app2", "dos");
+        b
+    }
+
+    #[test]
+    fn opening_a_surface_yields_a_window_opened_event() {
+        let mut b = BodyState::new();
+        let ev = b.open_surface(7, "org.brahman.shuma", "shell");
+        assert_eq!(
+            ev,
+            BodyEvent::WindowOpened {
+                id: 7,
+                app_id: "org.brahman.shuma".into(),
+                title: "shell".into()
+            }
+        );
+        assert_eq!(b.surface_count(), 1);
+    }
+
+    #[test]
+    fn placing_surfaces_configures_and_focuses_them() {
+        let mut b = body_with_two();
+        let ops = b.apply(BrainCommand::Place(vec![
+            placement(1, true, false),
+            placement(2, true, true),
+        ]));
+        // Dos Configure + un Focus.
+        let configures = ops.iter().filter(|o| matches!(o, BodyOp::Configure { .. })).count();
+        assert_eq!(configures, 2);
+        assert!(ops.contains(&BodyOp::Focus(2)));
+        assert_eq!(b.focused(), Some(2));
+        assert!(b.surface(2).unwrap().focused);
+    }
+
+    #[test]
+    fn an_identical_place_produces_no_ops() {
+        let mut b = body_with_two();
+        let cmd = BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]);
+        assert!(!b.apply(cmd.clone()).is_empty());
+        // Repetir el mismo Place no cambia nada.
+        assert!(b.apply(cmd).is_empty());
+    }
+
+    #[test]
+    fn dropping_a_surface_from_the_list_hides_it() {
+        let mut b = body_with_two();
+        b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
+        // El Cerebro deja de colocar la 2 (p. ej. cambió de escritorio).
+        let ops = b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
+        assert!(ops.contains(&BodyOp::Configure {
+            id: 2,
+            rect: Rect::new(0, 0, 800, 600),
+            visible: false,
+            floating: false,
+            fullscreen: false,
+        }));
+        assert!(!b.surface(2).unwrap().visible);
+    }
+
+    #[test]
+    fn placement_for_an_unknown_surface_is_ignored() {
+        let mut b = body_with_two();
+        // La 99 no existe — no debe producir Configure.
+        let ops = b.apply(BrainCommand::Place(vec![placement(99, true, true)]));
+        assert!(!ops.iter().any(|o| matches!(o, BodyOp::Configure { id: 99, .. })));
+    }
+
+    #[test]
+    fn close_and_kill_map_to_client_ops() {
+        let mut b = body_with_two();
+        assert_eq!(b.apply(BrainCommand::Close(1)), vec![BodyOp::CloseClient(1)]);
+        assert_eq!(b.apply(BrainCommand::Kill(2)), vec![BodyOp::KillClient(2)]);
+    }
+
+    #[test]
+    fn grab_keys_cursor_and_shutdown_pass_through() {
+        let mut b = BodyState::new();
+        assert_eq!(
+            b.apply(BrainCommand::GrabKeys(vec!["Super+q".into()])),
+            vec![BodyOp::SetGrabs(vec!["Super+q".into()])]
+        );
+        assert_eq!(
+            b.apply(BrainCommand::SetCursor("crosshair".into())),
+            vec![BodyOp::SetCursor("crosshair".into())]
+        );
+        assert_eq!(b.apply(BrainCommand::Shutdown), vec![BodyOp::Shutdown]);
+    }
+
+    #[test]
+    fn closing_a_surface_clears_its_focus() {
+        let mut b = body_with_two();
+        b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
+        assert_eq!(b.focused(), Some(1));
+        let ev = b.close_surface(1);
+        assert_eq!(ev, Some(BodyEvent::WindowClosed { id: 1 }));
+        assert_eq!(b.focused(), None);
+        assert_eq!(b.surface_count(), 1);
+    }
+
+    #[test]
+    fn closing_an_unknown_surface_yields_nothing() {
+        let mut b = body_with_two();
+        assert!(b.close_surface(404).is_none());
+    }
+
+    #[test]
+    fn retitling_updates_the_surface() {
+        let mut b = body_with_two();
+        let ev = b.retitle_surface(1, "uno bis");
+        assert_eq!(ev, Some(BodyEvent::WindowRetitled { id: 1, title: "uno bis".into() }));
+        assert_eq!(b.surface(1).unwrap().title, "uno bis");
+    }
+
+    #[test]
+    fn outputs_are_tracked() {
+        let mut b = BodyState::new();
+        b.add_output(0, 2560, 1440);
+        b.add_output(1, 1920, 1080);
+        assert_eq!(b.outputs().len(), 2);
+        b.remove_output(0);
+        assert_eq!(b.outputs().len(), 1);
+        assert_eq!(b.outputs()[0].0, 1);
+    }
+
+    #[test]
+    fn moving_focus_emits_a_single_focus_op() {
+        let mut b = body_with_two();
+        b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
+        // Cambia el foco a la 2; geometría igual → sólo un Focus.
+        let ops = b.apply(BrainCommand::Place(vec![
+            placement(1, true, false),
+            placement(2, true, true),
+        ]));
+        assert_eq!(ops, vec![BodyOp::Focus(2)]);
+    }
+
+    #[test]
+    fn a_floating_change_alone_triggers_a_configure() {
+        let mut b = body_with_two();
+        let mut p1 = placement(1, true, true);
+        b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
+        // Sólo cambia `floating` — misma geometría y visibilidad.
+        p1.floating = true;
+        let ops = b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
+        assert!(ops
+            .iter()
+            .any(|o| matches!(o, BodyOp::Configure { id: 1, floating: true, .. })));
+        assert!(b.surface(1).unwrap().floating);
+    }
+
+    #[test]
+    fn visible_iterates_only_shown_surfaces() {
+        let mut b = body_with_two();
+        b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, false, false)]));
+        let shown: Vec<_> = b.visible().map(|(id, _)| id).collect();
+        assert_eq!(shown, vec![1]);
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/Cargo.toml b/02_ruway/mirada/mirada-brain/Cargo.toml
new file mode 100644
index 0000000..f55109a
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "mirada-brain"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — orquestador de escritorio del compositor: mantiene salidas, escritorios virtuales, ventanas y foco; consume BodyEvent y produce BrainCommand. Agnóstico de GPUI y de smithay."
+
+[dependencies]
+# `serde` activa los `derive` de los tipos de layout (este crate
+# serializa el estado del escritorio a RON).
+mirada-layout = { path = "../mirada-layout", features = ["serde"] }
+mirada-protocol = { path = "../mirada-protocol" }
+serde = { workspace = true }
+ron = { workspace = true }
+directories = { workspace = true }
+notify = { workspace = true }
diff --git a/02_ruway/mirada/mirada-brain/LEEME.md b/02_ruway/mirada/mirada-brain/LEEME.md
new file mode 100644
index 0000000..1ce11bb
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/LEEME.md
@@ -0,0 +1,9 @@
+# mirada-brain
+
+> Inteligencia del compositor (placement, focus) de [mirada](../README.md).
+
+Reglas que deciden dónde se abre una ventana nueva, qué ventana recibe focus al cerrar otra, cómo se distribuye el espacio entre tiles. Reglas declarativas; permite scripting básico sin recompilar.
+
+## Deps
+
+- [`mirada-layout`](../mirada-layout/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-brain/README.md b/02_ruway/mirada/mirada-brain/README.md
new file mode 100644
index 0000000..59736c1
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/README.md
@@ -0,0 +1,9 @@
+# mirada-brain
+
+> Compositor intelligence (placement, focus) of [mirada](../README.md).
+
+Rules that decide where a new window opens, which window gets focus when another closes, how space is distributed between tiles. Declarative rules; allows basic scripting without recompiling.
+
+## Deps
+
+- [`mirada-layout`](../mirada-layout/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-brain/examples/headless-ctl.rs b/02_ruway/mirada/mirada-brain/examples/headless-ctl.rs
new file mode 100644
index 0000000..1dec3ca
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/examples/headless-ctl.rs
@@ -0,0 +1,117 @@
+//! Un Cerebro *headless* para probar el API de control sin gráficos.
+//!
+//! Abre el socket de `mirada-ctl`, arranca un [`Desktop`] con una pantalla
+//! y unas ventanas de muestra, y atiende peticiones en bucle, imprimiendo
+//! el estado tras cada una. Útil para ejercitar `mirada-ctl` en modo
+//! desatendido.
+//!
+//! ```sh
+//! cargo run -p mirada-brain --example headless-ctl   # terminal 1
+//! mirada-ctl windows                                 # terminal 2
+//! mirada-ctl focus-next
+//! mirada-ctl focus-window 2
+//! ```
+
+use std::thread;
+use std::time::Duration;
+
+use mirada_brain::ctl::{self, CtlReply, CtlRequest, CtlServer};
+use mirada_brain::{BodyEvent, BrainCommand, Desktop};
+
+fn main() {
+    let path = ctl::default_socket_path();
+    let server = match CtlServer::bind(&path) {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("Cerebro headless · no pude abrir el control: {e}");
+            std::process::exit(1);
+        }
+    };
+    eprintln!("Cerebro headless · control en {}", path.display());
+
+    // Dos pantallas y tres ventanas de muestra.
+    let mut desktop = Desktop::new();
+    desktop.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+    desktop.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
+    for id in 1..=3 {
+        desktop.on_event(BodyEvent::WindowOpened {
+            id,
+            app_id: format!("org.brahman.app{id}"),
+            title: format!("ventana {id}"),
+        });
+    }
+    print_state(&desktop);
+    eprintln!("   esperando a mirada-ctl …");
+
+    loop {
+        if let Some(mut conn) = server.poll() {
+            if let Ok(Some(req)) = conn.read_request() {
+                let reply = match req {
+                    CtlRequest::Do(action) => {
+                        eprintln!("· {action}");
+                        for cmd in desktop.apply(action) {
+                            match cmd {
+                                // La geometría que el Cerebro mandaría al Cuerpo.
+                                BrainCommand::Place(places) => {
+                                    for p in places {
+                                        eprintln!(
+                                            "    win {} → {:>5}×{:<4} @ ({:>5},{:>4}){}{}",
+                                            p.id,
+                                            p.rect.w,
+                                            p.rect.h,
+                                            p.rect.x,
+                                            p.rect.y,
+                                            if p.fullscreen {
+                                                "  ~pantalla"
+                                            } else if p.floating {
+                                                "  ~flotante"
+                                            } else {
+                                                ""
+                                            },
+                                            if p.focused { "  *" } else { "" },
+                                        );
+                                    }
+                                }
+                                // Sin Cuerpo: simulamos nosotros el cierre.
+                                BrainCommand::Close(id) | BrainCommand::Kill(id) => {
+                                    desktop.on_event(BodyEvent::WindowClosed { id });
+                                }
+                                _ => {}
+                            }
+                        }
+                        print_state(&desktop);
+                        CtlReply::Ok
+                    }
+                    CtlRequest::ListWindows => CtlReply::Windows(desktop.window_lines()),
+                    // Las zonas son del Cuerpo (compositor); este ejemplo
+                    // headless del Cerebro no las tiene.
+                    CtlRequest::CycleZones => CtlReply::Ok,
+                };
+                let _ = conn.reply(&reply);
+            }
+        }
+        thread::sleep(Duration::from_millis(16));
+    }
+}
+
+fn print_state(d: &Desktop) {
+    let ws = d.active_workspace();
+    eprintln!(
+        "  activo: escritorio {} · {:?} (maestra {:.0}%) · foco {:?}",
+        d.active_index() + 1,
+        ws.params().mode,
+        ws.params().master_ratio * 100.0,
+        d.focused_window(),
+    );
+    for (i, o) in d.outputs().iter().enumerate() {
+        let mark = if i == d.focused_output() { '*' } else { ' ' };
+        eprintln!(
+            "  {mark} salida {} {}×{} @ x{} → escritorio {}",
+            o.id,
+            o.rect.w,
+            o.rect.h,
+            o.rect.x,
+            o.workspace + 1,
+        );
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/examples/keymap-default.rs b/02_ruway/mirada/mirada-brain/examples/keymap-default.rs
new file mode 100644
index 0000000..89e0c6b
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/examples/keymap-default.rs
@@ -0,0 +1,11 @@
+//! Imprime el keymap por defecto de mirada en format RON — exactamente
+//! lo que la app escribe la primera vez en `~/.config/mirada/keymap.ron`.
+//!
+//! ```sh
+//! cargo run -p mirada-brain --example keymap-default
+//! cargo run -p mirada-brain --example keymap-default > ~/.config/mirada/keymap.ron
+//! ```
+
+fn main() {
+    print!("{}", mirada_brain::Keymap::default().documented_ron());
+}
diff --git a/02_ruway/mirada/mirada-brain/src/action.rs b/02_ruway/mirada/mirada-brain/src/action.rs
new file mode 100644
index 0000000..add0083
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/action.rs
@@ -0,0 +1,454 @@
+//! Acciones de escritorio y su mapa de teclas por defecto.
+//!
+//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya
+//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las
+//! aplica sin saber qué combinación las disparó.
+//!
+//! Cada acción tiene una **forma textual** estable ([`Display`] /
+//! [`FromStr`]) — `"focus-next"`, `"layout:grid"`, `"workspace:3"` — que
+//! es el vocabulario del keymap configurable en RON (ver [`crate::keymap`]).
+
+use std::fmt;
+use std::str::FromStr;
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::{LayoutMode, WindowId};
+
+/// Número de escritorios virtuales que mantiene el `Desktop`.
+pub const WORKSPACE_COUNT: usize = 9;
+
+/// Una dirección cardinal en pantalla — para el foco (y, a futuro, el
+/// movimiento) espacial entre ventanas teseladas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Direction {
+    Left,
+    Right,
+    Up,
+    Down,
+}
+
+impl Direction {
+    /// El sufijo textual de la dirección — `"left"`, `"right"`, …
+    fn slug(self) -> &'static str {
+        match self {
+            Direction::Left => "left",
+            Direction::Right => "right",
+            Direction::Up => "up",
+            Direction::Down => "down",
+        }
+    }
+
+    /// La dirección desde su sufijo, o `None` si no calza.
+    fn from_slug(s: &str) -> Option<Direction> {
+        Some(match s {
+            "left" => Direction::Left,
+            "right" => Direction::Right,
+            "up" => Direction::Up,
+            "down" => Direction::Down,
+            _ => return None,
+        })
+    }
+}
+
+/// Una orden de escritorio de alto nivel.
+///
+/// Es serializable (`postcard`) para viajar por el API de control
+/// ([`crate::ctl`]) y tiene una forma textual estable ([`Display`] /
+/// [`FromStr`]) para el keymap y `mirada-ctl`.
+///
+/// No es `Copy`: [`Spawn`](DesktopAction::Spawn) lleva su comando.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DesktopAction {
+    /// Mueve el foco a la ventana siguiente del escritorio activo.
+    FocusNext,
+    /// Mueve el foco a la ventana anterior.
+    FocusPrev,
+    /// Mueve el foco a la ventana teselada más cercana en una dirección
+    /// cardinal (espacial, no cíclico) — el `Super+flechas` clásico.
+    FocusDir(Direction),
+    /// Enfoca una ventana concreta por su id; si está en otro escritorio,
+    /// salta a él. Para clics de taskbar o `mirada-ctl focus-window`.
+    FocusWindow(WindowId),
+    /// Intercambia la ventana enfocada con su vecina teselada en una
+    /// dirección cardinal (mueve la ventana por geometría) — Super+Shift+flechas.
+    MoveDir(Direction),
+    /// Adelanta la ventana enfocada en el orden de teselado.
+    MoveForward,
+    /// Atrasa la ventana enfocada en el orden de teselado.
+    MoveBackward,
+    /// Cierra la ventana enfocada (cierre ordenado).
+    CloseFocused,
+    /// Alterna entre flotante y teselada la ventana enfocada.
+    ToggleFloat,
+    /// Alterna el escritorio entero entre teselado y flotante: si queda
+    /// alguna teselada, las hace flotar todas (en cascada); si ya están
+    /// todas flotando, las devuelve al teselado.
+    ToggleTiling,
+    /// Alterna pantalla completa en la ventana enfocada.
+    ToggleFullscreen,
+    /// Guarda la ventana enfocada en el scratchpad (la oculta).
+    SendToScratchpad,
+    /// Invoca u oculta la ventana del scratchpad — aparece flotando.
+    ToggleScratchpad,
+    /// Despliega/oculta la terminal dropdown estilo *quake* — un toplevel
+    /// real anclado arriba a todo el ancho, con foco de teclado normal. La
+    /// crea perezosamente la primera vez (patrón pypr).
+    ToggleDropterm,
+    /// Pasa al siguiente modo de teselado.
+    CycleLayout,
+    /// Fija un modo de teselado concreto.
+    SetLayout(LayoutMode),
+    /// Agranda el área de la ventana maestra (`MasterStack`/`CenteredMaster`).
+    GrowMaster,
+    /// Encoge el área de la ventana maestra.
+    ShrinkMaster,
+    /// Mete una ventana más en el área maestra (`nmaster`).
+    IncMaster,
+    /// Saca una ventana del área maestra.
+    DecMaster,
+    /// Lleva la ventana enfocada al puesto maestro (la inserta al frente,
+    /// desplazando al resto).
+    PromoteToMaster,
+    /// Intercambia la ventana enfocada con la maestra (sólo esas dos; el
+    /// resto del orden queda igual). El foco acompaña a la ventana.
+    SwapMaster,
+    /// Activa el escritorio virtual `n` (índice 0-based).
+    SwitchWorkspace(usize),
+    /// Manda la ventana enfocada al escritorio virtual `n` (queda donde está).
+    SendToWorkspace(usize),
+    /// Manda la ventana enfocada al escritorio `n` y salta con ella allí.
+    MoveToWorkspace(usize),
+    /// Mueve el foco a la siguiente salida (monitor).
+    FocusOutputNext,
+    /// Mueve el foco a la salida (monitor) vecina en una dirección cardinal.
+    FocusOutputDir(Direction),
+    /// Manda la ventana enfocada a la salida vecina en una dirección — pasa
+    /// al escritorio que muestra esa salida.
+    SendToOutputDir(Direction),
+    /// Redimensiona la ventana flotante enfocada hacia una dirección
+    /// (derecha/abajo agrandan; izquierda/arriba achican), por `float_step`
+    /// px. No hace nada sobre una teselada.
+    ResizeFloatDir(Direction),
+    /// Lanza un programa — abre una terminal, un navegador, lo que sea.
+    /// El comando se pasa a `sh -c` en el Cuerpo.
+    Spawn(String),
+    /// Apaga el compositor.
+    Quit,
+}
+
+/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos
+/// para identificadores: aquí van como valor de cadena, no de enum).
+pub(crate) fn layout_slug(mode: LayoutMode) -> &'static str {
+    match mode {
+        LayoutMode::MasterStack => "master-stack",
+        LayoutMode::Monocle => "monocle",
+        LayoutMode::Grid => "grid",
+        LayoutMode::Columns => "columns",
+        LayoutMode::Rows => "rows",
+        LayoutMode::CenteredMaster => "centered-master",
+        LayoutMode::Spiral => "spiral",
+    }
+}
+
+/// Modo de teselado desde su `slug`.
+pub(crate) fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
+    Some(match slug {
+        "master-stack" => LayoutMode::MasterStack,
+        "monocle" => LayoutMode::Monocle,
+        "grid" => LayoutMode::Grid,
+        "columns" => LayoutMode::Columns,
+        "rows" => LayoutMode::Rows,
+        "centered-master" => LayoutMode::CenteredMaster,
+        "spiral" => LayoutMode::Spiral,
+        _ => return None,
+    })
+}
+
+impl fmt::Display for DesktopAction {
+    /// La forma textual estable de la acción — el vocabulario del keymap.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            DesktopAction::FocusNext => f.write_str("focus-next"),
+            DesktopAction::FocusPrev => f.write_str("focus-prev"),
+            DesktopAction::FocusDir(d) => write!(f, "focus-{}", d.slug()),
+            DesktopAction::MoveDir(d) => write!(f, "move-{}", d.slug()),
+            DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
+            DesktopAction::MoveForward => f.write_str("move-forward"),
+            DesktopAction::MoveBackward => f.write_str("move-backward"),
+            DesktopAction::CloseFocused => f.write_str("close-focused"),
+            DesktopAction::ToggleFloat => f.write_str("toggle-float"),
+            DesktopAction::ToggleTiling => f.write_str("toggle-tiling"),
+            DesktopAction::ToggleFullscreen => f.write_str("toggle-fullscreen"),
+            DesktopAction::SendToScratchpad => f.write_str("send-to-scratchpad"),
+            DesktopAction::ToggleScratchpad => f.write_str("toggle-scratchpad"),
+            DesktopAction::ToggleDropterm => f.write_str("toggle-dropterm"),
+            DesktopAction::CycleLayout => f.write_str("cycle-layout"),
+            DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
+            DesktopAction::GrowMaster => f.write_str("grow-master"),
+            DesktopAction::ShrinkMaster => f.write_str("shrink-master"),
+            DesktopAction::IncMaster => f.write_str("inc-master"),
+            DesktopAction::DecMaster => f.write_str("dec-master"),
+            DesktopAction::PromoteToMaster => f.write_str("promote-to-master"),
+            DesktopAction::SwapMaster => f.write_str("swap-master"),
+            // Los escritorios se numeran 1-based de cara al usuario.
+            DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
+            DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
+            DesktopAction::MoveToWorkspace(n) => write!(f, "move-to-workspace:{}", n + 1),
+            DesktopAction::FocusOutputNext => f.write_str("focus-output-next"),
+            DesktopAction::FocusOutputDir(d) => write!(f, "focus-output-{}", d.slug()),
+            DesktopAction::SendToOutputDir(d) => write!(f, "send-to-output-{}", d.slug()),
+            DesktopAction::ResizeFloatDir(d) => write!(f, "resize-float-{}", d.slug()),
+            DesktopAction::Spawn(cmd) => write!(f, "spawn:{cmd}"),
+            DesktopAction::Quit => f.write_str("quit"),
+        }
+    }
+}
+
+impl FromStr for DesktopAction {
+    /// Mensaje de error ya formateado, listo para mostrar al usuario.
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, String> {
+        let s = s.trim();
+        Ok(match s {
+            "focus-next" => Self::FocusNext,
+            "focus-prev" => Self::FocusPrev,
+            "move-forward" => Self::MoveForward,
+            "move-backward" => Self::MoveBackward,
+            "close-focused" => Self::CloseFocused,
+            "toggle-float" => Self::ToggleFloat,
+            "toggle-tiling" => Self::ToggleTiling,
+            "toggle-fullscreen" => Self::ToggleFullscreen,
+            "send-to-scratchpad" => Self::SendToScratchpad,
+            "toggle-scratchpad" => Self::ToggleScratchpad,
+            "toggle-dropterm" => Self::ToggleDropterm,
+            "cycle-layout" => Self::CycleLayout,
+            "grow-master" => Self::GrowMaster,
+            "shrink-master" => Self::ShrinkMaster,
+            "inc-master" => Self::IncMaster,
+            "dec-master" => Self::DecMaster,
+            "promote-to-master" => Self::PromoteToMaster,
+            "swap-master" => Self::SwapMaster,
+            "focus-output-next" => Self::FocusOutputNext,
+            "quit" => Self::Quit,
+            _ => {
+                if let Some(slug) = s.strip_prefix("layout:") {
+                    Self::SetLayout(
+                        layout_from_slug(slug)
+                            .ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
+                    )
+                } else if let Some(d) = s.strip_prefix("focus-").and_then(Direction::from_slug) {
+                    Self::FocusDir(d)
+                } else if let Some(d) = s.strip_prefix("focus-output-").and_then(Direction::from_slug) {
+                    Self::FocusOutputDir(d)
+                } else if let Some(d) = s.strip_prefix("send-to-output-").and_then(Direction::from_slug) {
+                    Self::SendToOutputDir(d)
+                } else if let Some(d) = s.strip_prefix("resize-float-").and_then(Direction::from_slug) {
+                    Self::ResizeFloatDir(d)
+                } else if let Some(d) = s.strip_prefix("move-").and_then(Direction::from_slug) {
+                    Self::MoveDir(d)
+                } else if let Some(id) = s.strip_prefix("focus-window:") {
+                    Self::FocusWindow(
+                        id.trim()
+                            .parse()
+                            .map_err(|_| format!("id de ventana inválido: '{id}'"))?,
+                    )
+                } else if let Some(n) = s.strip_prefix("send-to-workspace:") {
+                    Self::SendToWorkspace(parse_workspace(n)?)
+                } else if let Some(n) = s.strip_prefix("move-to-workspace:") {
+                    Self::MoveToWorkspace(parse_workspace(n)?)
+                } else if let Some(n) = s.strip_prefix("workspace:") {
+                    Self::SwitchWorkspace(parse_workspace(n)?)
+                } else if let Some(cmd) = s.strip_prefix("spawn:") {
+                    let cmd = cmd.trim();
+                    if cmd.is_empty() {
+                        return Err("spawn: necesita un comando".into());
+                    }
+                    Self::Spawn(cmd.to_string())
+                } else {
+                    return Err(format!("acción desconocida: '{s}'"));
+                }
+            }
+        })
+    }
+}
+
+/// Parsea el número de escritorio del keymap (1-based) a índice (0-based),
+/// acotado a [`WORKSPACE_COUNT`].
+fn parse_workspace(s: &str) -> Result<usize, String> {
+    let n: usize = s
+        .trim()
+        .parse()
+        .map_err(|_| format!("número de escritorio inválido: '{s}'"))?;
+    if (1..=WORKSPACE_COUNT).contains(&n) {
+        Ok(n - 1)
+    } else {
+        Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}"))
+    }
+}
+
+/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
+///
+/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
+/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son
+/// también las que se registran con
+/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys).
+pub fn default_keymap() -> Vec<(String, DesktopAction)> {
+    let mut map = vec![
+        ("Super+j".into(), DesktopAction::FocusNext),
+        ("Super+k".into(), DesktopAction::FocusPrev),
+        // Foco espacial estilo i3/sway — el clásico Super+flechas.
+        ("Super+Left".into(), DesktopAction::FocusDir(Direction::Left)),
+        ("Super+Right".into(), DesktopAction::FocusDir(Direction::Right)),
+        ("Super+Up".into(), DesktopAction::FocusDir(Direction::Up)),
+        ("Super+Down".into(), DesktopAction::FocusDir(Direction::Down)),
+        // Mover la ventana enfocada por geometría — Super+Shift+flechas.
+        ("Super+Shift+Left".into(), DesktopAction::MoveDir(Direction::Left)),
+        ("Super+Shift+Right".into(), DesktopAction::MoveDir(Direction::Right)),
+        ("Super+Shift+Up".into(), DesktopAction::MoveDir(Direction::Up)),
+        ("Super+Shift+Down".into(), DesktopAction::MoveDir(Direction::Down)),
+        ("Super+Shift+j".into(), DesktopAction::MoveForward),
+        ("Super+Shift+k".into(), DesktopAction::MoveBackward),
+        ("Super+q".into(), DesktopAction::CloseFocused),
+        ("Super+f".into(), DesktopAction::ToggleFloat),
+        ("Super+Shift+f".into(), DesktopAction::ToggleFullscreen),
+        // La tecla «quake» clásica baja la terminal dropdown.
+        ("Super+`".into(), DesktopAction::ToggleDropterm),
+        // Scratchpad genérico: enviar la enfocada / invocar la guardada.
+        // (Shift+` produce «~» tras canonizar, así que ése es el combo.)
+        ("Super+Shift+s".into(), DesktopAction::SendToScratchpad),
+        ("Super+Shift+~".into(), DesktopAction::ToggleScratchpad),
+        ("Super+space".into(), DesktopAction::CycleLayout),
+        ("Super+Shift+space".into(), DesktopAction::ToggleTiling),
+        ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
+        ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
+        ("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
+        ("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
+        ("Super+r".into(), DesktopAction::SetLayout(LayoutMode::Rows)),
+        ("Super+d".into(), DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+        ("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)),
+        ("Super+h".into(), DesktopAction::ShrinkMaster),
+        ("Super+l".into(), DesktopAction::GrowMaster),
+        ("Super+o".into(), DesktopAction::FocusOutputNext),
+        ("Super+Return".into(), DesktopAction::PromoteToMaster),
+        ("Super+Shift+Return".into(), DesktopAction::Spawn("foot".into())),
+        ("Super+p".into(), DesktopAction::Spawn("foot -e mirada-launcher".into())),
+        ("Super+,".into(), DesktopAction::IncMaster),
+        ("Super+.".into(), DesktopAction::DecMaster),
+        ("Super+Shift+e".into(), DesktopAction::Quit),
+    ];
+    // Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
+    // `Super+Shift+1`.. mandan la ventana enfocada allí.
+    for n in 0..WORKSPACE_COUNT {
+        map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n)));
+        map.push((
+            format!("Super+Shift+{}", n + 1),
+            DesktopAction::SendToWorkspace(n),
+        ));
+    }
+    map
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn keymap_has_no_duplicate_bindings() {
+        let map = default_keymap();
+        let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect();
+        keys.sort();
+        let unique = keys.len();
+        keys.dedup();
+        assert_eq!(keys.len(), unique, "hay un atajo repetido");
+    }
+
+    #[test]
+    fn keymap_covers_every_virtual_workspace() {
+        let map = default_keymap();
+        for n in 0..WORKSPACE_COUNT {
+            assert!(map
+                .iter()
+                .any(|(_, a)| a == &DesktopAction::SwitchWorkspace(n)));
+            assert!(map
+                .iter()
+                .any(|(_, a)| a == &DesktopAction::SendToWorkspace(n)));
+        }
+    }
+
+    #[test]
+    fn every_default_action_round_trips_through_its_text_form() {
+        for (_, action) in default_keymap() {
+            let text = action.to_string();
+            let back: DesktopAction = text.parse().unwrap();
+            assert_eq!(action, back, "no redondea: {text}");
+        }
+    }
+
+    #[test]
+    fn every_layout_mode_round_trips() {
+        for mode in LayoutMode::ALL {
+            let a = DesktopAction::SetLayout(mode);
+            assert_eq!(a, a.to_string().parse().unwrap());
+        }
+    }
+
+    #[test]
+    fn workspace_actions_are_one_based_in_text() {
+        assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1");
+        assert_eq!(
+            "workspace:1".parse::<DesktopAction>().unwrap(),
+            DesktopAction::SwitchWorkspace(0)
+        );
+        assert_eq!(
+            "send-to-workspace:9".parse::<DesktopAction>().unwrap(),
+            DesktopAction::SendToWorkspace(8)
+        );
+    }
+
+    #[test]
+    fn out_of_range_or_unknown_actions_are_rejected() {
+        assert!("workspace:0".parse::<DesktopAction>().is_err());
+        assert!("workspace:99".parse::<DesktopAction>().is_err());
+        assert!("layout:fractal".parse::<DesktopAction>().is_err());
+        assert!("focus-window:abc".parse::<DesktopAction>().is_err());
+        assert!("teleport".parse::<DesktopAction>().is_err());
+    }
+
+    #[test]
+    fn directional_actions_round_trip_in_every_direction() {
+        for d in [Direction::Left, Direction::Right, Direction::Up, Direction::Down] {
+            for a in [
+                DesktopAction::FocusDir(d),
+                DesktopAction::MoveDir(d),
+                DesktopAction::FocusOutputDir(d),
+                DesktopAction::SendToOutputDir(d),
+                DesktopAction::ResizeFloatDir(d),
+            ] {
+                let text = a.to_string();
+                assert_eq!(text.parse::<DesktopAction>().unwrap(), a, "no redondea: {text}");
+            }
+        }
+    }
+
+    #[test]
+    fn focus_window_round_trips_with_its_id() {
+        let a = DesktopAction::FocusWindow(42);
+        assert_eq!(a.to_string(), "focus-window:42");
+        assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
+    }
+
+    #[test]
+    fn spawn_round_trips_keeping_the_whole_command() {
+        let a = DesktopAction::Spawn("foot --title diario".into());
+        assert_eq!(a.to_string(), "spawn:foot --title diario");
+        assert_eq!(a.to_string().parse::<DesktopAction>().unwrap(), a);
+    }
+
+    #[test]
+    fn spawn_without_a_command_is_rejected() {
+        assert!("spawn:".parse::<DesktopAction>().is_err());
+        assert!("spawn:   ".parse::<DesktopAction>().is_err());
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/config.rs b/02_ruway/mirada/mirada-brain/src/config.rs
new file mode 100644
index 0000000..f47d331
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/config.rs
@@ -0,0 +1,843 @@
+//! Config general del WM — los ajustes que no son atajos ([`crate::keymap`])
+//! ni reglas de ventana ([`crate::rules`]): el comando de la terminal
+//! dropdown, la geometría del cajón quake, los parámetros iniciales del
+//! teselado y si el foco sigue al puntero.
+//!
+//! Mismo patrón que keymap/rules: RON de texto en
+//! `~/.config/mirada/config.ron`, leído una vez al arrancar y aplicado al
+//! [`Desktop`](crate::Desktop). Si el archivo no existe se escribe una
+//! plantilla documentada y se usan los defaults; si está corrupto, se
+//! avisa y se cae a los defaults.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::{Disposicion, LayoutMode, LayoutParams, WallpaperFit};
+use mirada_protocol::Decorations;
+
+/// `app_id` con el que se marca y reconoce la terminal dropdown (quake).
+/// El comando configurable [`Config::dropterm_cmd`] **debe** fijar este
+/// `app_id` (con `kitty --class`, `foot --app-id`, etc.) o el Cerebro no
+/// la reconocerá al abrirse.
+pub const DROPTERM_APP_ID: &str = "mirada.dropterm";
+
+/// El comando por defecto de la terminal dropdown. `kitty --class` fija el
+/// `app_id` en Wayland, que es como se la reconoce.
+const DEFAULT_DROPTERM_CMD: &str = "kitty --class mirada.dropterm";
+
+/// (De)serializa un [`LayoutMode`] como su `slug` de cadena (`"grid"`,
+/// `"master-stack"`, …), reusando el vocabulario de [`crate::action`].
+mod layout_slug_serde {
+    use mirada_layout::LayoutMode;
+    use serde::{Deserialize, Deserializer, Serializer};
+
+    pub fn serialize<S: Serializer>(mode: &LayoutMode, s: S) -> Result<S::Ok, S::Error> {
+        s.serialize_str(crate::action::layout_slug(*mode))
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<LayoutMode, D::Error> {
+        let slug = String::deserialize(d)?;
+        crate::action::layout_from_slug(&slug).ok_or_else(|| {
+            serde::de::Error::custom(format!(
+                "modo de teselado desconocido «{slug}» (usa master-stack, centered-master, \
+                 spiral, grid, columns, rows o monocle)"
+            ))
+        })
+    }
+}
+
+/// (De)serializa un [`WallpaperFit`] como su slug en kebab-case (`"stretch"`,
+/// `"fit"`, `"fill"`, `"center"`, `"tile"`). El derive `serde` del propio
+/// enum produce identificadores RON desnudos, incompatibles con la forma
+/// quoteada que escribimos en la plantilla.
+mod wallpaper_fit_slug_serde {
+    use mirada_layout::WallpaperFit;
+    use serde::{Deserialize, Deserializer, Serializer};
+
+    pub fn serialize<S: Serializer>(fit: &WallpaperFit, s: S) -> Result<S::Ok, S::Error> {
+        s.serialize_str(fit.slug())
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<WallpaperFit, D::Error> {
+        let slug = String::deserialize(d)?;
+        WallpaperFit::from_slug(&slug).ok_or_else(|| {
+            serde::de::Error::custom(format!(
+                "modo de wallpaper desconocido «{slug}» (usa stretch, fit, fill, center o tile)"
+            ))
+        })
+    }
+}
+
+/// Los ajustes del escritorio que el usuario puede configurar sin tocar el
+/// keymap ni las reglas.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Config {
+    /// Comando que lanza la terminal dropdown (quake). Debe fijar el
+    /// `app_id` [`DROPTERM_APP_ID`] para que el Cerebro la reconozca.
+    pub dropterm_cmd: String,
+    /// Alto del cajón dropdown como porcentaje (`1..=100`) del alto de la
+    /// salida; baja anclado arriba a todo el ancho.
+    pub dropterm_height_pct: u32,
+    /// Modo de teselado inicial de cada escritorio. En RON va como cadena
+    /// con su `slug` (`"master-stack"`, `"grid"`, …): los guiones no son
+    /// identificadores válidos para un enum sin comillas.
+    #[serde(with = "layout_slug_serde")]
+    pub layout: LayoutMode,
+    /// Margen en píxeles alrededor de cada ventana teselada.
+    pub gap: i32,
+    /// Fracción del ancho de la ventana maestra (se acota a `0.05..=0.95`).
+    pub master_ratio: f32,
+    /// Cuántas ventanas van en el área maestra (`nmaster`; al menos 1).
+    pub master_count: usize,
+    /// Paso al agrandar/encoger el área maestra (`grow-master`/`shrink-master`).
+    /// Más chico = control más fino. Se acota al rango útil.
+    pub master_step: f32,
+    /// Paso en px al mover o redimensionar una ventana flotante por teclado.
+    pub float_step: i32,
+    /// El foco del teclado sigue al puntero, sin necesidad de click.
+    pub focus_follows_mouse: bool,
+    /// Grosor del marco de ventana en píxeles; `0` = sin marco.
+    pub border_width: i32,
+    /// Color RGBA (`0..=255`) del marco de la ventana enfocada.
+    pub border_focus: [u8; 4],
+    /// Color RGBA (`0..=255`) del marco de las ventanas sin foco.
+    pub border_normal: [u8; 4],
+    /// Alto de la barra de título en px; `0` = sin barra (sólo el título de la
+    /// ventana enfocada superpuesto). Se reserva arriba de cada ventana.
+    pub titlebar_height: i32,
+    /// Ruta a la fuente para las etiquetas del compositor (título, menú).
+    /// Vacía = se prueba una lista de fuentes comunes del sistema.
+    pub font_path: String,
+    /// Ruta a la imagen de fondo del escritorio (PNG/JPEG/WebP). Vacía =
+    /// color sólido. Su colocación dentro de la salida la dicta
+    /// [`Self::wallpaper_fit`].
+    pub wallpaper_path: String,
+    /// Cómo se ajusta el wallpaper a la salida: `stretch` (deforma para cubrir),
+    /// `fit` (entra entero con barras), `fill` (cubre y recorta), `center`
+    /// (tamaño nativo centrado) o `tile` (repetido). En RON va como cadena
+    /// kebab-case: `"stretch"`, `"fit"`, `"fill"`, `"center"`, `"tile"`.
+    #[serde(with = "wallpaper_fit_slug_serde")]
+    pub wallpaper_fit: WallpaperFit,
+    /// Entradas del menú raíz (estilo openbox) que aparece al click derecho
+    /// sobre el fondo. Vacío = sin menú (el click derecho en el fondo no hace
+    /// nada). Cada entrada lanza su `command` con `sh -c`.
+    pub menu: Vec<MenuEntry>,
+    /// Zonas de la pantalla (fracciones `0..=1`): **blancos de arrastre**.
+    /// Al arrastrar una ventana sobre una zona, el compositor la resalta; al
+    /// soltarla encima, la ancla a ese rect (flotante). Soltarla fuera de toda
+    /// zona la deja flotando donde cae (overflow). Vacío = sin zonas. Es el
+    /// primer preset; `mirada-ctl cycle-zones` cicla a los de [`Self::zone_presets`].
+    pub zones: Vec<ZoneCfg>,
+    /// Presets adicionales de zonas. `mirada-ctl cycle-zones` (bindeable a un
+    /// atajo) cicla `zones → preset 0 → preset 1 → … → zones`. Cada preset es
+    /// una lista de zonas como [`Self::zones`].
+    pub zone_presets: Vec<Vec<ZoneCfg>>,
+    /// Cómo se reparten los monitores en el escritorio global cuando hay más
+    /// de uno: `"horizontal"` (uno al lado del otro, default) o `"vertical"`
+    /// (uno encima del otro). El orden lo dicta [`OutputOverride::order`].
+    /// Mismo vocabulario que [`mirada_layout::Disposicion`].
+    pub output_direction: String,
+    /// Overrides por salida (monitor). Cada entrada se identifica por el
+    /// `name` del conector DRM (`HDMI-A-1`, `DP-1`, …) y puede sobreescribir
+    /// el wallpaper, su modo de ajuste y el orden de la salida en el
+    /// escritorio compuesto. Lo que no se indique cae al valor global.
+    /// Vacío = orden de discovery, wallpaper global para todas.
+    pub outputs: Vec<OutputOverride>,
+}
+
+/// Ajustes específicos de una salida (monitor) — se aplican sólo a la salida
+/// cuyo nombre coincide. Hoy alcanzan el fondo del escritorio: imagen y modo
+/// de ajuste. Lo que se deja vacío (`""`) cae al valor global.
+///
+/// El `name` es el nombre del conector como lo reporta el backend DRM en sus
+/// logs de arranque: `HDMI-A-1`, `DP-1`, `eDP-1`, … (mayúsculas y guiones).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct OutputOverride {
+    /// Nombre del conector DRM al que se aplica este override.
+    pub name: String,
+    /// Wallpaper específico de esta salida. Vacío = usa el global.
+    #[serde(default)]
+    pub wallpaper_path: String,
+    /// Ajuste del wallpaper específico para esta salida. Vacío = usa el
+    /// global. Mismo vocabulario que [`Config::wallpaper_fit`] (`"stretch"`,
+    /// `"fit"`, `"fill"`, `"center"`, `"tile"`). Se guarda como slug en vez
+    /// de [`WallpaperFit`] para que en RON quepa una cadena desnuda y
+    /// `""` sirva como ausente — el `Option<WallpaperFit>` exigiría
+    /// `Some("fill")` / `None`, ruido innecesario en la config.
+    #[serde(default)]
+    pub wallpaper_fit: String,
+    /// Orden de esta salida en el escritorio compuesto: las salidas se
+    /// disponen ordenadas crecientemente por `(order, name)`. La de menor
+    /// `order` queda **primaria** (origen `(0, 0)`). Default `0` — entonces
+    /// el desempate por `name` da un orden estable, predecible y reproducible
+    /// (sin override, todas son `0` y mandan los nombres alfabéticamente).
+    #[serde(default)]
+    pub order: i32,
+    /// Escala HiDPI en 120-avos: `120` = 100 %, `180` = 150 %, `240` = 200 %.
+    /// Misma convención que `wp_fractional_scale` de Wayland y que
+    /// [`mirada_layout::ESCALA_100`]. Vale `0` (default) = sin override → la
+    /// salida se anuncia a 100 % nativo. Valores `<= 0` se ignoran.
+    #[serde(default)]
+    pub scale_120: u32,
+    /// Rotación / espejado del scanout. Slugs: `"normal"` (default si vacío),
+    /// `"90"`, `"180"`, `"270"`, `"flipped"`, `"flipped-90"`, `"flipped-180"`,
+    /// `"flipped-270"`. Validado al cargar la config (`from_ron`); el
+    /// compositor lo parsea a su `Transform` al usar.
+    #[serde(default)]
+    pub transform: String,
+}
+
+/// Parsea el slug de [`Config::output_direction`] a [`Disposicion`].
+fn parse_disposition(slug: &str) -> Option<Disposicion> {
+    match slug {
+        "horizontal" => Some(Disposicion::Horizontal),
+        "vertical" => Some(Disposicion::Vertical),
+        _ => None,
+    }
+}
+
+/// Slugs válidos para [`OutputOverride::transform`]. Mismo orden que la enum
+/// `Transform` de smithay (Normal / 90 / 180 / 270 / Flipped / Flipped90 /
+/// Flipped180 / Flipped270). El consumidor (drm_backend) hace el match a su
+/// tipo; acá sólo validamos.
+pub const TRANSFORM_SLUGS: &[&str] = &[
+    "normal",
+    "90",
+    "180",
+    "270",
+    "flipped",
+    "flipped-90",
+    "flipped-180",
+    "flipped-270",
+];
+
+/// `true` si `slug` es un valor reconocido de [`OutputOverride::transform`].
+/// Vacío (`""`) cuenta como ausente y es válido — significa «sin override».
+pub fn is_valid_transform_slug(slug: &str) -> bool {
+    slug.is_empty() || TRANSFORM_SLUGS.contains(&slug)
+}
+
+impl OutputOverride {
+    /// El `wallpaper_fit` parseado, si la cadena no está vacía. `None` =
+    /// no se setea (el llamante debe caer al global). `Err` si la cadena
+    /// trae un slug desconocido — se propaga al cargar la config.
+    fn parsed_wallpaper_fit(&self) -> Result<Option<WallpaperFit>, String> {
+        if self.wallpaper_fit.is_empty() {
+            return Ok(None);
+        }
+        WallpaperFit::from_slug(&self.wallpaper_fit)
+            .map(Some)
+            .ok_or_else(|| {
+                format!(
+                    "modo de wallpaper desconocido «{}» en outputs[name=\"{}\"] (usa stretch, fit, fill, center o tile)",
+                    self.wallpaper_fit, self.name
+                )
+            })
+    }
+}
+
+/// Una zona: `(x, y, w, h)` en fracciones `0..=1` de la pantalla. El `name` es
+/// opcional, sólo una etiqueta para tu propia referencia (no se pinta).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ZoneCfg {
+    #[serde(default)]
+    pub name: String,
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+}
+
+/// Una entrada del menú raíz. Es una **hoja** que lanza `command`, o un
+/// **submenú** si trae `submenu` no vacío (en ese caso `command` se ignora).
+/// La forma plana `(label, command)` sigue siendo válida: `submenu` default
+/// vacío. Anidan a cualquier profundidad.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct MenuEntry {
+    pub label: String,
+    /// Comando a lanzar (`sh -c`) si es hoja. Ignorado si hay `submenu`.
+    #[serde(default)]
+    pub command: String,
+    /// Entradas hijas; no vacío = esta entrada es un submenú.
+    #[serde(default)]
+    pub submenu: Vec<MenuEntry>,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        let lp = LayoutParams::default();
+        let dec = Decorations::default();
+        Self {
+            dropterm_cmd: DEFAULT_DROPTERM_CMD.to_string(),
+            dropterm_height_pct: 45,
+            layout: lp.mode,
+            gap: lp.gap,
+            master_ratio: lp.master_ratio,
+            master_count: lp.master_count,
+            master_step: 0.05,
+            float_step: 40,
+            focus_follows_mouse: true,
+            border_width: dec.border_width,
+            border_focus: dec.border_focus,
+            border_normal: dec.border_normal,
+            titlebar_height: dec.titlebar_height,
+            font_path: String::new(),
+            wallpaper_path: String::new(),
+            wallpaper_fit: WallpaperFit::default(),
+            menu: Vec::new(),
+            zones: Vec::new(),
+            zone_presets: Vec::new(),
+            output_direction: "horizontal".to_string(),
+            outputs: Vec::new(),
+        }
+    }
+}
+
+impl Config {
+    /// El paso del área maestra, acotado a un rango útil (`0.01..=0.5`).
+    pub fn master_step(&self) -> f32 {
+        self.master_step.clamp(0.01, 0.5)
+    }
+
+    /// El paso en px para mover/redimensionar flotantes, al menos `1`.
+    pub fn float_step(&self) -> i32 {
+        self.float_step.max(1)
+    }
+
+    /// El alto del dropdown acotado a `1..=100`, listo para multiplicar.
+    pub fn dropterm_height_pct(&self) -> i32 {
+        self.dropterm_height_pct.clamp(1, 100) as i32
+    }
+
+    /// Los parámetros de decoración que derivan de la config (marco, …),
+    /// con el grosor acotado a `>= 0`.
+    pub fn decorations(&self) -> Decorations {
+        Decorations {
+            border_width: self.border_width.max(0),
+            border_focus: self.border_focus,
+            border_normal: self.border_normal,
+            titlebar_height: self.titlebar_height.max(0),
+        }
+    }
+
+    /// La dirección de disposición de las salidas en el escritorio compuesto.
+    /// Default `Horizontal` si el slug no se reconoce — el chequeo duro se
+    /// hace al cargar la config (ver [`Self::from_ron`]).
+    pub fn output_disposition(&self) -> Disposicion {
+        parse_disposition(&self.output_direction).unwrap_or(Disposicion::Horizontal)
+    }
+
+    /// El `order` configurado para la salida `name` — `0` si no hay override.
+    pub fn output_order_for(&self, name: &str) -> i32 {
+        self.outputs
+            .iter()
+            .find(|o| o.name == name)
+            .map(|o| o.order)
+            .unwrap_or(0)
+    }
+
+    /// Escala HiDPI en 120-avos a usar para la salida `name`: el override si
+    /// existe y es positivo; si no, `120` (100 % nativo, [`mirada_layout::ESCALA_100`]).
+    pub fn output_scale_120_for(&self, name: &str) -> u32 {
+        for o in &self.outputs {
+            if o.name == name && o.scale_120 > 0 {
+                return o.scale_120;
+            }
+        }
+        mirada_layout::ESCALA_100 as u32
+    }
+
+    /// Slug de transformación a usar para la salida `name`: el override si
+    /// existe y es no vacío; si no, `"normal"`. Vocabulario en
+    /// [`TRANSFORM_SLUGS`]. Un slug inválido se ignora silenciosamente —
+    /// el chequeo duro se hace al cargar la config (ver [`Self::from_ron`]).
+    pub fn output_transform_for(&self, name: &str) -> &str {
+        for o in &self.outputs {
+            if o.name == name && is_valid_transform_slug(&o.transform) && !o.transform.is_empty() {
+                return &o.transform;
+            }
+        }
+        "normal"
+    }
+
+    /// La ruta del wallpaper a usar para la salida `name`. Si hay un override
+    /// en [`Self::outputs`] con `wallpaper_path` no vacío para esa salida, se
+    /// usa esa; si no, cae al global [`Self::wallpaper_path`]. Vacía = fondo
+    /// de color sólido.
+    pub fn wallpaper_path_for(&self, name: &str) -> &str {
+        for o in &self.outputs {
+            if o.name == name && !o.wallpaper_path.is_empty() {
+                return &o.wallpaper_path;
+            }
+        }
+        &self.wallpaper_path
+    }
+
+    /// El modo de ajuste del wallpaper para la salida `name`. Si hay un
+    /// override en [`Self::outputs`] con `wallpaper_fit` no vacío para esa
+    /// salida, se usa ese; si no, cae al global [`Self::wallpaper_fit`].
+    /// Un slug inválido en el override se ignora silenciosamente — el chequeo
+    /// duro se hace al cargar la config (ver [`Self::from_ron`]).
+    pub fn wallpaper_fit_for(&self, name: &str) -> WallpaperFit {
+        for o in &self.outputs {
+            if o.name == name {
+                if let Ok(Some(f)) = o.parsed_wallpaper_fit() {
+                    return f;
+                }
+            }
+        }
+        self.wallpaper_fit
+    }
+
+    /// Los parámetros de teselado iniciales que derivan de la config, ya
+    /// acotados — lo que se le da a cada escritorio al arrancar.
+    pub fn layout_params(&self) -> LayoutParams {
+        LayoutParams {
+            mode: self.layout,
+            master_ratio: self.master_ratio.clamp(0.05, 0.95),
+            master_count: self.master_count.max(1),
+            gap: self.gap.max(0),
+        }
+    }
+
+    /// Parsea la config desde el texto RON de un archivo. Valida también que
+    /// los slugs de overrides sean conocidos —`wallpaper_fit` de cada
+    /// [`OutputOverride`] y el [`Self::output_direction`] global— para que un
+    /// typo (ej. `"marciano"`) se rechace acá con un mensaje claro, en vez
+    /// de ignorarse en silencio al pintar.
+    pub fn from_ron(text: &str) -> Result<Config, String> {
+        let cfg: Config = ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))?;
+        for o in &cfg.outputs {
+            o.parsed_wallpaper_fit()?;
+            if !is_valid_transform_slug(&o.transform) {
+                return Err(format!(
+                    "transform desconocido «{}» en outputs[name=\"{}\"] (usa {})",
+                    o.transform,
+                    o.name,
+                    TRANSFORM_SLUGS.join(", ")
+                ));
+            }
+        }
+        if parse_disposition(&cfg.output_direction).is_none() {
+            return Err(format!(
+                "output_direction desconocido «{}» (usa horizontal o vertical)",
+                cfg.output_direction
+            ));
+        }
+        Ok(cfg)
+    }
+
+    /// La ruta canónica de la config: `~/.config/mirada/config.ron`.
+    pub fn default_path() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "mirada")
+            .map(|d| d.config_dir().join("config.ron"))
+    }
+
+    /// Carga la config de un archivo RON.
+    pub fn load(path: &Path) -> Result<Config, String> {
+        let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
+        Config::from_ron(&text)
+    }
+
+    /// Vigila el archivo de config para recargarlo en caliente.
+    pub fn watch(path: &Path) -> notify::Result<crate::watch::FileWatch> {
+        crate::watch::FileWatch::new(path)
+    }
+
+    /// Carga la config del usuario con un fallback amable: si el archivo no
+    /// existe, escribe una plantilla documentada y devuelve los defaults; si
+    /// está corrupto, avisa y devuelve los defaults.
+    pub fn load_or_default(path: &Path) -> Config {
+        if path.exists() {
+            match Config::load(path) {
+                Ok(c) => c,
+                Err(e) => {
+                    eprintln!(
+                        "mirada · config «{}» inválida ({e}); uso los valores por defecto.",
+                        path.display()
+                    );
+                    Config::default()
+                }
+            }
+        } else {
+            if let Some(dir) = path.parent() {
+                let _ = std::fs::create_dir_all(dir);
+            }
+            match std::fs::write(path, CONFIG_TEMPLATE) {
+                Ok(()) => eprintln!("mirada · plantilla de config escrita en {}", path.display()),
+                Err(e) => eprintln!("mirada · no pude escribir la plantilla de config: {e}"),
+            }
+            Config::default()
+        }
+    }
+}
+
+/// La plantilla que se escribe la primera vez: los defaults explícitos, con
+/// comentarios. Editarla cambia el comportamiento al reiniciar mirada.
+const CONFIG_TEMPLATE: &str = "\
+// Config de mirada — ajustes del escritorio que no son atajos (keymap.ron)
+// ni reglas de ventana (rules.ron). Reinicia mirada para aplicar cambios.
+(
+    // Comando de la terminal dropdown (quake), que Super+grave despliega.
+    // DEBE fijar el app_id `mirada.dropterm` para que mirada la reconozca:
+    //   kitty --class mirada.dropterm   ·   foot --app-id mirada.dropterm
+    dropterm_cmd: \"kitty --class mirada.dropterm\",
+    // Alto del cajón dropdown, en % del alto de pantalla (baja desde arriba).
+    dropterm_height_pct: 45,
+
+    // Teselado inicial de cada escritorio.
+    //   master-stack · centered-master · spiral · grid · columns · rows · monocle
+    layout: \"master-stack\",
+    gap: 8,                    // margen en px alrededor de cada ventana
+    master_ratio: 0.6,         // fracción de ancho de la ventana maestra
+    master_count: 1,           // cuántas ventanas en el área maestra
+    master_step: 0.05,         // paso de grow/shrink-master (más chico = más fino)
+    float_step: 40,            // paso en px para mover/redimensionar flotantes por teclado
+
+    // El foco del teclado sigue al puntero (sin click). false = foco al clickear.
+    focus_follows_mouse: true,
+
+    // Marco de ventana. Colores RGBA en 0..=255; border_width: 0 = sin marco.
+    border_width: 2,
+    border_focus: (92, 143, 235, 255),    // azul al foco
+    border_normal: (56, 56, 69, 255),     // gris discreto sin foco
+    // Barra de título sobre cada ventana (px). 0 = sin barra (sólo el título
+    // de la ventana enfocada, superpuesto). La franja se reserva arriba.
+    titlebar_height: 24,
+
+    // Fuente para las etiquetas (título, menú). Vacía = se prueba una lista
+    // de fuentes comunes del sistema (Liberation, DejaVu, Noto, Adwaita…).
+    font_path: \"\",
+
+    // Imagen de fondo del escritorio (PNG/JPEG/WebP). Vacía = color sólido.
+    // Ej: \"/home/yo/.config/mirada/fondo.png\".
+    wallpaper_path: \"\",
+    // Cómo encaja la imagen en la salida:
+    //   stretch — deforma para cubrir exactamente (default).
+    //   fit     — la imagen entra entera, con barras negras (letterbox).
+    //   fill    — la imagen cubre la salida, los bordes se recortan.
+    //   center  — tamaño nativo centrado (padding negro o recorte si es grande).
+    //   tile    — repetida en su tamaño nativo desde la esquina superior-izquierda.
+    wallpaper_fit: \"stretch\",
+
+    // Menú raíz (estilo openbox): aparece al click DERECHO sobre el fondo.
+    // Vacío = sin menú. Una entrada es hoja (lanza command con `sh -c`) o
+    // submenú (si trae `submenu`, anidable a cualquier profundidad). Ej:
+    //   menu: [
+    //       (label: \"Terminal\",  command: \"kitty\"),
+    //       (label: \"Apps\", submenu: [
+    //           (label: \"Navegador\", command: \"firefox\"),
+    //           (label: \"Editores\", submenu: [
+    //               (label: \"nada\", command: \"nada\"),
+    //           ]),
+    //       ]),
+    //   ],
+    menu: [],
+
+    // Zonas: blancos de arrastre (fracciones 0..=1 de la pantalla). Al arrastrar
+    // una ventana sobre una zona se resalta; al soltarla encima, aterriza en ese
+    // rect; soltarla fuera la deja flotando donde cae (overflow). El `name` es
+    // opcional (sólo tu referencia). Vacío = sin zonas. Ej (media/cuartos):
+    //   zones: [
+    //       (x: 0.0, y: 0.0, w: 0.5, h: 1.0),
+    //       (x: 0.5, y: 0.0, w: 0.5, h: 0.5),
+    //       (x: 0.5, y: 0.5, w: 0.5, h: 0.5),
+    //   ],
+    zones: [],
+
+    // Presets adicionales de zonas. `mirada-ctl cycle-zones` (bindealo a un
+    // atajo) cicla zones → preset 0 → preset 1 → … → zones. Ej:
+    //   zone_presets: [
+    //       [ (x: 0.0, y: 0.0, w: 0.5, h: 1.0), (x: 0.5, y: 0.0, w: 0.5, h: 1.0) ],
+    //       [ (x: 0.0, y: 0.0, w: 1.0, h: 1.0) ],
+    //   ],
+    zone_presets: [],
+
+    // Cómo se reparten los monitores en el escritorio global cuando hay más
+    // de uno: \"horizontal\" (uno al lado del otro) o \"vertical\" (encima).
+    output_direction: \"horizontal\",
+
+    // Overrides por salida (monitor). Cada entrada identifica el conector
+    // DRM por su `name` (ej. \"HDMI-A-1\", \"DP-1\", \"eDP-1\"; sale en los
+    // logs de arranque del compositor). Sobreescribe wallpaper + orden +
+    // escala HiDPI + transformación de la salida. Lo que se deja vacío
+    // cae al global. La salida con `order` más chico queda primaria
+    // (origen 0,0). `scale_120` en 120-avos (120=100%, 180=150%, 240=200%).
+    // `transform`: normal / 90 / 180 / 270 / flipped / flipped-90 /
+    // flipped-180 / flipped-270. Vacío = orden alfabético, sin overrides. Ej:
+    //   outputs: [
+    //       (name: \"DP-1\",     order: 0, scale_120: 240,
+    //                            wallpaper_path: \"/home/yo/fondos/code.png\",
+    //                            wallpaper_fit: \"fill\"),
+    //       (name: \"HDMI-A-1\", order: 1, transform: \"90\",
+    //                            wallpaper_path: \"/home/yo/fondos/sala.png\"),
+    //   ],
+    outputs: [],
+)
+";
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn the_template_parses_to_the_defaults() {
+        assert_eq!(Config::from_ron(CONFIG_TEMPLATE).unwrap(), Config::default());
+    }
+
+    #[test]
+    fn omitted_fields_fall_back_to_defaults() {
+        let c = Config::from_ron("( gap: 20 )").unwrap();
+        assert_eq!(c.gap, 20);
+        // El resto queda en su default.
+        assert_eq!(c.dropterm_cmd, Config::default().dropterm_cmd);
+        assert!(c.focus_follows_mouse);
+    }
+
+    #[test]
+    fn layout_params_clamp_out_of_range_values() {
+        let c = Config::from_ron("( master_ratio: 2.0, master_count: 0, gap: -5 )").unwrap();
+        let lp = c.layout_params();
+        assert_eq!(lp.master_ratio, 0.95);
+        assert_eq!(lp.master_count, 1);
+        assert_eq!(lp.gap, 0);
+    }
+
+    #[test]
+    fn dropterm_height_is_clamped() {
+        let c = Config::from_ron("( dropterm_height_pct: 250 )").unwrap();
+        assert_eq!(c.dropterm_height_pct(), 100);
+        let c = Config::from_ron("( dropterm_height_pct: 0 )").unwrap();
+        assert_eq!(c.dropterm_height_pct(), 1);
+    }
+
+    #[test]
+    fn decorations_derive_from_the_config_and_clamp_width() {
+        let c = Config::from_ron(
+            "( border_width: -3, border_focus: (10, 20, 30, 255), border_normal: (1, 2, 3, 4) )",
+        )
+        .unwrap();
+        let d = c.decorations();
+        assert_eq!(d.border_width, 0); // acotado a >= 0
+        assert_eq!(d.border_focus, [10, 20, 30, 255]);
+        assert_eq!(d.border_normal, [1, 2, 3, 4]);
+    }
+
+    #[test]
+    fn default_decorations_match_the_protocol_default() {
+        assert_eq!(Config::default().decorations(), Decorations::default());
+    }
+
+    #[test]
+    fn the_layout_mode_parses_from_its_slug_string() {
+        let c = Config::from_ron(r#"( layout: "centered-master" )"#).unwrap();
+        assert_eq!(c.layout, LayoutMode::CenteredMaster);
+    }
+
+    #[test]
+    fn an_unknown_layout_slug_is_rejected() {
+        assert!(Config::from_ron(r#"( layout: "tetris" )"#).is_err());
+    }
+
+    #[test]
+    fn menu_flat_entries_parse_without_submenu() {
+        let c = Config::from_ron(
+            r#"( menu: [(label: "Terminal", command: "kitty")] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.menu.len(), 1);
+        assert_eq!(c.menu[0].label, "Terminal");
+        assert_eq!(c.menu[0].command, "kitty");
+        assert!(c.menu[0].submenu.is_empty());
+    }
+
+    #[test]
+    fn zones_parsean_con_nombre_opcional() {
+        let c = Config::from_ron(
+            r#"( zones: [
+                (x: 0.0, y: 0.0, w: 0.6, h: 1.0),
+                (name: "chat", x: 0.6, y: 0.0, w: 0.4, h: 1.0),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.zones.len(), 2);
+        assert_eq!(c.zones[0].name, ""); // name es opcional
+        assert!((c.zones[0].w - 0.6).abs() < 1e-6);
+        assert_eq!(c.zones[1].name, "chat");
+    }
+
+    #[test]
+    fn wallpaper_fit_parsea_de_su_slug() {
+        for (slug, want) in [
+            ("stretch", WallpaperFit::Stretch),
+            ("fit", WallpaperFit::Fit),
+            ("fill", WallpaperFit::Fill),
+            ("center", WallpaperFit::Center),
+            ("tile", WallpaperFit::Tile),
+        ] {
+            let c = Config::from_ron(&format!(r#"( wallpaper_fit: "{slug}" )"#)).unwrap();
+            assert_eq!(c.wallpaper_fit, want);
+        }
+    }
+
+    #[test]
+    fn wallpaper_fit_default_es_stretch() {
+        assert_eq!(Config::default().wallpaper_fit, WallpaperFit::Stretch);
+    }
+
+    #[test]
+    fn output_override_aplica_su_wallpaper_solo_al_monitor_nombrado() {
+        let c = Config::from_ron(
+            r#"( wallpaper_path: "global.png", outputs: [
+                (name: "HDMI-A-1", wallpaper_path: "sala.png"),
+                (name: "DP-1",     wallpaper_path: "code.png", wallpaper_fit: "fill"),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.wallpaper_path_for("HDMI-A-1"), "sala.png");
+        assert_eq!(c.wallpaper_fit_for("HDMI-A-1"), WallpaperFit::Stretch);
+        assert_eq!(c.wallpaper_path_for("DP-1"), "code.png");
+        assert_eq!(c.wallpaper_fit_for("DP-1"), WallpaperFit::Fill);
+        // Salida sin override cae al global.
+        assert_eq!(c.wallpaper_path_for("eDP-1"), "global.png");
+        assert_eq!(c.wallpaper_fit_for("eDP-1"), WallpaperFit::Stretch);
+    }
+
+    #[test]
+    fn output_override_con_path_vacio_no_pisa_al_global() {
+        // Un override con sólo `name` (path vacío) deja el wallpaper en el
+        // global — útil si sólo se quiere cambiar el `fit` del monitor.
+        let c = Config::from_ron(
+            r#"( wallpaper_path: "global.png", outputs: [
+                (name: "HDMI-A-1", wallpaper_fit: "fit"),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.wallpaper_path_for("HDMI-A-1"), "global.png");
+        assert_eq!(c.wallpaper_fit_for("HDMI-A-1"), WallpaperFit::Fit);
+    }
+
+    #[test]
+    fn output_override_rechaza_fit_desconocido() {
+        let err = Config::from_ron(
+            r#"( outputs: [ (name: "DP-1", wallpaper_fit: "marciano") ] )"#,
+        )
+        .unwrap_err();
+        assert!(err.contains("marciano"), "mensaje útil: {err}");
+    }
+
+    #[test]
+    fn output_direction_default_es_horizontal() {
+        let c = Config::default();
+        assert_eq!(c.output_disposition(), Disposicion::Horizontal);
+    }
+
+    #[test]
+    fn output_direction_parsea_vertical_y_horizontal() {
+        let c = Config::from_ron(r#"( output_direction: "vertical" )"#).unwrap();
+        assert_eq!(c.output_disposition(), Disposicion::Vertical);
+        let c = Config::from_ron(r#"( output_direction: "horizontal" )"#).unwrap();
+        assert_eq!(c.output_disposition(), Disposicion::Horizontal);
+    }
+
+    #[test]
+    fn output_direction_desconocido_es_rechazado() {
+        let err = Config::from_ron(r#"( output_direction: "diagonal" )"#).unwrap_err();
+        assert!(err.contains("diagonal"), "mensaje útil: {err}");
+    }
+
+    #[test]
+    fn output_order_for_cae_a_cero_sin_override() {
+        let c = Config::default();
+        assert_eq!(c.output_order_for("HDMI-A-1"), 0);
+    }
+
+    #[test]
+    fn output_scale_120_default_es_100_pct_si_no_hay_override() {
+        let c = Config::default();
+        assert_eq!(c.output_scale_120_for("HDMI-A-1"), 120);
+    }
+
+    #[test]
+    fn output_scale_120_lee_el_override() {
+        let c = Config::from_ron(
+            r#"( outputs: [
+                (name: "DP-1", scale_120: 240),
+                (name: "HDMI-A-1", scale_120: 0),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.output_scale_120_for("DP-1"), 240);
+        // `scale_120: 0` cuenta como sin override → 100 %.
+        assert_eq!(c.output_scale_120_for("HDMI-A-1"), 120);
+        // Salida sin entrada → 100 %.
+        assert_eq!(c.output_scale_120_for("eDP-1"), 120);
+    }
+
+    #[test]
+    fn output_transform_default_es_normal() {
+        let c = Config::default();
+        assert_eq!(c.output_transform_for("HDMI-A-1"), "normal");
+    }
+
+    #[test]
+    fn output_transform_lee_el_override() {
+        let c = Config::from_ron(
+            r#"( outputs: [
+                (name: "HDMI-A-1", transform: "90"),
+                (name: "DP-1", transform: "flipped-180"),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.output_transform_for("HDMI-A-1"), "90");
+        assert_eq!(c.output_transform_for("DP-1"), "flipped-180");
+        assert_eq!(c.output_transform_for("eDP-1"), "normal");
+    }
+
+    #[test]
+    fn output_transform_desconocido_es_rechazado() {
+        let err = Config::from_ron(
+            r#"( outputs: [ (name: "DP-1", transform: "diagonal") ] )"#,
+        )
+        .unwrap_err();
+        assert!(err.contains("diagonal"), "mensaje útil: {err}");
+    }
+
+    #[test]
+    fn output_order_for_lee_el_override() {
+        let c = Config::from_ron(
+            r#"( outputs: [
+                (name: "DP-1", order: 0),
+                (name: "HDMI-A-1", order: 5),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.output_order_for("HDMI-A-1"), 5);
+        assert_eq!(c.output_order_for("DP-1"), 0);
+        // Sin entrada cae a 0.
+        assert_eq!(c.output_order_for("eDP-1"), 0);
+    }
+
+    #[test]
+    fn menu_nested_submenus_parse() {
+        let c = Config::from_ron(
+            r#"( menu: [
+                (label: "Apps", submenu: [
+                    (label: "Navegador", command: "firefox"),
+                    (label: "Más", submenu: [
+                        (label: "nada", command: "nada"),
+                    ]),
+                ]),
+            ] )"#,
+        )
+        .unwrap();
+        assert_eq!(c.menu.len(), 1);
+        let apps = &c.menu[0];
+        assert_eq!(apps.label, "Apps");
+        assert_eq!(apps.submenu.len(), 2);
+        assert_eq!(apps.submenu[0].command, "firefox");
+        assert_eq!(apps.submenu[1].submenu[0].label, "nada");
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/ctl.rs b/02_ruway/mirada/mirada-brain/src/ctl.rs
new file mode 100644
index 0000000..ee0905b
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/ctl.rs
@@ -0,0 +1,229 @@
+//! `ctl` — el API de control externo del Cerebro.
+//!
+//! Mientras el keymap ([`crate::keymap`]) es la cara *configurable* de las
+//! acciones, este módulo es su cara *programable*: deja que otro proceso
+//! —un script, una taskbar, el binario `mirada-ctl`— dispare una
+//! [`DesktopAction`] o consulte el estado, sin tocar el teclado.
+//!
+//! Todo converge igualmente en `Desktop::apply`: una petición de control
+//! no es más que otro front-end del mismo embudo. El transporte es un
+//! socket Unix de petición/respuesta, con el marco `postcard` que ya usa
+//! [`mirada_protocol`]; `DesktopAction` viaja como enum serializado (no
+//! como cadena), así que el contrato es tipado de punta a punta.
+//!
+//! - El Cerebro abre un [`CtlServer`] y atiende [`CtlConn`]s en su bucle.
+//! - El cliente usa [`send_request`] — una petición, una respuesta, cierra.
+
+use std::io::{self, ErrorKind};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::WindowId;
+use mirada_protocol::{read_frame, write_frame};
+
+use crate::action::DesktopAction;
+
+/// Una orden de un cliente de control al Cerebro.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CtlRequest {
+    /// Aplica una acción de escritorio — el equivalente a pulsar su atajo.
+    Do(DesktopAction),
+    /// Pide la lista de ventanas conocidas, en todos los escritorios.
+    ListWindows,
+    /// Cicla al siguiente conjunto de zonas de arrastre (presets de
+    /// `config.ron`). Lo atiende el Cuerpo (las zonas son suyas), no el Cerebro.
+    CycleZones,
+}
+
+/// La respuesta del Cerebro a un [`CtlRequest`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CtlReply {
+    /// La orden se aplicó.
+    Ok,
+    /// La orden no se pudo aplicar; el motivo, para mostrar al usuario.
+    Error(String),
+    /// La lista pedida con [`CtlRequest::ListWindows`].
+    Windows(Vec<WindowLine>),
+}
+
+/// Una ventana en la vista de `mirada-ctl windows`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WindowLine {
+    /// Id de la ventana — el que se pasa a `focus-window:N`.
+    pub id: WindowId,
+    pub app_id: String,
+    pub title: String,
+    /// Escritorio virtual donde está (1-based); `0` = guardada en el
+    /// scratchpad, en ningún escritorio.
+    pub workspace: usize,
+    /// `true` si es la ventana enfocada del escritorio activo.
+    pub focused: bool,
+}
+
+/// La ruta del socket de control: `$XDG_RUNTIME_DIR/mirada-ctl.sock`, o
+/// el directorio temporal si esa variable no está.
+pub fn default_socket_path() -> PathBuf {
+    let dir = std::env::var_os("XDG_RUNTIME_DIR")
+        .map(PathBuf::from)
+        .unwrap_or_else(std::env::temp_dir);
+    dir.join("mirada-ctl.sock")
+}
+
+/// El extremo servidor del API de control — lo abre el dueño del
+/// [`Desktop`](crate::Desktop) (la app `mirada`, o `mirada-compositor`
+/// con el Cerebro embebido).
+pub struct CtlServer {
+    listener: UnixListener,
+    path: PathBuf,
+}
+
+impl CtlServer {
+    /// Abre el socket de control en `path`. Si ya hay un Cerebro vivo
+    /// escuchando ahí, falla; si encuentra un socket muerto (de un
+    /// compositor anterior), lo retira y se queda con él.
+    pub fn bind(path: &Path) -> io::Result<Self> {
+        if path.exists() {
+            if UnixStream::connect(path).is_ok() {
+                return Err(io::Error::new(
+                    ErrorKind::AddrInUse,
+                    "ya hay un Cerebro escuchando en el socket de control",
+                ));
+            }
+            let _ = std::fs::remove_file(path);
+        }
+        if let Some(dir) = path.parent() {
+            std::fs::create_dir_all(dir)?;
+        }
+        let listener = UnixListener::bind(path)?;
+        listener.set_nonblocking(true)?;
+        Ok(Self { listener, path: path.to_path_buf() })
+    }
+
+    /// Acepta una conexión pendiente sin bloquear. `None` si no hay
+    /// ninguna — pensado para llamarse cada vuelta del bucle de eventos.
+    pub fn poll(&self) -> Option<CtlConn> {
+        match self.listener.accept() {
+            Ok((stream, _)) => Some(CtlConn { stream }),
+            Err(_) => None,
+        }
+    }
+}
+
+impl Drop for CtlServer {
+    fn drop(&mut self) {
+        // Dejar el socket limpio para el próximo arranque.
+        let _ = std::fs::remove_file(&self.path);
+    }
+}
+
+/// Una conexión de control aceptada: una petición y una respuesta.
+pub struct CtlConn {
+    stream: UnixStream,
+}
+
+impl CtlConn {
+    /// Lee la petición del cliente (bloquea hasta el marco completo; es
+    /// uno solo y llega enseguida).
+    pub fn read_request(&mut self) -> io::Result<Option<CtlRequest>> {
+        self.stream.set_nonblocking(false)?;
+        read_frame(&mut self.stream)
+    }
+
+    /// Envía la respuesta. El cliente cierra al recibirla.
+    pub fn reply(&mut self, reply: &CtlReply) -> io::Result<()> {
+        write_frame(&mut self.stream, reply)
+    }
+}
+
+/// Envía una petición al Cerebro y espera su respuesta. Es el camino que
+/// usa el binario `mirada-ctl`: conecta, pregunta, cierra.
+pub fn send_request(path: &Path, request: &CtlRequest) -> io::Result<CtlReply> {
+    let mut stream = UnixStream::connect(path)?;
+    write_frame(&mut stream, request)?;
+    read_frame(&mut stream)?
+        .ok_or_else(|| io::Error::new(ErrorKind::UnexpectedEof, "el Cerebro cerró sin responder"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::thread;
+    use std::time::{SystemTime, UNIX_EPOCH};
+
+    /// Una ruta de socket única para un test (los sockets no se pueden
+    /// reabrir; cada test necesita la suya).
+    fn temp_socket(tag: &str) -> PathBuf {
+        let nanos = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_nanos();
+        std::env::temp_dir().join(format!("mirada-ctl-test-{tag}-{nanos}.sock"))
+    }
+
+    #[test]
+    fn default_socket_path_lives_under_a_runtime_dir() {
+        let p = default_socket_path();
+        assert_eq!(p.file_name().unwrap(), "mirada-ctl.sock");
+    }
+
+    #[test]
+    fn a_request_round_trips_over_the_socket() {
+        let path = temp_socket("roundtrip");
+        let server = CtlServer::bind(&path).unwrap();
+
+        // El "Cerebro": atiende una petición y responde.
+        let srv = thread::spawn(move || loop {
+            if let Some(mut conn) = server.poll() {
+                let req = conn.read_request().unwrap().unwrap();
+                let reply = match req {
+                    CtlRequest::Do(DesktopAction::FocusNext) => CtlReply::Ok,
+                    other => CtlReply::Error(format!("inesperado: {other:?}")),
+                };
+                conn.reply(&reply).unwrap();
+                return;
+            }
+            thread::yield_now();
+        });
+
+        let reply = send_request(&path, &CtlRequest::Do(DesktopAction::FocusNext)).unwrap();
+        assert_eq!(reply, CtlReply::Ok);
+        srv.join().unwrap();
+    }
+
+    #[test]
+    fn list_windows_carries_the_window_lines() {
+        let path = temp_socket("windows");
+        let server = CtlServer::bind(&path).unwrap();
+        let lines = vec![WindowLine {
+            id: 7,
+            app_id: "org.brahman.shuma".into(),
+            title: "shell".into(),
+            workspace: 2,
+            focused: true,
+        }];
+        let expected = lines.clone();
+
+        let srv = thread::spawn(move || loop {
+            if let Some(mut conn) = server.poll() {
+                assert_eq!(conn.read_request().unwrap().unwrap(), CtlRequest::ListWindows);
+                conn.reply(&CtlReply::Windows(lines)).unwrap();
+                return;
+            }
+            thread::yield_now();
+        });
+
+        let reply = send_request(&path, &CtlRequest::ListWindows).unwrap();
+        assert_eq!(reply, CtlReply::Windows(expected));
+        srv.join().unwrap();
+    }
+
+    #[test]
+    fn binding_twice_on_a_live_socket_is_refused() {
+        let path = temp_socket("dup");
+        let _first = CtlServer::bind(&path).unwrap();
+        // El primero sigue vivo: el segundo debe rechazarse.
+        assert!(CtlServer::bind(&path).is_err());
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/desktop.rs b/02_ruway/mirada/mirada-brain/src/desktop.rs
new file mode 100644
index 0000000..220d0eb
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/desktop.rs
@@ -0,0 +1,1946 @@
+//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`.
+
+use std::collections::HashMap;
+
+use mirada_layout::{LayoutParams, Rect, WindowId, Workspace};
+use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
+
+use crate::action::{Direction, DesktopAction, WORKSPACE_COUNT};
+use crate::config::Config;
+use crate::keymap::Keymap;
+use crate::rules::Rules;
+
+pub use crate::config::DROPTERM_APP_ID;
+
+/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct WindowInfo {
+    pub app_id: String,
+    pub title: String,
+}
+
+/// Una salida física y el escritorio virtual que muestra ahora mismo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Output {
+    pub id: OutputId,
+    /// Rectángulo en el espacio global — las salidas van en fila horizontal.
+    pub rect: Rect,
+    /// Zonas exclusivas reservadas por el marco (`pata`/shell), en px desde
+    /// cada borde: `(top, bottom, left, right)`. El teselado las esquiva.
+    pub reserved: (i32, i32, i32, i32),
+    /// Índice del escritorio que esta salida muestra.
+    pub workspace: usize,
+}
+
+impl Output {
+    /// El área teselable: el rect global menos las zonas reservadas. Es lo que
+    /// se le pasa al motor de layout, así que las barras de cualquier borde
+    /// quedan libres de ventanas.
+    pub fn work_rect(&self) -> Rect {
+        let (top, bottom, left, right) = self.reserved;
+        Rect::new(
+            self.rect.x + left,
+            self.rect.y + top,
+            (self.rect.w - left - right).max(1),
+            (self.rect.h - top - bottom).max(1),
+        )
+    }
+}
+
+/// El estado completo del escritorio.
+///
+/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
+/// virtuales, el registro de ventanas, el keymap y las reglas. El único
+/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
+/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
+///
+/// **Multi-monitor**: cada salida muestra un escritorio distinto; el
+/// teselado se calcula para todas y el `Place` resultante las cubre. Un
+/// escritorio se ve en una salida como mucho — pedir uno que ya muestra
+/// otra salida las intercambia.
+pub struct Desktop {
+    /// Salidas físicas, en fila horizontal y en orden de aparición.
+    outputs: Vec<Output>,
+    /// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
+    workspaces: Vec<Workspace>,
+    /// Índice (en `outputs`) de la salida con el foco.
+    focused_output: usize,
+    /// Identidad de cada ventana conocida.
+    windows: HashMap<WindowId, WindowInfo>,
+    /// Atajos globales → acción. Configurable, recargable en caliente.
+    keymap: Keymap,
+    /// Reglas de ventana — escritorio/flotante por `app_id`/título.
+    rules: Rules,
+    /// Config general del WM — dropterm, parámetros del teselado, foco.
+    config: Config,
+    /// Ventanas del scratchpad: se invocan flotando y se ocultan a
+    /// voluntad; mientras están guardadas no viven en ningún escritorio.
+    scratchpad: Vec<WindowId>,
+}
+
+impl Default for Desktop {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl Desktop {
+    /// Escritorio recién arrancado: sin salidas ni ventanas, con los
+    /// escritorios virtuales vacíos y el mapa de teclas por defecto.
+    pub fn new() -> Self {
+        Self::with_keymap(Keymap::default())
+    }
+
+    /// Como [`Desktop::new`], pero con un keymap dado — el que la app
+    /// cargó del archivo de configuración del usuario.
+    pub fn with_keymap(keymap: Keymap) -> Self {
+        let workspaces = (0..WORKSPACE_COUNT)
+            .map(|_| Workspace::new(LayoutParams::default()))
+            .collect();
+        Self {
+            outputs: Vec::new(),
+            workspaces,
+            focused_output: 0,
+            windows: HashMap::new(),
+            keymap,
+            rules: Rules::default(),
+            config: Config::default(),
+            scratchpad: Vec::new(),
+        }
+    }
+
+    /// Reemplaza las reglas de ventana. Se aplican a las ventanas que se
+    /// abran a partir de ahora; las ya abiertas no se tocan.
+    pub fn set_rules(&mut self, rules: Rules) {
+        self.rules = rules;
+    }
+
+    /// Aplica la config general del WM. Los parámetros de teselado
+    /// (modo/gap/ratio/nmaster) se siembran en **todos** los escritorios;
+    /// el resto (dropterm, foco-sigue-ratón) se consulta cuando hace falta.
+    /// Pensado para llamarse una vez al arrancar, antes de conectar salidas.
+    pub fn set_config(&mut self, config: Config) {
+        let params = config.layout_params();
+        for ws in &mut self.workspaces {
+            ws.set_params(params);
+        }
+        self.config = config;
+    }
+
+    /// La config general vigente — para un HUD o un editor de ajustes.
+    pub fn config(&self) -> &Config {
+        &self.config
+    }
+
+    /// Recarga la config en caliente: re-siembra los parámetros de teselado
+    /// (el archivo manda — un cambio de gap/modo/ratio se ve al guardar,
+    /// aunque pise un layout cambiado a mano) y devuelve el comando que
+    /// re-envía la decoración al Cuerpo. dropterm/foco se leen en vivo.
+    pub fn reload_config(&mut self, config: Config) -> Vec<BrainCommand> {
+        self.set_config(config);
+        vec![self.decorations()]
+    }
+
+    /// El comando que registra los atajos globales en el Cuerpo. La app
+    /// lo envía al conectar, y de nuevo tras cada recarga del keymap.
+    pub fn grab_keys(&self) -> BrainCommand {
+        BrainCommand::GrabKeys(self.keymap.grab_list())
+    }
+
+    /// El comando que fija la decoración de ventana (marco, …) en el
+    /// Cuerpo, según la config. La app lo envía al arrancar (junto a
+    /// [`grab_keys`](Desktop::grab_keys)) y tras recargar la config.
+    pub fn decorations(&self) -> BrainCommand {
+        BrainCommand::SetDecorations(self.config.decorations())
+    }
+
+    /// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que
+    /// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta.
+    pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand {
+        self.keymap = keymap;
+        self.grab_keys()
+    }
+
+    /// El keymap vigente — para un HUD o un editor visual de atajos.
+    pub fn keymap(&self) -> &Keymap {
+        &self.keymap
+    }
+
+    /// Geometría de la salida enfocada, si hay alguna conectada.
+    pub fn screen(&self) -> Option<Rect> {
+        self.outputs.get(self.focused_output).map(|o| o.rect)
+    }
+
+    /// Procesa un evento del Cuerpo: muta el estado y devuelve los
+    /// comandos a enviar de vuelta.
+    pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
+        match event {
+            BodyEvent::OutputAdded { id, width, height } => {
+                // La salida nueva muestra el primer escritorio que no
+                // muestre ya otra salida.
+                let taken: Vec<usize> = self.outputs.iter().map(|o| o.workspace).collect();
+                let workspace = (0..self.workspaces.len())
+                    .find(|n| !taken.contains(n))
+                    .unwrap_or(0);
+                self.outputs.push(Output {
+                    id,
+                    rect: Rect::new(0, 0, width, height),
+                    reserved: (0, 0, 0, 0),
+                    workspace,
+                });
+                self.reflow_outputs();
+                self.relayout()
+            }
+            BodyEvent::OutputRemoved { id } => {
+                self.outputs.retain(|o| o.id != id);
+                if self.focused_output >= self.outputs.len() {
+                    self.focused_output = self.outputs.len().saturating_sub(1);
+                }
+                self.reflow_outputs();
+                self.relayout()
+            }
+            BodyEvent::OutputResized { id, width, height } => {
+                // Sólo cambia el área útil; el escritorio que muestra la
+                // salida se conserva.
+                if let Some(o) = self.outputs.iter_mut().find(|o| o.id == id) {
+                    o.rect.w = width;
+                    o.rect.h = height;
+                    self.reflow_outputs();
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            BodyEvent::OutputReserved {
+                id,
+                top,
+                bottom,
+                left,
+                right,
+            } => {
+                // El marco reservó/liberó franjas: cambia el área teselable
+                // sin tocar el tamaño físico ni el escritorio que muestra.
+                if let Some(o) = self.outputs.iter_mut().find(|o| o.id == id) {
+                    o.reserved = (top.max(0), bottom.max(0), left.max(0), right.max(0));
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            BodyEvent::WindowOpened { id, app_id, title } => {
+                // La terminal dropdown se reconoce por su `app_id`: nace
+                // flotando anclada arriba y enfocada, lista para escribir.
+                let is_dropterm = app_id == DROPTERM_APP_ID;
+                // Las reglas pueden mandarla a otro escritorio o hacerla flotar.
+                let outcome = self.rules.resolve(&app_id, &title);
+                self.windows.insert(id, WindowInfo { app_id, title });
+                let ws = outcome
+                    .workspace
+                    .filter(|&n| n < self.workspaces.len())
+                    .unwrap_or(self.active_index());
+                self.workspaces[ws].add(id);
+                if is_dropterm {
+                    let pct = self.config.dropterm_height_pct();
+                    let rect = self
+                        .screen()
+                        .map(|s| dropdown_rect(s, pct))
+                        .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                    self.workspaces[ws].set_floating(id, Some(rect));
+                } else if outcome.floating {
+                    let rect = self
+                        .screen()
+                        .map(centered_float_rect)
+                        .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                    self.workspaces[ws].set_floating(id, Some(rect));
+                }
+                self.relayout()
+            }
+            BodyEvent::WindowClosed { id } => {
+                self.windows.remove(&id);
+                self.scratchpad.retain(|&w| w != id);
+                for ws in &mut self.workspaces {
+                    ws.remove(id);
+                }
+                self.relayout()
+            }
+            BodyEvent::WindowRetitled { id, title } => {
+                if let Some(info) = self.windows.get_mut(&id) {
+                    info.title = title;
+                }
+                // Un cambio de título no altera la geometría.
+                Vec::new()
+            }
+            BodyEvent::PointerEntered { id } => {
+                // Foco al pasar el puntero, si la config lo habilita y la
+                // ventana está en el escritorio activo.
+                if !self.config.focus_follows_mouse {
+                    return Vec::new();
+                }
+                let active = self.active_index();
+                if self.workspaces[active].focus_window(id) {
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            BodyEvent::Clicked { id } => {
+                // Foco-al-click: enfoca la ventana clickeada, esté donde
+                // esté, sin depender del foco-sigue-ratón. El z-order
+                // (levantar la flotante clickeada) lo resuelve el Cuerpo al
+                // pintar la enfocada encima.
+                self.apply(DesktopAction::FocusWindow(id))
+            }
+            BodyEvent::WindowDragged { id, x, y } => {
+                // Arrastre de una ventana teselada: la intercambia con la
+                // teselada que haya bajo el puntero. Una flotante no entra
+                // aquí (usa WindowFloatTo) — si llega, la ignoramos.
+                let active = self.active_index();
+                if self.workspaces[active].is_floating(id)
+                    || !self.workspaces[active].windows().contains(&id)
+                {
+                    return Vec::new();
+                }
+                let Some(o) = self.outputs.get(self.focused_output).copied() else {
+                    return Vec::new();
+                };
+                let target = self.workspaces[active]
+                    .layout(o.work_rect())
+                    .into_iter()
+                    .find(|(wid, rect)| {
+                        *wid != id
+                            && !self.workspaces[active].is_floating(*wid)
+                            && rect.contains(x, y)
+                    })
+                    .map(|(wid, _)| wid);
+                match target {
+                    Some(t) if self.workspaces[active].swap(id, t) => self.relayout(),
+                    _ => Vec::new(),
+                }
+            }
+            BodyEvent::Keybind(key) => match self.keymap.lookup(&key) {
+                Some(action) => self.apply(action),
+                None => Vec::new(),
+            },
+            BodyEvent::FullscreenRequest { id, fullscreen } => {
+                // El cliente (un reproductor, un juego) pidió pantalla
+                // completa: la fijamos en el escritorio que tiene la ventana.
+                let mut changed = false;
+                for ws in &mut self.workspaces {
+                    if ws.windows().contains(&id) {
+                        if fullscreen {
+                            ws.set_fullscreen(Some(id));
+                        } else if ws.fullscreen() == Some(id) {
+                            ws.set_fullscreen(None);
+                        }
+                        changed = true;
+                        break;
+                    }
+                }
+                if changed {
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            BodyEvent::WindowFloatTo { id, rect } => {
+                // Arrastre interactivo: la ventana pasa a flotar en el
+                // rectángulo dado, en el escritorio donde viva.
+                let mut changed = false;
+                for ws in &mut self.workspaces {
+                    if ws.windows().contains(&id) {
+                        ws.set_floating(id, Some(rect));
+                        changed = true;
+                        break;
+                    }
+                }
+                if changed {
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+        }
+    }
+
+    /// Aplica una acción de escritorio directamente (sin pasar por una
+    /// tecla). Útil para disparar acciones desde un HUD.
+    pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
+        let active = self.active_index();
+        match action {
+            DesktopAction::FocusNext => {
+                self.workspaces[active].focus_next();
+                self.relayout()
+            }
+            DesktopAction::FocusPrev => {
+                self.workspaces[active].focus_prev();
+                self.relayout()
+            }
+            DesktopAction::FocusDir(dir) => self.focus_in_direction(dir),
+            DesktopAction::MoveDir(dir) => self.move_in_direction(dir),
+            DesktopAction::FocusWindow(id) => {
+                // En el escritorio activo basta enfocar; si la ventana
+                // está en otro, lo traemos a la salida enfocada.
+                if self.workspaces[active].focus_window(id) {
+                    return self.relayout();
+                }
+                for n in 0..self.workspaces.len() {
+                    if n != active && self.workspaces[n].focus_window(id) {
+                        self.show_workspace(n);
+                        return self.relayout();
+                    }
+                }
+                Vec::new()
+            }
+            DesktopAction::MoveForward => {
+                self.workspaces[active].move_focused_forward();
+                self.relayout()
+            }
+            DesktopAction::MoveBackward => {
+                self.workspaces[active].move_focused_backward();
+                self.relayout()
+            }
+            DesktopAction::CloseFocused => {
+                // Pedimos el cierre; el estado se actualiza al recibir el
+                // `WindowClosed` de vuelta, no antes.
+                match self.workspaces[active].focused() {
+                    Some(id) => vec![BrainCommand::Close(id)],
+                    None => Vec::new(),
+                }
+            }
+            DesktopAction::ToggleFloat => {
+                let Some(id) = self.workspaces[active].focused() else {
+                    return Vec::new();
+                };
+                let screen = self.screen();
+                let ws = &mut self.workspaces[active];
+                if ws.is_floating(id) {
+                    ws.set_floating(id, None);
+                } else {
+                    let rect = screen
+                        .map(centered_float_rect)
+                        .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                    ws.set_floating(id, Some(rect));
+                }
+                self.relayout()
+            }
+            DesktopAction::ToggleTiling => {
+                let screen = self.screen();
+                let ws = &mut self.workspaces[active];
+                // Las teseladas: las no flotantes que sí están en el orden.
+                let tiled: Vec<WindowId> = ws
+                    .windows()
+                    .iter()
+                    .copied()
+                    .filter(|&id| !ws.is_floating(id))
+                    .collect();
+                if tiled.is_empty() {
+                    // Todas flotando ya: devolverlas al teselado.
+                    let floating: Vec<WindowId> = ws.windows().to_vec();
+                    for id in floating {
+                        ws.set_floating(id, None);
+                    }
+                } else if let Some(base) = screen {
+                    // Hacerlas flotar todas, en cascada desde una base.
+                    let w = base.w * 3 / 5;
+                    let h = base.h * 3 / 5;
+                    for (i, id) in tiled.into_iter().enumerate() {
+                        let off = (i as i32) * 32;
+                        let rect = Rect::new(
+                            base.x + (base.w - w) / 2 + off,
+                            base.y + (base.h - h) / 2 + off,
+                            w,
+                            h,
+                        );
+                        ws.set_floating(id, Some(rect));
+                    }
+                }
+                self.relayout()
+            }
+            DesktopAction::ToggleFullscreen => {
+                let Some(id) = self.workspaces[active].focused() else {
+                    return Vec::new();
+                };
+                let ws = &mut self.workspaces[active];
+                if ws.fullscreen() == Some(id) {
+                    ws.set_fullscreen(None);
+                } else {
+                    ws.set_fullscreen(Some(id));
+                }
+                self.relayout()
+            }
+            DesktopAction::SendToScratchpad => {
+                let Some(id) = self.workspaces[active].focused() else {
+                    return Vec::new();
+                };
+                for ws in &mut self.workspaces {
+                    ws.remove(id);
+                }
+                if !self.scratchpad.contains(&id) {
+                    self.scratchpad.push(id);
+                }
+                self.relayout()
+            }
+            DesktopAction::ToggleScratchpad => {
+                // ¿Hay alguna ventana del scratchpad en el escritorio activo?
+                let shown: Vec<WindowId> = self.workspaces[active]
+                    .windows()
+                    .iter()
+                    .copied()
+                    .filter(|id| self.scratchpad.contains(id))
+                    .collect();
+                if !shown.is_empty() {
+                    for id in shown {
+                        self.workspaces[active].remove(id);
+                    }
+                    self.relayout()
+                } else if let Some(&id) = self.scratchpad.first() {
+                    // La traemos de donde esté y la mostramos flotando.
+                    for ws in &mut self.workspaces {
+                        ws.remove(id);
+                    }
+                    let rect = self
+                        .screen()
+                        .map(centered_float_rect)
+                        .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                    self.workspaces[active].add(id);
+                    self.workspaces[active].set_floating(id, Some(rect));
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            DesktopAction::ToggleDropterm => {
+                // Buscamos la terminal dropdown por su marca de `app_id`.
+                let existing = self
+                    .windows
+                    .iter()
+                    .find(|(_, info)| info.app_id == DROPTERM_APP_ID)
+                    .map(|(&id, _)| id);
+                match existing {
+                    // Ya existe: si está a la vista, la guardamos; si está
+                    // guardada, la bajamos flotando y enfocada.
+                    Some(id) => {
+                        if self.workspaces[active].windows().contains(&id) {
+                            for ws in &mut self.workspaces {
+                                ws.remove(id);
+                            }
+                            if !self.scratchpad.contains(&id) {
+                                self.scratchpad.push(id);
+                            }
+                        } else {
+                            for ws in &mut self.workspaces {
+                                ws.remove(id);
+                            }
+                            self.scratchpad.retain(|&w| w != id);
+                            let pct = self.config.dropterm_height_pct();
+                            let rect = self
+                                .screen()
+                                .map(|s| dropdown_rect(s, pct))
+                                .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                            self.workspaces[active].add(id);
+                            self.workspaces[active].set_floating(id, Some(rect));
+                        }
+                        self.relayout()
+                    }
+                    // Aún no existe: la creamos perezosamente con el comando
+                    // de la config. Al abrirse, `WindowOpened` la reconoce
+                    // (por su `app_id`) y la baja flotando+enfocada.
+                    None => vec![BrainCommand::Spawn(self.config.dropterm_cmd.clone())],
+                }
+            }
+            DesktopAction::CycleLayout => {
+                let next = self.workspaces[active].params().mode.next();
+                self.workspaces[active].set_mode(next);
+                self.relayout()
+            }
+            DesktopAction::SetLayout(mode) => {
+                self.workspaces[active].set_mode(mode);
+                self.relayout()
+            }
+            DesktopAction::GrowMaster => self.nudge_master(self.config.master_step()),
+            DesktopAction::ShrinkMaster => self.nudge_master(-self.config.master_step()),
+            DesktopAction::IncMaster => self.nudge_master_count(1),
+            DesktopAction::DecMaster => self.nudge_master_count(-1),
+            DesktopAction::PromoteToMaster => {
+                self.workspaces[active].promote_focused();
+                self.relayout()
+            }
+            DesktopAction::SwapMaster => {
+                let ws = &mut self.workspaces[active];
+                let Some(focused) = ws.focused() else {
+                    return Vec::new();
+                };
+                let Some(&master) = ws.windows().first() else {
+                    return Vec::new();
+                };
+                // Sólo intercambia esas dos; el resto del orden no se mueve.
+                if focused != master && ws.swap(focused, master) {
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            DesktopAction::SwitchWorkspace(n) => {
+                if n < self.workspaces.len() && n != active {
+                    self.show_workspace(n);
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            DesktopAction::SendToWorkspace(n) => {
+                if n >= self.workspaces.len() || n == active {
+                    return Vec::new();
+                }
+                match self.workspaces[active].focused() {
+                    Some(id) => {
+                        self.workspaces[active].remove(id);
+                        self.workspaces[n].add(id);
+                        self.relayout()
+                    }
+                    None => Vec::new(),
+                }
+            }
+            DesktopAction::MoveToWorkspace(n) => {
+                if n >= self.workspaces.len() || n == active {
+                    return Vec::new();
+                }
+                match self.workspaces[active].focused() {
+                    Some(id) => {
+                        self.workspaces[active].remove(id);
+                        self.workspaces[n].add(id);
+                        // …y salta con ella al escritorio destino.
+                        self.show_workspace(n);
+                        self.relayout()
+                    }
+                    None => Vec::new(),
+                }
+            }
+            DesktopAction::FocusOutputNext => {
+                if self.outputs.len() > 1 {
+                    self.focused_output = (self.focused_output + 1) % self.outputs.len();
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
+            DesktopAction::FocusOutputDir(dir) => {
+                let Some(oid) = self.output_in_direction(dir) else {
+                    return Vec::new();
+                };
+                match self.outputs.iter().position(|o| o.id == oid) {
+                    Some(i) => {
+                        self.focused_output = i;
+                        self.relayout()
+                    }
+                    None => Vec::new(),
+                }
+            }
+            DesktopAction::SendToOutputDir(dir) => {
+                let Some(id) = self.workspaces[active].focused() else {
+                    return Vec::new();
+                };
+                let Some(oid) = self.output_in_direction(dir) else {
+                    return Vec::new();
+                };
+                let Some(ti) = self.outputs.iter().position(|o| o.id == oid) else {
+                    return Vec::new();
+                };
+                let target_ws = self.outputs[ti].workspace;
+                self.workspaces[active].remove(id);
+                self.workspaces[target_ws].add(id);
+                self.relayout()
+            }
+            DesktopAction::ResizeFloatDir(dir) => self.resize_float(dir),
+            DesktopAction::Spawn(cmd) => vec![BrainCommand::Spawn(cmd)],
+            DesktopAction::Quit => vec![BrainCommand::Shutdown],
+        }
+    }
+
+    /// El índice del escritorio activo — el que muestra la salida
+    /// enfocada. `0` si todavía no hay ninguna salida.
+    pub fn active_index(&self) -> usize {
+        self.outputs
+            .get(self.focused_output)
+            .map(|o| o.workspace)
+            .unwrap_or(0)
+    }
+
+    /// Hace que la salida enfocada muestre el escritorio `n`. Si otra
+    /// salida ya lo mostraba, intercambian — así ningún escritorio se
+    /// ve en dos sitios a la vez.
+    fn show_workspace(&mut self, n: usize) {
+        if n >= self.workspaces.len() || self.focused_output >= self.outputs.len() {
+            return;
+        }
+        let current = self.outputs[self.focused_output].workspace;
+        if current == n {
+            return;
+        }
+        if let Some(other) = self.outputs.iter().position(|o| o.workspace == n) {
+            self.outputs[other].workspace = current;
+        }
+        self.outputs[self.focused_output].workspace = n;
+    }
+
+    /// Recoloca las salidas en fila horizontal, en su orden de aparición.
+    fn reflow_outputs(&mut self) {
+        let mut x = 0;
+        for o in &mut self.outputs {
+            o.rect.x = x;
+            o.rect.y = 0;
+            x += o.rect.w;
+        }
+    }
+
+    /// Ajusta la fracción del área maestra del escritorio activo (la usan
+    /// `MasterStack` y `CenteredMaster`), acotada a `0.05..=0.95`.
+    fn nudge_master(&mut self, delta: f32) -> Vec<BrainCommand> {
+        let active = self.active_index();
+        let ws = &mut self.workspaces[active];
+        let ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95);
+        ws.set_master_ratio(ratio);
+        self.relayout()
+    }
+
+    /// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`.
+    fn nudge_master_count(&mut self, delta: i32) -> Vec<BrainCommand> {
+        let active = self.active_index();
+        let ws = &mut self.workspaces[active];
+        let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize;
+        ws.set_master_count(n);
+        self.relayout()
+    }
+
+    /// Mueve el foco a la ventana más cercana en una dirección cardinal,
+    /// según la geometría real del escritorio activo (no el orden de
+    /// teselado). Sin ventana enfocada, sin salida, o sin candidata en esa
+    /// dirección, no hace nada.
+    fn focus_in_direction(&mut self, dir: Direction) -> Vec<BrainCommand> {
+        let active = self.active_index();
+        let Some(focused) = self.workspaces[active].focused() else {
+            return Vec::new();
+        };
+        let Some(o) = self.outputs.get(self.focused_output).copied() else {
+            return Vec::new();
+        };
+        let layout = self.workspaces[active].layout(o.work_rect());
+        let Some(from) = layout.iter().find(|(id, _)| *id == focused).map(|(_, r)| *r) else {
+            return Vec::new();
+        };
+        match nearest_in_direction(from, &layout, focused, dir) {
+            Some(target) if self.workspaces[active].focus_window(target) => self.relayout(),
+            _ => Vec::new(),
+        }
+    }
+
+    /// La salida (monitor) vecina en una dirección cardinal, por geometría.
+    /// `None` si hay menos de dos salidas o ninguna en esa dirección.
+    fn output_in_direction(&self, dir: Direction) -> Option<OutputId> {
+        if self.outputs.len() < 2 {
+            return None;
+        }
+        let cur = self.outputs.get(self.focused_output)?;
+        let cands: Vec<(OutputId, Rect)> = self.outputs.iter().map(|o| (o.id, o.rect)).collect();
+        nearest_in_direction(cur.rect, &cands, cur.id, dir)
+    }
+
+    /// Redimensiona la ventana flotante enfocada hacia una dirección, por
+    /// `float_step` px (acotada a un mínimo). No hace nada sobre teseladas.
+    fn resize_float(&mut self, dir: Direction) -> Vec<BrainCommand> {
+        const MIN: i32 = 80;
+        let active = self.active_index();
+        let Some(id) = self.workspaces[active].focused() else {
+            return Vec::new();
+        };
+        let Some(mut rect) = self.workspaces[active].floating_rect(id) else {
+            return Vec::new(); // no flota
+        };
+        let step = self.config.float_step();
+        match dir {
+            Direction::Right => rect.w = (rect.w + step).max(MIN),
+            Direction::Left => rect.w = (rect.w - step).max(MIN),
+            Direction::Down => rect.h = (rect.h + step).max(MIN),
+            Direction::Up => rect.h = (rect.h - step).max(MIN),
+        }
+        self.workspaces[active].set_floating(id, Some(rect));
+        self.relayout()
+    }
+
+    /// Intercambia la ventana enfocada con su vecina **teselada** más
+    /// cercana en una dirección — mover la ventana por geometría. Una
+    /// ventana **flotante** se desplaza `float_step` px en esa dirección.
+    /// Sin vecina (teselada) en esa dirección, no hace nada. El foco
+    /// acompaña a la ventana movida.
+    fn move_in_direction(&mut self, dir: Direction) -> Vec<BrainCommand> {
+        let active = self.active_index();
+        let Some(focused) = self.workspaces[active].focused() else {
+            return Vec::new();
+        };
+        // Una flotante se mueve nudgeando su posición, no intercambiando.
+        if let Some(mut rect) = self.workspaces[active].floating_rect(focused) {
+            let step = self.config.float_step();
+            match dir {
+                Direction::Left => rect.x -= step,
+                Direction::Right => rect.x += step,
+                Direction::Up => rect.y -= step,
+                Direction::Down => rect.y += step,
+            }
+            self.workspaces[active].set_floating(focused, Some(rect));
+            return self.relayout();
+        }
+        let Some(o) = self.outputs.get(self.focused_output).copied() else {
+            return Vec::new();
+        };
+        let layout = self.workspaces[active].layout(o.work_rect());
+        let Some(from) = layout.iter().find(|(id, _)| *id == focused).map(|(_, r)| *r) else {
+            return Vec::new();
+        };
+        // Sólo teseladas son candidatas a intercambio.
+        let tiled: Vec<(WindowId, Rect)> = layout
+            .into_iter()
+            .filter(|(id, _)| !self.workspaces[active].is_floating(*id))
+            .collect();
+        match nearest_in_direction(from, &tiled, focused, dir) {
+            Some(target) if self.workspaces[active].swap(focused, target) => self.relayout(),
+            _ => Vec::new(),
+        }
+    }
+
+    /// Recalcula la geometría de **todas** las salidas y la empaqueta en
+    /// un único [`BrainCommand::Place`]. Sin salidas, no hay nada que
+    /// colocar.
+    fn relayout(&self) -> Vec<BrainCommand> {
+        if self.outputs.is_empty() {
+            return Vec::new();
+        }
+        let mut all = Vec::new();
+        for o in &self.outputs {
+            // El teselado usa el área útil (rect menos zonas reservadas), así las
+            // barras del marco en cualquier borde quedan libres de ventanas.
+            all.extend(placements(&self.workspaces[o.workspace], o.work_rect()));
+        }
+        // El foco del teclado es único: sólo la ventana enfocada de la
+        // salida enfocada. `placements` marca el foco por escritorio (lo
+        // necesita para la visibilidad en `Monocle`); aquí lo unificamos.
+        let global_focus = self.focused_window();
+        for p in &mut all {
+            p.focused = Some(p.id) == global_focus;
+        }
+        vec![BrainCommand::Place(all)]
+    }
+
+    // --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
+
+    /// El escritorio activo — el de la salida enfocada.
+    pub fn active_workspace(&self) -> &Workspace {
+        &self.workspaces[self.active_index()]
+    }
+
+    /// Las salidas conectadas, en orden, con el escritorio que muestran.
+    pub fn outputs(&self) -> &[Output] {
+        &self.outputs
+    }
+
+    /// Índice (en [`outputs`](Desktop::outputs)) de la salida enfocada.
+    pub fn focused_output(&self) -> usize {
+        self.focused_output
+    }
+
+    /// Identidad de una ventana conocida.
+    pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
+        self.windows.get(&id)
+    }
+
+    /// La ventana con el foco del teclado: la enfocada del escritorio
+    /// activo — o su ventana en pantalla completa, si la hay.
+    pub fn focused_window(&self) -> Option<WindowId> {
+        let ws = &self.workspaces[self.active_index()];
+        ws.fullscreen().or_else(|| ws.focused())
+    }
+
+    /// Cuántas ventanas hay en cada escritorio virtual.
+    pub fn workspace_loads(&self) -> Vec<usize> {
+        self.workspaces.iter().map(Workspace::len).collect()
+    }
+
+    /// Una vista de todas las ventanas conocidas, en todos los
+    /// escritorios — la base de `mirada-ctl windows` y de una taskbar.
+    pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
+        let active = self.active_index();
+        let mut lines = Vec::new();
+        for (n, ws) in self.workspaces.iter().enumerate() {
+            let ws_focus = ws.focused();
+            for &id in ws.windows() {
+                let info = self.windows.get(&id);
+                lines.push(crate::ctl::WindowLine {
+                    id,
+                    app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
+                    title: info.map(|i| i.title.clone()).unwrap_or_default(),
+                    workspace: n + 1,
+                    focused: n == active && ws_focus == Some(id),
+                });
+            }
+        }
+        // Ventanas guardadas en el scratchpad — en ningún escritorio.
+        for &id in &self.scratchpad {
+            let stashed = !self.workspaces.iter().any(|ws| ws.windows().contains(&id));
+            if stashed {
+                let info = self.windows.get(&id);
+                lines.push(crate::ctl::WindowLine {
+                    id,
+                    app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
+                    title: info.map(|i| i.title.clone()).unwrap_or_default(),
+                    workspace: 0, // 0 = guardada en el scratchpad
+                    focused: false,
+                });
+            }
+        }
+        lines
+    }
+}
+
+/// El elemento de `candidates` (ventana o salida) más cercano a `from` en
+/// la dirección `dir`, excluyendo a `self_id`. Pura — la base del foco
+/// espacial entre ventanas y entre monitores.
+///
+/// Criterio (estilo i3/sway): sólo cuentan los candidatos cuyo centro cae
+/// en el semiplano de esa dirección respecto al centro de `from`; entre
+/// ellos gana el de menor distancia en el eje principal, penalizando el
+/// desvío en el eje perpendicular (`×2`) para preferir el que está
+/// «enfrente». Empates: el id menor, para ser determinista.
+fn nearest_in_direction<T: Copy + Ord>(
+    from: Rect,
+    candidates: &[(T, Rect)],
+    self_id: T,
+    dir: Direction,
+) -> Option<T> {
+    let center = |r: &Rect| (r.x + r.w / 2, r.y + r.h / 2);
+    let (fx, fy) = center(&from);
+    let mut best: Option<(i64, T)> = None;
+    for (id, rect) in candidates {
+        if *id == self_id {
+            continue;
+        }
+        let (cx, cy) = center(rect);
+        let (dx, dy) = ((cx - fx) as i64, (cy - fy) as i64);
+        // ¿Está en el semiplano de la dirección? (`primary` > 0) y, si sí,
+        // el coste = primary + 2·|perpendicular|.
+        let (primary, perp) = match dir {
+            Direction::Left => (-dx, dy),
+            Direction::Right => (dx, dy),
+            Direction::Up => (-dy, dx),
+            Direction::Down => (dy, dx),
+        };
+        if primary <= 0 {
+            continue;
+        }
+        let cost = primary + 2 * perp.abs();
+        let better = match best {
+            None => true,
+            Some((c, bid)) => cost < c || (cost == c && *id < bid),
+        };
+        if better {
+            best = Some((cost, *id));
+        }
+    }
+    best.map(|(_, id)| id)
+}
+
+/// El rectángulo de la terminal dropdown: anclada arriba, a todo el ancho,
+/// `pct` % del alto — el gesto «quake» de bajar desde el borde superior.
+/// El porcentaje sale de la config ([`Config::dropterm_height_pct`]).
+fn dropdown_rect(screen: Rect, pct: i32) -> Rect {
+    Rect::new(screen.x, screen.y, screen.w, (screen.h * pct / 100).max(1))
+}
+
+/// El rectángulo flotante por defecto: 60 % de la pantalla, centrado.
+fn centered_float_rect(screen: Rect) -> Rect {
+    let w = screen.w * 3 / 5;
+    let h = screen.h * 3 / 5;
+    Rect::new(
+        screen.x + (screen.w - w) / 2,
+        screen.y + (screen.h - h) / 2,
+        w,
+        h,
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use mirada_layout::LayoutMode;
+
+    /// Un escritorio con una salida 1920×1080 ya conectada.
+    fn desktop_with_screen() -> Desktop {
+        let mut d = Desktop::new();
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        d
+    }
+
+    fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
+        d.on_event(BodyEvent::WindowOpened {
+            id,
+            app_id: format!("app{id}"),
+            title: format!("win {id}"),
+        })
+    }
+
+    /// Extrae las colocaciones de un único `Place`.
+    fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
+        match cmds {
+            [BrainCommand::Place(p)] => p,
+            other => panic!("se esperaba un solo Place, no {other:?}"),
+        }
+    }
+
+    #[test]
+    fn grab_keys_lists_the_whole_keymap() {
+        let d = Desktop::new();
+        match d.grab_keys() {
+            BrainCommand::GrabKeys(keys) => {
+                assert!(keys.contains(&"Super+j".to_string()));
+                assert!(keys.contains(&"Super+Shift+e".to_string()));
+            }
+            other => panic!("se esperaba GrabKeys, no {other:?}"),
+        }
+    }
+
+    #[test]
+    fn set_keymap_swaps_the_bindings_and_regrabs() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        // El keymap por defecto no usa Alt.
+        assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty());
+        // Cargamos un keymap a medida; el comando devuelto re-registra grabs.
+        let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap();
+        match d.set_keymap(custom) {
+            BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]),
+            other => panic!("se esperaba GrabKeys, no {other:?}"),
+        }
+        // Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no.
+        assert_eq!(d.focused_window(), Some(3));
+        d.on_event(BodyEvent::Keybind("Alt+x".into()));
+        assert_eq!(d.focused_window(), Some(2));
+        assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty());
+    }
+
+    #[test]
+    fn focus_window_addresses_a_specific_window() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert_eq!(d.focused_window(), Some(3));
+        d.apply(DesktopAction::FocusWindow(1));
+        assert_eq!(d.focused_window(), Some(1));
+    }
+
+    #[test]
+    fn focus_window_jumps_to_the_workspace_that_holds_it() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        // Manda la 2 al escritorio 3; seguimos en el 1.
+        d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
+        assert_eq!(d.active_index(), 0);
+        // Enfocar la 2 nos lleva a su escritorio.
+        d.apply(DesktopAction::FocusWindow(2));
+        assert_eq!(d.active_index(), 2);
+        assert_eq!(d.focused_window(), Some(2));
+    }
+
+    #[test]
+    fn window_lines_cover_every_window_with_its_workspace() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
+        let lines = d.window_lines();
+        assert_eq!(lines.len(), 2);
+        let w1 = lines.iter().find(|l| l.id == 1).unwrap();
+        let w2 = lines.iter().find(|l| l.id == 2).unwrap();
+        assert_eq!(w1.workspace, 1);
+        assert_eq!(w2.workspace, 3);
+        // La 1 quedó enfocada en el escritorio activo (el 1).
+        assert!(w1.focused);
+        assert!(!w2.focused);
+    }
+
+    #[test]
+    fn toggle_float_marks_the_focused_window_and_floats_it_last() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        let cmds = d.apply(DesktopAction::ToggleFloat);
+        let p = places(&cmds);
+        assert!(p.iter().find(|x| x.id == 2).unwrap().floating);
+        // La flotante va al final de la lista — orden de pintado.
+        assert_eq!(p.last().unwrap().id, 2);
+        // Alternar de nuevo la devuelve al teselado.
+        let cmds = d.apply(DesktopAction::ToggleFloat);
+        assert!(!places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
+    }
+
+    #[test]
+    fn toggle_fullscreen_covers_the_screen_and_hides_the_rest() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        let cmds = d.apply(DesktopAction::ToggleFullscreen); // sobre la 3
+        let p = places(&cmds);
+        let fs = p.iter().find(|x| x.id == 3).unwrap();
+        assert!(fs.fullscreen && fs.visible);
+        assert_eq!(fs.rect, d.screen().unwrap());
+        assert!(p.iter().filter(|x| x.id != 3).all(|x| !x.visible));
+        // Alternar de nuevo restaura el teselado: las tres visibles.
+        let cmds = d.apply(DesktopAction::ToggleFullscreen);
+        assert_eq!(places(&cmds).iter().filter(|x| x.visible).count(), 3);
+    }
+
+    #[test]
+    fn a_rule_sends_a_new_window_to_its_workspace() {
+        let mut d = desktop_with_screen();
+        d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app2", workspace: 3) ] )"#).unwrap());
+        open(&mut d, 1); // app1 → sin regla → escritorio activo (1)
+        open(&mut d, 2); // app2 → regla → escritorio 3
+        assert_eq!(d.workspace_loads()[0], 1);
+        assert_eq!(d.workspace_loads()[2], 1);
+    }
+
+    #[test]
+    fn a_rule_can_open_a_window_floating() {
+        let mut d = desktop_with_screen();
+        d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app1", floating: true) ] )"#).unwrap());
+        let cmds = open(&mut d, 1);
+        assert!(places(&cmds).iter().find(|p| p.id == 1).unwrap().floating);
+    }
+
+    #[test]
+    fn without_a_screen_nothing_is_placed() {
+        let mut d = Desktop::new();
+        assert!(open(&mut d, 1).is_empty());
+    }
+
+    #[test]
+    fn opening_a_window_places_it() {
+        let mut d = desktop_with_screen();
+        let cmds = open(&mut d, 1);
+        assert_eq!(places(&cmds).len(), 1);
+        assert_eq!(d.focused_window(), Some(1));
+    }
+
+    #[test]
+    fn closing_a_window_removes_it_everywhere() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
+        assert_eq!(places(&cmds).len(), 1);
+        assert!(d.window_info(1).is_none());
+        assert_eq!(d.focused_window(), Some(2));
+    }
+
+    #[test]
+    fn focus_keybind_cycles_within_the_active_workspace() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert_eq!(d.focused_window(), Some(3));
+        d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
+        assert_eq!(d.focused_window(), Some(1));
+        d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
+        assert_eq!(d.focused_window(), Some(3));
+    }
+
+    #[test]
+    fn close_focused_keybind_asks_to_close_the_focused_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 7);
+        let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
+        assert_eq!(cmds, vec![BrainCommand::Close(7)]);
+        // No se elimina hasta que el Cuerpo confirme con WindowClosed.
+        assert!(d.window_info(7).is_some());
+    }
+
+    #[test]
+    fn cycle_layout_walks_every_mode_and_returns() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        let start = d.active_workspace().params().mode;
+        for _ in 0..LayoutMode::ALL.len() {
+            let before = d.active_workspace().params().mode;
+            d.on_event(BodyEvent::Keybind("Super+space".into()));
+            assert_eq!(d.active_workspace().params().mode, before.next());
+        }
+        // Una vuelta completa devuelve al modo inicial.
+        assert_eq!(d.active_workspace().params().mode, start);
+    }
+
+    #[test]
+    fn grow_and_shrink_master_adjust_the_ratio() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        let r0 = d.active_workspace().params().master_ratio;
+        d.apply(DesktopAction::GrowMaster);
+        assert!(d.active_workspace().params().master_ratio > r0);
+        d.apply(DesktopAction::ShrinkMaster);
+        assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6);
+    }
+
+    #[test]
+    fn inc_and_dec_master_adjust_nmaster() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert_eq!(d.active_workspace().params().master_count, 1);
+        d.apply(DesktopAction::IncMaster);
+        assert_eq!(d.active_workspace().params().master_count, 2);
+        d.apply(DesktopAction::DecMaster);
+        d.apply(DesktopAction::DecMaster); // no baja de 1
+        assert_eq!(d.active_workspace().params().master_count, 1);
+    }
+
+    #[test]
+    fn swap_master_exchanges_only_the_focused_and_the_master() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3, 4] {
+            open(&mut d, id);
+        }
+        d.apply(DesktopAction::FocusWindow(3));
+        d.apply(DesktopAction::SwapMaster);
+        // 3 pasa al puesto maestro, 1 a donde estaba 3; el resto intacto.
+        assert_eq!(d.active_workspace().windows(), &[3, 2, 1, 4]);
+        assert_eq!(d.focused_window(), Some(3));
+        // A diferencia de promote-to-master, que rota: promover la 4…
+        d.apply(DesktopAction::FocusWindow(4));
+        d.apply(DesktopAction::PromoteToMaster);
+        assert_eq!(d.active_workspace().windows(), &[4, 3, 2, 1]);
+    }
+
+    #[test]
+    fn move_to_workspace_sends_the_window_and_follows_it() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        d.apply(DesktopAction::MoveToWorkspace(2)); // índice 2 = escritorio 3
+        // La 2 viajó y el foco saltó con ella.
+        assert_eq!(d.active_index(), 2);
+        assert_eq!(d.focused_window(), Some(2));
+        assert_eq!(d.workspace_loads()[2], 1);
+        // El escritorio original conserva sólo la 1.
+        assert_eq!(d.workspace_loads()[0], 1);
+    }
+
+    #[test]
+    fn promote_to_master_brings_the_focused_window_to_the_front() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        d.apply(DesktopAction::FocusWindow(3));
+        d.apply(DesktopAction::PromoteToMaster);
+        assert_eq!(d.active_workspace().windows()[0], 3);
+        assert_eq!(d.focused_window(), Some(3));
+    }
+
+    #[test]
+    fn master_ratio_stays_within_bounds() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        for _ in 0..50 {
+            d.apply(DesktopAction::GrowMaster);
+        }
+        assert!(d.active_workspace().params().master_ratio <= 0.95);
+        for _ in 0..50 {
+            d.apply(DesktopAction::ShrinkMaster);
+        }
+        assert!(d.active_workspace().params().master_ratio >= 0.05);
+    }
+
+    #[test]
+    fn monocle_keybind_hides_all_but_the_focused_window() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
+        let visible = places(&cmds).iter().filter(|p| p.visible).count();
+        assert_eq!(visible, 1);
+    }
+
+    #[test]
+    fn switching_workspace_changes_what_is_placed() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        // Escritorio 2 (índice 1) está vacío.
+        let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
+        assert!(places(&cmds).is_empty());
+        assert_eq!(d.active_index(), 1);
+        // Volver al 1 reaparece las dos ventanas.
+        let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
+        assert_eq!(places(&cmds).len(), 2);
+    }
+
+    #[test]
+    fn send_to_workspace_moves_the_focused_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
+        assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
+        assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
+        // La ventana 2 sigue registrada — sólo cambió de escritorio.
+        assert!(d.window_info(2).is_some());
+    }
+
+    #[test]
+    fn pointer_focuses_a_window_in_the_active_workspace() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        d.on_event(BodyEvent::PointerEntered { id: 1 });
+        assert_eq!(d.focused_window(), Some(1));
+    }
+
+    #[test]
+    fn dragging_floats_a_window_at_the_given_rect() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        assert!(!d.active_workspace().is_floating(2));
+        let target = Rect::new(300, 200, 640, 480);
+        let cmds = d.on_event(BodyEvent::WindowFloatTo { id: 2, rect: target });
+        // La 2 ahora flota exactamente en el rectángulo pedido.
+        assert!(d.active_workspace().is_floating(2));
+        let p = places(&cmds).iter().find(|p| p.id == 2).unwrap();
+        assert!(p.floating);
+        assert_eq!(p.rect, target);
+    }
+
+    #[test]
+    fn resizing_an_output_retiles_without_losing_the_workspace() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.on_event(BodyEvent::Keybind("Super+2".into())); // escritorio activo → 2
+        assert_eq!(d.active_index(), 1);
+        let cmds = d.on_event(BodyEvent::OutputResized {
+            id: 0,
+            width: 1920,
+            height: 1040,
+        });
+        // A diferencia de quitar y volver a añadir la salida, el
+        // escritorio activo se conserva.
+        assert_eq!(d.active_index(), 1);
+        assert!(matches!(cmds.as_slice(), [BrainCommand::Place(_)]));
+    }
+
+    #[test]
+    fn reservar_franja_desplaza_y_encoge_el_teselado() {
+        let mut d = desktop_with_screen(); // 1920×1080
+        open(&mut d, 1);
+        // Una sola ventana ocupa toda el área útil (smart gaps).
+        let cmds = open(&mut d, 1); // re-relayout
+        let p0 = places(&cmds)[0].rect;
+        assert_eq!(p0, Rect::new(0, 0, 1920, 1080));
+
+        // Reserva 40px arriba: la ventana arranca en y=40 y pierde 40 de alto.
+        let cmds = d.on_event(BodyEvent::OutputReserved {
+            id: 0,
+            top: 40,
+            bottom: 0,
+            left: 0,
+            right: 0,
+        });
+        let p = places(&cmds)[0].rect;
+        assert_eq!(p, Rect::new(0, 40, 1920, 1040));
+
+        // Reserva izquierda en vez de arriba: desplaza en x y encoge el ancho.
+        let cmds = d.on_event(BodyEvent::OutputReserved {
+            id: 0,
+            top: 0,
+            bottom: 0,
+            left: 48,
+            right: 0,
+        });
+        let p = places(&cmds)[0].rect;
+        assert_eq!(p, Rect::new(48, 0, 1872, 1080));
+
+        // Liberar (cero en los cuatro) restaura el monitor entero.
+        let cmds = d.on_event(BodyEvent::OutputReserved {
+            id: 0,
+            top: 0,
+            bottom: 0,
+            left: 0,
+            right: 0,
+        });
+        assert_eq!(places(&cmds)[0].rect, Rect::new(0, 0, 1920, 1080));
+    }
+
+    #[test]
+    fn a_spawn_keybind_becomes_a_spawn_command() {
+        let mut d = desktop_with_screen();
+        let cmds = d.on_event(BodyEvent::Keybind("Super+Shift+Return".into()));
+        assert_eq!(cmds, vec![BrainCommand::Spawn("foot".into())]);
+    }
+
+    #[test]
+    fn dragging_an_unknown_window_does_nothing() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        let cmds = d.on_event(BodyEvent::WindowFloatTo {
+            id: 99,
+            rect: Rect::new(0, 0, 100, 100),
+        });
+        assert!(cmds.is_empty());
+    }
+
+    #[test]
+    fn retitling_updates_the_registry_without_relayout() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        let cmds = d.on_event(BodyEvent::WindowRetitled {
+            id: 1,
+            title: "nuevo".into(),
+        });
+        assert!(cmds.is_empty());
+        assert_eq!(d.window_info(1).unwrap().title, "nuevo");
+    }
+
+    #[test]
+    fn an_unknown_keybind_does_nothing() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
+    }
+
+    #[test]
+    fn quit_emits_a_shutdown() {
+        let mut d = desktop_with_screen();
+        assert_eq!(
+            d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
+            vec![BrainCommand::Shutdown]
+        );
+    }
+
+    // --- Multi-monitor -------------------------------------------------
+
+    /// Un escritorio con dos salidas 1920×1080.
+    fn desktop_with_two_outputs() -> Desktop {
+        let mut d = Desktop::new();
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        d.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
+        d
+    }
+
+    #[test]
+    fn outputs_lay_side_by_side() {
+        let mut d = Desktop::new();
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
+        assert_eq!(d.outputs().len(), 2);
+        // La segunda salida arranca donde acaba la primera.
+        assert_eq!(d.outputs()[1].rect.x, 1920);
+    }
+
+    #[test]
+    fn each_output_shows_a_distinct_workspace() {
+        let d = desktop_with_two_outputs();
+        assert_eq!(d.outputs()[0].workspace, 0);
+        assert_eq!(d.outputs()[1].workspace, 1);
+    }
+
+    #[test]
+    fn switching_to_a_workspace_shown_on_another_output_swaps_them() {
+        let mut d = desktop_with_two_outputs();
+        // La salida enfocada (0, ws 0) pide el ws 1, que muestra la 1 → swap.
+        d.apply(DesktopAction::SwitchWorkspace(1));
+        assert_eq!(d.outputs()[0].workspace, 1);
+        assert_eq!(d.outputs()[1].workspace, 0);
+    }
+
+    #[test]
+    fn focus_output_next_moves_the_focus_between_outputs() {
+        let mut d = desktop_with_two_outputs();
+        assert_eq!(d.active_index(), 0); // salida 0 → ws 0
+        d.apply(DesktopAction::FocusOutputNext);
+        assert_eq!(d.active_index(), 1); // salida 1 → ws 1
+        d.apply(DesktopAction::FocusOutputNext); // envuelve
+        assert_eq!(d.active_index(), 0);
+    }
+
+    #[test]
+    fn relayout_places_windows_on_every_output() {
+        let mut d = Desktop::new();
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        d.on_event(BodyEvent::OutputAdded { id: 1, width: 1280, height: 720 });
+        open(&mut d, 1); // en la salida 0 (ws 0)
+        d.apply(DesktopAction::FocusOutputNext);
+        let cmds = open(&mut d, 2); // en la salida 1 (ws 1)
+        let p = places(&cmds);
+        assert_eq!(p.len(), 2);
+        // Cada ventana cae en el rectángulo de su salida.
+        assert_eq!(p.iter().find(|x| x.id == 1).unwrap().rect.x, 0);
+        assert_eq!(p.iter().find(|x| x.id == 2).unwrap().rect.x, 1920);
+    }
+
+    #[test]
+    fn keyboard_focus_is_unique_across_outputs() {
+        let mut d = desktop_with_two_outputs();
+        open(&mut d, 1);
+        d.apply(DesktopAction::FocusOutputNext);
+        let cmds = open(&mut d, 2);
+        // Sólo una ventana con foco de teclado en todo el Place.
+        assert_eq!(places(&cmds).iter().filter(|p| p.focused).count(), 1);
+    }
+
+    #[test]
+    fn removing_an_output_keeps_its_windows_in_their_workspace() {
+        let mut d = desktop_with_two_outputs();
+        d.apply(DesktopAction::FocusOutputNext); // foco en la salida 1 (ws 1)
+        open(&mut d, 1); // en ws 1
+        d.on_event(BodyEvent::OutputRemoved { id: 1 });
+        // La ventana sigue registrada, en el ws 1.
+        assert!(d.window_info(1).is_some());
+        assert_eq!(d.workspace_loads()[1], 1);
+        assert_eq!(d.outputs().len(), 1);
+    }
+
+    // --- Scratchpad ----------------------------------------------------
+
+    #[test]
+    fn send_to_scratchpad_hides_the_focused_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        d.apply(DesktopAction::SendToScratchpad);
+        assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
+        assert!(d.window_info(2).is_some()); // sigue registrada
+    }
+
+    #[test]
+    fn toggle_scratchpad_shows_then_hides_the_stashed_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        d.apply(DesktopAction::SendToScratchpad); // guarda la 2
+        assert_eq!(d.workspace_loads()[0], 1);
+        // Toggle la invoca, flotando.
+        let cmds = d.apply(DesktopAction::ToggleScratchpad);
+        assert!(places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
+        assert_eq!(d.workspace_loads()[0], 2);
+        // Toggle de nuevo la oculta.
+        d.apply(DesktopAction::ToggleScratchpad);
+        assert_eq!(d.workspace_loads()[0], 1);
+    }
+
+    #[test]
+    fn a_scratchpad_window_follows_you_across_workspaces() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.apply(DesktopAction::SendToScratchpad);
+        d.apply(DesktopAction::ToggleScratchpad); // mostrada en el escritorio 1
+        assert_eq!(d.workspace_loads()[0], 1);
+        d.apply(DesktopAction::SwitchWorkspace(1)); // al escritorio 2
+        d.apply(DesktopAction::ToggleScratchpad); // estaba en el 1 → la trae al 2
+        assert_eq!(d.workspace_loads()[1], 1);
+        assert_eq!(d.workspace_loads()[0], 0);
+    }
+
+    #[test]
+    fn closing_a_stashed_window_drops_it_from_the_scratchpad() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.apply(DesktopAction::SendToScratchpad);
+        d.on_event(BodyEvent::WindowClosed { id: 1 });
+        // Ya no hay nada que invocar.
+        assert!(d.apply(DesktopAction::ToggleScratchpad).is_empty());
+    }
+
+    // --- Terminal dropdown (quake) ------------------------------------
+
+    #[test]
+    fn dropterm_lazy_spawns_when_absent() {
+        let mut d = desktop_with_screen();
+        // Sin terminal dropdown todavía: el toggle la crea con el comando
+        // de la config (por defecto, kitty con el app_id de la dropterm).
+        let cmds = d.apply(DesktopAction::ToggleDropterm);
+        let cmd = crate::config::Config::default().dropterm_cmd;
+        assert_eq!(cmds, vec![BrainCommand::Spawn(cmd)]);
+    }
+
+    #[test]
+    fn dropterm_opens_floating_top_anchored_and_focused() {
+        let mut d = desktop_with_screen(); // 1920×1080
+        let cmds = d.on_event(BodyEvent::WindowOpened {
+            id: 5,
+            app_id: super::DROPTERM_APP_ID.into(),
+            title: "dropterm".into(),
+        });
+        let p = places(&cmds).iter().find(|x| x.id == 5).unwrap();
+        assert!(p.floating);
+        assert_eq!(p.rect.x, 0);
+        assert_eq!(p.rect.w, 1920); // a todo el ancho
+        assert!(p.rect.h < 1080); // anclada arriba, no a pantalla completa
+        assert_eq!(d.focused_window(), Some(5));
+    }
+
+    #[test]
+    fn dropterm_toggles_hide_then_show_keeping_focus() {
+        let mut d = desktop_with_screen();
+        // Ya abierta (spawn + WindowOpened).
+        d.on_event(BodyEvent::WindowOpened {
+            id: 5,
+            app_id: super::DROPTERM_APP_ID.into(),
+            title: "t".into(),
+        });
+        assert_eq!(d.workspace_loads()[0], 1);
+        // Toggle la guarda.
+        d.apply(DesktopAction::ToggleDropterm);
+        assert_eq!(d.workspace_loads()[0], 0);
+        assert!(d.window_info(5).is_some()); // sigue registrada
+        // Toggle la baja de nuevo, flotando y enfocada.
+        let cmds = d.apply(DesktopAction::ToggleDropterm);
+        assert_eq!(d.workspace_loads()[0], 1);
+        assert!(places(&cmds).iter().find(|x| x.id == 5).unwrap().floating);
+        assert_eq!(d.focused_window(), Some(5));
+    }
+
+    #[test]
+    fn nearest_in_direction_picks_the_window_in_front() {
+        let from = Rect::new(0, 0, 100, 100); // centro (50,50)
+        let cands = vec![
+            (1, Rect::new(0, 0, 100, 100)),   // la propia
+            (2, Rect::new(200, 0, 100, 100)), // a la derecha, enfrente
+            (3, Rect::new(200, 400, 100, 100)), // a la derecha pero muy abajo
+            (4, Rect::new(-200, 0, 100, 100)), // a la izquierda
+        ];
+        assert_eq!(nearest_in_direction(from, &cands, 1, Direction::Right), Some(2));
+        assert_eq!(nearest_in_direction(from, &cands, 1, Direction::Left), Some(4));
+        // Hacia arriba no hay nada (todas a la misma altura o abajo).
+        assert_eq!(nearest_in_direction(from, &cands, 1, Direction::Up), None);
+    }
+
+    #[test]
+    fn focus_dir_moves_focus_spatially_in_columns() {
+        let mut d = desktop_with_screen();
+        // Tres columnas: la 1 a la izquierda, la 3 a la derecha.
+        d.apply(DesktopAction::SetLayout(LayoutMode::Columns));
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        // La última abierta (3) queda enfocada, en la columna derecha.
+        assert_eq!(d.focused_window(), Some(3));
+        // Foco a la izquierda → la del medio, luego la primera.
+        d.apply(DesktopAction::FocusDir(Direction::Left));
+        assert_eq!(d.focused_window(), Some(2));
+        d.apply(DesktopAction::FocusDir(Direction::Left));
+        assert_eq!(d.focused_window(), Some(1));
+        // Más a la izquierda no hay nada: el foco no se mueve.
+        d.apply(DesktopAction::FocusDir(Direction::Left));
+        assert_eq!(d.focused_window(), Some(1));
+        // Y de vuelta a la derecha.
+        d.apply(DesktopAction::FocusDir(Direction::Right));
+        assert_eq!(d.focused_window(), Some(2));
+    }
+
+    #[test]
+    fn move_dir_swaps_the_focused_tile_with_its_neighbor() {
+        let mut d = desktop_with_screen();
+        d.apply(DesktopAction::SetLayout(LayoutMode::Columns));
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+        assert_eq!(d.focused_window(), Some(3)); // columna derecha
+        // Mover a la izquierda intercambia la 3 con su vecina (la 2).
+        d.apply(DesktopAction::MoveDir(Direction::Left));
+        assert_eq!(d.active_workspace().windows(), &[1, 3, 2]);
+        assert_eq!(d.focused_window(), Some(3)); // el foco acompaña a la movida
+        // Y a la derecha la devuelve.
+        d.apply(DesktopAction::MoveDir(Direction::Right));
+        assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+        assert_eq!(d.focused_window(), Some(3));
+    }
+
+    #[test]
+    fn move_dir_nudges_a_floating_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada → flotar
+        d.apply(DesktopAction::ToggleFloat);
+        let r0 = d.active_workspace().floating_rect(2).unwrap();
+        d.apply(DesktopAction::MoveDir(Direction::Right));
+        let r1 = d.active_workspace().floating_rect(2).unwrap();
+        // Se desplazó float_step px a la derecha, sin cambiar tamaño.
+        assert_eq!(r1.x, r0.x + d.config().float_step());
+        assert_eq!((r1.w, r1.h), (r0.w, r0.h));
+    }
+
+    #[test]
+    fn resize_float_grows_and_shrinks_the_focused_floating_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1); // enfocada → flotar
+        d.apply(DesktopAction::ToggleFloat);
+        let r0 = d.active_workspace().floating_rect(1).unwrap();
+        let step = d.config().float_step();
+        d.apply(DesktopAction::ResizeFloatDir(Direction::Right));
+        assert_eq!(d.active_workspace().floating_rect(1).unwrap().w, r0.w + step);
+        d.apply(DesktopAction::ResizeFloatDir(Direction::Down));
+        assert_eq!(d.active_workspace().floating_rect(1).unwrap().h, r0.h + step);
+        // Sobre una teselada no hace nada.
+        open(&mut d, 2); // teselada, enfocada
+        assert!(d.apply(DesktopAction::ResizeFloatDir(Direction::Right)).is_empty());
+    }
+
+    #[test]
+    fn focus_and_send_to_output_dir_cross_monitors() {
+        let mut d = desktop_with_two_outputs(); // salida 0 a la izq, 1 a la der
+        open(&mut d, 1); // en la salida 0 (ws 0)
+        assert_eq!(d.active_index(), 0);
+        // Foco a la salida de la derecha → su escritorio (ws 1).
+        d.apply(DesktopAction::FocusOutputDir(Direction::Right));
+        assert_eq!(d.active_index(), 1);
+        // Volver a la izquierda.
+        d.apply(DesktopAction::FocusOutputDir(Direction::Left));
+        assert_eq!(d.active_index(), 0);
+        // Mandar la ventana 1 a la salida derecha → viaja al ws 1.
+        d.apply(DesktopAction::SendToOutputDir(Direction::Right));
+        assert_eq!(d.workspace_loads()[0], 0);
+        assert_eq!(d.workspace_loads()[1], 1);
+    }
+
+    #[test]
+    fn master_step_from_config_drives_grow_master() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        d.set_config(Config::from_ron("( master_step: 0.1 )").unwrap());
+        open(&mut d, 1);
+        let r0 = d.active_workspace().params().master_ratio;
+        d.apply(DesktopAction::GrowMaster);
+        assert!((d.active_workspace().params().master_ratio - (r0 + 0.1)).abs() < 1e-6);
+    }
+
+    #[test]
+    fn clicked_focuses_even_with_focus_follows_mouse_off() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        d.set_config(Config::from_ron("( focus_follows_mouse: false )").unwrap());
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        // El hover ya no enfoca…
+        d.on_event(BodyEvent::PointerEntered { id: 1 });
+        assert_eq!(d.focused_window(), Some(2));
+        // …pero el click sí.
+        d.on_event(BodyEvent::Clicked { id: 1 });
+        assert_eq!(d.focused_window(), Some(1));
+    }
+
+    #[test]
+    fn clicked_jumps_to_the_workspace_holding_the_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
+        assert_eq!(d.active_index(), 0);
+        d.on_event(BodyEvent::Clicked { id: 2 });
+        assert_eq!(d.active_index(), 2);
+        assert_eq!(d.focused_window(), Some(2));
+    }
+
+    #[test]
+    fn dragging_a_tiled_window_swaps_with_the_window_under_the_pointer() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+        // El centro de la tesela de la 3.
+        let o = d.outputs()[d.focused_output()];
+        let layout = d.active_workspace().layout(o.work_rect());
+        let r3 = layout.iter().find(|(id, _)| *id == 3).unwrap().1;
+        let (cx, cy) = (r3.x + r3.w / 2, r3.y + r3.h / 2);
+        // Arrastrar la 1 sobre la 3 las intercambia, el foco sigue a la 1.
+        d.on_event(BodyEvent::WindowDragged { id: 1, x: cx, y: cy });
+        assert_eq!(d.active_workspace().windows(), &[3, 2, 1]);
+        assert_eq!(d.focused_window(), Some(1));
+    }
+
+    #[test]
+    fn window_dragged_ignores_a_floating_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada → la flotamos
+        d.apply(DesktopAction::ToggleFloat);
+        let before = d.active_workspace().windows().to_vec();
+        let cmds = d.on_event(BodyEvent::WindowDragged { id: 2, x: 10, y: 10 });
+        assert!(cmds.is_empty());
+        assert_eq!(d.active_workspace().windows(), &before[..]);
+    }
+
+    #[test]
+    fn toggle_tiling_floats_all_then_restores() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert!([1, 2, 3]
+            .iter()
+            .all(|&id| !d.active_workspace().is_floating(id)));
+        // Todo flota.
+        d.apply(DesktopAction::ToggleTiling);
+        assert!([1, 2, 3]
+            .iter()
+            .all(|&id| d.active_workspace().is_floating(id)));
+        // Y vuelve al teselado.
+        d.apply(DesktopAction::ToggleTiling);
+        assert!([1, 2, 3]
+            .iter()
+            .all(|&id| !d.active_workspace().is_floating(id)));
+    }
+
+    #[test]
+    fn reload_config_reseeds_params_and_re_sends_decorations() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        let cfg = Config::from_ron("( gap: 30, border_width: 5 )").unwrap();
+        let cmds = d.reload_config(cfg);
+        // El gap nuevo se sembró (el archivo manda).
+        assert_eq!(d.active_workspace().params().gap, 30);
+        // Y se devuelve un SetDecorations con el marco nuevo.
+        match cmds.as_slice() {
+            [BrainCommand::SetDecorations(dec)] => assert_eq!(dec.border_width, 5),
+            other => panic!("se esperaba un SetDecorations, no {other:?}"),
+        }
+    }
+
+    #[test]
+    fn reload_config_preserves_open_windows_and_focus() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        d.apply(DesktopAction::FocusWindow(2));
+        d.reload_config(Config::from_ron("( gap: 14 )").unwrap());
+        // Las ventanas siguen ahí, el foco intacto — recargar no las pierde.
+        assert_eq!(d.active_workspace().windows(), &[1, 2, 3]);
+        assert_eq!(d.focused_window(), Some(2));
+        assert_eq!(d.active_workspace().params().gap, 14);
+    }
+
+    #[test]
+    fn reload_config_applies_focus_follows_mouse_live() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        // Por defecto el hover enfoca.
+        d.on_event(BodyEvent::PointerEntered { id: 1 });
+        assert_eq!(d.focused_window(), Some(1));
+        // Recargar con foco-sigue-ratón apagado lo desactiva en vivo.
+        d.reload_config(Config::from_ron("( focus_follows_mouse: false )").unwrap());
+        d.on_event(BodyEvent::PointerEntered { id: 2 });
+        assert_eq!(d.focused_window(), Some(1)); // el hover ya no mueve el foco
+    }
+
+    #[test]
+    fn reload_config_applies_the_dropterm_command_live() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        d.reload_config(
+            Config::from_ron("( dropterm_cmd: \"foot --app-id mirada.dropterm\" )").unwrap(),
+        );
+        let cmds = d.apply(DesktopAction::ToggleDropterm);
+        assert_eq!(
+            cmds,
+            vec![BrainCommand::Spawn("foot --app-id mirada.dropterm".into())]
+        );
+    }
+
+    #[test]
+    fn set_config_seeds_the_layout_params_of_every_workspace() {
+        use crate::config::Config;
+        let mut d = Desktop::new();
+        let cfg = Config::from_ron(r#"( gap: 20, master_ratio: 0.4, layout: "grid" )"#).unwrap();
+        d.set_config(cfg);
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        let p = d.active_workspace().params();
+        assert_eq!(p.gap, 20);
+        assert_eq!(p.mode, LayoutMode::Grid);
+        assert!((p.master_ratio - 0.4).abs() < 1e-6);
+    }
+
+    #[test]
+    fn focus_follows_mouse_can_be_disabled_by_config() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen();
+        d.set_config(Config::from_ron("( focus_follows_mouse: false )").unwrap());
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        // Con el foco-sigue-ratón apagado, pasar el puntero no cambia el foco.
+        d.on_event(BodyEvent::PointerEntered { id: 1 });
+        assert_eq!(d.focused_window(), Some(2));
+    }
+
+    #[test]
+    fn config_sets_the_dropterm_command_and_height() {
+        use crate::config::Config;
+        let mut d = desktop_with_screen(); // 1920×1080
+        d.set_config(
+            Config::from_ron("( dropterm_cmd: \"foot --app-id mirada.dropterm\", dropterm_height_pct: 30 )")
+                .unwrap(),
+        );
+        // El spawn perezoso usa el comando de la config.
+        let cmds = d.apply(DesktopAction::ToggleDropterm);
+        assert_eq!(
+            cmds,
+            vec![BrainCommand::Spawn("foot --app-id mirada.dropterm".into())]
+        );
+        // Y al abrirse, baja al 30 % del alto.
+        let cmds = d.on_event(BodyEvent::WindowOpened {
+            id: 9,
+            app_id: super::DROPTERM_APP_ID.into(),
+            title: "t".into(),
+        });
+        let p = places(&cmds).iter().find(|x| x.id == 9).unwrap();
+        assert_eq!(p.rect.h, 1080 * 30 / 100);
+    }
+
+    #[test]
+    fn a_client_fullscreen_request_is_honoured() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: true });
+        assert!(places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
+        // El cliente la suelta.
+        let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: false });
+        assert!(!places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
+    }
+
+    #[test]
+    fn a_fullscreen_request_for_an_unknown_window_does_nothing() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        assert!(d
+            .on_event(BodyEvent::FullscreenRequest { id: 99, fullscreen: true })
+            .is_empty());
+    }
+
+    #[test]
+    fn window_lines_show_a_stashed_window_as_workspace_zero() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.apply(DesktopAction::SendToScratchpad);
+        let line = d.window_lines().into_iter().find(|l| l.id == 1).unwrap();
+        assert_eq!(line.workspace, 0);
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/keymap.rs b/02_ruway/mirada/mirada-brain/src/keymap.rs
new file mode 100644
index 0000000..443134a
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/keymap.rs
@@ -0,0 +1,332 @@
+//! El keymap configurable — atajos del escritorio en RON, recargables en
+//! caliente.
+//!
+//! # Dónde vive el keymap
+//!
+//! Sólo en el Cerebro. El Cuerpo (`mirada-compositor`) **nunca** ve este
+//! mapa: lo único que recibe es la lista de cadenas a interceptar
+//! ([`grab_list`](Keymap::grab_list)) dentro de un
+//! [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). El
+//! Cuerpo hace un `Vec::contains` ciego y devuelve la combinación pulsada
+//! como [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); es el
+//! [`Desktop`](crate::Desktop) quien la traduce a una
+//! [`DesktopAction`]. Esa separación —*qué* interceptar vs. *qué
+//! significa*— es la que hace innecesario cualquier candado o `Arc`:
+//! el mapa es monohilo aquí y la lista viaja de golpe en un solo mensaje.
+//!
+//! # Persistencia
+//!
+//! En disco es RON de texto (`~/.config/mirada/keymap.ron`), editable a
+//! mano y versionable. El cable sólo lleva la lista de cadenas; no hay
+//! format binario de configuración. Hay un único ejecutable que hace de
+//! "configurador": la app `mirada`, que carga este archivo al arrancar.
+//!
+//! # Recarga en caliente
+//!
+//! [`Keymap::watch`] devuelve un [`KeymapWatch`] que vigila el archivo;
+//! cuando cambia, el dueño del [`Desktop`](crate::Desktop) recarga el
+//! keymap, llama a [`Desktop::set_keymap`](crate::Desktop::set_keymap) y
+//! reenvía el `GrabKeys` resultante. Sin reiniciar nada.
+
+use std::collections::BTreeMap;
+use std::fmt;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use crate::action::{default_keymap, DesktopAction};
+use crate::watch::FileWatch;
+
+/// Atajos del escritorio: combinación canónica → acción.
+///
+/// La combinación es la cadena que canoniza el Cuerpo (`"Super+Shift+j"`,
+/// `"Super+space"`…). El keymap es lo único que la traduce a una
+/// [`DesktopAction`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Keymap {
+    bindings: BTreeMap<String, DesktopAction>,
+}
+
+impl Default for Keymap {
+    /// El keymap por defecto, estilo *tiling WM* (ver [`default_keymap`]).
+    fn default() -> Self {
+        Self {
+            bindings: default_keymap().into_iter().collect(),
+        }
+    }
+}
+
+impl Keymap {
+    /// Construye un keymap a partir de pares `(combinación, acción)`.
+    pub fn from_pairs(pairs: impl IntoIterator<Item = (String, DesktopAction)>) -> Self {
+        Self {
+            bindings: pairs.into_iter().collect(),
+        }
+    }
+
+    /// La acción asociada a una combinación, si la hay.
+    pub fn lookup(&self, combo: &str) -> Option<DesktopAction> {
+        self.bindings.get(combo).cloned()
+    }
+
+    /// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
+    pub fn grab_list(&self) -> Vec<String> {
+        self.bindings.keys().cloned().collect()
+    }
+
+    /// Todos los atajos, en orden de combinación.
+    pub fn bindings(&self) -> &BTreeMap<String, DesktopAction> {
+        &self.bindings
+    }
+
+    /// Cuántos atajos hay.
+    pub fn len(&self) -> usize {
+        self.bindings.len()
+    }
+
+    /// `true` si no hay ningún atajo.
+    pub fn is_empty(&self) -> bool {
+        self.bindings.is_empty()
+    }
+
+    // --- RON ----------------------------------------------------------
+
+    /// Parsea un keymap desde el texto RON de un archivo de configuración.
+    pub fn from_ron(text: &str) -> Result<Keymap, KeymapError> {
+        let file: KeymapFile = ron::from_str(text)
+            .map_err(|e| KeymapError::Parse(format!("RON inválido: {e}")))?;
+        let mut bindings = BTreeMap::new();
+        for (combo, action) in file.bindings {
+            let parsed = action
+                .parse::<DesktopAction>()
+                .map_err(|e| KeymapError::Parse(format!("atajo \"{combo}\": {e}")))?;
+            bindings.insert(combo, parsed);
+        }
+        Ok(Keymap { bindings })
+    }
+
+    /// Serializa el keymap a RON (sin la cabecera de documentación).
+    pub fn to_ron(&self) -> String {
+        let file = KeymapFile {
+            bindings: self
+                .bindings
+                .iter()
+                .map(|(k, v)| (k.clone(), v.to_string()))
+                .collect(),
+        };
+        ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default())
+            .expect("un KeymapFile de cadenas siempre serializa")
+    }
+
+    // --- Disco --------------------------------------------------------
+
+    /// La ruta canónica del keymap del usuario: `~/.config/mirada/keymap.ron`.
+    /// `None` si no se puede determinar el directorio de configuración.
+    pub fn default_path() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "mirada")
+            .map(|d| d.config_dir().join("keymap.ron"))
+    }
+
+    /// Carga un keymap desde un archivo RON.
+    pub fn load(path: &Path) -> Result<Keymap, KeymapError> {
+        let text = std::fs::read_to_string(path)?;
+        Keymap::from_ron(&text)
+    }
+
+    /// El keymap como RON con la cabecera de documentación — exactamente
+    /// lo que [`save`](Keymap::save) escribe en disco.
+    pub fn documented_ron(&self) -> String {
+        format!("{KEYMAP_HEADER}\n{}", self.to_ron())
+    }
+
+    /// Escribe el keymap a `path` como RON documentado (con cabecera de
+    /// comentarios), creando el directorio padre si falta.
+    pub fn save(&self, path: &Path) -> Result<(), KeymapError> {
+        if let Some(dir) = path.parent() {
+            std::fs::create_dir_all(dir)?;
+        }
+        std::fs::write(path, self.documented_ron())?;
+        Ok(())
+    }
+
+    /// Carga el keymap del usuario con un fallback amable:
+    ///
+    /// - si el archivo no existe, escribe uno por defecto documentado y lo
+    ///   devuelve (así el usuario lo descubre y lo puede editar);
+    /// - si existe pero está corrupto, avisa por `stderr` y devuelve el
+    ///   keymap por defecto **sin tocar el archivo** (no se pierde el
+    ///   trabajo del usuario por un error de sintaxis).
+    pub fn load_or_init(path: &Path) -> Keymap {
+        if path.exists() {
+            match Keymap::load(path) {
+                Ok(km) => km,
+                Err(e) => {
+                    eprintln!(
+                        "mirada · keymap «{}» inválido ({e}); uso el de por defecto.",
+                        path.display()
+                    );
+                    Keymap::default()
+                }
+            }
+        } else {
+            let km = Keymap::default();
+            match km.save(path) {
+                Ok(()) => eprintln!("mirada · keymap inicial escrito en {}", path.display()),
+                Err(e) => eprintln!("mirada · no pude escribir el keymap inicial: {e}"),
+            }
+            km
+        }
+    }
+
+    /// Vigila el archivo del keymap para recargarlo en caliente — un
+    /// [`FileWatch`] genérico, igual que la config y las reglas.
+    pub fn watch(path: &Path) -> notify::Result<KeymapWatch> {
+        FileWatch::new(path)
+    }
+}
+
+/// Vigía del archivo de keymap para la recarga en caliente. Hoy es un
+/// alias del [`FileWatch`] genérico — se conserva el nombre por
+/// compatibilidad con quien lo nombra (`mirada-compositor`).
+pub type KeymapWatch = FileWatch;
+
+/// La forma en disco del keymap — un mapa de cadenas. Las acciones van
+/// como texto (`"layout:grid"`) y no como enum, para que el RON sea
+/// trivial y los errores se reporten atajo a atajo.
+#[derive(Serialize, Deserialize)]
+struct KeymapFile {
+    bindings: BTreeMap<String, String>,
+}
+
+/// La cabecera de comentarios del archivo que escribe [`Keymap::save`].
+const KEYMAP_HEADER: &str = "\
+// keymap de mirada — atajos del escritorio (carmen).
+//
+// Formato:  \"Combinación\": \"acción\"
+// La combinación la canoniza el compositor: Super, Ctrl, Shift, Alt y la
+// tecla, en ese orden (p. ej. \"Super+Shift+j\", \"Super+space\").
+//
+// Acciones:
+//   focus-next / focus-prev          mueve el foco (cíclico)
+//   focus-left/right/up/down         mueve el foco espacial (Super+flechas)
+//   move-forward / move-backward     reordena la ventana enfocada (orden)
+//   move-left/right/up/down          mueve la ventana por geometría (Super+Shift+flechas)
+//   close-focused                    cierra la enfocada
+//   toggle-float                     alterna flotante / teselada (una)
+//   toggle-tiling                    alterna todo el escritorio teselado/flotante
+//   toggle-fullscreen                alterna pantalla completa
+//   send-to-scratchpad               guarda la enfocada en el scratchpad
+//   toggle-scratchpad                invoca / oculta la del scratchpad
+//   toggle-dropterm                  baja / sube la terminal dropdown (quake)
+//   cycle-layout                     siguiente modo de teselado
+//   layout:<modo>                    master-stack | centered-master | spiral
+//                                    grid | columns | rows | monocle
+//   grow-master / shrink-master      redimensiona el área maestra
+//   inc-master / dec-master          nº de ventanas maestras (nmaster)
+//   promote-to-master                la enfocada al puesto maestro (rota)
+//   swap-master                      intercambia la enfocada con la maestra (sólo esas dos)
+//   resize-float-left/right/up/down   redimensiona la flotante enfocada
+//   focus-output-next                pasa el foco al siguiente monitor
+//   focus-output-left/right/up/down   foco al monitor vecino (por geometría)
+//   send-to-output-left/right/up/down manda la enfocada al monitor vecino
+//   workspace:N                      activa el escritorio N (1..9)
+//   send-to-workspace:N              manda la enfocada al escritorio N (sin saltar)
+//   move-to-workspace:N              manda la enfocada al escritorio N y salta allí
+//   spawn:<comando>                  lanza un programa (p. ej. spawn:foot)
+//   quit                             apaga el compositor
+//
+// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";
+
+/// Un fallo al cargar o guardar un keymap.
+#[derive(Debug)]
+pub enum KeymapError {
+    /// El RON no parsea, o una acción no se reconoce. El mensaje ya está
+    /// formateado para mostrarse al usuario.
+    Parse(String),
+    /// Fallo de E/S al leer o escribir el archivo.
+    Io(io::Error),
+}
+
+impl fmt::Display for KeymapError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            KeymapError::Parse(msg) => f.write_str(msg),
+            KeymapError::Io(e) => write!(f, "E/S: {e}"),
+        }
+    }
+}
+
+impl std::error::Error for KeymapError {}
+
+impl From<io::Error> for KeymapError {
+    fn from(e: io::Error) -> Self {
+        KeymapError::Io(e)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use mirada_layout::LayoutMode;
+
+    #[test]
+    fn the_default_keymap_round_trips_through_ron() {
+        let km = Keymap::default();
+        let back = Keymap::from_ron(&km.to_ron()).unwrap();
+        assert_eq!(km, back);
+    }
+
+    #[test]
+    fn the_saved_file_carries_the_documentation_header() {
+        let km = Keymap::default();
+        let written = km.documented_ron();
+        // La cabecera son comentarios — RON los ignora al reparsear.
+        assert!(written.starts_with("// keymap de mirada"));
+        assert_eq!(Keymap::from_ron(&written).unwrap(), km);
+    }
+
+    #[test]
+    fn grab_list_is_exactly_the_set_of_bound_combos() {
+        let km = Keymap::default();
+        let grabs = km.grab_list();
+        assert_eq!(grabs.len(), km.len());
+        assert!(grabs.contains(&"Super+j".to_string()));
+        assert!(grabs.contains(&"Super+Shift+e".to_string()));
+    }
+
+    #[test]
+    fn lookup_resolves_a_default_binding() {
+        let km = Keymap::default();
+        assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused));
+        assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack)));
+        assert_eq!(km.lookup("Super+sin-asignar"), None);
+    }
+
+    #[test]
+    fn a_custom_keymap_parses_from_ron() {
+        let ron = r#"(
+            bindings: {
+                "Alt+Return": "cycle-layout",
+                "Alt+x": "close-focused",
+                "Alt+3": "workspace:3",
+            },
+        )"#;
+        let km = Keymap::from_ron(ron).unwrap();
+        assert_eq!(km.len(), 3);
+        assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout));
+        assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2)));
+    }
+
+    #[test]
+    fn an_unknown_action_names_the_offending_binding() {
+        let ron = r#"( bindings: { "Super+z": "fly-away" } )"#;
+        let err = Keymap::from_ron(ron).unwrap_err().to_string();
+        assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}");
+    }
+
+    #[test]
+    fn malformed_ron_is_rejected() {
+        assert!(Keymap::from_ron("esto no es ron").is_err());
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/lib.rs b/02_ruway/mirada/mirada-brain/src/lib.rs
new file mode 100644
index 0000000..5b69ca8
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/lib.rs
@@ -0,0 +1,42 @@
+//! `mirada-brain` — el orquestador de escritorio del compositor.
+//!
+//! Es el "Cerebro" de mirada sin pantalla: mantiene el estado del
+//! escritorio (salidas, escritorios virtuales, ventanas, foco), consume
+//! los [`BodyEvent`]s que reporta el Cuerpo y produce los
+//! [`BrainCommand`]s que el Cuerpo aplica.
+//!
+//! Es agnóstico de GPUI y de `smithay`: una app GPUI sólo lo *envuelve*
+//! para pintar un HUD y para mover los bytes por el cable de
+//! [`mirada_protocol`]. Toda la lógica vive aquí y es determinista —
+//! la misma secuencia de eventos da siempre el mismo estado.
+//!
+//! - [`action`] — las acciones de escritorio y el mapa de teclas.
+//! - [`config`] — la [`Config`] general del WM (dropterm, teselado, foco).
+//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
+//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
+//! - [`rules`] — las [`Rules`] de ventana (escritorio/flotante por `app_id`).
+//! - [`ctl`] — el API de control externo (`mirada-ctl`, taskbars, scripts).
+
+#![forbid(unsafe_code)]
+
+pub mod action;
+pub mod config;
+pub mod ctl;
+pub mod desktop;
+pub mod keymap;
+pub mod rules;
+pub mod watch;
+
+pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
+pub use config::{Config, MenuEntry, OutputOverride, ZoneCfg, DROPTERM_APP_ID};
+pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
+pub use desktop::{Desktop, Output, WindowInfo};
+pub use keymap::{Keymap, KeymapError, KeymapWatch};
+pub use rules::{Rule, RuleOutcome, Rules};
+pub use watch::FileWatch;
+
+pub use mirada_layout::{
+    disponer, envolvente, wallpaper_dst_rect, Disposicion, LayoutMode, LayoutParams, Rect,
+    WallpaperFit, WindowId, Workspace, ZoneFrac,
+};
+pub use mirada_protocol::{BodyEvent, BrainCommand, Decorations, OutputId, WindowPlacement};
diff --git a/02_ruway/mirada/mirada-brain/src/rules.rs b/02_ruway/mirada/mirada-brain/src/rules.rs
new file mode 100644
index 0000000..ce62f8d
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/rules.rs
@@ -0,0 +1,216 @@
+//! Reglas de ventana — config declarativa que decide, al abrirse una
+//! ventana, a qué escritorio va y si flota.
+//!
+//! Mismo patrón que [`crate::keymap`]: RON de texto en
+//! `~/.config/mirada/rules.ron`, que el [`Desktop`](crate::Desktop)
+//! consulta en cada `WindowOpened` — el evento ya trae `app_id` y
+//! `title`. Una regla casa por subcadena (sin distinguir mayúsculas);
+//! gana la primera que case.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Una regla: criterio de coincidencia + qué aplicar.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct Rule {
+    /// Subcadena que debe contener el `app_id`; vacía = casa con cualquiera.
+    #[serde(default)]
+    pub app_id: String,
+    /// Subcadena que debe contener el título; vacía = cualquiera.
+    #[serde(default)]
+    pub title: String,
+    /// Escritorio de destino (1-based); `0` = no moverla.
+    #[serde(default)]
+    pub workspace: usize,
+    /// Abrir la ventana flotando.
+    #[serde(default)]
+    pub floating: bool,
+}
+
+impl Rule {
+    /// `true` si la regla casa con una ventana de este `app_id`/`title`.
+    fn matches(&self, app_id: &str, title: &str) -> bool {
+        let app_ok = self.app_id.is_empty() || contains_ci(app_id, &self.app_id);
+        let title_ok = self.title.is_empty() || contains_ci(title, &self.title);
+        app_ok && title_ok
+    }
+}
+
+/// `true` si `haystack` contiene `needle`, sin distinguir mayúsculas.
+fn contains_ci(haystack: &str, needle: &str) -> bool {
+    haystack.to_lowercase().contains(&needle.to_lowercase())
+}
+
+/// Qué hacer con una ventana recién abierta, según las reglas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct RuleOutcome {
+    /// Escritorio de destino, ya como índice 0-based. `None` = el activo.
+    pub workspace: Option<usize>,
+    /// Abrir flotando.
+    pub floating: bool,
+}
+
+/// El conjunto de reglas de ventana del usuario.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Rules {
+    #[serde(default)]
+    rules: Vec<Rule>,
+}
+
+impl Rules {
+    /// Construye un conjunto de reglas a partir de una lista.
+    pub fn new(rules: Vec<Rule>) -> Self {
+        Self { rules }
+    }
+
+    /// Resuelve qué hacer con una ventana — gana la primera regla que case.
+    pub fn resolve(&self, app_id: &str, title: &str) -> RuleOutcome {
+        for r in &self.rules {
+            if r.matches(app_id, title) {
+                return RuleOutcome {
+                    workspace: (r.workspace >= 1).then(|| r.workspace - 1),
+                    floating: r.floating,
+                };
+            }
+        }
+        RuleOutcome::default()
+    }
+
+    /// Cuántas reglas hay.
+    pub fn len(&self) -> usize {
+        self.rules.len()
+    }
+
+    /// `true` si no hay ninguna regla.
+    pub fn is_empty(&self) -> bool {
+        self.rules.is_empty()
+    }
+
+    /// Parsea las reglas desde el texto RON de un archivo de config.
+    pub fn from_ron(text: &str) -> Result<Rules, String> {
+        ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))
+    }
+
+    /// La ruta canónica de las reglas: `~/.config/mirada/rules.ron`.
+    pub fn default_path() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "mirada")
+            .map(|d| d.config_dir().join("rules.ron"))
+    }
+
+    /// Carga las reglas de un archivo RON.
+    pub fn load(path: &Path) -> Result<Rules, String> {
+        let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
+        Rules::from_ron(&text)
+    }
+
+    /// Vigila el archivo de reglas para recargarlo en caliente.
+    pub fn watch(path: &Path) -> notify::Result<crate::watch::FileWatch> {
+        crate::watch::FileWatch::new(path)
+    }
+
+    /// Carga las reglas del usuario con un fallback amable: si el archivo
+    /// no existe, escribe una plantilla documentada y devuelve un
+    /// conjunto vacío; si está corrupto, avisa y devuelve vacío.
+    pub fn load_or_default(path: &Path) -> Rules {
+        if path.exists() {
+            match Rules::load(path) {
+                Ok(r) => r,
+                Err(e) => {
+                    eprintln!(
+                        "mirada · reglas «{}» inválidas ({e}); las ignoro.",
+                        path.display()
+                    );
+                    Rules::default()
+                }
+            }
+        } else {
+            if let Some(dir) = path.parent() {
+                let _ = std::fs::create_dir_all(dir);
+            }
+            match std::fs::write(path, RULES_TEMPLATE) {
+                Ok(()) => eprintln!("mirada · plantilla de reglas escrita en {}", path.display()),
+                Err(e) => eprintln!("mirada · no pude escribir la plantilla de reglas: {e}"),
+            }
+            Rules::default()
+        }
+    }
+}
+
+/// La plantilla que se escribe la primera vez — sin reglas, con ejemplos
+/// comentados para que el usuario los descubra.
+const RULES_TEMPLATE: &str = "\
+// Reglas de ventana de mirada — qué hacer con una ventana al abrirse.
+//
+// Cada regla casa por subcadena de `app_id` y/o `title` (sin distinguir
+// mayúsculas; cadena vacía = cualquiera) y aplica un destino: `workspace`
+// (1..9; 0 = no mover) y/o `floating`. Gana la primera regla que case.
+//
+// Descomenta y edita los ejemplos:
+(
+    rules: [
+        // (app_id: \"pavucontrol\", floating: true),
+        // (app_id: \"firefox\", workspace: 2),
+        // (title: \"Picture-in-Picture\", floating: true),
+    ],
+)
+";
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn the_template_parses_to_an_empty_rule_set() {
+        assert!(Rules::from_ron(RULES_TEMPLATE).unwrap().is_empty());
+    }
+
+    #[test]
+    fn rules_parse_from_ron_with_omitted_fields() {
+        let ron = r#"(
+            rules: [
+                (app_id: "pavucontrol", floating: true),
+                (app_id: "firefox", workspace: 2),
+            ],
+        )"#;
+        assert_eq!(Rules::from_ron(ron).unwrap().len(), 2);
+    }
+
+    #[test]
+    fn resolve_sends_a_match_to_its_workspace() {
+        let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 3) ] )"#).unwrap();
+        let out = r.resolve("org.mozilla.firefox", "");
+        assert_eq!(out.workspace, Some(2)); // 3 (1-based) -> índice 2
+        assert!(!out.floating);
+    }
+
+    #[test]
+    fn resolve_matches_app_id_case_insensitively_by_substring() {
+        let r = Rules::from_ron(r#"( rules: [ (app_id: "FIREFOX", floating: true) ] )"#).unwrap();
+        assert!(r.resolve("org.mozilla.firefox", "").floating);
+    }
+
+    #[test]
+    fn resolve_matches_by_title() {
+        let r =
+            Rules::from_ron(r#"( rules: [ (title: "Picture-in-Picture", floating: true) ] )"#)
+                .unwrap();
+        assert!(r.resolve("cualquiera", "YouTube — Picture-in-Picture").floating);
+        assert!(!r.resolve("cualquiera", "ventana normal").floating);
+    }
+
+    #[test]
+    fn the_first_matching_rule_wins() {
+        let r = Rules::from_ron(
+            r#"( rules: [ (app_id: "term", workspace: 1), (app_id: "term", workspace: 5) ] )"#,
+        )
+        .unwrap();
+        assert_eq!(r.resolve("term", "").workspace, Some(0));
+    }
+
+    #[test]
+    fn no_match_yields_the_default_outcome() {
+        let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 2) ] )"#).unwrap();
+        assert_eq!(r.resolve("xterm", ""), RuleOutcome::default());
+    }
+}
diff --git a/02_ruway/mirada/mirada-brain/src/watch.rs b/02_ruway/mirada/mirada-brain/src/watch.rs
new file mode 100644
index 0000000..961626e
--- /dev/null
+++ b/02_ruway/mirada/mirada-brain/src/watch.rs
@@ -0,0 +1,87 @@
+//! Vigía genérico de un archivo de configuración, para la recarga en
+//! caliente. Lo comparten el keymap, la config y las reglas: los tres son
+//! RON en `~/.config/mirada/` que el usuario edita a mano y mirada
+//! recarga sin reiniciar.
+//!
+//! El patrón: se vigila el **directorio** (los editores reescriben el
+//! archivo por *rename*, no editándolo en sitio) y se filtra al archivo de
+//! interés. Una ráfaga de eventos de un solo guardado se *coalesce* en un
+//! único [`changed`](FileWatch::changed).
+
+use std::path::Path;
+use std::sync::mpsc;
+
+/// Vigía de un archivo para la recarga en caliente.
+///
+/// Mantenlo vivo mientras quieras recargas; al soltarlo, la vigilancia
+/// cesa. Consulta [`changed`](FileWatch::changed) en tu bucle de eventos.
+pub struct FileWatch {
+    _watcher: notify::RecommendedWatcher,
+    rx: mpsc::Receiver<()>,
+}
+
+impl FileWatch {
+    /// Empieza a vigilar `path`. Vigila su directorio padre (si existe) y
+    /// filtra los eventos al archivo concreto, así capta los guardados por
+    /// *rename* de los editores.
+    pub fn new(path: &Path) -> notify::Result<FileWatch> {
+        use notify::{RecursiveMode, Watcher};
+
+        let target = path.to_path_buf();
+        let (tx, rx) = mpsc::channel();
+        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
+            if let Ok(event) = res {
+                if event.paths.iter().any(|p| p == &target) {
+                    let _ = tx.send(());
+                }
+            }
+        })?;
+        let dir = path.parent().filter(|d| d.exists());
+        watcher.watch(dir.unwrap_or(path), RecursiveMode::NonRecursive)?;
+        Ok(FileWatch { _watcher: watcher, rx })
+    }
+
+    /// `true` si el archivo cambió desde la última consulta. Coalesce una
+    /// ráfaga de eventos (un guardado dispara varios) en un solo `true`.
+    pub fn changed(&self) -> bool {
+        self.rx.try_iter().count() > 0
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::time::Duration;
+
+    /// Un `FileWatch` detecta una escritura del archivo vigilado. Es un test
+    /// de integración con el SO (inotify): si el entorno no provee un backend
+    /// de vigilancia (algunos sandboxes), `FileWatch::new` falla y el test se
+    /// salta — no queremos un test frágil que rompa el smoke del workspace.
+    #[test]
+    fn detects_a_write_to_the_watched_file() {
+        let dir = std::env::temp_dir().join(format!("mirada-watch-{}", std::process::id()));
+        let _ = std::fs::create_dir_all(&dir);
+        let file = dir.join("config.ron");
+        std::fs::write(&file, b"(\n)\n").unwrap();
+
+        let Ok(watch) = FileWatch::new(&file) else {
+            eprintln!("watch: sin backend de vigilancia en este entorno; salto el test.");
+            let _ = std::fs::remove_dir_all(&dir);
+            return;
+        };
+        assert!(!watch.changed(), "recién creado: nada que reportar todavía");
+
+        // Reescribe el archivo y espera (acotado) a que el evento llegue.
+        std::fs::write(&file, b"( gap: 12 )\n").unwrap();
+        let mut seen = false;
+        for _ in 0..60 {
+            if watch.changed() {
+                seen = true;
+                break;
+            }
+            std::thread::sleep(Duration::from_millis(50)); // hasta ~3 s
+        }
+        let _ = std::fs::remove_dir_all(&dir);
+        assert!(seen, "el FileWatch no reportó la escritura en 3 s");
+    }
+}
diff --git a/02_ruway/mirada/mirada-compositor/Cargo.toml b/02_ruway/mirada/mirada-compositor/Cargo.toml
new file mode 100644
index 0000000..c72a596
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "mirada-compositor"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — el Cuerpo del compositor: un compositor Wayland teselante sobre smithay (backend winit, nested). Tesela con un Cerebro embebido o uno externo por mirada-link."
+
+[[bin]]
+name = "mirada-compositor"
+path = "src/main.rs"
+
+[dependencies]
+mirada-brain = { path = "../mirada-brain" }
+mirada-body = { path = "../mirada-body" }
+mirada-link = { path = "../mirada-link" }
+auth-core = { path = "../../../shared/auth/auth-core" }
+nix = { workspace = true }
+smithay = "0.7"
+# Rasterizado de texto sobre CPU (etiquetas: barra de título, menú). Es un
+# rasterizador puro-Rust ligero; smithay sube el búfer RGBA como textura.
+ab_glyph = "0.2"
+# Decodifica y escala el wallpaper (PNG/JPEG/WebP) para componerlo en el fondo.
+image = { workspace = true }
diff --git a/02_ruway/mirada/mirada-compositor/LEEME.md b/02_ruway/mirada/mirada-compositor/LEEME.md
new file mode 100644
index 0000000..ee1c11e
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/LEEME.md
@@ -0,0 +1,221 @@
+# mirada-compositor — el Cuerpo de carmen
+
+Un compositor Wayland teselante real, sobre [`smithay`]. Es el **Cuerpo**
+de la arquitectura Cerebro↔Cuerpo de `mirada` (ver
+`crates/modules/mirada/SDD.md`): habla el protocolo Wayland con los
+clientes, compone sus superficies y aplica la geometría que decide el
+Cerebro.
+
+Tiene **dos backends gráficos**:
+
+- **`winit`** — corre **anidado**, como una ventana dentro de tu sesión
+  gráfica actual (X11 o Wayland). Para desarrollar y probar sin dejar el
+  escritorio.
+- **`drm`** — corre **nativo** sobre una TTY, sin sesión anfitriona:
+  toma la GPU (DRM/KMS/GBM/EGL), el teclado (`libinput`) y la pantalla
+  entera. Es carmen como tu escritorio de verdad.
+
+Sin argumentos elige solo: con `DISPLAY`/`WAYLAND_DISPLAY` → `winit`;
+sin ellos → `drm`. O fuérzalo: `mirada-compositor --winit` / `--drm`.
+
+La bandera `--greeter` (ortogonal al backend) arranca el compositor como
+gestor de login — ver **Modo greeter (DM)** más abajo.
+
+## Backends
+
+### winit — anidado
+
+```sh
+cargo run -p mirada-compositor -- --winit
+```
+
+Necesita una sesión gráfica anfitriona (X11 o Wayland) donde dibujar su
+ventana; sin ella aborta con un mensaje que lo explica.
+
+### drm — nativo sobre TTY
+
+```sh
+cargo run -p mirada-compositor -- --drm
+```
+
+Corre directo sobre el hardware. Requiere una **TTY** (`Ctrl+Alt+F3`),
+una GPU con `/dev/dri`, y `seatd` o `logind` para la sesión. Toma la
+pantalla completa; sal con `Super+Shift+e` o `Ctrl+C`.
+
+Lleva teclado y ratón por `libinput`: el foco sigue al puntero y los
+clics y la rueda llegan a la ventana que tienes debajo. El cursor toma
+la forma que pide el cliente (la «I» sobre texto, una mano…) y cae a un
+cuadrado por defecto sobre el escritorio. **`Super`+arrastre** con el
+botón izquierdo mueve una ventana, con el derecho la redimensiona — al
+arrastrarla, la ventana pasa a flotar. Cada ventana lleva un marco
+fino: azul la que tiene el foco, gris las demás.
+
+- `MIRADA_STARTUP=<cmd>` — lanza una app al arrancar (`MIRADA_STARTUP=foot`).
+- `MIRADA_DRM_TIMEOUT=<s>` — cierra el compositor solo tras N segundos
+  (0 o sin definir = sin tope).
+
+## Como sesión de escritorio
+
+Para usar carmen como tu escritorio de verdad — entrar a una sesión, no
+sólo probarlo:
+
+1. Compila e instala los binarios en el `PATH`:
+
+   ```sh
+   cargo build --release -p mirada-compositor -p mirada-ctl -p mirada-launcher
+   sudo install -m755 target/release/mirada-compositor \
+        target/release/mirada-ctl target/release/mirada-launcher /usr/local/bin/
+   sudo install -m755 session/mirada-session /usr/local/bin/
+   ```
+
+2. Arranca desde una TTY:
+
+   ```sh
+   mirada-session
+   ```
+
+   O regístralo en un gestor de login copiando `session/carmen.desktop`
+   a `/usr/share/wayland-sessions/` — aparecerá carmen como sesión.
+
+3. **Autoarranque** — los programas que quieras al iniciar van en
+   `~/.config/mirada/autostart`, uno por línea (`#` comenta). Tienes un
+   ejemplo en `session/autostart.example`:
+
+   ```sh
+   mkdir -p ~/.config/mirada
+   cp 02_ruway/mirada/mirada-compositor/session/autostart.example \
+      ~/.config/mirada/autostart
+   ```
+
+Dentro de la sesión, `Ctrl+Alt+F1…F12` salta a otra TTY y vuelve sin
+romper carmen.
+
+## Modo greeter (DM)
+
+`mirada-compositor --greeter` arranca el compositor como **gestor de
+login**: en vez de la sesión, compone el greeter (`mirada-greeter`),
+que lanza como proceso hijo. El usuario teclea sus credenciales; cuando
+el login es válido el greeter emite un `SessionTicket` por su stdout y
+el compositor **muta a modo sesión sin reiniciar el servidor Wayland**
+— el mismo proceso, la misma GPU, las mismas ventanas («mutación
+atómica»). Desde ahí baja privilegios al usuario autenticado
+(`setuid`/`setgid` + grupos) para todo lo que lanza.
+
+La bandera es ortogonal al backend: `--greeter` solo (auto), o
+`--greeter --drm` / `--greeter --winit`.
+
+```sh
+# DM real, sobre una TTY — el compositor corre como root: PAM lo exige
+sudo mirada-compositor --greeter --drm
+
+# iterar el greeter anidado, con credenciales de prueba
+MIRADA_GREETER_MOCK=demo:demo \
+  cargo run -p mirada-compositor -- --greeter --winit
+```
+
+En modo greeter no se registran atajos (todas las teclas van al
+greeter — que el usuario no pueda lanzar nada ni cerrar el compositor),
+se rechaza `spawn:` y no corre el autoarranque; los atajos y la sesión
+arrancan sólo tras el traspaso. `MIRADA_GREETER_BIN` apunta a otro
+binario de greeter (cómodo para señalar a `target/…` en desarrollo).
+
+## Lanzador de aplicaciones
+
+`mirada-launcher` escanea los `.desktop` del sistema y lanza el que
+elijas. Es un programa de terminal sin dependencias: lo abres en una
+terminal pequeña y filtras escribiendo. El keymap por defecto ata
+`Super+p` a `spawn:foot -e mirada-launcher` — pulsa el atajo, escribe
+unas letras del nombre, Enter.
+
+Necesita `mirada-launcher` y `foot` en el `PATH` (ver la instalación de
+arriba). Suelto también vale: `mirada-launcher` en cualquier terminal.
+
+## Dos modos
+
+- **Autónomo** (por defecto) — lleva un `Desktop` (de `mirada-brain`)
+  embebido. Es un compositor teselante completo en un solo proceso.
+
+  ```sh
+  cargo run -p mirada-compositor
+  ```
+
+- **Enlazado** — el Cuerpo escucha en un socket y la app `mirada` (el
+  Cerebro GPUI) se conecta y decide la geometría.
+
+  ```sh
+  # terminal 1 — el Cuerpo
+  MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada-compositor
+  # terminal 2 — el Cerebro
+  MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada
+  ```
+
+## Probarlo
+
+Al arrancar imprime el `WAYLAND_DISPLAY` que abrió. Lanza cualquier
+cliente Wayland contra él:
+
+```sh
+WAYLAND_DISPLAY=wayland-1 foot      # o weston-terminal, alacritty, …
+```
+
+Las ventanas se teselan solas. El teclado, con la ventana del compositor
+enfocada, maneja el escritorio con atajos `Super+…`: el lanzador de
+aplicaciones `Super+p`, una terminal `Super+Shift+Return`, foco
+`Super+j/k`, los 7 layouts en `Super+t/m/g/c/r/d/s` (o ciclar con
+`Super+space`), área maestra `Super+h/l`, `nmaster` `Super+,/.`,
+promover a maestra `Super+Return`, escritorios `Super+1..9`, cerrar
+`Super+q`. Cierra la ventana del compositor para salir.
+
+## Atajos de teclado
+
+Los atajos son configurables en RON: `~/.config/mirada/keymap.ron`. En
+modo autónomo, el Cuerpo lo carga al arrancar (si no existe, escribe uno
+por defecto documentado) y lo **recarga en caliente** — edita el archivo,
+guarda, y los atajos cambian sin reiniciar. En modo enlazado el keymap es
+asunto del Cerebro (la app `mirada`).
+
+```sh
+cargo run -p mirada-brain --example keymap-default   # ver el formato
+```
+
+El compositor en sí no interpreta atajos: sólo intercepta las
+combinaciones que el Cerebro le pide (`GrabKeys`) y le devuelve la
+pulsada. *Qué significa* cada una lo decide `mirada-brain`. Ver el SDD.
+
+## Control externo
+
+En modo autónomo, el compositor abre un socket de control y `mirada-ctl`
+lo maneja desde la terminal — al estilo de `swaymsg`/`hyprctl`:
+
+```sh
+mirada-ctl focus-next            # cambia el foco
+mirada-ctl focus-window 5        # enfoca una ventana concreta
+mirada-ctl workspace 3           # va al escritorio 3
+mirada-ctl windows               # lista las ventanas
+```
+
+En modo enlazado el socket de control lo abre el Cerebro (la app
+`mirada`), no el compositor.
+
+## Qué implementa
+
+`wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat`
+(teclado, y ratón en el backend DRM), `wl_output`, `wl_data_device`
+(selección), `xdg-decoration` — fuerza decoración del servidor y no
+dibuja ninguna, así las ventanas van sin barra de título — y
+`zwp_linux_dmabuf`, que deja conectarse a los clientes que pintan por
+GPU (apps GPUI, navegadores acelerados). Composición con `GlesRenderer`
+— en `winit` sobre la ventana, en `drm` con un `DrmCompositor` por
+salida.
+
+Reusa `mirada-body` para la contabilidad de salidas y superficies, y
+`mirada-link` para el cable hacia un Cerebro externo. Toda la lógica
+espacial es agnóstica de Wayland y vive en los crates de
+`crates/modules/mirada/`.
+
+## Pendiente
+
+Del backend DRM: conmutación de VT, hotplug de monitores, multi-GPU.
+Puntero en el backend `winit`. Aislamiento de clientes. Ver el SDD.
+
+[`smithay`]: https://github.com/Smithay/smithay
diff --git a/02_ruway/mirada/mirada-compositor/README.md b/02_ruway/mirada/mirada-compositor/README.md
new file mode 100644
index 0000000..c0d60fd
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/README.md
@@ -0,0 +1,15 @@
+# mirada-compositor
+
+> Tiling Wayland compositor — the "Body" of [mirada](../README.md).
+
+A real tiling Wayland compositor built on [`smithay`]. It's the **Body** of `mirada`'s Brain↔Body architecture: it speaks the Wayland protocol with apps, owns surfaces and inputs, executes the layout decisions [`mirada-brain`](../mirada-brain/README.md) emits. Without `mirada-brain`, it still runs as a minimal default layout.
+
+## Usage
+
+```sh
+cargo run --release -p mirada-compositor
+```
+
+## Deps
+
+- `smithay`, [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
diff --git a/02_ruway/mirada/mirada-compositor/session/autostart.example b/02_ruway/mirada/mirada-compositor/session/autostart.example
new file mode 100644
index 0000000..422d294
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/autostart.example
@@ -0,0 +1,21 @@
+# Autoarranque de carmen — cópialo a ~/.config/mirada/autostart
+#
+# Un comando por línea; se lanza al arrancar el compositor, con
+# WAYLAND_DISPLAY ya puesto. Las líneas en blanco y las que empiezan
+# por # se ignoran. Cada línea se pasa a `sh -c`, así que valen las
+# variables, las tuberías y el `&` final no hace falta.
+
+# El marco pata — barra/dock acoplado a un borde; carmen lo reconoce por su
+# app_id y le reserva la franja. (Si no hay autostart, el compositor lo levanta
+# solo; acá lo ponemos explícito para combinarlo con shuma.)
+pata-llimphi
+# El shell de carmen propiamente dicho (shuma): barra superior con apps y
+# launcher. Es el binario que instala install-mirada-dm.sh.
+shuma-shell-llimphi
+
+# Una terminal para empezar.
+foot
+
+# Ejemplos (descoméntalos si los quieres):
+# mirada-ctl layout spiral
+# wbg ~/fondo.png
diff --git a/02_ruway/mirada/mirada-compositor/session/carmen.desktop b/02_ruway/mirada/mirada-compositor/session/carmen.desktop
new file mode 100644
index 0000000..f81b0e2
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/carmen.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Name=carmen
+Comment=Compositor Wayland teselante (mirada)
+Exec=mirada-session
+Type=Application
+DesktopNames=carmen
diff --git a/02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop b/02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop
new file mode 100644
index 0000000..27a7a33
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/mirada-pata.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Name=mirada · pata
+Comment=Compositor Wayland teselante (mirada) con el marco pata
+Exec=mirada-session-pata
+Type=Application
+DesktopNames=mirada
diff --git a/02_ruway/mirada/mirada-compositor/session/mirada-session b/02_ruway/mirada/mirada-compositor/session/mirada-session
new file mode 100755
index 0000000..31bf0f6
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/mirada-session
@@ -0,0 +1,30 @@
+#!/bin/sh
+# mirada-session — arranca carmen (el compositor mirada) como una sesión
+# de escritorio. Pensado para lanzarse desde una TTY o desde un gestor de
+# login (greetd, ly, …).
+#
+# Instálalo en el PATH (p. ej. /usr/local/bin/mirada-session) junto al
+# binario `mirada-compositor`.
+#
+# CAMINO CANÓNICO (recomendado): el Ente `mirada-session` declarado en la
+# Tarjeta Semilla de arje-zero (`03_ukupacha/arje/init/arje-zero/src/seed.rs::mirada_session_card`)
+# levanta el compositor con estas mismas variables de entorno pero bajo
+# supervisión del fractal — back-off automático ante crash y trazado por
+# el bus de eventos. Este script queda como FALLBACK para hosts con DM
+# externo (greetd, ly, sddm) o setups manuales que aún no migraron a
+# arje-zero como PID 1.
+
+# Carmen es un compositor Wayland.
+export XDG_SESSION_TYPE=wayland
+export XDG_CURRENT_DESKTOP=carmen
+export XDG_SESSION_DESKTOP=carmen
+
+# Que las apps GUI prefieran sus backends Wayland.
+export MOZ_ENABLE_WAYLAND=1
+export QT_QPA_PLATFORM="wayland;xcb"
+export SDL_VIDEODRIVER=wayland
+export _JAVA_AWT_WM_NONREPARENTING=1
+
+# El backend DRM toma la TTY entera. Los programas de arranque van en
+# ~/.config/mirada/autostart (uno por línea).
+exec mirada-compositor --drm
diff --git a/02_ruway/mirada/mirada-compositor/session/mirada-session-pata b/02_ruway/mirada/mirada-compositor/session/mirada-session-pata
new file mode 100755
index 0000000..0130374
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/session/mirada-session-pata
@@ -0,0 +1,26 @@
+#!/bin/sh
+# mirada-session-pata — sesión «mirada · pata» para gestores de login
+# EXTERNOS (sddm, greetd, ly…): arranca el compositor mirada y deja a pata
+# como marco del escritorio. Instálalo en /usr/local/bin junto a
+# `mirada-compositor` y `pata-llimphi`.
+#
+# (Cuando el DM es el propio mirada —`mirada-dm`—, no hace falta este
+# script: elegís «mirada · pata» en el greeter y el marco arranca como
+# cliente sin reiniciar el servidor.)
+set -eu
+
+export XDG_SESSION_TYPE=wayland
+export XDG_CURRENT_DESKTOP=mirada
+export XDG_SESSION_DESKTOP=mirada
+export MOZ_ENABLE_WAYLAND=1
+export QT_QPA_PLATFORM="wayland;xcb"
+export SDL_VIDEODRIVER=wayland
+
+# Asegura pata en el autoarranque del usuario (idempotente). pata ancla por
+# wlr-layer-shell (su backend nativo), que mirada ahora soporta.
+mkdir -p "${HOME}/.config/mirada"
+AUTO="${HOME}/.config/mirada/autostart"
+LINE='pata-llimphi'
+grep -qxF "$LINE" "$AUTO" 2>/dev/null || echo "$LINE" >> "$AUTO"
+
+exec mirada-compositor --drm
diff --git a/02_ruway/mirada/mirada-compositor/src/drm_backend.rs b/02_ruway/mirada/mirada-compositor/src/drm_backend.rs
new file mode 100644
index 0000000..49865db
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/src/drm_backend.rs
@@ -0,0 +1,2448 @@
+//! `drm_backend` — el Cuerpo del compositor sobre **DRM/KMS**, sin
+//! sesión gráfica anfitriona: corre directo sobre una TTY, como tu
+//! escritorio de verdad.
+//!
+//! Construido por fases para verificarlo en hardware paso a paso:
+//!
+//! - **Fase 1 — bring-up**: sesión (`libseat`), GPU, dispositivo DRM,
+//!   enumerar salidas.
+//! - **Fase 2a — pipeline de render**: GBM, EGL y `GlesRenderer`, con un
+//!   `DrmCompositor` para la salida conectada.
+//! - **Fase 2b — bucle Wayland** (esto): un bucle `calloop` que atiende
+//!   a los clientes Wayland, el teclado (`libinput`) y el VBlank, y
+//!   compone las ventanas de verdad. Aquí `mirada-compositor --drm` ya
+//!   es un escritorio funcionando.
+//!
+//! Todo con logs para diagnosticar sin el hardware delante.
+
+use std::error::Error;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
+use smithay::backend::allocator::Fourcc;
+use smithay::backend::drm::compositor::{DrmCompositor, FrameFlags};
+use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter;
+use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent};
+use smithay::backend::egl::{EGLContext, EGLDisplay};
+use smithay::backend::input::{
+    AbsolutePositionEvent, Axis, AxisSource, ButtonState, InputEvent, KeyState, KeyboardKeyEvent,
+    PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
+};
+use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
+use smithay::backend::renderer::element::memory::{
+    MemoryRenderBuffer, MemoryRenderBufferRenderElement,
+};
+use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
+use smithay::backend::renderer::element::surface::{
+    render_elements_from_surface_tree, WaylandSurfaceRenderElement,
+};
+use smithay::backend::renderer::element::{render_elements, Id, Kind};
+use smithay::backend::renderer::gles::GlesRenderer;
+use smithay::backend::renderer::utils::CommitCounter;
+use smithay::backend::renderer::{ImportAll, ImportDma, ImportMem};
+use smithay::backend::session::libseat::LibSeatSession;
+use smithay::backend::session::{Event as SessionEvent, Session};
+use smithay::backend::udev;
+use smithay::input::keyboard::FilterResult;
+use smithay::input::pointer::{AxisFrame, ButtonEvent, CursorImageStatus, MotionEvent};
+use smithay::output::OutputModeSource;
+use smithay::reexports::calloop::channel::{channel as ticket_channel, Event as TicketEvent};
+use smithay::reexports::calloop::generic::Generic;
+use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
+use smithay::reexports::calloop::{EventLoop, Interest, Mode as CalloopMode, PostAction};
+use smithay::reexports::drm::control::connector::State as ConnectorState;
+use smithay::reexports::drm::control::{Device as ControlDevice, ModeTypeFlags};
+use smithay::reexports::input::Libinput;
+use smithay::reexports::rustix::fs::OFlags;
+use smithay::reexports::wayland_server::{Display, DisplayHandle, ListeningSocket};
+use smithay::utils::{
+    DeviceFd, IsAlive, Logical, Physical, Point, Rectangle, Scale, Size, Transform, SERIAL_COUNTER,
+};
+
+use auth_core::SessionTicket;
+use mirada_brain::{BodyEvent, CtlReply, CtlRequest, Rect, ZoneFrac};
+
+use crate::{
+    combo_string, send_frames_surface_tree, App, BodyMode, ClientState, DragGrab, DragMode,
+    Setup,
+};
+
+/// El `DrmCompositor` concreto para una salida (un solo GPU). Hay uno por
+/// cada conector activo en multi-monitor.
+type Compositor =
+    DrmCompositor<GbmAllocator<DrmDeviceFd>, GbmFramebufferExporter<DrmDeviceFd>, (), DrmDeviceFd>;
+
+/// Una salida física activa: su conector + CRTC + `DrmCompositor` propio +
+/// el [`smithay::output::Output`] anunciado en Wayland + su posición en el
+/// escritorio global (multi-monitor). La primera de la lista es la
+/// **primaria**: ahí van layer-shell, tiling, menú, zonas y HUD; las
+/// secundarias renderizan sólo wallpaper hasta tener distribución global.
+struct OutputCtx {
+    /// Nombre legible (`DP-1`, `HDMI-A-1`, …) — sale del conector DRM.
+    name: String,
+    /// El `Output` smithay (vive mientras el compositor corre).
+    output: smithay::output::Output,
+    /// El CRTC al que está atada esta salida — clave de routing del VBlank.
+    crtc: smithay::reexports::drm::control::crtc::Handle,
+    /// El `DrmCompositor` que pinta esta salida.
+    compositor: Compositor,
+    /// Rect en coordenadas del escritorio global. `(rect.x, rect.y)` es la
+    /// esquina superior-izquierda en el espacio común; `(rect.w, rect.h)`
+    /// es el tamaño nativo del modo.
+    rect: Rect,
+    /// Refresco en mHz (lo guardamos para futura reconfiguración / hotplug).
+    #[allow(dead_code)]
+    refresh_mhz: i32,
+    /// Wallpaper ya compuesto al tamaño de **esta** salida; `None` se rearma
+    /// perezosamente en el próximo render.
+    wallpaper: Option<(MemoryRenderBuffer, (i32, i32))>,
+    /// Ruta del wallpaper a usar en esta salida (`None` = fondo de color
+    /// sólido). Resuelta de la config: el override por nombre de
+    /// [`mirada_brain::OutputOverride`] gana; si no hay, cae al global.
+    wallpaper_path: Option<String>,
+    /// Modo de ajuste del wallpaper en esta salida — análogo a
+    /// [`Self::wallpaper_path`]: por-salida si hay override, global si no.
+    wallpaper_fit: mirada_brain::WallpaperFit,
+    /// `true` entre que esta salida encola un page-flip y llega su VBlank.
+    pending_flip: bool,
+}
+
+render_elements! {
+    /// Lo que el backend DRM compone en un cuadro: superficies de cliente,
+    /// rectángulos de color sólido (cursor, marcos) y etiquetas de texto
+    /// (búferes RGBA rasterizados — títulos, menú).
+    Frame<R> where R: ImportAll + ImportMem;
+    Window = WaylandSurfaceRenderElement<R>,
+    Solid = SolidColorRenderElement,
+    Text = MemoryRenderBufferRenderElement<R>,
+}
+
+/// Color de fondo del escritorio cuando no hay nada que lo tape.
+const CLEAR_COLOR: [f32; 4] = [0.05, 0.05, 0.08, 1.0];
+
+/// Lado del cursor de software, en píxeles.
+const CURSOR_SIZE: i32 = 12;
+
+/// Color del cursor — un cuadrado casi blanco, opaco.
+const CURSOR_COLOR: [f32; 4] = [0.95, 0.95, 0.97, 1.0];
+
+/// Alto de las etiquetas de título, en píxeles.
+const TITLE_PX: f32 = 16.0;
+
+/// Alto del texto de las filas del menú raíz, en píxeles.
+const MENU_TEXT_PX: f32 = 14.0;
+
+/// Color de fondo del menú raíz (RGBA `0..=1`, casi opaco).
+const MENU_BG: [f32; 4] = [0.11, 0.11, 0.14, 0.97];
+
+/// Color del texto de las filas del menú raíz (RGBA `0..=255`).
+const MENU_TEXT_COLOR: [u8; 4] = [228, 228, 234, 255];
+
+
+/// Color RGBA de las etiquetas de título — casi blanco.
+const TITLE_COLOR: [u8; 4] = [230, 230, 235, 255];
+
+/// Alto del texto del HUD de presets de zonas, en píxeles.
+const HUD_TEXT_PX: f32 = 18.0;
+
+/// Color RGBA (`0..=255`) del texto del HUD de presets.
+const HUD_TEXT_COLOR: [u8; 4] = [240, 240, 246, 255];
+
+/// Color del fondo del HUD de presets (RGBA `0..=1`, casi opaco).
+const HUD_BG: [f32; 4] = [0.10, 0.10, 0.14, 0.85];
+
+/// Margen interno del HUD entre el texto y el borde de su panel, en píxeles.
+const HUD_PAD: i32 = 12;
+
+/// Distancia del HUD al borde superior de la salida, en píxeles.
+const HUD_TOP: i32 = 40;
+
+/// Duración del HUD del preset activo al ciclar zonas.
+const HUD_DURATION: Duration = Duration::from_millis(1500);
+
+/// Lado mínimo de una ventana al redimensionarla con el ratón.
+const MIN_WINDOW: i32 = 120;
+
+/// Convierte un color RGBA en `0..=255` (como viaja por el protocolo y la
+/// config) al `[f32; 4]` normalizado que consume el renderer.
+fn rgba_f32(c: [u8; 4]) -> [f32; 4] {
+    [
+        c[0] as f32 / 255.0,
+        c[1] as f32 / 255.0,
+        c[2] as f32 / 255.0,
+        c[3] as f32 / 255.0,
+    ]
+}
+
+/// Los 4 rectángulos `(x, y, w, h)` del marco de grosor `bw` de una ventana
+/// cuyo contenido ocupa `(sx, sy, sw, sh)`. El marco va *hacia adentro*
+/// (pisa el borde de la superficie), así nunca se solapa con el de la
+/// ventana vecina: arriba, abajo, izquierda, derecha.
+fn border_rects(sx: i32, sy: i32, sw: i32, sh: i32, bw: i32) -> [(i32, i32, i32, i32); 4] {
+    let side_h = (sh - 2 * bw).max(0);
+    [
+        (sx, sy, sw, bw),
+        (sx, sy + sh - bw, sw, bw),
+        (sx, sy + bw, bw, side_h),
+        (sx + sw - bw, sy + bw, bw, side_h),
+    ]
+}
+
+/// Decodifica el wallpaper de `path` y lo compone en un buffer del tamaño
+/// de la salida (`w` × `h`) según `fit`. El resto del buffer queda en negro
+/// opaco (BGRA `[0, 0, 0, 255]`). Devuelve `None` si la imagen no abre o el
+/// tamaño es degenerado. El formato es `Argb8888` (little-endian → bytes
+/// `[B, G, R, A]` en memoria); el wallpaper es opaco, así que la
+/// premultiplicación por alfa es identidad.
+fn load_wallpaper(
+    path: &str,
+    fit: mirada_brain::WallpaperFit,
+    w: i32,
+    h: i32,
+) -> Option<MemoryRenderBuffer> {
+    use mirada_brain::WallpaperFit;
+    if w <= 0 || h <= 0 {
+        return None;
+    }
+    let img = match image::open(path) {
+        Ok(i) => i,
+        Err(e) => {
+            eprintln!("mirada-compositor · wallpaper «{path}» no carga ({e}); fondo sólido.");
+            return None;
+        }
+    };
+    let rgba = img.to_rgba8();
+    let sw = rgba.width() as i32;
+    let sh = rgba.height() as i32;
+    if sw <= 0 || sh <= 0 {
+        return None;
+    }
+
+    // Lienzo negro opaco del tamaño de la salida.
+    let mut bgra = vec![0u8; (w as usize) * (h as usize) * 4];
+    for px in bgra.chunks_exact_mut(4) {
+        px[3] = 255;
+    }
+
+    match fit {
+        WallpaperFit::Tile => {
+            // Repetir desde la esquina superior-izquierda en tamaño nativo.
+            let src = rgba.as_raw();
+            let sw_u = sw as usize;
+            let sh_u = sh as usize;
+            for y in 0..(h as usize) {
+                let sy = y % sh_u;
+                let src_row = &src[sy * sw_u * 4..(sy + 1) * sw_u * 4];
+                let dst_row = &mut bgra[y * (w as usize) * 4..(y + 1) * (w as usize) * 4];
+                for x in 0..(w as usize) {
+                    let sx = x % sw_u;
+                    let si = sx * 4;
+                    let di = x * 4;
+                    dst_row[di] = src_row[si + 2]; // B
+                    dst_row[di + 1] = src_row[si + 1]; // G
+                    dst_row[di + 2] = src_row[si]; // R
+                    dst_row[di + 3] = 255;
+                }
+            }
+        }
+        _ => {
+            let (dx, dy, dw, dh) = mirada_brain::wallpaper_dst_rect(fit, sw, sh, w, h);
+            // Para Stretch/Fit/Fill escalamos la imagen al rect que cae en
+            // pantalla; para Center la dejamos a tamaño nativo. En todos los
+            // casos el "lienzo" donde se pintará es de tamaño `(dw, dh)` y
+            // luego se composita con offset `(dx, dy)` clipeando al destino.
+            let (img_pixels, iw, ih) = if matches!(fit, WallpaperFit::Center) {
+                (rgba.as_raw().clone(), sw, sh)
+            } else if dw <= 0 || dh <= 0 {
+                return None;
+            } else {
+                let scaled = image::imageops::resize(
+                    &rgba,
+                    dw as u32,
+                    dh as u32,
+                    image::imageops::FilterType::Triangle,
+                );
+                (scaled.into_raw(), dw, dh)
+            };
+            paste_rgba_into_bgra(&mut bgra, w, h, &img_pixels, iw, ih, dx, dy);
+        }
+    }
+
+    Some(MemoryRenderBuffer::from_slice(
+        &bgra,
+        Fourcc::Argb8888,
+        (w, h),
+        1,
+        Transform::Normal,
+        None,
+    ))
+}
+
+/// Pega un bloque RGBA de `(iw, ih)` en un lienzo BGRA `(dw, dh)` con esquina
+/// superior-izquierda en `(dx, dy)`. Filas y columnas fuera del destino se
+/// clipean. Alfa se fuerza opaco.
+fn paste_rgba_into_bgra(
+    dst: &mut [u8],
+    dw: i32,
+    dh: i32,
+    src: &[u8],
+    iw: i32,
+    ih: i32,
+    dx: i32,
+    dy: i32,
+) {
+    if iw <= 0 || ih <= 0 || dw <= 0 || dh <= 0 {
+        return;
+    }
+    let row_dst = (dw as usize) * 4;
+    let row_src = (iw as usize) * 4;
+    // Recorta horizontalmente: rango de columnas de la fuente que cae en pantalla.
+    let x0 = dx.max(0);
+    let x1 = (dx + iw).min(dw);
+    if x1 <= x0 {
+        return;
+    }
+    let src_x0 = (x0 - dx) as usize;
+    let copy_w = (x1 - x0) as usize;
+    for y in 0..ih {
+        let ty = dy + y;
+        if ty < 0 || ty >= dh {
+            continue;
+        }
+        let src_row = &src[(y as usize) * row_src..(y as usize + 1) * row_src];
+        let dst_row = &mut dst[(ty as usize) * row_dst..(ty as usize + 1) * row_dst];
+        for k in 0..copy_w {
+            let si = (src_x0 + k) * 4;
+            let di = (x0 as usize + k) * 4;
+            dst_row[di] = src_row[si + 2]; // B
+            dst_row[di + 1] = src_row[si + 1]; // G
+            dst_row[di + 2] = src_row[si]; // R
+            dst_row[di + 3] = 255;
+        }
+    }
+}
+
+/// Rasteriza un título a un `MemoryRenderBuffer` (Argb8888); si no rasteriza
+/// nada, devuelve un búfer 1×1 transparente. Lo cachea el llamador.
+fn title_buffer(tr: &crate::text::TextRenderer, title: &str) -> MemoryRenderBuffer {
+    match tr.rasterize(title, TITLE_PX, TITLE_COLOR) {
+        Some(r) => MemoryRenderBuffer::from_slice(
+            &r.rgba,
+            Fourcc::Argb8888,
+            (r.width, r.height),
+            1,
+            Transform::Normal,
+            None,
+        ),
+        None => MemoryRenderBuffer::from_slice(
+            &[0u8; 4],
+            Fourcc::Argb8888,
+            (1, 1),
+            1,
+            Transform::Normal,
+            None,
+        ),
+    }
+}
+
+/// Códigos de botón de `<linux/input-event-codes.h>`.
+const BTN_LEFT: u32 = 0x110;
+const BTN_RIGHT: u32 = 0x111;
+
+/// El estado del bucle DRM — lo comparten todos los callbacks de `calloop`.
+struct DrmState {
+    app: App,
+    /// La sesión libseat — se conserva para conmutar de VT (`Ctrl+Alt+Fn`).
+    session: LibSeatSession,
+    display: Display<App>,
+    /// El dispositivo DRM — se conserva para pausarlo y reactivarlo al
+    /// conmutar de VT.
+    drm: DrmDevice,
+    /// Recursos para crear `OutputCtx` nuevos en caliente (hotplug). Se
+    /// guardan acá porque la creación inicial los necesita y el handler de
+    /// `UdevEvent::Changed` también, dentro del bucle de eventos.
+    gbm: GbmDevice<DrmDeviceFd>,
+    allocator: GbmAllocator<DrmDeviceFd>,
+    exporter: GbmFramebufferExporter<DrmDeviceFd>,
+    renderer_formats: smithay::backend::allocator::format::FormatSet,
+    /// Handle al display Wayland — `announce_output` lo necesita al crear
+    /// el `wl_output` global de cada monitor nuevo.
+    dh: DisplayHandle,
+    /// Salidas físicas activas: una por conector. `outputs[0]` es la
+    /// primaria — soporta layer-shell, tiling, menú, zonas, HUD.
+    outputs: Vec<OutputCtx>,
+    renderer: GlesRenderer,
+    /// Contexto `libinput` — se suspende y reanuda al conmutar de VT.
+    libinput: Libinput,
+    /// `false` mientras la sesión está cedida a otra VT — no se compone.
+    active: bool,
+    /// Vigías de los archivos de config recargables en caliente.
+    watches: crate::ConfigWatches,
+    ctl: Option<crate::CtlServer>,
+    /// Inicio del compositor — base de tiempos para los frame-callbacks.
+    start: Instant,
+    /// Nº de ventanas en el último `tick` — para registrar los cambios.
+    last_windows: usize,
+    /// Identidad estable del cursor de software — el seguimiento de daño
+    /// la usa para no recomponer todo cuando el cursor sólo se mueve.
+    cursor_id: Id,
+    /// Ventana sobre la que estaba el puntero — para el foco-sigue-ratón.
+    last_pointer_window: Option<u64>,
+    /// Tamaño de la salida, en píxeles — los topes del puntero.
+    output_size: (f64, f64),
+    /// Renderizador de texto (etiquetas de título/menú). `None` si no se
+    /// encontró ninguna fuente — entonces no se pintan etiquetas.
+    text: Option<crate::text::TextRenderer>,
+    /// Caché de etiquetas ya rasterizadas, por (texto, color) → búfer subido.
+    /// Evita re-rasterizar y re-subir la textura en cada cuadro.
+    text_cache: std::collections::HashMap<(String, [u8; 4]), MemoryRenderBuffer>,
+    /// Árbol del menú raíz (de la config), con submenús anidados.
+    menu_entries: Vec<crate::menu::MenuNode>,
+    /// Menú raíz abierto, si lo hay (click derecho sobre el fondo). Sus
+    /// coordenadas son **locales** a la salida [`Self::menu_output_idx`]:
+    /// el menú se abre en el monitor donde se hizo right-click.
+    root_menu: Option<crate::menu::RootMenu>,
+    /// Índice de la salida en la que vive el menú raíz abierto. `None` =
+    /// no hay menú.
+    menu_output_idx: Option<usize>,
+    /// Zonas de arrastre activas (fracciones del área útil) — el preset actual.
+    zones: Vec<ZoneFrac>,
+    /// Todos los presets de zonas (preset 0 = `config.zones`, luego
+    /// `config.zone_presets`). `mirada-ctl cycle-zones` avanza entre ellos.
+    zone_presets: Vec<Vec<ZoneFrac>>,
+    /// Índice del preset activo dentro de [`Self::zone_presets`].
+    active_preset: usize,
+    /// Índice de la zona resaltada bajo el puntero durante un arrastre.
+    drag_zone: Option<usize>,
+    /// Instante hasta el que pintar el HUD del preset activo (al ciclar
+    /// zonas). `None` = sin HUD. Se setea en cada ciclo y se respeta hasta
+    /// que el reloj de `start` lo supera; el siguiente tick (~60 Hz) lo
+    /// borra y damasca su área para el siguiente flip.
+    preset_hud_until: Option<Instant>,
+    /// Etiqueta cacheada del HUD (la última que se rasterizó). Permite
+    /// reusar `text_cache` sin recomputar el texto en cada cuadro.
+    preset_hud_label: String,
+}
+
+impl DrmState {
+    /// Índice de la salida primaria en [`Self::outputs`]. Hoy hard-coded a 0
+    /// (la primera descubierta); a futuro será configurable.
+    const PRIMARY: usize = 0;
+
+    /// Encuentra el índice de la salida cuyo CRTC es `crtc`, si existe.
+    fn output_index_by_crtc(
+        &self,
+        crtc: smithay::reexports::drm::control::crtc::Handle,
+    ) -> Option<usize> {
+        self.outputs.iter().position(|o| o.crtc == crtc)
+    }
+
+    /// Índice de la salida que contiene el punto global `(gx, gy)`. Si el
+    /// punto cae en zona muerta entre rects (puede pasar con salidas de
+    /// distinto tamaño dispuestas side-by-side), devuelve la primaria.
+    fn output_at_point(&self, gx: i32, gy: i32) -> usize {
+        self.outputs
+            .iter()
+            .position(|o| {
+                gx >= o.rect.x
+                    && gy >= o.rect.y
+                    && gx < o.rect.x + o.rect.w
+                    && gy < o.rect.y + o.rect.h
+            })
+            .unwrap_or(Self::PRIMARY)
+    }
+
+    /// Acota un punto al **interior de algún output**, respetando la geometría
+    /// real. Si `(x, y)` ya cae sobre un output, se devuelve tal cual; si no
+    /// (zona muerta entre rects de distinto tamaño), se proyecta al borde
+    /// del output euclídeamente más cercano. Sin esto, el cursor podía
+    /// quedar atrapado en zonas que ninguna salida pinta — el usuario lo ve
+    /// como un cursor fantasma sobre fondo negro.
+    fn clamp_to_outputs(&self, x: f64, y: f64) -> (f64, f64) {
+        let xi = x.round() as i32;
+        let yi = y.round() as i32;
+        if self.outputs.iter().any(|o| {
+            xi >= o.rect.x
+                && yi >= o.rect.y
+                && xi < o.rect.x + o.rect.w
+                && yi < o.rect.y + o.rect.h
+        }) {
+            return (x, y);
+        }
+        // El menor cuadrado-distancia al rect proyecta `(x, y)` al borde.
+        let mut best = (self.outputs[Self::PRIMARY].rect, f64::INFINITY);
+        for o in &self.outputs {
+            let r = o.rect;
+            if r.w <= 0 || r.h <= 0 {
+                continue;
+            }
+            let cx = x.clamp(r.x as f64, (r.x + r.w - 1) as f64);
+            let cy = y.clamp(r.y as f64, (r.y + r.h - 1) as f64);
+            let d = (x - cx).powi(2) + (y - cy).powi(2);
+            if d < best.1 {
+                best = (r, d);
+            }
+        }
+        let r = best.0;
+        (
+            x.clamp(r.x as f64, (r.x + r.w - 1) as f64),
+            y.clamp(r.y as f64, (r.y + r.h - 1) as f64),
+        )
+    }
+
+    /// El área útil (rect menos reservas) **de una salida concreta**: a las
+    /// layers exclusivas de su `layer_map` se le suma, sólo en la primaria,
+    /// la franja del shell (pata). Devuelve rect en coords globales — el
+    /// teselado y las zonas de arrastre lo usan como dominio efectivo.
+    fn output_work_rect(&self, idx: usize) -> Rect {
+        let o = &self.outputs[idx];
+        // Layers exclusivas de ESTA salida: la zona "no exclusiva" da los
+        // insets directos.
+        let z = smithay::desktop::layer_map_for_output(&o.output).non_exclusive_zone();
+        let mut top = z.loc.y.max(0);
+        let mut left = z.loc.x.max(0);
+        let mut right = (o.rect.w - (z.loc.x + z.size.w)).max(0);
+        let mut bottom = (o.rect.h - (z.loc.y + z.size.h)).max(0);
+        // El dock del shell (pata) sólo se descuenta en la primaria.
+        if idx == Self::PRIMARY {
+            let (rt, rb, rl, rr) = self.app.reserved;
+            // Las reservas del shell ya están sumadas en `self.app.reserved`
+            // (recompute_reservations las publica), pero `app.reserved`
+            // incluye también las de layer-shell (no podemos restarlas
+            // limpiamente). Tomamos `max` para no doble-contar: la mayor
+            // gana, y el shell (que es la suma) cubre los dos casos.
+            top = top.max(rt);
+            bottom = bottom.max(rb);
+            left = left.max(rl);
+            right = right.max(rr);
+        }
+        Rect::new(
+            o.rect.x + left,
+            o.rect.y + top,
+            (o.rect.w - left - right).max(1),
+            (o.rect.h - top - bottom).max(1),
+        )
+    }
+
+    /// Compone un cuadro por cada salida y avisa a los clientes una sola vez.
+    /// Si una salida tiene su `pending_flip` puesto, se saltea hasta el
+    /// próximo VBlank. Refresca los búferes de marco una vez al principio.
+    fn render(&mut self) {
+        if !self.active {
+            return; // la sesión está en otra VT — no tocamos la GPU
+        }
+        self.refresh_window_borders();
+        for i in 0..self.outputs.len() {
+            self.render_output(i);
+        }
+        self.send_frames_to_clients();
+    }
+
+    /// Si el puntero global cae sobre `rect`, emite el cursor en coordenadas
+    /// **locales** a `rect`. Si el cliente publicó una superficie de cursor,
+    /// usa esa; si no, el cuadrado por defecto. `Hidden` o puntero fuera del
+    /// rect no emiten nada.
+    fn emit_cursor(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+        let (cx, cy) = self.app.pointer_loc;
+        let (cxi, cyi) = (cx.round() as i32, cy.round() as i32);
+        if cxi < rect.x || cyi < rect.y || cxi >= rect.x + rect.w || cyi >= rect.y + rect.h {
+            return;
+        }
+        match &self.app.cursor_status {
+            CursorImageStatus::Hidden => {}
+            CursorImageStatus::Surface(surface) if surface.alive() => {
+                let (hx, hy) = crate::cursor_hotspot(surface);
+                let loc = (cxi - rect.x - hx, cyi - rect.y - hy);
+                for el in render_elements_from_surface_tree(
+                    &mut self.renderer,
+                    surface,
+                    loc,
+                    1.0,
+                    1.0,
+                    Kind::Cursor,
+                ) {
+                    into.push(Frame::Window(el));
+                }
+            }
+            _ => {
+                let cursor_rect = Rectangle::new(
+                    Point::<i32, Physical>::from((cxi - rect.x, cyi - rect.y)),
+                    Size::<i32, Physical>::from((CURSOR_SIZE, CURSOR_SIZE)),
+                );
+                into.push(Frame::Solid(SolidColorRenderElement::new(
+                    self.cursor_id.clone(),
+                    cursor_rect,
+                    CommitCounter::default(),
+                    CURSOR_COLOR,
+                    Kind::Cursor,
+                )));
+            }
+        }
+    }
+
+    /// Emite todas las ventanas visibles cuya posición global intersecta `rect`,
+    /// traducidas a coordenadas locales a `rect`. Incluye marcos, barras de
+    /// título y el árbol de superficie del cliente, en orden front-to-back
+    /// (`shell` arriba > flotantes > teseladas). Se saltea ventanas que no
+    /// caen sobre `rect` para no malgastar trabajo del compositor.
+    fn emit_windows(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+        let mut shown: Vec<_> = self.app.windows.iter().filter(|w| w.visible).collect();
+        shown.sort_by_key(|w| (!w.is_shell, !w.floating, !w.focused));
+        let tbh = self.app.decorations.titlebar_height;
+        // `render_loc` necesita el alto de la "salida lógica" sólo para anclar
+        // el shell al borde inferior. En multi-monitor esa salida es la
+        // primaria (output 0); el shell vive ahí.
+        let primary_h = self.outputs[Self::PRIMARY].rect.h;
+        for w in &shown {
+            let tb = crate::titlebar_for(w, tbh);
+            let (gx, gy) = crate::render_loc(w, primary_h, tbh);
+            let (sw, sh) = crate::surface_px_size(w).unwrap_or((w.size.0, (w.size.1 - tb).max(1)));
+            // Rect decorado en coords globales (incluye barra + superficie).
+            let gxd = gx;
+            let gyd = gy - tb;
+            let gwd = sw;
+            let ghd = sh + tb;
+            // Filtrar por intersección con `rect`.
+            if gxd + gwd <= rect.x
+                || gyd + ghd <= rect.y
+                || gxd >= rect.x + rect.w
+                || gyd >= rect.y + rect.h
+            {
+                continue;
+            }
+            // Posición local de la superficie y de la decoración.
+            let x = gx - rect.x;
+            let y = gy - rect.y;
+            let dec_y = y - tb;
+            let dec_h = sh + tb;
+
+            if tb > 0 {
+                if let Some(tr) = &self.text {
+                    if !w.title.is_empty() {
+                        if self.text_cache.len() > 256 {
+                            self.text_cache.clear();
+                        }
+                        let buf = self
+                            .text_cache
+                            .entry((w.title.clone(), TITLE_COLOR))
+                            .or_insert_with(|| title_buffer(tr, &w.title));
+                        let ty = dec_y + (tb - TITLE_PX as i32) / 2;
+                        if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+                            &mut self.renderer,
+                            ((x + 8) as f64, ty as f64),
+                            buf,
+                            None,
+                            None,
+                            None,
+                            Kind::Unspecified,
+                        ) {
+                            into.push(Frame::Text(el));
+                        }
+                    }
+                }
+                let color = rgba_f32(if w.focused {
+                    self.app.decorations.border_focus
+                } else {
+                    self.app.decorations.border_normal
+                });
+                let mut bar = SolidColorBuffer::default();
+                bar.update((sw, tb), color);
+                into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+                    &bar,
+                    (x, dec_y),
+                    1.0,
+                    1.0,
+                    Kind::Unspecified,
+                )));
+            } else if w.focused && !w.is_shell && !w.title.is_empty() {
+                if let Some(tr) = &self.text {
+                    if self.text_cache.len() > 256 {
+                        self.text_cache.clear();
+                    }
+                    let buf = self
+                        .text_cache
+                        .entry((w.title.clone(), TITLE_COLOR))
+                        .or_insert_with(|| title_buffer(tr, &w.title));
+                    if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+                        &mut self.renderer,
+                        ((x + 6) as f64, (y + 4) as f64),
+                        buf,
+                        None,
+                        None,
+                        None,
+                        Kind::Unspecified,
+                    ) {
+                        into.push(Frame::Text(el));
+                    }
+                }
+            }
+            if !w.is_shell && self.app.decorations.border_width > 0 {
+                let rects = border_rects(x, dec_y, sw, dec_h, self.app.decorations.border_width);
+                for (buf, (bx, by, _, _)) in w.borders.iter().zip(rects) {
+                    into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+                        buf,
+                        (bx, by),
+                        1.0,
+                        1.0,
+                        Kind::Unspecified,
+                    )));
+                }
+            }
+            for el in render_elements_from_surface_tree(
+                &mut self.renderer,
+                &w.surface,
+                (x, y),
+                1.0,
+                1.0,
+                Kind::Unspecified,
+            ) {
+                into.push(Frame::Window(el));
+            }
+        }
+    }
+
+    /// Emite el HUD del preset activo en la salida `rect` — un panel discreto
+    /// arriba al centro de la salida (no del escritorio global) mientras dure
+    /// la ventana de feedback. Si el deadline pasó, limpia el estado. Llamar
+    /// sólo en la salida dueña del HUD (hoy la primaria).
+    fn emit_hud(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+        let Some(deadline) = self.preset_hud_until else {
+            return;
+        };
+        if Instant::now() >= deadline {
+            self.preset_hud_until = None;
+            return;
+        }
+        let Some(tr) = &self.text else { return };
+        if self.preset_hud_label.is_empty() {
+            return;
+        }
+        let Some(r) = tr.rasterize(&self.preset_hud_label, HUD_TEXT_PX, HUD_TEXT_COLOR) else {
+            return;
+        };
+        let tw = r.width;
+        let th = r.height;
+        let panel_w = tw + 2 * HUD_PAD;
+        let panel_h = th.max(HUD_TEXT_PX as i32) + 2 * HUD_PAD;
+        // Centra el panel en el ancho de la salida (no del escritorio total),
+        // en coords locales — el frame de esta salida arranca en (0,0).
+        let panel_x = ((rect.w - panel_w) / 2).max(0);
+        let panel_y = HUD_TOP;
+        let tx = panel_x + (panel_w - tw) / 2;
+        let ty = panel_y + (panel_h - th) / 2;
+        let buf = MemoryRenderBuffer::from_slice(
+            &r.rgba,
+            Fourcc::Argb8888,
+            (tw, th),
+            1,
+            Transform::Normal,
+            None,
+        );
+        if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+            &mut self.renderer,
+            (tx as f64, ty as f64),
+            &buf,
+            None,
+            None,
+            None,
+            Kind::Unspecified,
+        ) {
+            into.push(Frame::Text(el));
+        }
+        let mut bg = SolidColorBuffer::default();
+        bg.update((panel_w, panel_h), HUD_BG);
+        into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+            &bg,
+            (panel_x, panel_y),
+            1.0,
+            1.0,
+            Kind::Unspecified,
+        )));
+    }
+
+    /// Emite el overlay de zonas de arrastre (drag-to-zone) — visible sólo
+    /// durante un arrastre Move/Tile. Las zonas se escalan al monitor bajo
+    /// el puntero y se emiten traducidas a coords locales de `rect`. Si las
+    /// zonas no caen sobre `rect` (drag en otro monitor), no emite nada.
+    fn emit_zone_overlay(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+        let drag_mode = self.app.drag.as_ref().map(|d| d.mode);
+        if !matches!(drag_mode, Some(DragMode::Move) | Some(DragMode::Tile)) {
+            return;
+        }
+        if self.zones.is_empty() {
+            return;
+        }
+        let wr = self.work_rect();
+        // Las zonas son del monitor bajo el puntero — si ese no es esta
+        // salida, no las pintamos acá.
+        if wr.x + wr.w <= rect.x
+            || wr.y + wr.h <= rect.y
+            || wr.x >= rect.x + rect.w
+            || wr.y >= rect.y + rect.h
+        {
+            return;
+        }
+        let acc = self.app.decorations.border_focus;
+        let fill = |a: f32| {
+            [
+                acc[0] as f32 / 255.0,
+                acc[1] as f32 / 255.0,
+                acc[2] as f32 / 255.0,
+                a,
+            ]
+        };
+        for (i, z) in self.zones.iter().enumerate() {
+            let r = z.to_rect(wr);
+            let color = if Some(i) == self.drag_zone { fill(0.40) } else { fill(0.16) };
+            let mut buf = SolidColorBuffer::default();
+            buf.update((r.w, r.h), color);
+            into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+                &buf,
+                (r.x - rect.x, r.y - rect.y),
+                1.0,
+                1.0,
+                Kind::Unspecified,
+            )));
+        }
+    }
+
+    /// Emite el menú raíz en `rect` si esta salida es la dueña del menú.
+    /// El menú vive en **coords locales** de su salida (se abrió ahí), así
+    /// que las posiciones de las columnas no necesitan traducción.
+    fn emit_menu(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+        let Some(m) = self.root_menu.as_ref() else { return };
+        // El menú se rasteriza con el puntero **local** a su salida — así
+        // resaltado y hover apuntan a la fila correcta.
+        let (px, py) = self.app.pointer_loc;
+        let cols = m.render(px.round() as i32 - rect.x, py.round() as i32 - rect.y);
+        let menu_hl_color = rgba_f32(self.app.decorations.border_focus);
+        if self.text_cache.len() > 256 {
+            self.text_cache.clear();
+        }
+        for col in cols.iter().rev() {
+            // Texto (caché).
+            if let Some(tr) = &self.text {
+                for row in &col.rows {
+                    let label = if row.submenu {
+                        format!("{}   ›", row.label)
+                    } else {
+                        row.label.clone()
+                    };
+                    let buf = self
+                        .text_cache
+                        .entry((label.clone(), MENU_TEXT_COLOR))
+                        .or_insert_with(|| {
+                            match tr.rasterize(&label, MENU_TEXT_PX, MENU_TEXT_COLOR) {
+                                Some(r) => MemoryRenderBuffer::from_slice(
+                                    &r.rgba,
+                                    Fourcc::Argb8888,
+                                    (r.width, r.height),
+                                    1,
+                                    Transform::Normal,
+                                    None,
+                                ),
+                                None => MemoryRenderBuffer::from_slice(
+                                    &[0u8; 4],
+                                    Fourcc::Argb8888,
+                                    (1, 1),
+                                    1,
+                                    Transform::Normal,
+                                    None,
+                                ),
+                            }
+                        });
+                    let ty = row.y + (crate::menu::ITEM_H - MENU_TEXT_PX as i32) / 2;
+                    if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+                        &mut self.renderer,
+                        ((row.x + 10) as f64, ty as f64),
+                        buf,
+                        None,
+                        None,
+                        None,
+                        Kind::Unspecified,
+                    ) {
+                        into.push(Frame::Text(el));
+                    }
+                }
+            }
+            // Resaltado.
+            for row in &col.rows {
+                if row.highlighted {
+                    let mut hl = SolidColorBuffer::default();
+                    hl.update((col.w, crate::menu::ITEM_H), menu_hl_color);
+                    into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+                        &hl,
+                        (row.x, row.y),
+                        1.0,
+                        1.0,
+                        Kind::Unspecified,
+                    )));
+                }
+            }
+            // Fondo.
+            let mut bg = SolidColorBuffer::default();
+            bg.update((col.w, col.h), MENU_BG);
+            into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+                &bg,
+                (col.x, col.y),
+                1.0,
+                1.0,
+                Kind::Unspecified,
+            )));
+        }
+    }
+
+    /// Emite la pista de revelado del dock autoescondido — una franja fina en
+    /// el borde anclado mientras está oculto. Sólo en la salida donde vive
+    /// el shell (primaria).
+    fn emit_reveal_band(&mut self, rect: Rect, into: &mut Vec<Frame<GlesRenderer>>) {
+        if !(crate::shell_dock().autohide && self.app.shell_hidden) {
+            return;
+        }
+        let (ow, oh) = (rect.w, rect.h);
+        if ow <= 0 || oh <= 0 {
+            return;
+        }
+        let dock = crate::shell_dock();
+        let limite = if dock.anchor.es_horizontal() { oh } else { ow };
+        let t = dock.thickness.clamp(1, limite.max(1));
+        let (bx, by, bw, bh) =
+            crate::shell_reveal_band(dock.anchor, ow, oh, t, crate::SHELL_REVEAL_BAND);
+        let menu_hl_color = rgba_f32(self.app.decorations.border_focus);
+        let mut band = SolidColorBuffer::default();
+        band.update((bw, bh), menu_hl_color);
+        into.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+            &band,
+            (bx, by),
+            1.0,
+            1.0,
+            Kind::Unspecified,
+        )));
+    }
+
+    /// Emite el wallpaper de la salida `idx` al fondo (rearmándolo si quedó
+    /// stale). Cada salida tiene su propio búfer escalado, su propia ruta y
+    /// su propio modo de ajuste — un override por nombre puede pintarle un
+    /// fondo distinto a cada monitor.
+    fn emit_wallpaper(&mut self, idx: usize, into: &mut Vec<Frame<GlesRenderer>>) {
+        let ctx = &mut self.outputs[idx];
+        let Some(path) = ctx.wallpaper_path.clone() else {
+            return;
+        };
+        let fit = ctx.wallpaper_fit;
+        let size = (ctx.rect.w, ctx.rect.h);
+        let stale = ctx
+            .wallpaper
+            .as_ref()
+            .map(|(_, s)| *s != size)
+            .unwrap_or(true);
+        if stale && size.0 > 0 && size.1 > 0 {
+            ctx.wallpaper = load_wallpaper(&path, fit, size.0, size.1).map(|b| (b, size));
+        }
+        let Some((buf, _)) = &ctx.wallpaper else {
+            return;
+        };
+        if let Ok(el) = MemoryRenderBufferRenderElement::from_buffer(
+            &mut self.renderer,
+            (0.0, 0.0),
+            buf,
+            None,
+            None,
+            None,
+            Kind::Unspecified,
+        ) {
+            into.push(Frame::Text(el));
+        }
+    }
+
+    /// Refresca los búferes de marco (color por foco) de todas las ventanas
+    /// visibles. Es estado global que no depende de cuál salida se está
+    /// rindiendo — se hace una vez al inicio de [`Self::render`].
+    fn refresh_window_borders(&mut self) {
+        let dec = self.app.decorations;
+        let output_h = self.outputs[Self::PRIMARY].rect.h;
+        for w in &mut self.app.windows {
+            if !w.visible || w.is_shell {
+                continue;
+            }
+            let tb = crate::titlebar_for(w, dec.titlebar_height);
+            let (x, y) = crate::render_loc(w, output_h, dec.titlebar_height);
+            let (sw, sh) = crate::surface_px_size(w).unwrap_or((w.size.0, w.size.1 - tb));
+            let (x, y, sh) = (x, y - tb, sh + tb);
+            let color = rgba_f32(if w.focused {
+                dec.border_focus
+            } else {
+                dec.border_normal
+            });
+            let rects = border_rects(x, y, sw, sh, dec.border_width);
+            for (buf, (_, _, bw, bh)) in w.borders.iter_mut().zip(rects) {
+                buf.update((bw, bh), color);
+            }
+        }
+    }
+
+    /// Avisa a cada cliente (ventanas, layers de la primaria, cursor) de
+    /// que puede dibujar el siguiente cuadro. Se llama una sola vez por
+    /// `render`, no por salida.
+    fn send_frames_to_clients(&mut self) {
+        let time = self.start.elapsed().as_millis() as u32;
+        for w in &self.app.windows {
+            send_frames_surface_tree(&w.surface, time);
+        }
+        // Layers de TODAS las salidas — un cliente puede tener barras en
+        // distintos monitores, cada una con su frame-callback propio.
+        for output in self.app.outputs.clone() {
+            for layer in smithay::desktop::layer_map_for_output(&output).layers() {
+                send_frames_surface_tree(layer.wl_surface(), time);
+            }
+        }
+        if let CursorImageStatus::Surface(surface) = &self.app.cursor_status {
+            if surface.alive() {
+                send_frames_surface_tree(surface, time);
+            }
+        }
+    }
+
+    /// Render unificado de una salida. Cada feature decide si pertenece o
+    /// no a esta salida (gates por dueño) — el cursor en la del puntero,
+    /// HUD/layer-shell/reveal-band en primaria, menú y zonas en la salida
+    /// donde se inició la acción, ventanas y wallpaper en todas.
+    fn render_output(&mut self, idx: usize) {
+        if self.outputs[idx].pending_flip {
+            return;
+        }
+        let rect = self.outputs[idx].rect;
+        let is_primary = idx == Self::PRIMARY;
+        let owns_menu = self.menu_output_idx == Some(idx);
+
+        let elements: Vec<Frame<GlesRenderer>> = {
+            let mut out: Vec<Frame<GlesRenderer>> = Vec::new();
+
+            // 1. Cursor (si el puntero cae sobre esta salida).
+            self.emit_cursor(rect, &mut out);
+
+            // 2. HUD del preset (primaria por ahora; centrado en su rect).
+            if is_primary {
+                self.emit_hud(rect, &mut out);
+            }
+
+            // 3. Zonas de arrastre — en la salida bajo el puntero durante
+            //    un drag (helper filtra por intersección de work-rect).
+            self.emit_zone_overlay(rect, &mut out);
+
+            // 4. Menú raíz — sólo en la salida donde se abrió.
+            if owns_menu {
+                self.emit_menu(rect, &mut out);
+            }
+
+            // 5. Pista de revelado del dock autoescondido — primaria, el
+            //    shell vive ahí.
+            if is_primary {
+                self.emit_reveal_band(rect, &mut out);
+            }
+
+            // 6. Layer surfaces (waybar, swaybg…) de **esta** salida —
+            //    smithay las cuelga por output, así que un layer mapeado a
+            //    una secundaria con `output_hint` aparece donde toca.
+            let output_for_layers = self.outputs[idx].output.clone();
+            let (over_layers, under_layers) =
+                crate::layer_render_elements(Some(&output_for_layers), &mut self.renderer);
+            for el in over_layers {
+                out.push(Frame::Window(el));
+            }
+            // 7. Ventanas entre layers Overlay/Top y Bottom/Background.
+            self.emit_windows(rect, &mut out);
+            for el in under_layers {
+                out.push(Frame::Window(el));
+            }
+
+            // 8. Wallpaper al fondo (por salida).
+            self.emit_wallpaper(idx, &mut out);
+
+            out
+        };
+
+        let ctx = &mut self.outputs[idx];
+        match ctx.compositor.render_frame::<_, _>(
+            &mut self.renderer,
+            &elements,
+            CLEAR_COLOR,
+            FrameFlags::DEFAULT,
+        ) {
+            Ok(result) => {
+                if !result.is_empty {
+                    match ctx.compositor.queue_frame(()) {
+                        Ok(()) => ctx.pending_flip = true,
+                        Err(e) => eprintln!(
+                            "mirada-compositor · queue_frame[{}]: {e}",
+                            ctx.name
+                        ),
+                    }
+                }
+            }
+            Err(e) => eprintln!(
+                "mirada-compositor · render_frame[{}]: {e}",
+                ctx.name
+            ),
+        }
+    }
+
+    /// La sesión se cede a otra VT (`Ctrl+Alt+Fn`): suelta la GPU y deja
+    /// de leer el ratón y el teclado, para no chocar con quien ahora
+    /// manda en la pantalla.
+    fn pause_session(&mut self) {
+        self.active = false;
+        self.drm.pause();
+        self.libinput.suspend();
+        println!("mirada-compositor · sesión cedida a otra VT.");
+    }
+
+    /// La sesión vuelve a esta VT: recupera la GPU y la entrada, reinicia
+    /// el estado de cada compositor y repinta.
+    fn resume_session(&mut self) {
+        if self.libinput.resume().is_err() {
+            eprintln!("mirada-compositor · libinput.resume falló.");
+        }
+        if let Err(e) = self.drm.activate(false) {
+            eprintln!("mirada-compositor · drm.activate falló: {e}");
+        }
+        for ctx in &mut self.outputs {
+            if let Err(e) = ctx.compositor.reset_state() {
+                eprintln!(
+                    "mirada-compositor · compositor.reset_state[{}]: {e}",
+                    ctx.name
+                );
+            }
+            ctx.pending_flip = false;
+        }
+        self.active = true;
+        self.render();
+        println!("mirada-compositor · sesión recuperada.");
+    }
+
+    /// Tarea periódica: Cerebro enlazado, recarga del keymap, API de
+    /// control, composición y vaciado hacia los clientes.
+    fn tick(&mut self) {
+        self.app.brain_poll();
+
+        let n = self.app.windows.len();
+        if n != self.last_windows {
+            eprintln!("mirada-compositor · ventanas en pantalla: {n}");
+            self.last_windows = n;
+        }
+
+        // Recarga en caliente de keymap/config/reglas si cambiaron en disco.
+        // Si la config general cambió, refresca las cachés que el Cuerpo deriva
+        // de ella (menú raíz, wallpaper, fuente de etiquetas) — el Cerebro ya
+        // aplicó teselado/decoración/foco.
+        if self.watches.poll(&mut self.app) {
+            self.menu_entries = self.app.config_menu();
+            // Reconstruye los presets de zonas y reacota el activo.
+            let mut presets = vec![self.app.config_zones()];
+            presets.extend(self.app.config_zone_presets());
+            self.zone_presets = presets;
+            if self.active_preset >= self.zone_presets.len() {
+                self.active_preset = 0;
+            }
+            self.zones = self.zone_presets.get(self.active_preset).cloned().unwrap_or_default();
+            self.root_menu = None; // un menú abierto puede quedar obsoleto
+            self.menu_output_idx = None;
+            // Refresca el wallpaper por salida: cada `OutputCtx` resuelve su
+            // ruta y su `fit` por nombre del conector (override o global).
+            for ctx in &mut self.outputs {
+                let new_wp = self.app.config_wallpaper_path_for(&ctx.name);
+                let new_fit = self.app.config_wallpaper_fit_for(&ctx.name);
+                if new_wp != ctx.wallpaper_path || new_fit != ctx.wallpaper_fit {
+                    ctx.wallpaper_path = new_wp;
+                    ctx.wallpaper_fit = new_fit;
+                    ctx.wallpaper = None; // se rearma en el próximo render
+                }
+            }
+            self.text = crate::text::TextRenderer::system(self.app.config_font_path().as_deref());
+        }
+
+        if let Some(ctl) = &self.ctl {
+            while let Some(mut conn) = ctl.poll() {
+                let reply = match conn.read_request() {
+                    // El ciclo de zonas es estado del Cuerpo (DRM): lo atendemos
+                    // acá, no en el Cerebro. Avanza al siguiente preset.
+                    Ok(Some(CtlRequest::CycleZones)) => {
+                        if !self.zone_presets.is_empty() {
+                            self.active_preset =
+                                (self.active_preset + 1) % self.zone_presets.len();
+                            self.zones = self.zone_presets[self.active_preset].clone();
+                            self.preset_hud_label = format!(
+                                "Zonas · {}/{}",
+                                self.active_preset + 1,
+                                self.zone_presets.len()
+                            );
+                            self.preset_hud_until = Some(Instant::now() + HUD_DURATION);
+                        }
+                        CtlReply::Ok
+                    }
+                    Ok(Some(req)) => self.app.serve_ctl(req),
+                    Ok(None) => continue,
+                    Err(e) => CtlReply::Error(format!("{e}")),
+                };
+                let _ = conn.reply(&reply);
+            }
+        }
+
+        self.render();
+        let _ = self.display.flush_clients();
+    }
+
+    /// Procesa un evento de `libinput`: teclado y puntero.
+    fn handle_input(&mut self, event: InputEvent<LibinputInputBackend>) {
+        let time = self.start.elapsed().as_millis() as u32;
+        match event {
+            // --- Teclado: intercepta los atajos del Cerebro --------------
+            InputEvent::Keyboard { event } => {
+                let Some(keyboard) = self.app.keyboard.clone() else {
+                    return;
+                };
+                let code = event.key_code();
+                let key_state = event.state();
+                let pressed = key_state == KeyState::Pressed;
+                keyboard.input::<(), _>(
+                    &mut self.app,
+                    code,
+                    key_state,
+                    SERIAL_COUNTER.next_serial(),
+                    time,
+                    |st, mods, handle| {
+                        if !pressed {
+                            return FilterResult::Forward;
+                        }
+                        let sym = handle.modified_sym();
+                        // Conmutar de VT (Ctrl+Alt+Fn o XF86Switch_VT_n). Lo
+                        // aplica el backend tras el evento (sólo él tiene la
+                        // sesión). Se chequea a nivel de keysym, antes del
+                        // combo, porque según el keymap no llega como «Fn».
+                        if let Some(vt) = crate::vt_target(mods, sym) {
+                            st.pending_vt = Some(vt);
+                            return FilterResult::Intercept(());
+                        }
+                        if let Some(combo) = combo_string(mods, sym) {
+                            if crate::is_escape_hatch(&combo) {
+                                eprintln!(
+                                    "mirada-compositor · salida de emergencia ({combo})."
+                                );
+                                st.running = false;
+                                return FilterResult::Intercept(());
+                            }
+                            if st.grabs.contains(&combo) {
+                                st.pending_keybind = Some(combo);
+                                return FilterResult::Intercept(());
+                            }
+                        }
+                        FilterResult::Forward
+                    },
+                );
+                if let Some(combo) = self.app.pending_keybind.take() {
+                    let ev = self.app.body.keybind(combo);
+                    self.app.brain_feed(ev);
+                }
+                if let Some(vt) = self.app.pending_vt.take() {
+                    if let Err(e) = self.session.change_vt(vt) {
+                        eprintln!("mirada-compositor · no pude conmutar a VT{vt}: {e}");
+                    }
+                }
+            }
+
+            // --- Puntero: movimiento relativo (ratón, touchpad) ----------
+            InputEvent::PointerMotion { event } => {
+                let (x0, y0) = self.app.pointer_loc;
+                // Pre-acotado al bounding box: descarta los outliers extremos
+                // sin hacer rondas innecesarias en `clamp_to_outputs`.
+                let x = (x0 + event.delta_x()).clamp(0.0, self.output_size.0);
+                let y = (y0 + event.delta_y()).clamp(0.0, self.output_size.1);
+                // Proyectado al output más cercano si cayó en zona muerta.
+                let (x, y) = self.clamp_to_outputs(x, y);
+                self.app.pointer_loc = (x, y);
+                if self.root_menu.is_some() {
+                    // El menú vive en coords locales a su salida.
+                    let idx = self.menu_output_idx.unwrap_or(Self::PRIMARY);
+                    let r = self.outputs[idx].rect;
+                    let lx = x.round() as i32 - r.x;
+                    let ly = y.round() as i32 - r.y;
+                    self.root_menu.as_mut().unwrap().update_hover(lx, ly);
+                    return; // con el menú abierto, el puntero lo navega
+                }
+                self.app.update_shell_autohide(x, y);
+                if !self.drag_update() {
+                    self.pointer_motion(time);
+                }
+            }
+
+            // --- Puntero: movimiento absoluto (táctil, tableta) ----------
+            InputEvent::PointerMotionAbsolute { event } => {
+                let space = Size::<i32, Logical>::from((
+                    self.output_size.0 as i32,
+                    self.output_size.1 as i32,
+                ));
+                let pos = event.position_transformed(space);
+                let x = pos.x.clamp(0.0, self.output_size.0);
+                let y = pos.y.clamp(0.0, self.output_size.1);
+                self.app.pointer_loc = self.clamp_to_outputs(x, y);
+                if self.root_menu.is_some() {
+                    let (x, y) = self.app.pointer_loc;
+                    let idx = self.menu_output_idx.unwrap_or(Self::PRIMARY);
+                    let r = self.outputs[idx].rect;
+                    let lx = x.round() as i32 - r.x;
+                    let ly = y.round() as i32 - r.y;
+                    self.root_menu.as_mut().unwrap().update_hover(lx, ly);
+                    return; // con el menú abierto, el puntero lo navega
+                }
+                let (x, y) = self.app.pointer_loc;
+                self.app.update_shell_autohide(x, y);
+                if !self.drag_update() {
+                    self.pointer_motion(time);
+                }
+            }
+
+            // --- Puntero: botones ----------------------------------------
+            InputEvent::PointerButton { event } => {
+                let pressed = event.state() == ButtonState::Pressed;
+                let button = event.button_code();
+
+                // Menú raíz abierto: el botón se lo come el menú. Click
+                // izquierdo sobre una hoja la lanza y cierra; sobre una
+                // fila-submenú la abre y sigue; click derecho o fuera cierra.
+                // (Sólo al apretar; soltar no hace nada.)
+                if pressed && self.root_menu.is_some() {
+                    use crate::menu::ClickResult;
+                    let (x, y) = self.app.pointer_loc;
+                    let idx = self.menu_output_idx.unwrap_or(Self::PRIMARY);
+                    let r = self.outputs[idx].rect;
+                    let lx = x.round() as i32 - r.x;
+                    let ly = y.round() as i32 - r.y;
+                    let res = if button == BTN_LEFT {
+                        self.root_menu.as_mut().unwrap().click(lx, ly)
+                    } else {
+                        ClickResult::Close
+                    };
+                    match res {
+                        ClickResult::Launch(cmd) => {
+                            self.root_menu = None;
+                            self.menu_output_idx = None;
+                            self.app.spawn_user(&cmd);
+                        }
+                        ClickResult::Stay => {}
+                        ClickResult::Close => {
+                            self.root_menu = None;
+                            self.menu_output_idx = None;
+                        }
+                    }
+                    return; // el menú captura el botón
+                }
+
+                // Click DERECHO sobre el fondo (sin ventana ni `Super`): abre el
+                // menú raíz, si hay entradas configuradas. No aplica en greeter.
+                if pressed
+                    && button == BTN_RIGHT
+                    && !self.menu_entries.is_empty()
+                    && self.app.mode != BodyMode::Greeter
+                {
+                    let super_held = self
+                        .app
+                        .keyboard
+                        .as_ref()
+                        .is_some_and(|kb| kb.modifier_state().logo);
+                    let (x, y) = self.app.pointer_loc;
+                    if !super_held && self.window_at(x, y).is_none() {
+                        // El menú vive en el monitor donde se hizo el click; su
+                        // origen y su rect de acotamiento son **locales** a ese
+                        // monitor — así no se sale del borde de su pantalla.
+                        let idx = self.output_at_point(x.round() as i32, y.round() as i32);
+                        let r = self.outputs[idx].rect;
+                        self.menu_output_idx = Some(idx);
+                        self.root_menu = Some(crate::menu::RootMenu::open(
+                            x.round() as i32 - r.x,
+                            y.round() as i32 - r.y,
+                            self.menu_entries.clone(),
+                            r.w,
+                            r.h,
+                        ));
+                        return; // el botón abrió el menú, no va al cliente
+                    }
+                }
+
+                // ¿Empieza un arrastre? `Super`+botón sobre una ventana:
+                // izquierdo mueve, derecho redimensiona. En modo greeter no
+                // hay arrastre: el login está clavado a pantalla completa.
+                if pressed && self.app.drag.is_none() && self.app.mode != BodyMode::Greeter {
+                    let super_held = self
+                        .app
+                        .keyboard
+                        .as_ref()
+                        .is_some_and(|kb| kb.modifier_state().logo);
+                    // `Super`+izquierdo arrastra: una flotante se mueve, una
+                    // teselada se reordena (swap). `Super`+derecho redimensiona
+                    // (flotando la ventana si estaba teselada).
+                    let (x, y) = self.app.pointer_loc;
+                    let hit = self.window_at(x, y);
+                    let mode = match (button, hit) {
+                        (BTN_LEFT, Some(i)) if super_held => Some(if self.app.windows[i].floating {
+                            DragMode::Move
+                        } else {
+                            DragMode::Tile
+                        }),
+                        (BTN_RIGHT, Some(_)) if super_held => Some(DragMode::Resize),
+                        _ => None,
+                    };
+                    if let (Some(mode), Some(i)) = (mode, hit) {
+                        let w = &self.app.windows[i];
+                        let grab = DragGrab {
+                            id: w.id,
+                            mode,
+                            start_pointer: (x, y),
+                            start_rect: (w.loc.0, w.loc.1, w.size.0, w.size.1),
+                        };
+                        self.app.drag = Some(grab);
+                        return; // el arrastre captura el botón
+                    }
+                }
+
+                // Click izquierdo sobre la BARRA DE TÍTULO (sin `Super`): arranca
+                // un arrastre Move — saca la ventana de su tile y la lleva
+                // flotante, lista para aterrizar en una zona (drag-to-zone) o
+                // quedar overflow. La barra deja de ser chrome inerte.
+                if pressed
+                    && button == BTN_LEFT
+                    && self.app.drag.is_none()
+                    && self.app.mode != BodyMode::Greeter
+                {
+                    let (x, y) = self.app.pointer_loc;
+                    if let Some(i) = self.titlebar_at(x, y) {
+                        let (id, loc, size) = {
+                            let w = &self.app.windows[i];
+                            (w.id, w.loc, w.size)
+                        };
+                        self.app.drag = Some(DragGrab {
+                            id,
+                            mode: DragMode::Move,
+                            start_pointer: (x, y),
+                            start_rect: (loc.0, loc.1, size.0, size.1),
+                        });
+                        let ev = self.app.body.clicked(id); // enfoca la agarrada
+                        self.app.brain_feed(ev);
+                        return; // el arrastre captura el botón
+                    }
+                }
+
+                // Durante un arrastre los botones no llegan al cliente;
+                // soltar cualquiera lo termina. Si se soltó sobre una zona
+                // (drag-to-zone), la ventana aterriza en ese rect (flotante);
+                // si no, queda flotando donde cayó (overflow, ya aplicado por
+                // el último drag_update).
+                if self.app.drag.is_some() {
+                    if !pressed {
+                        let mode = self.app.drag.as_ref().map(|d| d.mode);
+                        let id = self.app.drag.as_ref().map(|d| d.id);
+                        let zone = self.drag_zone.take();
+                        self.app.drag = None;
+                        if let (Some(mode), Some(id), Some(zi)) = (mode, id, zone) {
+                            if matches!(mode, DragMode::Move | DragMode::Tile) {
+                                if let Some(rect) = self.zone_rect(zi) {
+                                    self.app.brain_feed(BodyEvent::WindowFloatTo { id, rect });
+                                }
+                            }
+                        }
+                    }
+                    return;
+                }
+
+                // Click sobre una barra que acepta teclado (cabezal de shuma):
+                // le damos el foco de teclado para poder escribir en el drawer.
+                // (El click en sí llega al cliente vía pointer.button de abajo,
+                // porque el motion ya enfocó el puntero en esa layer.)
+                if pressed {
+                    let (x, y) = self.app.pointer_loc;
+                    if let Some(surf) = self.app.keyboard_focusable_layer_under(x, y) {
+                        if let Some(kb) = self.app.keyboard.clone() {
+                            kb.set_focus(&mut self.app, Some(surf), SERIAL_COUNTER.next_serial());
+                        }
+                    } else if button == BTN_LEFT {
+                        // Foco-al-click: la ventana clickeada pide el foco al
+                        // Cerebro (que la pinta encima). Independiente del
+                        // foco-sigue-ratón; el click sigue llegando al cliente.
+                        if let Some(i) = self.window_at(x, y) {
+                            if !self.app.windows[i].is_shell {
+                                let id = self.app.windows[i].id;
+                                let ev = self.app.body.clicked(id);
+                                self.app.brain_feed(ev);
+                            }
+                        }
+                    }
+                }
+
+                // Botón normal: a la ventana (o layer) bajo el puntero.
+                let Some(pointer) = self.app.pointer.clone() else {
+                    return;
+                };
+                pointer.button(
+                    &mut self.app,
+                    &ButtonEvent {
+                        serial: SERIAL_COUNTER.next_serial(),
+                        time,
+                        button,
+                        state: event.state(),
+                    },
+                );
+                pointer.frame(&mut self.app);
+            }
+
+            // --- Puntero: rueda / desplazamiento -------------------------
+            InputEvent::PointerAxis { event } => {
+                let Some(pointer) = self.app.pointer.clone() else {
+                    return;
+                };
+                let source = event.source();
+                let mut frame = AxisFrame::new(time).source(source);
+                for axis in [Axis::Horizontal, Axis::Vertical] {
+                    match event.amount(axis) {
+                        Some(v) if v != 0.0 => frame = frame.value(axis, v),
+                        Some(_) if source == AxisSource::Finger => {
+                            frame = frame.stop(axis);
+                        }
+                        _ => {}
+                    }
+                    if let Some(d) = event.amount_v120(axis) {
+                        frame = frame.v120(axis, d as i32);
+                    }
+                }
+                pointer.axis(&mut self.app, frame);
+                pointer.frame(&mut self.app);
+            }
+
+            _ => {} // otros dispositivos: aún no
+        }
+    }
+
+    /// Reenvía el puntero a la ventana que tiene debajo y, si esa ventana
+    /// cambió, aplica el foco-sigue-ratón avisando al Cerebro.
+    fn pointer_motion(&mut self, time: u32) {
+        let Some(pointer) = self.app.pointer.clone() else {
+            return;
+        };
+        let (x, y) = self.app.pointer_loc;
+
+        // Las capas Overlay/Top (las barras de `pata`) están por encima de las
+        // ventanas: el puntero va ahí primero. Sin esto, los clicks sólo llegaban
+        // a las ventanas y las barras quedaban muertas al mouse.
+        if let Some((surface, loc)) = self.app.layer_under(x, y) {
+            pointer.motion(
+                &mut self.app,
+                Some((surface, loc)),
+                &MotionEvent {
+                    location: Point::from((x, y)),
+                    serial: SERIAL_COUNTER.next_serial(),
+                    time,
+                },
+            );
+            pointer.frame(&mut self.app);
+            // El cliente del layer pondría su propio cursor; por ahora, el default.
+            self.app.cursor_status = CursorImageStatus::default_named();
+            // Dejamos de sobrevolar cualquier ventana.
+            self.last_pointer_window = None;
+            return;
+        }
+
+        let hit = self.window_at(x, y);
+        let focus = hit.map(|i| {
+            let w = &self.app.windows[i];
+            let (lx, ly) =
+                crate::render_loc(w, self.app.output_size.1, self.app.decorations.titlebar_height);
+            (
+                w.surface.clone(),
+                Point::<f64, Logical>::from((lx as f64, ly as f64)),
+            )
+        });
+        pointer.motion(
+            &mut self.app,
+            focus,
+            &MotionEvent {
+                location: Point::from((x, y)),
+                serial: SERIAL_COUNTER.next_serial(),
+                time,
+            },
+        );
+        pointer.frame(&mut self.app);
+
+        // Sobre el escritorio pelado no manda ningún cliente: el cursor
+        // vuelve al de por defecto (si no, se queda con la «I» del texto
+        // de la última ventana).
+        if hit.is_none() {
+            self.app.cursor_status = CursorImageStatus::default_named();
+        }
+
+        // Foco-sigue-ratón: al pasar a otra ventana, que la enfoque quien
+        // corresponda — el Cerebro para las teseladas, carmen mismo para
+        // el shell (que no vive en el Cerebro). PERO si una layer reclama teclado
+        // Exclusive (el drawer Quake de pata abierto), no le robamos el foco al
+        // mover el mouse sobre una ventana: seguís escribiendo en el drawer.
+        let exclusive_layer = self.app.exclusive_layer_surface().is_some();
+        let hovered = hit.map(|i| self.app.windows[i].id);
+        if hovered != self.last_pointer_window {
+            self.last_pointer_window = hovered;
+            match hit {
+                _ if exclusive_layer => {}
+                Some(i) if self.app.windows[i].is_shell => {
+                    let surf = self.app.windows[i].surface.clone();
+                    if let Some(kb) = self.app.keyboard.clone() {
+                        kb.set_focus(&mut self.app, Some(surf), SERIAL_COUNTER.next_serial());
+                    }
+                }
+                Some(i) => {
+                    let id = self.app.windows[i].id;
+                    let ev = self.app.body.pointer_enter(id);
+                    self.app.brain_feed(ev);
+                }
+                None => {}
+            }
+        }
+    }
+
+    /// Si hay un arrastre en curso, recalcula el rectángulo de la ventana
+    /// y se lo manda al Cerebro (que la hace flotar ahí). Devuelve `true`
+    /// si consumió el movimiento — entonces el puntero no llega al cliente.
+    fn drag_update(&mut self) -> bool {
+        let Some(drag) = self.app.drag.as_ref() else {
+            return false;
+        };
+        let mode = drag.mode;
+        let (spx, spy) = drag.start_pointer;
+        let (sx, sy, sw, sh) = drag.start_rect;
+        let id = drag.id;
+
+        let (px, py) = self.app.pointer_loc;
+        // Drag-to-zone: resalta la zona bajo el puntero (Move/Tile, no Resize).
+        // Sobre una zona, la ventana aterrizará ahí al soltar.
+        self.drag_zone = if mode == DragMode::Resize { None } else { self.zone_at(px, py) };
+        // Arrastre de una teselada: el Cerebro la intercambia con la tesela
+        // bajo el puntero — no flota, sólo reordena el stack. Pero si está
+        // sobre una zona, suprimimos el swap (se resolverá al soltar).
+        if mode == DragMode::Tile {
+            if self.drag_zone.is_none() {
+                self.app
+                    .brain_feed(BodyEvent::WindowDragged { id, x: px as i32, y: py as i32 });
+            }
+            return true;
+        }
+        let dx = (px - spx) as i32;
+        let dy = (py - spy) as i32;
+        let rect = match mode {
+            DragMode::Move => Rect::new(sx + dx, sy + dy, sw, sh),
+            DragMode::Resize => Rect::new(
+                sx,
+                sy,
+                (sw + dx).max(MIN_WINDOW),
+                (sh + dy).max(MIN_WINDOW),
+            ),
+            DragMode::Tile => unreachable!("Tile se maneja arriba"),
+        };
+        self.app.brain_feed(BodyEvent::WindowFloatTo { id, rect });
+        true
+    }
+
+    /// El índice de la ventana visible bajo el punto `(x, y)`, si la hay
+    /// — en orden front-to-back (el shell gana a las flotantes, y éstas a
+    /// las teseladas).
+    fn window_at(&self, x: f64, y: f64) -> Option<usize> {
+        let mut idx: Vec<usize> = (0..self.app.windows.len())
+            .filter(|&i| self.app.windows[i].visible)
+            .collect();
+        idx.sort_by_key(|&i| {
+            let w = &self.app.windows[i];
+            (!w.is_shell, !w.floating, !w.focused)
+        });
+        // `output_h` se usa para anclar el shell al borde inferior; el shell
+        // vive en la primaria, así que usamos su altura, no la total.
+        let output_h = self.outputs[Self::PRIMARY].rect.h;
+        let tbh = self.app.decorations.titlebar_height;
+        idx.into_iter().find(|&i| {
+            let w = &self.app.windows[i];
+            let tb = crate::titlebar_for(w, tbh);
+            let (lx, ly) = crate::render_loc(w, output_h, tbh);
+            let (sw, sh) = crate::surface_px_size(w).unwrap_or((w.size.0, (w.size.1 - tb).max(1)));
+            // Impacto sobre la SUPERFICIE (la barra de título es chrome inerte
+            // en este MVP: no captura el puntero hacia el cliente).
+            x >= lx as f64 && y >= ly as f64 && x < (lx + sw) as f64 && y < (ly + sh) as f64
+        })
+    }
+
+    /// El índice de la ventana cuya **barra de título** está bajo `(x, y)`, si
+    /// la hay (front-to-back). Permite agarrar la ventana por su barra para
+    /// arrastrarla (sin `Super`).
+    fn titlebar_at(&self, x: f64, y: f64) -> Option<usize> {
+        let tbh = self.app.decorations.titlebar_height;
+        if tbh <= 0 {
+            return None;
+        }
+        // `output_h` se usa para anclar el shell al borde inferior; el shell
+        // vive en la primaria, así que usamos su altura, no la total.
+        let output_h = self.outputs[Self::PRIMARY].rect.h;
+        let mut idx: Vec<usize> = (0..self.app.windows.len())
+            .filter(|&i| self.app.windows[i].visible)
+            .collect();
+        idx.sort_by_key(|&i| {
+            let w = &self.app.windows[i];
+            (!w.is_shell, !w.floating, !w.focused)
+        });
+        idx.into_iter().find(|&i| {
+            let w = &self.app.windows[i];
+            let tb = crate::titlebar_for(w, tbh);
+            if tb == 0 {
+                return false;
+            }
+            let (lx, ly) = crate::render_loc(w, output_h, tbh);
+            let (sw, _) = crate::surface_px_size(w).unwrap_or((w.size.0, (w.size.1 - tb).max(1)));
+            let top = ly - tb;
+            x >= lx as f64 && y >= top as f64 && x < (lx + sw) as f64 && y < (top + tb) as f64
+        })
+    }
+
+    /// Reenumera los conectores DRM y aplica las diferencias con
+    /// [`Self::outputs`]: monitores recién enchufados se agregan, los que
+    /// dejan de estar Connected se quitan. En cualquier cambio, se re-dispone
+    /// la geometría global, se notifica al Brain y se rearman las reservas.
+    fn detect_connector_changes(&mut self) {
+        use smithay::reexports::drm::control::{connector, crtc};
+        let resources = match self.drm.resource_handles() {
+            Ok(r) => r,
+            Err(e) => {
+                eprintln!("mirada-compositor · hotplug · no pude releer DRM: {e}");
+                return;
+            }
+        };
+        // Conectores Connected ahora, con su handle + nombre.
+        let mut live: Vec<(connector::Handle, String)> = Vec::new();
+        for &h in resources.connectors() {
+            let Ok(c) = self.drm.get_connector(h, false) else {
+                continue;
+            };
+            if c.state() == ConnectorState::Connected {
+                live.push((h, format!("{:?}-{}", c.interface(), c.interface_id())));
+            }
+        }
+        let live_names: std::collections::HashSet<&str> =
+            live.iter().map(|(_, n)| n.as_str()).collect();
+        let known_names: std::collections::HashSet<String> =
+            self.outputs.iter().map(|o| o.name.clone()).collect();
+
+        let mut changed = false;
+
+        // 1 · Desenchufes — drop OutputCtx + remove_output al Brain.
+        let to_remove: Vec<usize> = self
+            .outputs
+            .iter()
+            .enumerate()
+            .filter(|(_, o)| !live_names.contains(o.name.as_str()))
+            .map(|(i, _)| i)
+            .collect();
+        for &i in to_remove.iter().rev() {
+            let name = self.outputs[i].name.clone();
+            println!("mirada-compositor · hotplug · monitor «{name}» desenchufado");
+            let ev = self.app.body.remove_output(i as u32);
+            self.app.brain_feed(ev);
+            // Drop del compositor + smithay::Output: la GPU libera recursos.
+            let _ = self.outputs.remove(i);
+            changed = true;
+        }
+
+        // 2 · Enchufes — armar OutputCtx para cada conector nuevo.
+        let used_crtcs: Vec<crtc::Handle> = self.outputs.iter().map(|o| o.crtc).collect();
+        let mut taken: Vec<crtc::Handle> = used_crtcs.clone();
+        for (conn_handle, name) in &live {
+            if known_names.contains(name) {
+                continue;
+            }
+            let Ok(conn) = self.drm.get_connector(*conn_handle, false) else {
+                continue;
+            };
+            // Modo: el de mayor área (a igualdad, mayor refresco).
+            let Some(mode) = conn
+                .modes()
+                .iter()
+                .max_by_key(|m| {
+                    let (w, h) = m.size();
+                    (w as u32 * h as u32, m.vrefresh())
+                })
+                .copied()
+            else {
+                continue;
+            };
+            // CRTC libre compatible.
+            let crtc_choice = conn
+                .encoders()
+                .iter()
+                .filter_map(|enc| self.drm.get_encoder(*enc).ok())
+                .find_map(|enc| {
+                    resources
+                        .filter_crtcs(enc.possible_crtcs())
+                        .into_iter()
+                        .find(|c| !taken.contains(c))
+                });
+            let Some(crtc_h) = crtc_choice else {
+                eprintln!("mirada-compositor · hotplug · «{name}» sin CRTC libre — se ignora");
+                continue;
+            };
+            taken.push(crtc_h);
+            match self.armar_output_ctx(*conn_handle, crtc_h, mode, name.clone()) {
+                Ok(ctx) => {
+                    println!("mirada-compositor · hotplug · monitor «{}» enchufado", ctx.name);
+                    let (w, h) = mode.size();
+                    let ev = self.app.body.add_output(
+                        self.outputs.len() as u32,
+                        w as i32,
+                        h as i32,
+                    );
+                    self.app.brain_feed(ev);
+                    self.outputs.push(ctx);
+                    changed = true;
+                }
+                Err(e) => eprintln!("mirada-compositor · hotplug · falló «{name}»: {e}"),
+            }
+        }
+
+        if changed {
+            self.redisponer_outputs();
+        }
+    }
+
+    /// Crea un `OutputCtx` nuevo desde un conector recién enchufado: arma
+    /// `DrmSurface` + `DrmCompositor` + `smithay::Output` con la escala y
+    /// transformación que mande la config. Idéntica a la rama del discovery
+    /// inicial — el día que haya que tocar uno hay que tocar el otro.
+    fn armar_output_ctx(
+        &mut self,
+        conn_handle: smithay::reexports::drm::control::connector::Handle,
+        crtc_h: smithay::reexports::drm::control::crtc::Handle,
+        mode: smithay::reexports::drm::control::Mode,
+        name: String,
+    ) -> Result<OutputCtx, String> {
+        let (w, h) = mode.size();
+        let surface = self
+            .drm
+            .create_surface(crtc_h, mode, &[conn_handle])
+            .map_err(|e| format!("create_surface: {e}"))?;
+        let scale_120 = self.app.config_output_scale_120_for(&name);
+        let transform = self.app.config_output_transform_for(&name);
+        let scale_f64 = (if scale_120 > 0 { scale_120 } else { 120 }) as f64 / 120.0;
+        let mode_source = OutputModeSource::Static {
+            size: Size::from((w as i32, h as i32)),
+            scale: Scale::from(scale_f64),
+            transform,
+        };
+        let compositor: Compositor = DrmCompositor::new(
+            mode_source,
+            surface,
+            None,
+            self.allocator.clone(),
+            self.exporter.clone(),
+            [Fourcc::Argb8888, Fourcc::Xrgb8888],
+            self.renderer_formats.clone(),
+            self.drm.cursor_size(),
+            Some(self.gbm.clone()),
+        )
+        .map_err(|e| format!("DrmCompositor::new: {e}"))?;
+        let refresh_mhz = mode.vrefresh() as i32 * 1000;
+        let smithay_out = crate::announce_output(
+            &self.dh,
+            &name,
+            w as i32,
+            h as i32,
+            refresh_mhz,
+            scale_120,
+            transform,
+        );
+        let wp_path = self.app.config_wallpaper_path_for(&name);
+        let wp_fit = self.app.config_wallpaper_fit_for(&name);
+        Ok(OutputCtx {
+            name,
+            output: smithay_out,
+            crtc: crtc_h,
+            compositor,
+            // rect lo fija `redisponer_outputs` después de añadir.
+            rect: Rect::new(0, 0, w as i32, h as i32),
+            refresh_mhz,
+            wallpaper: None,
+            wallpaper_path: wp_path,
+            wallpaper_fit: wp_fit,
+            pending_flip: false,
+        })
+    }
+
+    /// Re-ordena las salidas por `(order, name)` de la config, recalcula sus
+    /// rects globales con `mirada-layout::disponer`, actualiza el espacio
+    /// total y resincroniza `app.outputs`/`app.output`/`app.output_size`,
+    /// invalida wallpapers (rearmados al próximo render) y re-emite las
+    /// reservas. Lo que NO toca: ventanas — el Brain decide a dónde van.
+    fn redisponer_outputs(&mut self) {
+        if self.outputs.is_empty() {
+            self.app.outputs.clear();
+            self.app.output = None;
+            self.app.output_size = (1, 1);
+            return;
+        }
+        // Sort por (order, name) — primaria queda en outputs[0].
+        let app_ref = &self.app;
+        self.outputs.sort_by(|a, b| {
+            let oa = app_ref.config_output_order_for(&a.name);
+            let ob = app_ref.config_output_order_for(&b.name);
+            oa.cmp(&ob).then_with(|| a.name.cmp(&b.name))
+        });
+        let tamanos: Vec<(i32, i32)> = self.outputs.iter().map(|o| (o.rect.w, o.rect.h)).collect();
+        let disp = self.app.config_output_disposition();
+        let rects = mirada_brain::disponer(&tamanos, disp);
+        for (ctx, r) in self.outputs.iter_mut().zip(rects.iter()) {
+            ctx.rect = *r;
+            ctx.wallpaper = None; // el tamaño global no cambia, pero la posición sí
+        }
+        let env = mirada_brain::envolvente(&rects);
+        let total_w = env.w.max(1);
+        let total_h = env.h.max(1);
+        self.app.output_size = (total_w, total_h);
+        self.output_size = (total_w as f64, total_h as f64);
+        // Resincronizar el registro Wayland.
+        self.app.outputs = self.outputs.iter().map(|c| c.output.clone()).collect();
+        self.app.output = self.outputs.first().map(|c| c.output.clone());
+        // Reposicionar el puntero al centro de la primaria si quedó fuera.
+        let (px, py) = self.app.pointer_loc;
+        let (px, py) = self.clamp_to_outputs(px, py);
+        self.app.pointer_loc = (px, py);
+        // Reservas y borders pueden cambiar con la nueva geometría.
+        self.app.recompute_reservations();
+    }
+
+    /// Work rect del monitor bajo el puntero — el "lienzo" de zonas para
+    /// arrastres. Multi-monitor: los zonas se escalan al monitor donde
+    /// está la acción, no al desktop global.
+    fn work_rect(&self) -> Rect {
+        let (px, py) = self.app.pointer_loc;
+        self.output_work_rect(self.output_at_point(px.round() as i32, py.round() as i32))
+    }
+
+    /// El rect en píxeles de la zona `i`, escalado al work-rect del
+    /// monitor bajo el puntero. Devuelve coords globales.
+    fn zone_rect(&self, i: usize) -> Option<Rect> {
+        let wr = self.work_rect();
+        self.zones.get(i).map(|z| z.to_rect(wr))
+    }
+
+    /// El índice de la zona de arrastre bajo `(x, y)`, si la hay. Las zonas
+    /// se hit-testean contra el work-rect del monitor que contiene `(x,y)`.
+    fn zone_at(&self, x: f64, y: f64) -> Option<usize> {
+        if self.zones.is_empty() {
+            return None;
+        }
+        let (xi, yi) = (x.round() as i32, y.round() as i32);
+        let wr = self.output_work_rect(self.output_at_point(xi, yi));
+        self.zones.iter().position(|z| {
+            let r = z.to_rect(wr);
+            xi >= r.x && yi >= r.y && xi < r.x + r.w && yi < r.y + r.h
+        })
+    }
+}
+
+/// Arranca el Cuerpo sobre DRM/KMS — fases 1, 2a y 2b. Con `greeter`,
+/// el compositor nace en modo DM: ver [`BodyMode`].
+pub fn run(greeter: bool) -> Result<(), Box<dyn Error>> {
+    println!("mirada-compositor · backend DRM.");
+    println!("──────────────────────────────────────────────────");
+
+    // 1 · Sesión.
+    println!("[1/8] abriendo la sesión (libseat) …");
+    let (mut session, session_notifier) = LibSeatSession::new().map_err(|e| {
+        format!(
+            "no pude abrir la sesión libseat: {e}\n       \
+             ¿estás en una TTY de verdad (Ctrl+Alt+F3), con `seatd` o `logind`?"
+        )
+    })?;
+    let seat_name = session.seat();
+    println!("      sesión abierta · seat «{seat_name}»");
+
+    // 2 · GPU primaria.
+    println!("[2/8] buscando la GPU primaria …");
+    let gpu = udev::primary_gpu(&seat_name)
+        .map_err(|e| format!("error consultando udev: {e}"))?
+        .ok_or("no encontré ninguna GPU — ¿existe algún /dev/dri/card*?")?;
+    println!("      GPU primaria: {}", gpu.display());
+
+    // 3 · Dispositivo DRM.
+    println!("[3/8] abriendo el dispositivo DRM …");
+    let fd = session
+        .open(&gpu, OFlags::RDWR | OFlags::CLOEXEC | OFlags::NONBLOCK)
+        .map_err(|e| format!("no pude abrir {}: {e}", gpu.display()))?;
+    let drm_fd = DrmDeviceFd::new(DeviceFd::from(fd));
+    let (mut drm, drm_notifier) =
+        DrmDevice::new(drm_fd.clone(), true).map_err(|e| format!("DrmDevice::new falló: {e}"))?;
+    println!("      dispositivo DRM listo.");
+
+    // 4 · Enumerar TODAS las salidas conectadas: conector + CRTC + modo.
+    println!("[4/8] enumerando salidas …");
+    let resources = drm
+        .resource_handles()
+        .map_err(|e| format!("no pude leer los recursos DRM: {e}"))?;
+    use smithay::reexports::drm::control::{crtc, Mode as DrmMode};
+    let mut chosen: Vec<(
+        smithay::reexports::drm::control::connector::Handle,
+        crtc::Handle,
+        DrmMode,
+        String,
+    )> = Vec::new();
+    // Pool de CRTCs ya tomados — un CRTC no se reparte entre dos salidas.
+    let mut used_crtcs: Vec<crtc::Handle> = Vec::new();
+    for &conn_handle in resources.connectors() {
+        let conn = match drm.get_connector(conn_handle, false) {
+            Ok(c) => c,
+            Err(_) => continue,
+        };
+        if conn.state() != ConnectorState::Connected {
+            continue;
+        }
+        let name = format!("{:?}-{}", conn.interface(), conn.interface_id());
+        for m in conn.modes() {
+            let (mw, mh) = m.size();
+            let pref = if m.mode_type().contains(ModeTypeFlags::PREFERRED) {
+                " [PREFERRED]"
+            } else {
+                ""
+            };
+            eprintln!("      modo de «{name}»: {mw}×{mh} @ {} Hz{pref}", m.vrefresh());
+        }
+        // Elige el modo de mayor área (a igualdad, mayor refresco) — el
+        // nativo del panel.
+        let mode = conn
+            .modes()
+            .iter()
+            .max_by_key(|m| {
+                let (mw, mh) = m.size();
+                (mw as u32 * mh as u32, m.vrefresh())
+            })
+            .copied();
+        let Some(mode) = mode else {
+            continue;
+        };
+        // El primer CRTC compatible que no esté ya tomado.
+        let crtc_choice = conn
+            .encoders()
+            .iter()
+            .filter_map(|enc| drm.get_encoder(*enc).ok())
+            .find_map(|enc| {
+                resources
+                    .filter_crtcs(enc.possible_crtcs())
+                    .into_iter()
+                    .find(|c| !used_crtcs.contains(c))
+            });
+        if let Some(crtc_h) = crtc_choice {
+            let (w, h) = mode.size();
+            println!("      salida «{name}» · {w}×{h} · CRTC {crtc_h:?}");
+            used_crtcs.push(crtc_h);
+            chosen.push((conn_handle, crtc_h, mode, name));
+        } else {
+            eprintln!("      salida «{name}» sin CRTC libre — se ignora");
+        }
+    }
+    if chosen.is_empty() {
+        return Err("ninguna salida conectada con CRTC disponible".into());
+    }
+    // El nombre de la primaria se decide tras ordenar por (order, name) más
+    // abajo — no por orden de discovery. Se captura justo después del sort.
+
+    // 5 · GBM + EGL + GlesRenderer.
+    println!("[5/8] inicializando GBM + EGL + GlesRenderer …");
+    let gbm = GbmDevice::new(drm_fd.clone()).map_err(|e| format!("GbmDevice::new falló: {e}"))?;
+    let egl_display =
+        unsafe { EGLDisplay::new(gbm.clone()) }.map_err(|e| format!("EGLDisplay::new falló: {e}"))?;
+    let egl_context =
+        EGLContext::new(&egl_display).map_err(|e| format!("EGLContext::new falló: {e}"))?;
+    let renderer =
+        unsafe { GlesRenderer::new(egl_context) }.map_err(|e| format!("GlesRenderer falló: {e}"))?;
+    println!("      renderer GLES listo.");
+
+    // 6 · Superficie DRM + DrmCompositor por cada salida descubierta.
+    // El renderer GLES se comparte (un solo EGLContext sobre la GPU); cada
+    // salida tiene su propio DrmSurface + DrmCompositor.
+    println!("[6/8] creando la superficie DRM y compositors ({}) …", chosen.len());
+    let allocator =
+        GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT);
+    let exporter = GbmFramebufferExporter::new(gbm.clone(), None);
+    let renderer_formats = renderer.dmabuf_formats();
+
+    // 7 · El estado Wayland (Cerebro, teclado, keymap, control).
+    println!("[7/8] armando el estado Wayland …");
+    let Setup { mut display, mut app, watches, ctl } =
+        crate::build_app(greeter)?;
+    // Con el renderer ya creado, anuncia dmabuf — sin esto las apps que
+    // pintan por GPU (GPUI, navegadores acelerados) no pueden conectarse.
+    crate::announce_dmabuf(&mut app, &display.handle(), &renderer);
+
+    // Ordenar las salidas según la config: `(order, name)` ascendente. La de
+    // menor `order` queda **primaria** (origen `(0,0)` y todas las features
+    // gated por primaria — layer-shell, tiling, HUD — viven ahí). Sin
+    // overrides todas son `order=0` y el desempate por nombre da un orden
+    // alfabético estable y reproducible (no el azar de discovery).
+    chosen.sort_by(|a, b| {
+        let oa = app.config_output_order_for(&a.3);
+        let ob = app.config_output_order_for(&b.3);
+        oa.cmp(&ob).then_with(|| a.3.cmp(&b.3))
+    });
+    // Primaria = la primera tras el sort.
+    let out_name = chosen[0].3.clone();
+
+    // Disponer los rects globales en la dirección que pide la config
+    // (horizontal o vertical). El cálculo lo hace `mirada-layout` —cero
+    // geometría a mano— y es la misma función que el día de mañana usará
+    // wawa cuando enumere scanouts.
+    let tamanos: Vec<(i32, i32)> = chosen
+        .iter()
+        .map(|(_, _, mode, _)| {
+            let (w, h) = mode.size();
+            (w as i32, h as i32)
+        })
+        .collect();
+    let disp = app.config_output_disposition();
+    let rects = mirada_brain::disponer(&tamanos, disp);
+
+    // Construir un OutputCtx por cada salida en el orden ya decidido.
+    let mut output_ctxs: Vec<OutputCtx> = Vec::with_capacity(chosen.len());
+    for (i, (conn_handle, crtc_h, mode, name)) in chosen.iter().cloned().enumerate() {
+        let (w, h) = mode.size();
+        let surface = drm
+            .create_surface(crtc_h, mode, &[conn_handle])
+            .map_err(|e| format!("create_surface[{name}] falló: {e}"))?;
+        // Scale/transform por salida — overrides de la config o defaults.
+        // Para `OutputModeSource::Static` (geometría DRM) hace falta el
+        // factor decimal; el announce al protocolo usa el enum de smithay
+        // (Integer/Fractional) para no romper clientes viejos.
+        let scale_120 = app.config_output_scale_120_for(&name);
+        let transform = app.config_output_transform_for(&name);
+        let scale_f64 = (if scale_120 > 0 { scale_120 } else { 120 }) as f64 / 120.0;
+        let mode_source = OutputModeSource::Static {
+            size: Size::from((w as i32, h as i32)),
+            scale: Scale::from(scale_f64),
+            transform,
+        };
+        let comp: Compositor = DrmCompositor::new(
+            mode_source,
+            surface,
+            None,
+            allocator.clone(),
+            exporter.clone(),
+            [Fourcc::Argb8888, Fourcc::Xrgb8888],
+            renderer_formats.clone(),
+            drm.cursor_size(),
+            Some(gbm.clone()),
+        )
+        .map_err(|e| format!("DrmCompositor::new[{name}] falló: {e}"))?;
+        let refresh_mhz = mode.vrefresh() as i32 * 1000;
+        let smithay_out = crate::announce_output(
+            &display.handle(),
+            &name,
+            w as i32,
+            h as i32,
+            refresh_mhz,
+            scale_120,
+            transform,
+        );
+        // Brain: cada salida es un id incremental con su tamaño local.
+        let ev = app.body.add_output(i as u32, w as i32, h as i32);
+        app.brain_feed(ev);
+        let rect = rects[i];
+        // Wallpaper resuelto por salida (override por nombre o global).
+        let wp_path = app.config_wallpaper_path_for(&name);
+        let wp_fit = app.config_wallpaper_fit_for(&name);
+        println!("      compositor de «{name}» listo · rect global {rect:?}");
+        output_ctxs.push(OutputCtx {
+            name,
+            output: smithay_out,
+            crtc: crtc_h,
+            compositor: comp,
+            rect,
+            refresh_mhz,
+            wallpaper: None,
+            wallpaper_path: wp_path,
+            wallpaper_fit: wp_fit,
+            pending_flip: false,
+        });
+    }
+
+    // Espacio global del escritorio: union de todos los rects ya dispuestos.
+    let env = mirada_brain::envolvente(&rects);
+    let total_w = env.w.max(1);
+    let total_h = env.h.max(1);
+    app.output_size = (total_w, total_h);
+    // El puntero arranca centrado en la salida primaria (no en el centro
+    // del escritorio global) — más predecible cuando hay varios monitores.
+    let primary_rect = output_ctxs[0].rect;
+    app.pointer_loc = (
+        (primary_rect.x + primary_rect.w / 2) as f64,
+        (primary_rect.y + primary_rect.h / 2) as f64,
+    );
+    // Primary `smithay::Output` para layer-shell, xdg-output handlers, etc.
+    app.output = Some(output_ctxs[0].output.clone());
+    app.outputs = output_ctxs.iter().map(|c| c.output.clone()).collect();
+
+    // El socket Wayland por el que se conectan los clientes.
+    let listener = ListeningSocket::bind_auto("wayland", 1..32)?;
+    let socket_name = listener
+        .socket_name()
+        .and_then(|s| s.to_str())
+        .unwrap_or("wayland-?")
+        .to_string();
+    std::env::set_var("WAYLAND_DISPLAY", &socket_name);
+    println!("      escuchando en WAYLAND_DISPLAY={socket_name}");
+
+    // Modo DM: lanza el greeter y recibe su tiquet por un canal de
+    // `calloop`. Modo normal: autoarranque + `MIRADA_STARTUP`.
+    let greeter_rx = if app.mode == BodyMode::Greeter {
+        let (tx, rx) = ticket_channel::<SessionTicket>();
+        crate::spawn_greeter(move |ticket| {
+            let _ = tx.send(ticket);
+        })?;
+        Some(rx)
+    } else {
+        // Autoarranque: los programas de `~/.config/mirada/autostart`.
+        // Modo no-greeter: ya corremos con la identidad del usuario, sin
+        // traspaso, así que no hay entorno de sesión que inyectar.
+        crate::spawn_autostart(None, &[]);
+        // App de arranque: si `MIRADA_STARTUP` trae un comando, se lanza
+        // como hijo (hereda `WAYLAND_DISPLAY`) — cómodo para probar sin
+        // saltar de VT.
+        if let Ok(cmd) = std::env::var("MIRADA_STARTUP") {
+            crate::spawn_command(&cmd, None, &[]);
+        }
+        None
+    };
+
+    // 8 · El bucle `calloop`: VBlank, teclado, clientes y un timer.
+    println!("[8/8] montando el bucle de eventos …");
+    let mut event_loop: EventLoop<DrmState> =
+        EventLoop::try_new().map_err(|e| format!("calloop falló: {e}"))?;
+    let handle = event_loop.handle();
+
+    // Sesión: pausa/activación al conmutar de VT.
+    handle
+        .insert_source(session_notifier, |event, _, state: &mut DrmState| match event {
+            SessionEvent::PauseSession => state.pause_session(),
+            SessionEvent::ActivateSession => state.resume_session(),
+        })
+        .map_err(|e| format!("insert session: {e}"))?;
+
+    // VBlank: el page-flip de un CRTC terminó. Rutearlo a la salida que lo
+    // posee. Si llega para un CRTC desconocido (no debería en estado estable)
+    // se ignora silenciosamente.
+    handle
+        .insert_source(drm_notifier, |event, _meta, state| match event {
+            DrmEvent::VBlank(crtc) => {
+                if let Some(idx) = state.output_index_by_crtc(crtc) {
+                    let ctx = &mut state.outputs[idx];
+                    if let Err(e) = ctx.compositor.frame_submitted() {
+                        eprintln!(
+                            "mirada-compositor · frame_submitted[{}]: {e}",
+                            ctx.name
+                        );
+                    }
+                    ctx.pending_flip = false;
+                }
+            }
+            DrmEvent::Error(e) => eprintln!("mirada-compositor · DRM: {e}"),
+        })
+        .map_err(|e| format!("insert drm: {e}"))?;
+
+    // Hotplug: udev notifica cuando un monitor se conecta/desconecta o
+    // cuando aparece/desaparece una GPU. En `Changed` reenumeramos los
+    // conectores y reconciliamos `outputs` (crear OutputCtx para los
+    // recién enchufados, dropear los desenchufados). Los eventos
+    // `Added`/`Removed` de GPU completa se logean: el compositor hoy
+    // sirve una sola GPU, cambiar la primaria pide reiniciar.
+    let udev = match smithay::backend::udev::UdevBackend::new(&seat_name) {
+        Ok(u) => u,
+        Err(e) => {
+            eprintln!("mirada-compositor · UdevBackend no arranca ({e}); sin hotplug");
+            return Err(format!("insert drm: udev: {e}").into());
+        }
+    };
+    handle
+        .insert_source(udev, |event, _meta, state| {
+            use smithay::backend::udev::UdevEvent;
+            match event {
+                UdevEvent::Added { device_id, path } => {
+                    eprintln!(
+                        "mirada-compositor · hotplug · GPU añadida: {} ({device_id:?}); ignorada (multi-GPU pendiente)",
+                        path.display()
+                    );
+                }
+                UdevEvent::Changed { device_id: _ } => state.detect_connector_changes(),
+                UdevEvent::Removed { device_id, .. } => {
+                    eprintln!(
+                        "mirada-compositor · hotplug · GPU retirada ({device_id:?}); ignorada (multi-GPU pendiente)"
+                    );
+                }
+            }
+        })
+        .map_err(|e| format!("insert udev: {e}"))?;
+
+    // Teclado y ratón vía libinput. Guardamos un clon del contexto (es
+    // un manejador con contador de referencias) para suspenderlo y
+    // reanudarlo al conmutar de VT.
+    let mut libinput = Libinput::new_with_udev(LibinputSessionInterface::from(session.clone()));
+    libinput
+        .udev_assign_seat(&seat_name)
+        .map_err(|()| "libinput: no pude asignar el seat")?;
+    let libinput_handle = libinput.clone();
+    handle
+        .insert_source(LibinputInputBackend::new(libinput), |event, _meta, state| {
+            state.handle_input(event);
+        })
+        .map_err(|e| format!("insert libinput: {e}"))?;
+
+    // Clientes Wayland nuevos.
+    handle
+        .insert_source(
+            Generic::new(listener, Interest::READ, CalloopMode::Level),
+            |_readiness, listener, state| {
+                while let Some(stream) = listener.accept()? {
+                    eprintln!("mirada-compositor · cliente Wayland conectado.");
+                    let _ = state
+                        .display
+                        .handle()
+                        .insert_client(stream, Arc::new(ClientState::default()));
+                }
+                Ok(PostAction::Continue)
+            },
+        )
+        .map_err(|e| format!("insert socket: {e}"))?;
+
+    // Peticiones de los clientes ya conectados.
+    let poll_fd = display.backend().poll_fd().try_clone_to_owned()?;
+    handle
+        .insert_source(
+            Generic::new(poll_fd, Interest::READ, CalloopMode::Level),
+            |_readiness, _fd, state| {
+                let DrmState { display, app, .. } = state;
+                if let Err(e) = display.dispatch_clients(app) {
+                    eprintln!("mirada-compositor · dispatch: {e}");
+                }
+                let _ = display.flush_clients();
+                Ok(PostAction::Continue)
+            },
+        )
+        .map_err(|e| format!("insert display: {e}"))?;
+
+    // Timer de composición + tareas — ~60 Hz.
+    handle
+        .insert_source(Timer::immediate(), |_instant, _meta, state| {
+            state.tick();
+            TimeoutAction::ToDuration(Duration::from_millis(16))
+        })
+        .map_err(|e| format!("insert timer: {e}"))?;
+
+    // Tiquet del greeter (modo DM): al llegar, el traspaso a la sesión.
+    // El hilo lector del greeter despierta el bucle por este canal.
+    if let Some(rx) = greeter_rx {
+        handle
+            .insert_source(rx, |event, _, state: &mut DrmState| {
+                if let TicketEvent::Msg(ticket) = event {
+                    state.app.complete_greeter_handoff(ticket);
+                }
+            })
+            .map_err(|e| format!("insert greeter: {e}"))?;
+    }
+
+    // Tope de tiempo opcional: `MIRADA_DRM_TIMEOUT=<segundos>` cierra el
+    // compositor solo (0 o sin definir = sin tope). El teclado ya
+    // funciona — `Super+Shift+e` o `Ctrl+C` son la salida normal.
+    let timeout_secs: u64 = std::env::var("MIRADA_DRM_TIMEOUT")
+        .ok()
+        .and_then(|v| v.parse().ok())
+        .unwrap_or(0);
+
+    println!("──────────────────────────────────────────────────");
+    println!("mirada-compositor · escritorio en marcha sobre «{out_name}».");
+    println!("   Lanza un cliente:  WAYLAND_DISPLAY={socket_name} foot");
+    println!("   Salir: Super+Shift+e  ·  o Ctrl+C en esta TTY.");
+    if timeout_secs > 0 {
+        println!("   Se cerrará solo a los {timeout_secs}s (MIRADA_DRM_TIMEOUT=0 lo quita).");
+    }
+
+    let font_path = app.config_font_path();
+    let menu_entries = app.config_menu();
+    let zones = app.config_zones();
+    // Lista de presets: el 0 es `config.zones`, luego los de `zone_presets`.
+    let mut zone_presets = vec![zones.clone()];
+    zone_presets.extend(app.config_zone_presets());
+    let dh = display.handle();
+    let mut state = DrmState {
+        app,
+        session: session.clone(),
+        display,
+        drm,
+        gbm: gbm.clone(),
+        allocator: allocator.clone(),
+        exporter: exporter.clone(),
+        renderer_formats: renderer_formats.clone(),
+        dh,
+        outputs: output_ctxs,
+        renderer,
+        libinput: libinput_handle,
+        active: true,
+        watches,
+        ctl,
+        start: Instant::now(),
+        last_windows: 0,
+        cursor_id: Id::new(),
+        last_pointer_window: None,
+        output_size: (total_w as f64, total_h as f64),
+        text: {
+            let t = crate::text::TextRenderer::system(font_path.as_deref());
+            if t.is_some() {
+                println!("mirada-compositor · fuente de etiquetas cargada.");
+            } else {
+                eprintln!("mirada-compositor · sin fuente para etiquetas; no pinto títulos.");
+            }
+            t
+        },
+        text_cache: std::collections::HashMap::new(),
+        menu_entries,
+        root_menu: None,
+        menu_output_idx: None,
+        zones,
+        zone_presets,
+        active_preset: 0,
+        drag_zone: None,
+        preset_hud_until: None,
+        preset_hud_label: String::new(),
+    };
+
+    let signal = event_loop.get_signal();
+    event_loop
+        .run(None, &mut state, |state| {
+            let timed_out =
+                timeout_secs > 0 && state.start.elapsed() > Duration::from_secs(timeout_secs);
+            if !state.app.running || timed_out {
+                if timed_out {
+                    println!("mirada-compositor · tope de tiempo — cerrando.");
+                }
+                signal.stop();
+            }
+        })
+        .map_err(|e| format!("el bucle de eventos falló: {e}"))?;
+
+    // Sesión ajena pendiente: soltamos TODO —`drop(state)` cierra el
+    // dispositivo DRM y `drop(event_loop)` libera el último clon de la
+    // sesión libseat (cede el seat)— y recién entonces ejecutamos el otro
+    // compositor, que ya puede tomar la GPU.
+    let pending = state.app.pending_session.take();
+    drop(state);
+    drop(event_loop);
+    if let Some((cmd, user)) = pending {
+        crate::exec_session(&cmd, user.as_ref());
+    }
+
+    println!("mirada-compositor · adiós.");
+    Ok(())
+}
diff --git a/02_ruway/mirada/mirada-compositor/src/main.rs b/02_ruway/mirada/mirada-compositor/src/main.rs
new file mode 100644
index 0000000..c759e59
--- /dev/null
+++ b/02_ruway/mirada/mirada-compositor/src/main.rs
@@ -0,0 +1,2572 @@
+//! `mirada-compositor` — el Cuerpo del compositor carmen.
+//!
+//! Un compositor Wayland teselante real, sobre `smithay`, con backend
+//! `winit`: corre **anidado** como una ventana dentro de tu sesión
+//! gráfica actual (X11 o Wayland). Habla el protocolo Wayland con los
+//! clientes, compone sus superficies y aplica la geometría que decide el
+//! Cerebro.
+//!
+//! Dos modos:
+//!
+//! - **Autónomo** (por defecto): lleva un [`Desktop`] embebido — es un
+//!   compositor teselante completo en un solo proceso. Lánzalo y abre
+//!   clientes; el teclado (`Super+…`) maneja el escritorio.
+//! - **Enlazado** (`MIRADA_SOCKET=/ruta`): el Cuerpo escucha ahí y la
+//!   app `mirada` (el Cerebro GPUI) se conecta; la geometría viaja por
+//!   [`mirada_link`].
+//!
+//! Cómo probarlo en un Linux real: ver `crates/apps/mirada-compositor/README.md`.
+
+use std::sync::Arc;
+use std::time::Instant;
+
+use smithay::backend::allocator::dmabuf::Dmabuf;
+use smithay::backend::input::{InputEvent, KeyState, KeyboardKeyEvent};
+use smithay::backend::renderer::element::surface::{
+    render_elements_from_surface_tree, WaylandSurfaceRenderElement,
+};
+use smithay::backend::renderer::element::solid::SolidColorBuffer;
+use smithay::backend::renderer::element::Kind;
+use smithay::backend::renderer::gles::GlesRenderer;
+use smithay::backend::renderer::utils::{
+    draw_render_elements, on_commit_buffer_handler, with_renderer_surface_state,
+};
+use smithay::backend::renderer::{Color32F, Frame, ImportDma, Renderer};
+use smithay::backend::winit::{self, WinitEvent};
+use smithay::input::keyboard::{xkb, FilterResult, KeyboardHandle, Keysym, ModifiersState};
+use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData, PointerHandle};
+use smithay::input::{Seat, SeatHandler, SeatState};
+use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
+use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
+use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason};
+use smithay::reexports::wayland_server::protocol::wl_buffer;
+use smithay::reexports::wayland_server::protocol::wl_output;
+use smithay::reexports::wayland_server::protocol::wl_seat;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::{Client, Display, DisplayHandle, ListeningSocket};
+use smithay::reexports::winit::platform::pump_events::PumpStatus;
+use smithay::utils::{Logical, Point, Rectangle, SERIAL_COUNTER};
+use smithay::utils::{Serial, Transform};
+use smithay::backend::egl::EGLDevice;
+use smithay::wayland::buffer::BufferHandler;
+use smithay::wayland::dmabuf::{
+    DmabufFeedbackBuilder, DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier,
+};
+use smithay::wayland::compositor::{
+    with_states, with_surface_tree_downward, CompositorClientState, CompositorHandler,
+    CompositorState, SurfaceAttributes, TraversalAction,
+};
+use smithay::wayland::selection::data_device::{
+    ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
+};
+use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState};
+use smithay::wayland::selection::SelectionHandler;
+use smithay::wayland::shell::xdg::decoration::{XdgDecorationHandler, XdgDecorationState};
+use smithay::wayland::shell::xdg::{
+    PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState,
+    XdgToplevelSurfaceData,
+};
+use smithay::wayland::output::{OutputHandler, OutputManagerState};
+use smithay::wayland::shell::wlr_layer::{
+    KeyboardInteractivity, Layer, LayerSurface as WlrLayerSurface, LayerSurfaceData,
+    WlrLayerShellHandler, WlrLayerShellState,
+};
+use smithay::wayland::shm::{ShmHandler, ShmState};
+use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface, WindowSurfaceType};
+use smithay::output::Output;
+use smithay::{
+    delegate_compositor, delegate_data_control, delegate_data_device, delegate_dmabuf,
+    delegate_layer_shell, delegate_output, delegate_seat, delegate_shm, delegate_xdg_decoration,
+    delegate_xdg_shell,
+};
+
+use auth_core::{SessionTicket, UserInfo};
+use mirada_body::{BodyOp, BodyState};
+use mirada_brain::{
+    BodyEvent, BrainCommand, CtlReply, CtlRequest, CtlServer, Desktop, Keymap, Rules,
+};
+use mirada_link::BodyLink;
+
+mod drm_backend;
+mod menu;
+mod text;
+
+// ---------------------------------------------------------------------
+// Estado
+// ---------------------------------------------------------------------
+
+/// De dónde salen las decisiones de geometría.
+enum Brain {
+    /// El compositor lleva su propio `Desktop` — proceso único.
+    Embedded(Desktop),
+    /// Un Cerebro externo (la app `mirada`) por socket.
+    Linked(BodyLink),
+}
+
+/// La fase del ciclo de vida del Cuerpo. Es un eje **ortogonal** a
+/// [`Brain`]: `Brain` dice de dónde sale la geometría; `BodyMode` dice
+/// si el compositor está pidiendo credenciales o sirviendo una sesión.
+/// Un arranque normal nace ya en [`BodyMode::Session`]; un arranque de
+/// DM (`--greeter`) nace en [`BodyMode::Greeter`] y muta una sola vez,
+/// al recibir el tiquet de un login válido — la «mutación atómica».
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum BodyMode {
+    /// Pantalla de login: el único cliente es el greeter, no se
+    /// registran atajos, se rechaza `Spawn` y no hay autoarranque.
+    Greeter,
+    /// Sesión de usuario: el compositor funciona con normalidad.
+    Session,
+}
+
+/// Grosor por defecto de la franja del shell (px), si el entorno no lo fija.
+const SHELL_DOCK_DEFAULT: i32 = 40;
+
+/// El borde de la salida al que se acopla la franja del shell (el marco `pata`).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum ShellAnchor {
+    Top,
+    Bottom,
+    Left,
+    Right,
+}
+
+impl ShellAnchor {
+    /// Parsea el valor de `MIRADA_SHELL_ANCHOR`; cae a `Bottom` si no calza.
+    fn parse(s: &str) -> Self {
+        match s.trim().to_ascii_lowercase().as_str() {
+            "top" => Self::Top,
+            "left" => Self::Left,
+            "right" => Self::Right,
+            _ => Self::Bottom,
+        }
+    }
+
+    /// `true` para los bordes horizontales (top/bottom): su grosor es alto.
+    fn es_horizontal(&self) -> bool {
+        matches!(self, Self::Top | Self::Bottom)
+    }
+}
+
+/// Config del acople del shell, resuelta una vez desde el entorno:
+/// - `MIRADA_SHELL_APP_ID` — identidad de la ventana-marco (default `gioser.pata`).
+/// - `MIRADA_SHELL_ANCHOR` — borde (`top`/`bottom`/`left`/`right`, default `bottom`).
+/// - `MIRADA_SHELL_THICKNESS` — grosor en px (default `40`).
+/// - `MIRADA_SHELL_AUTOHIDE` — `1`/`true` para autoesconder el dock: nunca
+///   reserva su franja (las ventanas usan toda la pantalla) y sólo se muestra,
+///   superpuesto, al acercar el puntero al borde anclado.
+struct ShellDock {
+    app_id: String,
+    anchor: ShellAnchor,
+    thickness: i32,
+    autohide: bool,
+}
+
+/// Banda fina (px) del borde anclado que revela el dock autoescondido, y
+/// grosor de la sutil franja-pista que se pinta mientras está oculto.
+const SHELL_REVEAL_BAND: i32 = 3;
+
+/// La config del shell, leída del entorno la primera vez que se consulta.
+fn shell_dock() -> &'static ShellDock {
+    static DOCK: std::sync::OnceLock<ShellDock> = std::sync::OnceLock::new();
+    DOCK.get_or_init(|| {
+        let app_id =
+            std::env::var("MIRADA_SHELL_APP_ID").unwrap_or_else(|_| "gioser.pata".to_string());
+        let anchor = std::env::var("MIRADA_SHELL_ANCHOR")
+            .map(|s| ShellAnchor::parse(&s))
+            .unwrap_or(ShellAnchor::Bottom);
+        let thickness = std::env::var("MIRADA_SHELL_THICKNESS")
+            .ok()
+            .and_then(|s| s.trim().parse::<i32>().ok())
+            .filter(|t| *t > 0)
+            .unwrap_or(SHELL_DOCK_DEFAULT);
+        let autohide = std::env::var("MIRADA_SHELL_AUTOHIDE")
+            .map(|s| matches!(s.trim(), "1" | "true" | "yes" | "on"))
+            .unwrap_or(false);
+        ShellDock {
+            app_id,
+            anchor,
+            thickness,
+            autohide,
+        }
+    })
+}
+
+/// `true` si el `app_id` corresponde al shell: la identidad de `pata` (o el
+/// override de `MIRADA_SHELL_APP_ID`), o el alias legacy `carmen.shell`.
+fn is_shell_app_id(app_id: &str) -> bool {
+    app_id == shell_dock().app_id || app_id == "carmen.shell"
+}
+
+/// El rect `(x, y, w, h)` de la franja del shell sobre una salida `ow×oh` con
+/// grosor `t`, según el borde. Pura — fácil de testear.
+fn shell_strip(anchor: ShellAnchor, ow: i32, oh: i32, t: i32) -> (i32, i32, i32, i32) {
+    match anchor {
+        ShellAnchor::Top => (0, 0, ow, t),
+        ShellAnchor::Bottom => (0, oh - t, ow, t),
+        ShellAnchor::Left => (0, 0, t, oh),
+        ShellAnchor::Right => (ow - t, 0, t, oh),
+    }
+}
+
+/// Las zonas exclusivas `(top, bottom, left, right)` que reserva una franja de
+/// grosor `t` en el borde `anchor` — lo que el teselado debe esquivar. Pura.
+fn shell_insets(anchor: ShellAnchor, t: i32) -> (i32, i32, i32, i32) {
+    match anchor {
+        ShellAnchor::Top => (t, 0, 0, 0),
+        ShellAnchor::Bottom => (0, t, 0, 0),
+        ShellAnchor::Left => (0, 0, t, 0),
+        ShellAnchor::Right => (0, 0, 0, t),
+    }
+}
+
+/// La franja-pista `(x, y, w, h)` que se pinta en el borde mientras el dock
+/// autoescondido está oculto: una banda fina de grosor `band` pegada al borde
+/// anclado, a lo ancho/alto de la franja del dock. Pura.
+fn shell_reveal_band(anchor: ShellAnchor, ow: i32, oh: i32, t: i32, band: i32) -> (i32, i32, i32, i32) {
+    let (sx, sy, sw, sh) = shell_strip(anchor, ow, oh, t);
+    match anchor {
+        ShellAnchor::Top => (sx, 0, sw, band),
+        ShellAnchor::Bottom => (sx, oh - band, sw, band),
+        ShellAnchor::Left => (0, sy, band, sh),
+        ShellAnchor::Right => (ow - band, sy, band, sh),
+    }
+}
+
+/// Decide el próximo estado oculto/visible del dock autoescondido según el
+/// puntero. Asimétrico (con histéresis): si está oculto, sólo se revela al
+/// tocar la banda fina del borde (`edge_band`); si está visible, sólo se
+/// oculta cuando el puntero sale de la franja completa del dock. Pura.
+fn autohide_next_hidden(
+    anchor: ShellAnchor,
+    ow: i32,
+    oh: i32,
+    t: i32,
+    px: i32,
+    py: i32,
+    hidden: bool,
+    edge_band: i32,
+) -> bool {
+    let (sx, sy, sw, sh) = shell_strip(anchor, ow, oh, t);
+    let over_strip = px >= sx && px < sx + sw && py >= sy && py < sy + sh;
+    let at_edge = match anchor {
+        ShellAnchor::Top => py <= edge_band,
+        ShellAnchor::Bottom => py >= oh - edge_band,
+        ShellAnchor::Left => px <= edge_band,
+        ShellAnchor::Right => px >= ow - edge_band,
+    };
+    if hidden {
+        !at_edge
+    } else {
+        !over_strip
+    }
+}
+
+/// Una ventana de cliente que el compositor gestiona.
+struct ManagedWindow {
+    id: u64,
+    toplevel: ToplevelSurface,
+    surface: WlSurface,
+    /// Esquina superior-izquierda de la celda asignada, según el Cerebro.
+    loc: (i32, i32),
+    /// Tamaño de la celda asignada — para centrar la ventana si el
+    /// cliente presenta una superficie más pequeña.
+    size: (i32, i32),
+    visible: bool,
+    /// `true` si flota: se compone por encima de las teseladas.
+    floating: bool,
+    /// `true` si tiene el foco del teclado — pinta el marco resaltado.
+    focused: bool,
+    /// `true` si es la ventana del shell — acoplada al pie, sin teselar.
+    is_shell: bool,
+    /// `true` si está a pantalla completa — no lleva barra de título ni marco.
+    fullscreen: bool,
+    /// Título del cliente — para pintar la etiqueta (barra de título).
+    /// Se actualiza en `title_changed`.
+    title: String,
+    /// Búferes de los 4 lados del marco (arriba, abajo, izq., der.) —
+    /// cada uno con su `Id` estable para el seguimiento de daño.
+    borders: [SolidColorBuffer; 4],
+}
+
+/// Un arrastre de ratón en curso: mueve o redimensiona una ventana.
+struct DragGrab {
+    /// La ventana que se arrastra.
+    id: u64,
+    /// Mover (`Super`+botón izquierdo) o redimensionar (`Super`+derecho).
+    mode: DragMode,
+    /// Posición del puntero al empezar el arrastre.
+    start_pointer: (f64, f64),
+    /// Rectángulo `(x, y, w, h)` de la ventana al empezar.
+    start_rect: (i32, i32, i32, i32),
+}
+
+/// Qué le hace un arrastre a la ventana.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum DragMode {
+    /// Reubicar una ventana **flotante** — la esquina la sigue al puntero.
+    Move,
+    /// Redimensionarla — la esquina inferior-derecha sigue al puntero.
+    Resize,
+    /// Reordenar una ventana **teselada**: la intercambia con la tesela
+    /// bajo el puntero (el Cerebro decide el swap), sin sacarla del teselado.
+    Tile,
+}
+
+/// El estado global del compositor.
+struct App {
+    compositor_state: CompositorState,
+    xdg_shell_state: XdgShellState,
+    shm_state: ShmState,
+    /// Estado de `zwp_linux_dmabuf` — deja que los clientes con GPU
+    /// (apps GPUI, navegadores acelerados) compartan búferes de vídeo.
+    dmabuf_state: DmabufState,
+    seat_state: SeatState<Self>,
+    data_device_state: DataDeviceState,
+    /// Estado de `zwlr_data_control_manager_v1` — lectura/escritura del
+    /// portapapeles SIN robar foco. Sin esto, `wl-paste` (el widget `clipboard`
+    /// de pata lo corre ~1Hz) caía a su fallback: crear una surface de tamaño 0,
+    /// robar el foco de teclado para leer la selección, y destruirla — titilando
+    /// el foco cada segundo. También lo usan cliphist y los gestores de
+    /// portapapeles.
+    data_control_state: DataControlState,
+    seat: Seat<Self>,
+    /// Estado del protocolo `wlr-layer-shell` (barras/fondos/overlays como
+    /// waybar, swaybg, wofi, mako).
+    layer_shell_state: WlrLayerShellState,
+    /// La salida **primaria** — la necesita `layer_map_for_output` para
+    /// arreglar anclajes y zonas exclusivas de los layer surfaces que el
+    /// cliente no ate a un output específico (cae al primario).
+    output: Option<Output>,
+    /// Todas las salidas activas (la primaria es `outputs[0]`). El compositor
+    /// las publica acá tras armarlas, así un layer surface con `output_hint`
+    /// puede mapearse al monitor que el cliente pidió, no siempre al primario.
+    outputs: Vec<Output>,
+    /// Gestor de salidas con `xdg-output` (`zxdg_output_manager_v1`): waybar
+    /// y otras barras lo exigen para conocer nombre/geometría de las salidas.
+    /// Se conserva sólo para mantener vivo el global (de ahí el `allow`).
+    #[allow(dead_code)]
+    output_manager_state: OutputManagerState,
+    keyboard: Option<KeyboardHandle<Self>>,
+    pointer: Option<PointerHandle<Self>>,
+    /// Posición del puntero en coordenadas globales.
+    pointer_loc: (f64, f64),
+    /// Qué cursor pide el cliente enfocado — una superficie suya, un
+    /// cursor con nombre, u oculto. El backend lo pinta en consecuencia.
+    cursor_status: CursorImageStatus,
+    /// Arrastre de ventana en curso (mover o redimensionar con el ratón).
+    drag: Option<DragGrab>,
+    /// Tamaño real de la salida (con la franja del shell incluida) — lo
+    /// fija el backend; sirve para acoplar la ventana del shell.
+    output_size: (i32, i32),
+    /// Con el dock autoescondido (`MIRADA_SHELL_AUTOHIDE`), si está oculto
+    /// ahora. Sin autohide se ignora. El puntero cerca del borde lo alterna.
+    shell_hidden: bool,
+    /// Última reserva publicada `(top, bottom, left, right)` en px — define el
+    /// área de trabajo (salida menos dock/layers). Las zonas se escalan a ella.
+    reserved: (i32, i32, i32, i32),
+
+    /// Ventanas gestionadas, en orden de aparición.
+    windows: Vec<ManagedWindow>,
+    /// La contabilidad del

Parcheqa qatikun, ichaqa kaypi kuchusqa: gitwan committa mañakuy llapanta rikunaykipaq.