tawasuyu

tawasuyu/pata

Declarative desktop frame (bars/dock/tray/widgets), portable Linux/Wawa, on Llimphi.

Rust 281Markdown 34TOML 15JSON 16602 Definiciones

feat: pata standalone — marco de escritorio declarativo (barras/dock/tray/widgets) portable Linux/Wawa (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (11 crates, 0 errores).

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

95bc028eeab35a55ae8bef41f820de73348d5baa · por Sergio · (hace 4 meses) · ver el árbol en este commit

Delta semántico · 539

02_ruway/pata/pata-config/src/lib.rs

02_ruway/pata/pata-config/src/main.rs

02_ruway/pata/pata-core/src/config.rs

02_ruway/pata/pata-core/src/lib.rs

02_ruway/pata/pata-core/src/widget.rs

02_ruway/pata/pata-core/src/wire.rs

02_ruway/pata/pata-host/src/client.rs

02_ruway/pata/pata-host/src/lib.rs

02_ruway/pata/pata-host/src/server.rs

02_ruway/pata/pata-host/tests/roundtrip.rs

02_ruway/pata/pata-llimphi/src/keys.rs

02_ruway/pata/pata-llimphi/src/layer.rs

02_ruway/pata/pata-llimphi/src/lib.rs

02_ruway/pata/pata-llimphi/src/main.rs

02_ruway/pata/pata-llimphi/src/nouser.rs

02_ruway/pata/pata-llimphi/src/render.rs

02_ruway/pata/pata-llimphi/src/render/sidebar.rs

02_ruway/pata/pata-llimphi/src/shuma.rs

02_ruway/pata/pata-llimphi/src/toplevel.rs

02_ruway/pata/pata-llimphi/src/tray.rs

37 archivos · +18266 −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/pata/LEEME.md b/02_ruway/pata/LEEME.md
new file mode 100644
index 0000000..cac846d
--- /dev/null
+++ b/02_ruway/pata/LEEME.md
@@ -0,0 +1,19 @@
+# pata
+
+> El marco del escritorio: barras, paneles y dock declarativos — widgets que
+> colocás donde quieras, desde un archivo de config. El mismo modelo en Linux y
+> en Wawa.
+
+`pata` (quechua: *borde, repisa, andén*) es la capa de chrome del escritorio
+gioser. No es el compositor (`mirada`) ni el shell (`shuma`): es el marco
+configurable que rodea a las ventanas. Desde un archivo desplegás **barras**,
+**paneles** y un **dock**, y dentro acomodás widgets — botón inicio, lista de
+ventanas abiertas, clipboard / volumen / brillo, tray, reloj, un widget
+**astro** (posición zodiacal del sol + ciclo lunar) y el input del shell que
+despliega `shuma` estilo Quake.
+
+El modelo vive en `pata-core`, agnóstico y `no_std`, así que el mismo marco
+corre como frontend Llimphi en Linux (sobre el compositor `mirada`) y desde el
+kernel launcher de Wawa.
+
+Definición canónica y plan por fases: [`SDD.md`](SDD.md).
diff --git a/02_ruway/pata/README.md b/02_ruway/pata/README.md
new file mode 100644
index 0000000..9b9cdad
--- /dev/null
+++ b/02_ruway/pata/README.md
@@ -0,0 +1,37 @@
+# pata
+
+> The desktop frame: declarative bars, panels and a dock — widgets you place
+> anywhere, from one config file. The same model on Linux and on Wawa.
+
+`pata` (Quechua: *edge, ledge, terrace*) is the chrome layer of the gioser
+desktop. It is **not** the compositor (`mirada`) nor the shell (`shuma`): it is
+the configurable frame that surrounds the windows. From a config file you deploy
+**bars**, **panels** and a **dock**, and inside them arrange widgets — start
+button, open-window list, clipboard / volume / brightness, tray, clock, an
+**astro** widget (the Sun's zodiac position + lunar cycle), and the shell input
+that unfolds `shuma` Quake-style.
+
+The model lives in `pata-core`, agnostic and `no_std`, so the very same frame
+runs as a Llimphi frontend on Linux (over the `mirada` compositor) and from the
+Wawa kernel launcher.
+
+See [`SDD.md`](SDD.md) for the canonical definition and the phase plan.
+
+## Crates
+
+| Crate | Role |
+|---|---|
+| [`pata-core`](pata-core/) | Agnostic model + layout: `Config → [Surface] → slots → [WidgetSpec]` and `resolve(config, screen) → Frame`. `no_std + alloc`. |
+| [`pata-config`](pata-config/) | Linux loader (std): reads the user's TOML from XDG paths into the model. Ships the `pata` inspector binary. |
+
+(`pata-llimphi` rendering and the Wawa launcher land in later phases.)
+
+## Try it
+
+```sh
+cargo run -p pata-config --bin pata -- \
+  --config 02_ruway/pata/pata-config/assets/launcher.toml --screen 1920x1080
+```
+
+Prints how the frame resolves: each surface's rect, whether it reserves a strip,
+its widgets per slot, and the work area left for windows.
diff --git a/02_ruway/pata/SDD.md b/02_ruway/pata/SDD.md
new file mode 100644
index 0000000..ff952cb
--- /dev/null
+++ b/02_ruway/pata/SDD.md
@@ -0,0 +1,536 @@
+# SDD — `pata`, el marco del escritorio
+
+> Estado: **Fase 8b** (layer-shell sobre wlroots: barras, Quake, clicks,
+> `window_list`). Este documento es la fuente autoritativa de qué es `pata` y
+> dónde termina, por encima de README.
+
+## 0. El problema que resuelve
+
+El escritorio de gioser tenía el concepto de "launcher" **triplicado y mal
+delimitado**: `mirada-launcher-llimphi` (la barra), `shuma-shell-llimphi` (un
+chasis con tabs) y `shuma-module-launcher` (un módulo lista-de-apps) competían
+por el mismo rol sin una frontera clara. Correr cualquiera bajo el compositor
+daba ventanas sueltas en vez de un escritorio coherente.
+
+`pata` fija la frontera: es **una sola capa**, la del *marco* (chrome) del
+escritorio, desacoplada del compositor y del shell, y portable entre Linux y
+wawa.
+
+## 1. Las tres capas (no se solapan)
+
+| Capa | Quechua/es | Qué es | Qué **no** es |
+|---|---|---|---|
+| **mirada** | mirar | El **compositor**: el Cuerpo Wayland/DRM. Tesela, acopla franjas, decora, enruta input. | No dibuja barras ni widgets. |
+| **shuma** | — | El **shell**: input + terminal + módulos. Se asoma como una barra inferior auto-escondible; al escribir, despliega el resto estilo Quake. | No es el chrome del escritorio; es un inquilino del marco. |
+| **pata** | borde, repisa, andén | El **marco**: barras, paneles y dock declarados desde un archivo de config, con widgets colocables en cualquier slot. Hospeda el input de shuma. | No compone ventanas (eso es mirada) ni ejecuta comandos (eso es shuma). |
+
+Regla mnemónica: **mirada** pone las ventanas, **pata** pone el marco
+alrededor, **shuma** es la boca por la que le hablás al sistema.
+
+## 2. Forma del dominio
+
+```
+pata-core      Modelo agnóstico (no_std + alloc): el esquema declarativo
+               (Config → [Surface] → slots → [WidgetSpec]) + el layout
+               (resolve: config+pantalla → superficies colocadas + work_area).
+               No pinta, no toca el SO. Cruza a wawa por `path`, como mirada-layout.
+pata-config    Loader Linux (std): lee el TOML del usuario desde las rutas XDG y
+               lo materializa en el modelo. El límite std→no_std del marco. Trae
+               el binario `pata` para inspeccionar la geometría resuelta. En wawa
+               este rol lo cumple akasha, no este crate.
+pata-llimphi   Frontend Linux: monta pata-core sobre Llimphi. Cada Surface es
+               una ventana Wayland que el compositor mirada acopla; despacha
+               los widgets builtin; el shuma_input despliega shuma. (Fase 3,
+               hereda de mirada-launcher-llimphi.)
+pata (wawa)    El kernel launcher de wawa consume el MISMO pata-core y pinta
+               sobre el framebuffer. (Fase 7.)
+```
+
+UIs intercambiables sobre un `*-core` agnóstico — la regla dura del repo. El
+modelo se escribe una vez; Linux y wawa son dos pinceles.
+
+## 3. El modelo (`pata-core`)
+
+- **`Config`** = `general` + `Vec<Surface>`. Múltiples superficies, no un único
+  panel. El usuario despliega tantas barras/paneles/docks como quiera.
+- **`Surface`** = `kind` (Bar | Panel | Dock) + `anchor` (Top/Bottom/Left/Right)
+  + `thickness` + `autohide` + tres slots `start`/`center`/`end` (+ `cards`
+  para paneles). Cada slot es una lista ordenada de widgets.
+- **`WidgetSpec`** = `kind: String` (conjunto **abierto**) + `props` arbitrarias.
+  El frontend despacha por string y cae a un placeholder si no conoce el kind:
+  agregar un widget no toca el core.
+- **`Prop`** = `Bool | Int | Num | Str`. Valor de propiedad **agnóstico del
+  formato en disco** — ni `toml::Value` ni nada atado a una plataforma. El
+  loader de cada SO (TOML en Linux, akasha en wawa) deserializa a esto.
+
+El formato en disco no es parte del modelo: en Linux un loader TOML deserializa
+directo a estos tipos vía `serde` (contrato fijado en `tests/toml_contract.rs`);
+en wawa el config llega por akasha.
+
+## 4. Widgets builtin previstos
+
+`start_button` · `window_list` (ventanas abiertas, vía mirada-ctl/-link) ·
+`clipboard` · `volume` · `brightness` · `tray` · `clock` · medidores
+(`ram_meter`/`cpu_meter`) · **`astro`** (posición zodiacal del sol + ciclo
+lunar, reusando `cosmos-ephemeris`) · `shuma_input` (el cabezal del shell).
+
+Cada uno se coloca libremente: superficie + slot se eligen desde el config.
+
+## 5. Integración con shuma (el Quake)
+
+El `shuma_input` es un widget que vive típicamente en una `Surface { kind: Bar,
+anchor: Bottom, autohide: true }`. Muestra el cabezal del shell; al recibir
+foco/escritura, el frontend **anima el despliegue** del resto de shuma sobre el
+escritorio (drawer estilo Quake) y lo repliega al soltar. El marco provee el
+borde; shuma provee el contenido.
+
+## 6. Estado y plan por fases
+
+- **Fase 1 ✅** — `pata-core` config: esquema declarativo, `Prop` agnóstico,
+  contrato TOML, `no_std` verificado (wasm32). En el workspace.
+- **Fase 2 ✅** — `pata-core::layout`: resuelve config+pantalla en superficies
+  colocadas + `work_area` (lo que mirada tesela). Geometría pura testeada.
+- **Fase 3 ✅** — `pata-config`: loader TOML/XDG → modelo + binario `pata`
+  inspector. Pipeline config→layout verificado sobre archivos reales.
+- **Fase 4 ✅** — modelo de widget agnóstico (`pata-core::widget`): trait
+  [`Widget`] (`tick(&WidgetCtx)` / `view() → WidgetView`), un `WidgetCtx` que el
+  host muestrea (reloj, cpu, ram, volumen, brillo) y un view-model
+  `Text | Meter | Placeholder | Empty` sin pincel. Builtins con lógica portada:
+  `clock` (strftime reducido), `cpu_meter` / `ram_meter` / `volume` /
+  `brightness` (medidor genérico). `build(spec)` despacha por string y cae a
+  `Placeholder` para kinds no implementados. `no_std` verificado (wasm32); el
+  inspector `pata --widgets` lo muestra de punta a punta.
+- **Fase 5 ✅** — `pata-llimphi`: el frontend Linux. `sampler` muestrea el
+  sistema (chrono + `/proc/stat` + `/proc/meminfo` + `/sys/class/backlight`) en
+  un `WidgetCtx`; `render` traduce cada `WidgetView` a `View<Msg>` (texto,
+  medidor con barra, placeholder tenue) y coloca las superficies en los rects
+  que el layout resolvió (posición absoluta). `PataApp` (app-id `gioser.pata`)
+  carga config vía `pata-config`, `tick`ea a 1 Hz y pinta. Por ahora una sola
+  ventana; mirada acopla por superficie en la Fase 8.
+- **Fase 6 (parcial)** — widgets nuevos:
+  - `astro` ✅ — posición zodiacal del Sol (signo + grado) + fase lunar. La
+    efeméride la computa el sampler (host) y la entrega en `WidgetCtx`
+    (`sun_longitude_deg` + `moon_phase`); el widget de core sólo mapea grados→
+    signo y fracción→fase, con aritmética entera (no_std). El sampler usa la
+    fórmula de baja precisión del *Astronomical Almanac*; `cosmos-ephemeris`
+    es el upgrade drop-in cuando se quiera alta precisión.
+  - `start_button` ✅ — muestra su `label` (default `⊞`). Cablear su acción
+    (abrir el lanzador) espera al ruteo de clicks (Fase 7).
+  - `window_list` ✅ (en layer-shell) — lista de ventanas abiertas vía el
+    protocolo `wlr-foreign-toplevel-management` (el que usan waybar/eww), no por
+    IPC de mirada. Ver el detalle en la Fase 8b. Bajo el compositor `mirada` (el
+    path winit) sigue vacío hasta que mirada exponga sus toplevels.
+  - `tray` ⏳ — StatusNotifierItem; diferido. Placeholder por ahora.
+- **Fase 7 ✅** — despliegue Quake de shuma desde `shuma_input`. El frontend
+  intercepta el kind `shuma_input` (es interacción, no pasa por el `build`
+  agnóstico de core, igual que mirada con su shuma_bar): un cabezal clicable en
+  la barra + hotkey (`keys`) despliegan un **drawer** animado (`llimphi-motion`,
+  scrim que cierra al click + panel inferior que crece con el tween) que captura
+  el teclado. El estado vive en `Model::shuma`, no en core. La ejecución del
+  comando es, estrictamente, de `shuma`: mientras no haya puente, `shuma::
+  ejecutar_stand_in` corre por `sh -c` como **sustituto temporal** (patrón de
+  mirada) — se reemplaza sin tocar el mecanismo del drawer.
+  - **Puente real + cards (✅)** — el drawer corre por `shuma-exec` (no `sh -c`
+    pelado): historial de *cards* (`$ cmd` + etapas + salida + código), plegables,
+    con scroll. **Captura por etapa (tee, paridad con el shell de shuma):** un
+    pipe «simple» (sólo comandos/args/flags y `|`, sin comillas/variables/
+    redirecciones/globs/`~`) corre por `Exec::Direct` con `capture_stages`; cada
+    etapa **intermedia** emite su stdout en vivo (`StageStdout`) y se guarda en
+    `DrawerBlock::stage_lines`. Clickear la chip de una etapa intermedia **revela
+    su salida capturada inline** (sin re-ejecutar); la última etapa no se captura
+    aparte (su stdout es el cuerpo de la card). Cualquier otra sintaxis cae a
+    `sh -c` (sin tee). Detección en `shuma::simple_pipe_stages` (espeja
+    `shuma-module-shell`), testeada.
+  - **Submit a IA (✅, paridad con el quake de mirada-launcher)** — el buffer sin
+    prefijo va al **LLM** (`pluma-llm::from_env`, cae a Mock sin credenciales); el
+    prefijo `!`/`$` lo fuerza a shell. `shuma::classify` decide (`Empty`/`Shell`/
+    `Ia`, testeada); las consultas IA abren una card `✦ <prompt>` sin chips de
+    etapa que muestra `…pensando` y luego la respuesta. El resultado llega por el
+    mismo `ShumaResult`/`finish_last` que un comando. Es el último gap que tenía
+    `mirada-launcher-llimphi` sobre pata de cara a la Fase 10.
+- **Fase 8 ✅** — `mirada-compositor` reconoce el marco `pata`:
+  - Identidad: el viejo `SHELL_APP_ID = "carmen.shell"` → `is_shell_app_id`, que
+    matchea `gioser.pata` (la identidad que anuncia `pata-llimphi`) o el alias
+    legacy `carmen.shell`, override por `MIRADA_SHELL_APP_ID`.
+  - Anclaje/grosor configurables (`MIRADA_SHELL_ANCHOR` / `MIRADA_SHELL_THICKNESS`,
+    defaults bottom/40), ya no una franja fija de 40px al pie. Geometría en
+    helpers puros testeados (`shell_strip` / `shell_insets`).
+  - **Zonas exclusivas en los cuatro bordes**: el acople ya no encoge la salida,
+    sino que reserva *insets* (top/bottom/left/right) vía el evento nuevo
+    `BodyEvent::OutputReserved` → `Body::reserve_output` → el Cerebro guarda
+    `Output::reserved` y tesela sobre `Output::work_rect()` (rect menos insets).
+    El motor de layout ya respetaba `screen.x/y`, así que top/left desplazan el
+    origen del teselado correctamente. Soporta barras en varios bordes a la vez.
+    Cerrar el shell libera la reserva (insets en cero).
+- **Fase 8b (en curso)** — `pata` como **layer surface** (nivel eww/waybar) en
+  compositores wlroots (Hyprland, Sway, river), no como ventana cliente. Sin
+  esto, en Hyprland pata abría como ventana flotante; el acople de la Fase 8 era
+  sólo para el compositor `mirada`, no para terceros.
+  - `llimphi-hal::RawSurface` — `Surface` sobre una `wgpu::Surface` creada desde
+    handles raw, **sin `winit::Window`** (misma intermedia + blit que
+    `WinitSurface`). Es la costura: el render de Llimphi ya era winit-free salvo
+    la creación de la surface.
+  - `pata-llimphi::layer` — backend `wlr-layer-shell` con
+    `smithay-client-toolkit`: crea **una layer surface por cada superficie
+    `Bar`** de la config (cada una anclada a su borde + `set_exclusive_zone`),
+    saca su `wgpu::Surface` de los punteros `wl_display`/`wl_surface`, y la pinta
+    reusando `mount → compute → paint → render` vía [`render::bar_view`]. Un
+    `Hal` (instancia/device de wgpu) compartido; estado wgpu por panel
+    (`PanelGpu`). Muestreo 1Hz compartido + flag `dirty` por panel (no
+    re-rasteriza a 60fps). `main` elige layer-shell si hay `WAYLAND_DISPLAY`
+    (salvo `PATA_BACKEND=winit`), con fallback a la ventana winit.
+    Verificado en runtime (Hyprland): salen todas las barras ancladas, sin
+    error, y el muestreo/leyenda quietos.
+    - **Gotcha Vulkan WSI + smithay (mirada)** — `draw` redimensiona la surface
+      en cada cuadro (no hay evento de resize como en winit), así que
+      `RawSurface::resize` es **no-op cuando el tamaño no cambia**: reconfigurar
+      el swapchain por cuadro reconstruye el `wl_buffer` y destruye el recién
+      presentado antes de que el compositor lo componga — wlroots lo tolera,
+      smithay (mirada) no, y la barra quedaba negra (`buffer=None`). `acquire`
+      reconfigura+reintenta una vez ante `Outdated`/`Lost`. Fix en `b8747b90`.
+  - **Input + Quake** ✅ (verificado en Hyprland): seat/keyboard/pointer
+    vía sctk. Un cliente layer-shell **no recibe hotkeys globales**, así que el
+    Quake se abre con **click** en la barra de shuma (foco de teclado vía
+    `OnDemand` → al abrir pasa a `Exclusive`). En vez de una segunda surface, la
+    propia barra de shuma **crece hacia arriba** hasta `DRAWER_H` (su exclusive
+    zone queda en el grosor de la barra, así no recoloca el teselado);
+    `render::shuma_open_view` pinta el cuerpo del drawer (input + salida) arriba
+    y el cabezal abajo. Teclado con foco: Esc cierra, Backspace, Enter ejecuta
+    (`shuma::ejecutar_stand_in`, `sh -c` bloqueante), texto → buffer.
+  - **Clicks por hit-test** ✅ — cada panel guarda su árbol pintado
+    (`RenderCache`: `Mounted` + `ComputedLayout`); al click, `hit_test_click`
+    ubica el nodo bajo el puntero y dispara su `on_click` (vía `handle_msg`). El
+    cabezal `› shuma` togglea con precisión (abre y cierra); clickear el reloj o
+    un medidor no hace nada. Reemplaza al "cualquier click en la barra abre".
+  - **Acciones por widget** ✅ — cualquier widget con una prop `exec` se vuelve
+    clickeable (estilo waybar): `SlotWidget::Core { widget, exec }` lleva el
+    comando; el render le pone `on_click(Msg::Spawn(cmd))` + `hover_fill`; ambos
+    backends lo lanzan con `spawn_cmd` (`sh -c`, sin esperar). Ej. en el asset:
+    `start_button` con `exec = "wofi --show drun"`.
+  - **Exec asíncrono del Quake** ✅ — el `Enter` corre el comando en un hilo y el
+    resultado llega por un `mpsc::Receiver` que el latido sondea (`try_recv`) cada
+    frame; ya no bloquea el loop. Mientras corre, el drawer muestra `…`.
+  - **Volumen real** ✅ — el sampler lee el volumen del sink por defecto vía
+    PipeWire (`wpctl get-volume`) con fallback a PulseAudio (`pactl`), y rellena
+    `WidgetCtx::{volume, muted}` (el brillo ya lo lee de `/sys`). El medidor deja
+    de marcar 0%. Parseo en funciones puras testeadas (`parse_wpctl`/
+    `parse_pactl_pct`). Bonus en el asset: `volume` con `exec = "pavucontrol"`.
+  - **`window_list`** ✅ — la lista de ventanas abiertas, vía
+    `wlr-foreign-toplevel-management` (`wayland-protocols-wlr`), el protocolo de
+    waybar/eww. El manager se bindea opcional (si el compositor no lo expone, el
+    widget queda vacío sin romper); cada toplevel acumula título/app_id/estado en
+    `pata-llimphi::toplevel::Toplevel` y se confirma en `done`. El render pinta un
+    chip clickeable por ventana (la activa resaltada); el click manda
+    `Msg::ActivateWindow(id)` → `activate(seat)`, que la trae al frente. Como el
+    `shuma_input`, es interacción + IPC: no pasa por el `build` agnóstico de core
+    sino que lo intercepta el frontend (`SlotWidget::WindowList`); los datos se
+    pasan al render aparte del view-model. El asset `launcher.toml` ya lo tiene en
+    el centro de la barra superior.
+  - **`clipboard`** ✅ — preview del texto copiado. El sampler lo lee con
+    `wl-paste --no-newline --type text/plain` (subproceso ~1Hz, como el volumen
+    con `wpctl`) y lo colapsa a una línea (`sampler::preview_clipboard`, testeada);
+    el render pinta `📋 <preview>` recortado. Como `window_list`, es dato del host
+    interceptado por el frontend (`SlotWidget::Clipboard`), no view-model de core;
+    los datos del host (ventanas + portapapeles) viajan juntos en `render::BarData`.
+    Una prop `exec` lo vuelve clickeable → selector de historial (en el asset:
+    `cliphist list | wofi --dmenu | cliphist decode | wl-copy`). Sólo en
+    layer-shell (el path winit pasa `BarData::default()`).
+  - **`tray`** ✅ — la bandeja del sistema (StatusNotifierItem). pata corre como
+    **watcher + host**: posee `org.kde.StatusNotifierWatcher` y atiende a las apps
+    que registran su item. Como el bucle sctk es bloqueante y zbus es async, el
+    tray vive en su **propio hilo** con un runtime tokio current-thread (el
+    workspace fija zbus con la feature `tokio`, no la blocking — patrón de
+    `mirada-portal`); comparte el snapshot de items por `Arc<Mutex>` y recibe los
+    clicks por un canal tokio (como el exec del Quake). El render pinta un chip por
+    item (resaltando `NeedsAttention`); el click manda `Msg::TrayActivate(key)` →
+    `Activate(0,0)` por D-Bus. Interceptado por el frontend (`SlotWidget::Tray`),
+    los items viajan en `render::BarData`. **Íconos** ✅: resuelve el `IconPixmap`
+    (ARGB32 por D-Bus → RGBA, sin tema) y, si no, el `IconName` como PNG en los
+    dirs estándar (hicolor + pixmaps, sólo PNG, sin `index.theme` ni SVG); cae a
+    texto si nada resuelve. El hilo del tray decodifica a `TrayIcon{rgba}` y el
+    render lo envuelve en `peniko::Image` (`View::image`, 18px). **No** emite
+    señales del watcher ni hace fallback si ya hay un watcher (si el nombre está
+    tomado, queda vacío y loguea). `split_service`
+    normaliza el registro (ruta+remitente / nombre de bus / combinado), testeada.
+    El tray sólo arranca si la config declara un widget `tray`. Ver `02_ruway/pata/
+    pata-llimphi/src/tray.rs`.
+  - **Fase 6 cerrada**: todos los widgets previstos (§4) existen, con íconos
+    reales en el tray. **`clipboard` y `tray` cableados también en el path winit**
+    (el `Model` muestrea el portapapeles cada tick y arranca el `TrayHandle` si la
+    config lo pide; `render::root` arma el `BarData` desde el `Model`). El único
+    pendiente es **`window_list` bajo el path winit/mirada**: necesita el cliente
+    foreign-toplevel (que vive en el backend layer-shell) o el IPC de toplevels de
+    mirada; hasta entonces queda vacío en ese path. Helper `config_tiene_widget`
+    compartido por ambos backends para arrancar el tray sólo si hace falta.
+- **Fase 8c — pulido de escritorio** (en curso):
+  - **Gradiente en los medidores** ✅ — la barra de relleno de cpu/ram/volumen/
+    brillo pinta un gradiente lineal (acento → acento aclarado) con `paint_with`
+    (Llimphi sólo tiene fill de color sólido). Ambos backends.
+  - **Task manager estilo KDE** ✅ — el `window_list` pasa de chips planos a
+    botones con ícono-badge (inicial del `app_id`) + título; la activa resaltada,
+    las minimizadas atenuadas. Clic izq. activa o **minimiza** si ya estaba activa;
+    clic der. **cierra** (`Msg::CloseWindow` → `handle.close()`). `Toplevel`
+    trackea `minimized`; el pointer del layer-shell rutea `BTN_RIGHT`.
+  - **Tarjetas flotantes (conky)** ✅ — `SurfaceKind::Panel` + `FloatingCard` ya
+    estaban en el modelo; ahora `render::card_view` las pinta y el layer-shell crea
+    **una layer surface por tarjeta** en `Layer::Bottom` (sobre el escritorio,
+    anclada a la esquina sup-izq con margen (x,y), sin reservar franja ni teclado).
+    En winit se pintan en absoluto. Asset con una tarjeta `sistema`.
+  - **Botón de inicio con menú nativo** ✅ — el `start_button` despliega un menú
+    de apps del registro (`app-bus AppRegistry::discover`). En layer-shell la barra
+    superior crece hacia abajo (truco del drawer Quake, al revés); en winit sale
+    por `view_overlay`. `exec` en el spec lo deja delegando a un lanzador externo.
+  - **Hover en todos los widgets** ✅ — el layer-shell pasaba `None` a `paint`, así
+    que `hover_fill` estaba muerto; ahora trackea `Motion`/`Leave` →
+    `hit_test_hover` → `hover_idx`. Todos los widgets dan realce al pasar el cursor.
+  - **Tooltip flotante (texto)** ✅ — cada widget lleva su tooltip vía el primitivo
+    nuevo `View::tooltip` de Llimphi (medidores: etiqueta + leyenda; ventanas/tray/
+    clipboard: el texto completo, útil cuando la barra lo recorta). El layer-shell
+    crea una **layer surface dedicada en `Overlay`** con región de input vacía (no
+    roba puntero); al cambiar el nodo hovereado, `update_tooltip` lee texto + rect
+    del cache de hit-test y reubica la surface bajo el widget (`set_margin`/
+    `set_size`); al salir se oculta fuera de vista. Cajita opaca (no depende de
+    transparencia de surface). Runtime a validar en compositor (norma de pata).
+- **Fase 9 ✅** — kernel launcher de wawa sobre `pata-core`. El kernel
+  enlaza `pata-core` por `path` (`default-features = false`, como mirada-layout) y
+  consume el **mismo** modelo de widgets que el frontend Llimphi: `compositor::
+  pata_marco` arma un `WidgetCtx` desde los datos del kernel (la RAM real del heap,
+  vía `memory::allocator::stats`), construye los widgets (`build_all`), los
+  `tick`ea y traduce cada `WidgetView` a las primitivas del framebuffer
+  (`grafico::Lienzo` + `texto::rasterizar`). Hoy pinta el cluster de indicadores
+  del taskbar (medidor de RAM) a la izquierda del reloj — un modelo, dos pinceles,
+  sobre bare-metal. Compila con `cargo +nightly check --target x86_64-unknown-none
+  -Z build-std=core,alloc`; runtime a validar en QEMU (norma de wawa).
+  - **`pata_core::resolve` integrado** — `pata_marco` ahora arma un `Config`
+    (barra de menú superior con `start_button` + `ram_meter`), lo resuelve con la
+    geometría canónica `resolve` (la misma que en Linux) y pinta **cada barra
+    resuelta** con sus tres slots (start izquierda / center centrado / end
+    derecha) en su rect. Se llama desde `consola::recomponer` sobre el área de
+    apps, tras componer el escritorio. El cluster suelto del taskbar se reemplazó
+    por esta barra completa.
+  - **Input al `start_button` cableado** — `pata_marco::start_button_rect(area)`
+    resuelve el mismo `Config` y devuelve el rect clickeable del ⊞ (espejando
+    dónde lo pinta `pintar_barra`); el ratón del compositor (`raton::atender_raton`)
+    detecta el clic ahí y **abre el launcher** (el mismo gesto que `Alt+P`), antes
+    de tocar foco/arrastre. El picker Spotlight ya existente se reusa tal cual.
+  - **Config por akasha** — el config del marco viaja por el grafo
+    direccionado por contenido, no armado en memoria. Como el modelo está afinado
+    para TOML (`WidgetSpec.props` con `flatten`, `Prop` `untagged`) y eso rompe
+    postcard (el codec de akasha, no auto-descriptivo), `pata-core` ganó un espejo
+    **postcard-safe**: `pata_core::wire::WireConfig` (props como lista ordenada,
+    `WireProp` etiquetado), con conversiones sin pérdida `Config ↔ WireConfig`
+    (round-trip por postcard fijado en un test del host). El kernel
+    (`pata_marco::marco`) serializa el default a `WireConfig`, lo **graba en el
+    grafo** (`almacen::almacenar`, BLAKE3 + postcard) y lo **lee de vuelta** —el
+    config hace el round-trip completo por akasha—, con fallback al default y
+    cacheado tras el primer uso.
+  - **Franja reservada** — el compositor ya **reserva** la franja de la barra de
+    menú: `area_apps` (la región que se tesela) descuenta `pata_marco::ALTO_BARRA`
+    además de las franjas de consola/taskbar, así las ventanas tilean **debajo**
+    de la barra (el equivalente al `Frame::work_area` de resolve). `region_barra_
+    marco` deriva la franja una sola vez; el render la pinta ahí y el ratón
+    hit-testea el `start_button` ahí — sin drift entre reservar, pintar y clickear.
+  - **Propuesta de config desde userspace** — la capacidad WASM
+    `sys_marco_proponer(ptr, len)` (en `wasm/env/config.rs`, gateada por
+    `PERMISO_CONFIG` + foco, espejo de `sys_config_proponer`) recibe un
+    `WireConfig` postcard de la app, lo valida, lo graba en el grafo y reemplaza
+    el marco activo (`pata_marco::proponer`) — el config por akasha es
+    bidireccional. El cache es un `Mutex<Config>` que la propuesta reescribe.
+  - Cierre: el launcher de wawa corre sobre el MISMO `pata-core` que Linux —
+    declarativo, resuelto por `resolve`, con widgets, render al framebuffer,
+    input al `start_button`, y config por akasha (lectura + escritura).
+  - **Refino: reserva dinámica ✅** — `area_apps`/`region_barra_marco` ya no usan
+    una constante: leen `pata_marco::alto_reservado()`, la suma de los grosores de
+    las barras `Bar` superiores no-`autohide` del config **resuelto**. Si una app
+    propone (vía `sys_marco_proponer`) una barra de otro alto, la reserva, el
+    render y el hit-test la siguen sin drift.
+  - **Refino: persistir el marco entre reinicios ✅** — el marco activo ahora se
+    ancla en el manifiesto, como `configuracion`/`overlay_revocacion`.
+    `format::Manifiesto` ganó `marco: Option<Hash>` (VERSION_MANIFIESTO 6→7); el
+    génesis (`wawa-boot`) nace con `marco: None`. Al arrancar, `pata_marco::
+    cargar_inicial` lee el marco del manifiesto si está anclado, o siembra el
+    default en el grafo y lo reancla (`manifiesto::enlazar_marco`). `proponer`
+    reancla al nodo nuevo, así un marco propuesto sobrevive al reinicio. Seguro
+    porque el génesis local **no se verifica por firma al boot** (confirmado por
+    el autor) y el operador re-forja la imagen en cada `cargo run -p boot`, así
+    que el bump de versión nace limpio.
+- **Fase 10 ✅** (2026-06-03) — `mirada-launcher-llimphi` **retirado**: pata cubre y
+  excede su rol (shell+tee+IA, task manager KDE, tarjetas conky, menú de inicio
+  nativo, tooltips, reloj UTC). Se borró el crate, se sacó del workspace y se
+  limpiaron las referencias (scripts/install-mirada-dm.sh, APPS.md, README de
+  mirada, REPORTE de shuma, LEEME de launcher-llimphi). El triplete de launchers
+  del §0 queda resuelto: el marco es **una sola capa**, `pata`.
+- **Fase 11 — sidebars acoplables (navegador de Mónadas/archivos)** (en curso):
+  el marco gana un cuarto tipo de superficie, el **Sidebar**, para integrar el
+  plano de datos de `chasqui`/nouser en el escritorio.
+  - **11a ✅ (modelo, `pata-core`)** — `SurfaceKind::Sidebar`: un **rail de
+    dientes** (`llimphi-widget-dock-rail`) anclado a un borde vertical (left/
+    right). Cada diente es un `SidebarTab { icon, label, content: WidgetSpec }`;
+    `content` es típicamente `kind = "navigator"`. Campos nuevos en `Surface`:
+    `tabs: Vec<SidebarTab>` + `panel_width` (el rail usa `thickness`, el panel
+    desplegado flota a su lado con `panel_width`). **Layout:** el rail reserva su
+    grosor como una barra vertical (salvo `autohide`, que flota); el panel que
+    despliega un diente **no** entra en `resolve` —flota sobre el área de trabajo
+    como un drawer de launcher, lo maneja el frontend—. Espejo postcard-safe
+    (`WireTab` + `tabs`/`panel_width` en `WireSurface`) para que viaje por akasha
+    en wawa; round-trip fijado en test. `no_std` (wasm32) verde, 36 tests.
+  - **11b-1 ✅ (protocolo nouser, `chasqui`)** — el navegador necesita el nivel
+    de archivos, y nouser es la **fuente autoritativa** de qué archivos componen
+    una Mónada (no el filesystem por su cuenta — decisión del autor). `chasqui-
+    card::query` gana `QueryRequest::ResolveMonad{id}` + `ResolveMonadResponse{
+    monad, members}` + `FileView` slim; `client::resolve_monad` (round-trip
+    extraído a un `request<R>()` genérico compartido con `list_monads`); el
+    `engine_socket` lo sirve mapeando `db.resolve_members(id) → FileView`. Tests
+    de roundtrip + servidor.
+  - **11b-2 ✅ (widget navegador Llimphi)** — `llimphi-widget-navigator`
+    **reutilizable y data-agnóstico**: bosque de `NavNode{id:u64,label,kind:
+    NavKind,children}` (kind = Monad/Group/Dir/File/Other) + dos modos
+    conmutables `NavMode::{Tree,Graph}`. **Árbol** reusa `llimphi-widget-tree`
+    (icono vectorial por kind, chevron toggle, click select, right-click
+    context). **Grafo** reusa `llimphi-widget-nodegraph` (layout en columnas por
+    profundidad, cables de contención padre→hijo, arrastrar selecciona, nodo
+    seleccionado resaltado por tint). Render-only: el estado (expanded/selected/
+    mode) vive en el caller. `navigator_view(spec, is_expanded, on_toggle,
+    on_select, on_context)`. Demo `navigator_demo` con toggle segmentado; 4
+    tests. El widget no sabe de nouser — lo alimenta pata.
+  - **11c ✅ (path winit, `pata-llimphi`)** — el frontend integra el plano de
+    datos de nouser y pinta el sidebar:
+    - **Plano de datos** (`nouser.rs`): descubre el socket del daemon (broker
+      brahman → fallback al default path, igual que `chasqui-explorer-llimphi`),
+      poll periódico de `list_monads` (2 s) y `resolve_monad` **bajo demanda** al
+      expandir una Mónada (carga perezosa: una Mónada con `cardinality > 0` aún
+      sin resolver lleva un hijo placeholder "…" para mostrar el chevron). El
+      `NavId` se deriva determinista del `MonadId`/path (FNV-1a con tag) para que
+      expansión y selección sobrevivan al re-poll. `NavState` (open/mode/selected/
+      expanded/scroll/roots/targets) vive en el `Model`; queries en thread vía
+      `Handle::spawn` (no bloquean el UI). 7 tests.
+    - **Render** (`render/sidebar.rs`): el rail (`llimphi-widget-dock-rail`) se
+      pinta en el rect que el layout reservó para el Sidebar, un diente por
+      `SidebarTab` (el del panel desplegado va resaltado). El panel **flota**
+      junto al rail (no entra en `resolve`): cabezal con el toggle Árbol/Grafo
+      (`llimphi-widget-segmented`) + el navegador (`llimphi-widget-navigator`)
+      dentro de un área de scroll (`llimphi-widget-scroll`). Clic en diente →
+      despliega/repliega; Esc cierra el panel. Iconos de diente vectoriales por
+      nombre (`monads`/`files`/…). 1 test.
+    - **Config**: el asset `pata-config/assets/launcher.toml` gana un sidebar de
+      ejemplo (`kind = "sidebar"`, un diente `navigator` source=nouser);
+      deserialización fijada en `toml_contract.rs`.
+    - Sólo arranca el poll si la config declara un navegador
+      (`config_tiene_navigator`).
+  - **11c-layer ✅ (runtime sin verificar, `layer.rs`)** — el rail/panel bajo
+    `wlr-layer-shell`, el path de producción en Hyprland:
+    - Una layer surface por Sidebar, anclada al borde vertical con exclusive zone
+      = `thickness`. Al activar un diente la surface **crece en ancho** (a
+      `thickness + panel_width`) manteniendo la exclusive zone, así el panel flota
+      sobre el área de trabajo sin recolocar el teselado — el truco del drawer de
+      shuma, pero en el eje horizontal. `set_sidebar_open` redimensiona +
+      invalida el cache de hit-test; `sidebar_surface_view` (render-fill, en
+      `render/sidebar.rs`) ordena rail+panel según el anclaje.
+    - **Plano de datos**: un hilo poolea `list_monads` cada 2 s y entrega por
+      canal (patrón sampler/exec); `resolve_monad` en hilos one-shot por otro
+      canal; `poll_nav` los drena cada frame sin bloquear. Sólo arranca si la
+      config declara un navegador.
+    - **Clics**: el hit-test del pointer handler ahora cae a `on_click_at`/
+      `on_right_click_at` (con coords locales al nodo) además de `on_click` —
+      necesario para los dientes del rail (que usan `on_click_at` para coexistir
+      con drag); paridad con el bucle winit. Navegación 100% por clic (sin
+      teclado): el panel se cierra re-clickeando el diente.
+    - **Limitación**: el modo **grafo** selecciona por arrastre (el nodegraph usa
+      `draggable`), que el backend layer-shell no rastrea aún → en layer-shell el
+      grafo es de sólo lectura; el modo **árbol** (default) funciona completo.
+      Compila; runtime se itera en el Hyprland del usuario (headless no verifica).
+  - **11d ✅** — abrir un archivo con la app que corresponda. El right-click sobre
+    un archivo (`Msg::NavOpen`) enruta por `open::open_file` (módulo `open.rs`) en
+    ambos backends: deriva el mime de la extensión (tabla acotada `mime_for_path`, sin
+    leer disco), busca una app nativa que lo declare (`app_bus::AppRegistry::
+    handlers_for(mime)` → `AppEntry::open(path)` con sustitución freedesktop
+    `%f`/`%u`), y si ninguna lo maneja cae a `xdg-open` (que respeta las
+    asociaciones del escritorio). Las apps de la suite tienen prioridad sobre el
+    handler del sistema. 3 tests del resolutor de mime. (mirada no expone API de
+    apertura —es WM puro—; spawnear el proceso es la vía, como en
+    `chasqui-explorer`.) Manifiestos de ejemplo de apps reales de la suite en
+    `shared/app-bus/assets/apps/` (`media.toml` para video/audio, `nada.toml` para
+    texto/código); se copian a `~/.config/gioser/apps/`. La decisión de ruteo
+    (`open::handler_for`) es pura y testeada; el formato de manifiesto tiene
+    canario en `app-bus`.
+  - **11d-extra ✅** — menú "Abrir con…" para elegir el handler. El right-click
+    sobre un archivo (`Msg::NavContextMenu`) precomputa sus apps nativas
+    (`open::handlers_for_path`, guardadas en `NavState::menu_options`) y abre un
+    selector **dentro del panel** (no un overlay flotante: así no necesita coords
+    del cursor y funciona idéntico en winit y layer-shell). Cada fila →
+    `Msg::NavOpenWith(id, Some(app_id))`; "el sistema" → `NavOpenWith(id, None)`
+    (xdg-open); "Cancelar"/Esc → `NavMenuCancel`. El render lee sólo `NavState`
+    (decoplado del registro). 2 tests del listado de handlers.
+  - **drag en layer-shell ✅** — el pointer handler del backend layer-shell rastrea
+    un drag mínimo (press→move→release) e invoca el handler `draggable` del nodo
+    bajo el cursor: en `Press` sobre un nodo arrastrable arranca el drag (no lo
+    trata como click), en `Motion` le pasa el delta (`Move`), en `Release` emite
+    `End`. Así el modo **grafo** del navegador selecciona también bajo Wayland (el
+    nodegraph selecciona al soltar) — ya no es de sólo lectura ahí. `LayerDrag`
+    guarda el handler + última posición.
+  - **Pendiente opcional**: discernimiento por contenido (`shuma-discern`) como
+    upgrade del mime por extensión (a costa de leer una muestra del archivo).
+
+- **Fase 12 — rail hospedado (sidebars de apps en el rail global)**:
+  una app puede **delegar su sidebar** al marco: cuando tiene foco, sus "dientes"
+  aparecen en el rail de pata (debajo de los propios) y su ventana queda como puro
+  lienzo; al clickear un diente, el comando vuelve a la app, que muestra ese panel
+  sobre su canvas. pata sólo hospeda el **rail** —no los paneles ricos de la app—.
+  - **Protocolo `pata-host`** (`02_ruway/pata/pata-host`): socket Unix dedicado
+    (`$XDG_RUNTIME_DIR/pata-sidebar.sock`, override `PATA_SIDEBAR_SOCKET`), marco
+    postcard con prefijo de longitud. `AppMsg::{Register{app_id,title,teeth},
+    Update,Bye}` (app→shell) + `ShellMsg::Activate{tooth}` (shell→app);
+    `HostedTooth{id,icon,label}`. `HostServer` (lado pata: acumula registros por
+    `app_id` en hilos lectores; `snapshot`/`activate`/`revision`) + `HostClient`
+    (lado app: registra, hilo lector entrega activaciones por callback, Drop manda
+    Bye). 5 tests.
+  - **Host en `pata-llimphi`** (sólo layer-shell, que conoce el foco): arranca el
+    `HostServer` si hay algún sidebar; `focused_app_id()` = toplevel activo; el
+    rail del sidebar muestra `host.snapshot(app_id)` de la app enfocada (segundo
+    `dock-rail` bajo los dientes de la config, con separador); clic →
+    `HostToothActivate(app_id,tooth)` → `host.activate`. `poll_host` re-pinta al
+    cambiar la revisión del host. El diente hospedado no abre panel ni redimensiona
+    pata: es control remoto del canvas de la app. El hit-test del pointer ya cae a
+    `on_click_at`, que estos dientes usan.
+  - **Integración cosmos** (opt-in `COSMOS_DELEGATE_SIDEBAR`): `app_id()=
+    "gioser.cosmos"`; publica sus `DockItem`s como dientes; `Msg::HostActivate`
+    togglea el panel correspondiente sobre su canvas; en modo delegado no pinta sus
+    rails (`dock_rail_overlay`→None) y un panel aparece sólo si su lado está
+    expandido → sin nada activo, puro canvas.
+  - **Requisitos runtime**: pata corriendo en layer-shell con un sidebar en la
+    config; cosmos lanzado con `COSMOS_DELEGATE_SIDEBAR=1`. Sin verificar headless.
+  - **media y pluma también delegan** (reusan el mismo `pata-host`):
+    - **media** (`MEDIA_DELEGATE_SIDEBAR`, `app_id="gioser.media"`): dientes
+      Config/Cola/Visualizadores/Ayuda; `Msg::HostActivate` despacha los Msgs de
+      toggle existentes (Config/Cola/Ayuda son ventanas/overlay) o togglea el flag
+      de visualizadores. media ya es canvas (no tiene rail propio que ocultar).
+    - **pluma** (`PLUMA_DELEGATE_SIDEBAR`, `app_id="gioser.pluma"`): dientes
+      Documentos/LLM/Buscar/Diff. Cambio **aditivo**: en modo delegado las columnas
+      laterales se vuelven colapsables (`side_izq_visible`/`side_der_visible`; cada
+      lado oculto sale del árbol con su splitter) → editor a pantalla completa;
+      Buscar/Diff reusan su lógica. Sin delegar, el layout de 3 columnas es idéntico.
+    - **shuma** (`SHUMA_DELEGATE_SIDEBAR`, `app_id="shuma.shell"`): un diente por
+      tab (id = índice → `Msg::HostActivate` selecciona esa tab) + un diente
+      "Monitores" (id sentinela `u32::MAX` → togglea el panel derecho). Cambio
+      **aditivo**: en modo delegado el panel de monitores arranca oculto (puro
+      lienzo) y el contenido toma todo el ancho sin splitter; el rail de pata lo
+      despliega. Sin delegar, el panel de monitores siempre se ve (`monitors_visible`
+      arranca según `host.is_none()`). La tira de tabs local sigue visible (el rail
+      es un switcher paralelo).
+  - **shuma embebida (in-process, NO socket)**: cuando el marco hospeda un
+    `shuma_input` (el drawer Quake; `ShumaState::present`), el rail del sidebar
+    suma un **tercer grupo** de dientes (bajo config-teeth y hosted-teeth, con
+    separador): un diente que despliega/repliega el drawer (`Msg::ShumaToggle`).
+    Al vivir en el propio proceso de pata —no detrás del socket `pata-host`—, el
+    diente **refleja el estado real** (`active = shuma.open`, a diferencia de los
+    hospedados, siempre inactivos) y **no depende del foco**: aparece igual en
+    winit y layer-shell mientras la config declare el `shuma_input`. Implementado
+    en `render::sidebar::{shuma_rail, rail_strip}` (firma de `sidebar_rail_view`/
+    `sidebar_surface_view` enhebra `&ShumaState`); icono `shell`/`terminal` =
+    glifo Group. Esto es la contraparte in-process de la delegación por socket:
+    cruzar la frontera de proceso (app aparte) usa `pata-host`; embebido se cablea
+    directo leyendo el `Model`.
+  - **Pendiente opcional**: re-registro de dientes al reordenar el dock (hoy se
+    registran una vez al init; el lado de activación se computa en vivo, así que el
+    drop entre lados sigue funcionando); estado "activo" del diente hospedado (hoy
+    siempre inactivo en pata, lo lleva la app).
diff --git a/02_ruway/pata/pata-config/Cargo.toml b/02_ruway/pata/pata-config/Cargo.toml
new file mode 100644
index 0000000..134a3cf
--- /dev/null
+++ b/02_ruway/pata/pata-config/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "pata-config"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata — la capa de carga (Linux): lee el TOML del usuario desde las rutas XDG y lo materializa en el modelo no_std de pata-core. Es el límite std→no_std del marco; en wawa este rol lo cumple akasha, no este crate. Trae el binario `pata` para inspeccionar cómo resuelve un config sobre una pantalla dada."
+
+[[bin]]
+name = "pata"
+path = "src/main.rs"
+
+[dependencies]
+pata-core = { path = "../pata-core" }
+toml = { workspace = true }
diff --git a/02_ruway/pata/pata-config/assets/launcher.toml b/02_ruway/pata/pata-config/assets/launcher.toml
new file mode 100644
index 0000000..a802b07
--- /dev/null
+++ b/02_ruway/pata/pata-config/assets/launcher.toml
@@ -0,0 +1,112 @@
+# Ejemplo de marco de pata. Copialo a ~/.config/pata/launcher.toml y editá.
+#
+# Un marco es una lista de superficies (bar | panel | dock). Cada una se ancla
+# a un borde (top/bottom/left/right) y reparte widgets en sus slots
+# start/center/end. Las barras sólidas reservan su franja; autohide/dock/panel
+# flotan. Inspeccioná el resultado con:
+#   cargo run -p pata-config --bin pata -- --config este.toml --screen 1920x1080
+
+[general]
+timezone = "auto"   # "auto" lo detecta del sistema; o un IANA como "America/Lima"
+
+# --- Barra superior: el menú clásico tipo waybar/eww ---
+[[surfaces]]
+kind = "bar"
+anchor = "top"
+thickness = 32
+gap = 16
+padding = 12
+
+[[surfaces.start]]
+kind = "start_button"
+label = "⊞"
+# Sin `exec`, el clic abre el menú nativo de apps (descubiertas de
+# ~/.config/gioser/apps/*.toml). Para delegar a un lanzador externo en su lugar,
+# fijá p. ej. `exec = "wofi --show drun"`.
+
+[[surfaces.start]]
+kind = "clock"
+format = "%H:%M"
+
+[[surfaces.center]]
+kind = "window_list"
+
+# El racimo de indicadores a la derecha — orden libre.
+[[surfaces.end]]
+kind = "astro"          # posición zodiacal del sol + ciclo lunar (aporte)
+moon = true
+
+[[surfaces.end]]
+kind = "clipboard"          # preview del texto copiado (vía wl-paste)
+# Click → selector de historial. Ejemplo con cliphist + wofi:
+exec = "cliphist list | wofi --dmenu | cliphist decode | wl-copy"
+
+[[surfaces.end]]
+kind = "volume"
+exec = "pavucontrol"   # click en el medidor → mezclador de audio
+
+[[surfaces.end]]
+kind = "brightness"
+
+[[surfaces.end]]
+kind = "tray"
+
+[[surfaces.end]]
+kind = "ram_meter"
+
+[[surfaces.end]]
+kind = "cpu_meter"
+
+# --- Panel: tarjetas flotantes estilo conky sobre el escritorio ---
+# Un panel no reserva franja: sus tarjetas flotan en (x, y) con tamaño (w, h).
+# En layer-shell cada tarjeta es su propia surface en la capa de fondo.
+[[surfaces]]
+kind = "panel"
+
+[[surfaces.cards]]
+x = 40
+y = 80
+w = 220
+h = 110
+title = "sistema"
+
+[[surfaces.cards.widgets]]
+kind = "cpu_meter"
+
+[[surfaces.cards.widgets]]
+kind = "ram_meter"
+
+# --- Sidebar: rail de dientes acoplable con el navegador de Mónadas (Fase 11) ---
+# Colapsado es sólo el rail (una franja de `thickness` px pegada al borde); al
+# clickear un diente despliega su panel (de `panel_width` px) sobre el escritorio
+# con el navegador de las Mónadas de nouser y sus archivos, conmutable
+# árbol/grafo. Requiere un daemon nouser vivo (descubierto por el broker o por el
+# socket default); sin él, el panel muestra "Conectando con nouser…".
+#
+# Abrir un archivo (right-click) lo enruta a la app nativa que declare su mime, o
+# a `xdg-open` si ninguna lo hace. Para que las apps de la suite lo reciban, copiá
+# sus manifiestos a ~/.config/gioser/apps/ (hay ejemplos en
+# shared/app-bus/assets/apps/: media.toml, nada.toml).
+[[surfaces]]
+kind = "sidebar"
+anchor = "left"
+thickness = 44
+panel_width = 300
+
+[[surfaces.tabs]]
+icon = "monads"
+label = "Mónadas"
+content = { kind = "navigator", source = "nouser" }
+
+# --- Shell: barra inferior autoescondible con el input de shuma ---
+# Al escribir, el frontend despliega el resto de shuma estilo Quake.
+[[surfaces]]
+kind = "bar"
+anchor = "bottom"
+thickness = 40
+autohide = true
+
+[[surfaces.center]]
+kind = "shuma_input"
+hotkey = "F12"
+placeholder = "› preguntá, lanzá, navegá"
diff --git a/02_ruway/pata/pata-config/src/lib.rs b/02_ruway/pata/pata-config/src/lib.rs
new file mode 100644
index 0000000..f427e63
--- /dev/null
+++ b/02_ruway/pata/pata-config/src/lib.rs
@@ -0,0 +1,92 @@
+//! `pata-config` — el loader del marco en Linux.
+//!
+//! `pata-core` es `no_std` y no sabe leer archivos; este crate es el puente al
+//! disco: busca el TOML del usuario en las rutas XDG, lo parsea al modelo y, si
+//! no hay nada, cae al [`Config::preset`]. En wawa este rol lo cumple akasha
+//! —el config llega direccionado por contenido—, no este crate.
+
+use std::path::PathBuf;
+
+pub use pata_core::{layout::resolve, Config, Frame, Rect};
+
+/// Las rutas donde se busca `launcher.toml`, en orden de prioridad:
+/// `$XDG_CONFIG_HOME/pata/` y luego `$HOME/.config/pata/`.
+pub fn candidate_paths() -> Vec<PathBuf> {
+    let mut out = Vec::new();
+    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
+        out.push(PathBuf::from(xdg).join("pata/launcher.toml"));
+    }
+    if let Some(home) = std::env::var_os("HOME") {
+        out.push(PathBuf::from(home).join(".config/pata/launcher.toml"));
+    }
+    out
+}
+
+/// Parsea un TOML al modelo. Error con el detalle de toml si no cuadra.
+pub fn load_from_str(src: &str) -> Result<Config, toml::de::Error> {
+    toml::from_str(src)
+}
+
+/// Carga el marco: el primer `launcher.toml` que parsee gana; si ninguno
+/// existe o todos fallan, devuelve el [`Config::preset`]. Diagnostica por
+/// stderr cuál cargó o por qué cayó al default.
+pub fn load() -> Config {
+    for path in candidate_paths() {
+        match std::fs::read_to_string(&path) {
+            Ok(text) => match load_from_str(&text) {
+                Ok(cfg) => {
+                    eprintln!("pata · cargué {}", path.display());
+                    return cfg;
+                }
+                Err(e) => {
+                    eprintln!("pata · {} no parsea ({e}); intento siguiente", path.display());
+                }
+            },
+            Err(_) => { /* no existe en esta ruta; sigo */ }
+        }
+    }
+    eprintln!("pata · sin launcher.toml; uso el preset");
+    Config::preset()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pata_core::{Anchor, SurfaceKind};
+
+    #[test]
+    fn load_from_str_parsea_dos_superficies() {
+        let cfg = load_from_str(
+            r#"
+            [[surfaces]]
+            anchor = "top"
+            thickness = 30
+
+            [[surfaces.start]]
+            kind = "clock"
+
+            [[surfaces]]
+            kind = "dock"
+            anchor = "bottom"
+            "#,
+        )
+        .unwrap();
+        assert_eq!(cfg.surfaces.len(), 2);
+        assert_eq!(cfg.surfaces[0].anchor, Anchor::Top);
+        assert_eq!(cfg.surfaces[0].start[0].kind, "clock");
+        assert_eq!(cfg.surfaces[1].kind, SurfaceKind::Dock);
+    }
+
+    #[test]
+    fn candidate_paths_respeta_xdg() {
+        // No tocamos el entorno global: sólo verificamos que la función
+        // produce rutas terminadas en pata/launcher.toml cuando hay HOME.
+        let paths = candidate_paths();
+        assert!(paths.iter().all(|p| p.ends_with("pata/launcher.toml")));
+    }
+
+    #[test]
+    fn toml_invalido_es_error_no_panic() {
+        assert!(load_from_str("esto no es toml [[[").is_err());
+    }
+}
diff --git a/02_ruway/pata/pata-config/src/main.rs b/02_ruway/pata/pata-config/src/main.rs
new file mode 100644
index 0000000..6da3e6c
--- /dev/null
+++ b/02_ruway/pata/pata-config/src/main.rs
@@ -0,0 +1,196 @@
+//! `pata` — inspector del marco.
+//!
+//! Carga el `launcher.toml` del usuario (o el preset) y muestra cómo `pata`
+//! resuelve las superficies sobre una pantalla dada: el rect de cada barra/
+//! dock/panel, si reserva franja, sus widgets por slot, y el área de trabajo
+//! que le queda al compositor. Sirve para autorear configs y ver la geometría
+//! sin levantar la UI.
+//!
+//! Con `--widgets` además materializa cada widget y lo `tick`ea con un contexto
+//! de muestra, mostrando el view-model que el frontend recibiría —los `kind`s
+//! que el core aún no implementa salen como `placeholder`—.
+//!
+//! ```sh
+//! cargo run -p pata-config --bin pata -- --screen 1920x1080
+//! cargo run -p pata-config --bin pata -- --config ./mi.toml --screen 2560x1440
+//! cargo run -p pata-config --bin pata -- --widgets
+//! ```
+
+use std::process::ExitCode;
+
+use pata_core::widget::{self, ClockReading, WidgetCtx, WidgetView};
+use pata_core::{Config, Rect, Surface, SurfaceKind, WidgetSpec};
+
+fn main() -> ExitCode {
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    let mut screen = (1920_i32, 1080_i32);
+    let mut config_path: Option<String> = None;
+    let mut mostrar_widgets = false;
+
+    let mut i = 0;
+    while i < args.len() {
+        match args[i].as_str() {
+            "--widgets" => mostrar_widgets = true,
+            "--screen" => {
+                i += 1;
+                match args.get(i).and_then(|s| parse_wxh(s)) {
+                    Some(s) => screen = s,
+                    None => {
+                        eprintln!("--screen espera WxH (ej. 1920x1080)");
+                        return ExitCode::FAILURE;
+                    }
+                }
+            }
+            "--config" => {
+                i += 1;
+                config_path = args.get(i).cloned();
+                if config_path.is_none() {
+                    eprintln!("--config espera una ruta");
+                    return ExitCode::FAILURE;
+                }
+            }
+            "-h" | "--help" => {
+                println!("uso: pata [--config <ruta>] [--screen WxH] [--widgets]");
+                return ExitCode::SUCCESS;
+            }
+            other => {
+                eprintln!("argumento desconocido: {other}");
+                return ExitCode::FAILURE;
+            }
+        }
+        i += 1;
+    }
+
+    let cfg: Config = match &config_path {
+        Some(p) => match std::fs::read_to_string(p) {
+            Ok(text) => match pata_config::load_from_str(&text) {
+                Ok(c) => c,
+                Err(e) => {
+                    eprintln!("no parsea {p}: {e}");
+                    return ExitCode::FAILURE;
+                }
+            },
+            Err(e) => {
+                eprintln!("no puedo leer {p}: {e}");
+                return ExitCode::FAILURE;
+            }
+        },
+        None => pata_config::load(),
+    };
+
+    let (sw, sh) = screen;
+    let frame = pata_config::resolve(&cfg, Rect::new(0, 0, sw, sh));
+
+    println!("pantalla: {sw}×{sh}   ·   zona horaria: {}", cfg.general.timezone);
+    println!("superficies: {}", cfg.surfaces.len());
+    for placed in &frame.surfaces {
+        let s = &cfg.surfaces[placed.index];
+        let r = placed.rect;
+        println!(
+            "  [{}] {:<6} {:<7} {:>4}×{:<4} @ ({:>4},{:>4})  {}",
+            placed.index,
+            kind_str(s.kind),
+            anchor_str(s),
+            r.w,
+            r.h,
+            r.x,
+            r.y,
+            if placed.reserva { "reserva" } else { "flota" },
+        );
+        print_slot("start ", &s.start, mostrar_widgets);
+        print_slot("center", &s.center, mostrar_widgets);
+        print_slot("end   ", &s.end, mostrar_widgets);
+    }
+    let w = frame.work_area;
+    println!(
+        "área de trabajo (ventanas): {}×{} @ ({},{})",
+        w.w, w.h, w.x, w.y
+    );
+
+    ExitCode::SUCCESS
+}
+
+fn print_slot(nombre: &str, widgets: &[WidgetSpec], con_view: bool) {
+    if widgets.is_empty() {
+        return;
+    }
+    let kinds: Vec<&str> = widgets.iter().map(|w| w.kind.as_str()).collect();
+    println!("        {nombre}: {}", kinds.join(" · "));
+    if !con_view {
+        return;
+    }
+    // Materializa cada widget y muéstralo ya `tick`eado con el contexto muestra.
+    let ctx = ctx_muestra();
+    for spec in widgets {
+        let mut w = widget::build(spec);
+        w.tick(&ctx);
+        println!("          {:<14} → {}", spec.kind, render_view(&w.view()));
+    }
+}
+
+/// Un [`WidgetCtx`] de muestra para que `--widgets` enseñe el view-model sin
+/// muestrear el sistema real (eso es trabajo del frontend, no del inspector).
+fn ctx_muestra() -> WidgetCtx {
+    WidgetCtx {
+        clock: ClockReading {
+            year: 2026,
+            month: 6,
+            day: 1,
+            weekday: 1,
+            hour: 14,
+            minute: 7,
+            second: 9,
+        },
+        cpu: 0.42,
+        ram: 0.61,
+        ram_used_mb: 9687,
+        ram_total_mb: 15872,
+        volume: 0.75,
+        muted: false,
+        brightness: 0.55,
+        sun_longitude_deg: 132.0, // Leo 12°
+        moon_phase: 0.5,          // llena
+    }
+}
+
+/// Renderiza un [`WidgetView`] como una línea legible para el inspector.
+fn render_view(v: &WidgetView) -> String {
+    match v {
+        WidgetView::Empty => "·".to_string(),
+        WidgetView::Text(t) => format!("text  «{t}»"),
+        WidgetView::Meter {
+            label,
+            fraction,
+            caption,
+        } => {
+            let etiqueta = label.as_deref().unwrap_or("—");
+            format!("meter [{etiqueta}] {:.0}% «{caption}»", fraction * 100.0)
+        }
+        WidgetView::Placeholder(k) => format!("placeholder ⟨{k}⟩"),
+    }
+}
+
+fn kind_str(k: SurfaceKind) -> &'static str {
+    match k {
+        SurfaceKind::Bar => "bar",
+        SurfaceKind::Panel => "panel",
+        SurfaceKind::Dock => "dock",
+        SurfaceKind::Sidebar => "sidebar",
+    }
+}
+
+fn anchor_str(s: &Surface) -> &'static str {
+    use pata_core::Anchor::*;
+    match s.anchor {
+        Top => "top",
+        Bottom => "bottom",
+        Left => "left",
+        Right => "right",
+    }
+}
+
+/// Parsea `"1920x1080"` (también acepta `X` mayúscula) a `(w, h)`.
+fn parse_wxh(s: &str) -> Option<(i32, i32)> {
+    let (a, b) = s.split_once(['x', 'X'])?;
+    Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
+}
diff --git a/02_ruway/pata/pata-core/Cargo.toml b/02_ruway/pata/pata-core/Cargo.toml
new file mode 100644
index 0000000..159715c
--- /dev/null
+++ b/02_ruway/pata/pata-core/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "pata-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata — el marco declarativo del escritorio: barras, paneles y dock con widgets colocables desde un archivo de config. Núcleo agnóstico (no_std + alloc) que comparten el frontend Llimphi (Linux, sobre el compositor mirada) y el kernel launcher de wawa. No conoce a Llimphi, ni a Wayland, ni al framebuffer: sólo el modelo."
+
+[dependencies]
+# pata-core cruza la frontera a wawa por `path` (como mirada-layout): mantiene
+# sus dependencias autocontenidas y `no_std`. `serde` es opcional —el modelo se
+# construye y consulta sin (de)serializar—; los loaders de cada plataforma
+# (TOML en Linux, akasha en wawa) activan la feature. `default-features = false`
+# evita arrastrar `std` aun con serde activo.
+serde = { version = "1", optional = true, default-features = false, features = ["derive", "alloc"] }
+
+[features]
+default = ["serde"]
+serde = ["dep:serde"]
+
+[dev-dependencies]
+# Sólo para los tests: fija el contrato del config en disco (Linux usa TOML).
+# No entra en la build `no_std` —dev-deps no cruzan a wawa—.
+toml = "0.8"
+# Verifica el round-trip del espejo `wire` por postcard (el codec de akasha en
+# wawa): postcard no soporta flatten/untagged, de ahí el espejo. Sólo en tests.
+postcard = { version = "1", features = ["alloc"] }
diff --git a/02_ruway/pata/pata-core/src/config.rs b/02_ruway/pata/pata-core/src/config.rs
new file mode 100644
index 0000000..a52fcab
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/config.rs
@@ -0,0 +1,491 @@
+//! El esquema declarativo de `pata`.
+//!
+//! El archivo de config manda; el código no asume superficies ni widgets que
+//! el config no nombre. Un [`Config`] es **una lista de [`Surface`]s** —no un
+//! único panel—: el usuario despliega tantas barras, paneles y docks como
+//! quiera, ancla cada uno a un borde, y reparte los widgets en sus slots
+//! (`start` / `center` / `end`) con total libertad.
+//!
+//! El modelo es agnóstico del formato en disco: en Linux un loader TOML
+//! deserializa directo a estos tipos (vía `serde`); en wawa el config llega por
+//! akasha. Los valores de propiedad de cada widget se guardan como [`Prop`],
+//! un enum cerrado y `no_std` —ni `toml::Value` ni nada atado a una
+//! plataforma—.
+
+use alloc::collections::BTreeMap;
+use alloc::string::{String, ToString};
+use alloc::vec;
+use alloc::vec::Vec;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+/// El borde de la pantalla al que se ancla una superficie.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
+pub enum Anchor {
+    /// Pegada al borde superior — la posición clásica de una barra de menú.
+    #[default]
+    Top,
+    /// Pegada al borde inferior — donde suele ir el dock o el shell.
+    Bottom,
+    /// Pegada al borde izquierdo (superficie vertical).
+    Left,
+    /// Pegada al borde derecho (superficie vertical).
+    Right,
+}
+
+impl Anchor {
+    /// `true` si la superficie se extiende horizontalmente (top/bottom): su
+    /// grosor es alto y sus slots se reparten en X. `false` para left/right.
+    pub fn es_horizontal(&self) -> bool {
+        matches!(self, Anchor::Top | Anchor::Bottom)
+    }
+}
+
+/// Qué clase de superficie del marco se está describiendo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
+pub enum SurfaceKind {
+    /// Barra fina pegada a un borde (estilo waybar/eww): tres slots
+    /// (`start` / `center` / `end`) con widgets en línea. Reserva su franja al
+    /// compositor para que las ventanas no la tapen.
+    #[default]
+    Bar,
+    /// Panel: un área donde flotan [`FloatingCard`]s posicionadas en píxeles
+    /// (estilo conky). No reserva franja; vive sobre el escritorio.
+    Panel,
+    /// Dock: lanzadores y/o ventanas abiertas, centrado en su borde y del
+    /// tamaño justo de su contenido. Puede autoesconderse.
+    Dock,
+    /// Sidebar acoplable: un **rail de dientes** vertical pegado a un borde
+    /// (left/right). Cada diente ([`SidebarTab`]) despliega un panel con su
+    /// widget de contenido (lógica de launcher: colapsado = sólo el rail,
+    /// activar un diente despliega su panel sobre el escritorio). El rail
+    /// reserva su grosor como una barra; si `autohide`, no reserva y reaparece
+    /// al rozar el borde. El panel desplegado flota, no reserva.
+    Sidebar,
+}
+
+/// El valor de una propiedad de widget, agnóstico del formato en disco. El
+/// loader de cada plataforma (TOML, RON, akasha) deserializa a esto; los
+/// widgets lo leen con los helpers de [`WidgetSpec`].
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[cfg_attr(feature = "serde", serde(untagged))]
+pub enum Prop {
+    /// Booleano (`true` / `false`).
+    Bool(bool),
+    /// Entero — se prueba antes que [`Prop::Num`] para no perder la
+    /// distinción int/float de formatos como TOML.
+    Int(i64),
+    /// Número de punto flotante.
+    Num(f64),
+    /// Cadena de texto.
+    Str(String),
+}
+
+impl Prop {
+    /// La prop como `&str`, si lo es.
+    pub fn as_str(&self) -> Option<&str> {
+        match self {
+            Prop::Str(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// La prop como número: un [`Prop::Int`] se promueve a `f64`.
+    pub fn as_num(&self) -> Option<f64> {
+        match self {
+            Prop::Num(n) => Some(*n),
+            Prop::Int(i) => Some(*i as f64),
+            _ => None,
+        }
+    }
+
+    /// La prop como booleano, si lo es.
+    pub fn as_bool(&self) -> Option<bool> {
+        match self {
+            Prop::Bool(b) => Some(*b),
+            _ => None,
+        }
+    }
+}
+
+/// Una entrada de widget: `kind` (qué widget) + props arbitrarias. El conjunto
+/// de `kind`s es **abierto** —el frontend despacha por string y cae a un
+/// placeholder si no lo conoce—, así que agregar un widget no toca el core.
+#[derive(Debug, Clone, PartialEq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct WidgetSpec {
+    /// El identificador del widget builtin: `"clock"`, `"volume"`,
+    /// `"astro"`, `"start_button"`, `"window_list"`, `"tray"`,
+    /// `"shuma_input"`, …
+    pub kind: String,
+    /// Props que cada widget interpreta a su gusto (formato del reloj, hotkey
+    /// del quake, etc.). Las claves no reconocidas se conservan.
+    #[cfg_attr(feature = "serde", serde(default, flatten))]
+    pub props: BTreeMap<String, Prop>,
+}
+
+impl WidgetSpec {
+    /// Un widget sin props.
+    pub fn new(kind: impl Into<String>) -> Self {
+        Self {
+            kind: kind.into(),
+            props: BTreeMap::new(),
+        }
+    }
+
+    /// El mismo widget con una prop añadida (encadenable).
+    pub fn with(mut self, key: impl Into<String>, value: Prop) -> Self {
+        self.props.insert(key.into(), value);
+        self
+    }
+
+    /// Lee una prop string. Devuelve `default` si falta o no es string.
+    pub fn str_prop<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
+        self.props.get(key).and_then(Prop::as_str).unwrap_or(default)
+    }
+
+    /// Lee una prop numérica. Devuelve `default` si falta o no es número.
+    pub fn num_prop(&self, key: &str, default: f64) -> f64 {
+        self.props.get(key).and_then(Prop::as_num).unwrap_or(default)
+    }
+
+    /// Lee una prop booleana. Devuelve `default` si falta o no es booleana.
+    pub fn bool_prop(&self, key: &str, default: bool) -> bool {
+        self.props.get(key).and_then(Prop::as_bool).unwrap_or(default)
+    }
+}
+
+/// Una tarjeta posicionada en píxeles dentro de un panel (estilo conky).
+#[derive(Debug, Clone, PartialEq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct FloatingCard {
+    /// Origen X en píxeles desde la esquina superior-izquierda del panel.
+    pub x: f32,
+    /// Origen Y en píxeles.
+    pub y: f32,
+    /// Ancho en píxeles.
+    pub w: f32,
+    /// Alto en píxeles.
+    pub h: f32,
+    /// Título opcional (chip arriba a la izquierda).
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub title: Option<String>,
+    /// Widgets apilados dentro de la tarjeta.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub widgets: Vec<WidgetSpec>,
+}
+
+/// Un diente de un [`SurfaceKind::Sidebar`]: una pestaña vertical del rail que,
+/// al activarse, despliega un panel con su widget de contenido.
+///
+/// El `icon` es un identificador que el frontend mapea a su glifo/dibujo (igual
+/// que el `kind` de un widget: conjunto abierto, cae a un default si no lo
+/// conoce). El `label` rotula el panel desplegado y el tooltip del diente. El
+/// `content` es un [`WidgetSpec`] —típicamente `kind = "navigator"`— que el
+/// frontend pinta en el panel; el modelo no asume qué es.
+#[derive(Debug, Clone, PartialEq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct SidebarTab {
+    /// Identificador del icono del diente (`"files"`, `"monads"`, `"search"`…).
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub icon: String,
+    /// Rótulo del panel desplegado y tooltip del diente.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub label: String,
+    /// El widget que se pinta en el panel cuando el diente está activo.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub content: WidgetSpec,
+}
+
+impl SidebarTab {
+    /// Un diente con icono, rótulo y widget de contenido.
+    pub fn new(icon: impl Into<String>, label: impl Into<String>, content: WidgetSpec) -> Self {
+        Self {
+            icon: icon.into(),
+            label: label.into(),
+            content,
+        }
+    }
+}
+
+/// Una superficie del marco: una barra, un panel, un dock o un sidebar anclado
+/// a un borde.
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct Surface {
+    /// Bar, Panel o Dock.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub kind: SurfaceKind,
+    /// A qué borde se ancla.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub anchor: Anchor,
+    /// Grosor en píxeles: alto para superficies horizontales (top/bottom),
+    /// ancho para verticales (left/right). Un dock puede ignorarlo y crecer
+    /// con su contenido.
+    #[cfg_attr(feature = "serde", serde(default = "default_thickness"))]
+    pub thickness: f32,
+    /// Si `true`, la superficie se esconde y reaparece al hover/hotkey. El
+    /// shell (`shuma_input`) vive típicamente en una barra inferior con esto.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub autohide: bool,
+    /// Padding interno (px) entre el borde y el primer widget.
+    #[cfg_attr(feature = "serde", serde(default = "default_padding"))]
+    pub padding: f32,
+    /// Separación (px) entre widgets adyacentes.
+    #[cfg_attr(feature = "serde", serde(default = "default_gap"))]
+    pub gap: f32,
+    /// Slot inicial: pegado al inicio del eje (izquierda / arriba).
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub start: Vec<WidgetSpec>,
+    /// Slot central: centrado en el eje.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub center: Vec<WidgetSpec>,
+    /// Slot final: pegado al final del eje (derecha / abajo).
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub end: Vec<WidgetSpec>,
+    /// Para `kind = panel`: las tarjetas flotantes que contiene.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub cards: Vec<FloatingCard>,
+    /// Monitor al que anclar la superficie (nombre del conector, ej.
+    /// `"HDMI-A-1"` o `"DP-1"`). Vacío = el compositor elige el primario.
+    /// El backend `wlr-layer-shell` pasa este `wl_output` a
+    /// `create_layer_surface`; si el nombre no matchea ninguno conectado,
+    /// también cae al primario y se loguea un aviso.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub output: String,
+    /// Para `kind = sidebar`: los dientes del rail. Cada uno despliega su panel.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub tabs: Vec<SidebarTab>,
+    /// Para `kind = sidebar`: ancho (px) del panel que despliega un diente. El
+    /// rail mismo usa `thickness`; el panel flota a su lado con este ancho.
+    #[cfg_attr(feature = "serde", serde(default = "default_panel_width"))]
+    pub panel_width: f32,
+}
+
+impl Default for Surface {
+    fn default() -> Self {
+        Self {
+            kind: SurfaceKind::default(),
+            anchor: Anchor::default(),
+            thickness: default_thickness(),
+            autohide: false,
+            padding: default_padding(),
+            gap: default_gap(),
+            start: Vec::new(),
+            center: Vec::new(),
+            end: Vec::new(),
+            cards: Vec::new(),
+            output: String::new(),
+            tabs: Vec::new(),
+            panel_width: default_panel_width(),
+        }
+    }
+}
+
+impl Surface {
+    /// Una barra anclada a `anchor`, con los slots vacíos.
+    pub fn bar(anchor: Anchor) -> Self {
+        Self {
+            kind: SurfaceKind::Bar,
+            anchor,
+            ..Self::default()
+        }
+    }
+
+    /// Un dock anclado a `anchor`.
+    pub fn dock(anchor: Anchor) -> Self {
+        Self {
+            kind: SurfaceKind::Dock,
+            anchor,
+            ..Self::default()
+        }
+    }
+
+    /// Un sidebar acoplable anclado a `anchor` (left/right), con el rail vacío.
+    /// El grosor por defecto es el ancho del rail de dientes.
+    pub fn sidebar(anchor: Anchor) -> Self {
+        Self {
+            kind: SurfaceKind::Sidebar,
+            anchor,
+            thickness: default_rail_thickness(),
+            ..Self::default()
+        }
+    }
+}
+
+/// Settings transversales a todas las superficies.
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct General {
+    /// Zona horaria del reloj. `"auto"` la detecta del sistema; también acepta
+    /// nombres IANA. La sincronización NTP no es de pata: la da el SO.
+    #[cfg_attr(feature = "serde", serde(default = "default_timezone"))]
+    pub timezone: String,
+}
+
+impl Default for General {
+    fn default() -> Self {
+        Self {
+            timezone: default_timezone(),
+        }
+    }
+}
+
+/// El marco completo: settings generales + las superficies a desplegar.
+#[derive(Debug, Clone, PartialEq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct Config {
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub general: General,
+    /// Las superficies del escritorio, en orden de declaración.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub surfaces: Vec<Surface>,
+}
+
+impl Config {
+    /// El marco por defecto cuando no hay config: una barra superior con el
+    /// botón de inicio y el reloj a la izquierda, la lista de ventanas al
+    /// centro, y el racimo de indicadores (astro, clipboard, volumen, brillo,
+    /// tray, medidores) más el input del shell a la derecha; y un dock inferior
+    /// autoescondible. Referencia widgets que aún no existen como builtin —el
+    /// frontend cae a un placeholder— para encodear la visión completa.
+    pub fn preset() -> Self {
+        let mut top = Surface::bar(Anchor::Top);
+        top.start = vec![WidgetSpec::new("start_button"), WidgetSpec::new("clock")];
+        top.center = vec![WidgetSpec::new("window_list")];
+        top.end = vec![
+            WidgetSpec::new("astro"),
+            WidgetSpec::new("clipboard"),
+            WidgetSpec::new("volume"),
+            WidgetSpec::new("brightness"),
+            WidgetSpec::new("tray"),
+            WidgetSpec::new("ram_meter"),
+            WidgetSpec::new("cpu_meter"),
+        ];
+
+        let mut shell = Surface::bar(Anchor::Bottom);
+        shell.autohide = true;
+        shell.thickness = 40.0;
+        shell.center = vec![
+            WidgetSpec::new("shuma_input").with("hotkey", Prop::Str("F12".to_string()))
+        ];
+
+        Self {
+            general: General::default(),
+            surfaces: vec![top, shell],
+        }
+    }
+}
+
+fn default_thickness() -> f32 {
+    32.0
+}
+fn default_padding() -> f32 {
+    12.0
+}
+fn default_gap() -> f32 {
+    16.0
+}
+fn default_rail_thickness() -> f32 {
+    44.0
+}
+fn default_panel_width() -> f32 {
+    280.0
+}
+fn default_timezone() -> String {
+    "auto".to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn anchor_horizontalidad() {
+        assert!(Anchor::Top.es_horizontal());
+        assert!(Anchor::Bottom.es_horizontal());
+        assert!(!Anchor::Left.es_horizontal());
+        assert!(!Anchor::Right.es_horizontal());
+    }
+
+    #[test]
+    fn defaults_de_anchor_y_kind() {
+        assert_eq!(Anchor::default(), Anchor::Top);
+        assert_eq!(SurfaceKind::default(), SurfaceKind::Bar);
+    }
+
+    #[test]
+    fn prop_helpers_y_promocion_int_a_num() {
+        let w = WidgetSpec::new("clock")
+            .with("format", Prop::Str("%H:%M".to_string()))
+            .with("size", Prop::Int(14))
+            .with("ratio", Prop::Num(0.5))
+            .with("flag", Prop::Bool(true));
+        assert_eq!(w.str_prop("format", "?"), "%H:%M");
+        // Int se promueve a f64 al leer como número.
+        assert_eq!(w.num_prop("size", 0.0), 14.0);
+        assert_eq!(w.num_prop("ratio", 0.0), 0.5);
+        assert!(w.bool_prop("flag", false));
+        // Defaults cuando la clave falta o el tipo no calza.
+        assert_eq!(w.str_prop("nope", "def"), "def");
+        assert_eq!(w.num_prop("format", -1.0), -1.0);
+    }
+
+    #[test]
+    fn preset_tiene_barra_top_y_shell_bottom() {
+        let cfg = Config::preset();
+        assert_eq!(cfg.surfaces.len(), 2);
+        let top = &cfg.surfaces[0];
+        assert_eq!(top.anchor, Anchor::Top);
+        assert_eq!(top.kind, SurfaceKind::Bar);
+        assert_eq!(top.start[0].kind, "start_button");
+        assert!(top.end.iter().any(|w| w.kind == "astro"));
+
+        let shell = &cfg.surfaces[1];
+        assert_eq!(shell.anchor, Anchor::Bottom);
+        assert!(shell.autohide);
+        assert_eq!(shell.center[0].kind, "shuma_input");
+        assert_eq!(shell.center[0].str_prop("hotkey", "?"), "F12");
+    }
+
+    #[test]
+    fn config_default_esta_vacio() {
+        // Default (derive) = sin superficies; `preset()` es el marco poblado.
+        let cfg = Config::default();
+        assert!(cfg.surfaces.is_empty());
+        assert_eq!(cfg.general.timezone, "auto");
+    }
+
+    #[test]
+    fn surface_constructores() {
+        assert_eq!(Surface::bar(Anchor::Left).anchor, Anchor::Left);
+        assert_eq!(Surface::dock(Anchor::Bottom).kind, SurfaceKind::Dock);
+    }
+
+    #[test]
+    fn sidebar_lleva_dientes_y_ancho_de_panel() {
+        let mut sb = Surface::sidebar(Anchor::Left);
+        sb.tabs.push(SidebarTab::new(
+            "monads",
+            "Mónadas",
+            WidgetSpec::new("navigator").with("source", Prop::Str("nouser".to_string())),
+        ));
+        sb.tabs
+            .push(SidebarTab::new("files", "Archivos", WidgetSpec::new("navigator")));
+        assert_eq!(sb.kind, SurfaceKind::Sidebar);
+        assert_eq!(sb.anchor, Anchor::Left);
+        // El rail por defecto es fino; el panel desplegado es ancho.
+        assert_eq!(sb.thickness, 44.0);
+        assert_eq!(sb.panel_width, 280.0);
+        assert_eq!(sb.tabs.len(), 2);
+        assert_eq!(sb.tabs[0].icon, "monads");
+        assert_eq!(sb.tabs[0].content.kind, "navigator");
+        assert_eq!(sb.tabs[0].content.str_prop("source", "?"), "nouser");
+    }
+}
diff --git a/02_ruway/pata/pata-core/src/layout.rs b/02_ruway/pata/pata-core/src/layout.rs
new file mode 100644
index 0000000..698cf86
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/layout.rs
@@ -0,0 +1,289 @@
+//! Resolución geométrica del marco: de [`Config`] + pantalla a superficies
+//! colocadas en píxeles + el **área de trabajo** que queda libre.
+//!
+//! Es pura geometría —`no_std`, determinista, sin servidor gráfico—. Dos
+//! consumidores la necesitan:
+//!
+//! - el **frontend** (Llimphi / framebuffer wawa), para saber dónde pintar cada
+//!   barra/dock/panel;
+//! - el **compositor** (`mirada`), para saber qué franja reservar: el
+//!   [`Frame::work_area`] es exactamente el rectángulo donde teselar las
+//!   ventanas, ya descontadas las barras sólidas.
+//!
+//! Reglas de reserva:
+//! - una **Bar** no-`autohide` reserva su grosor del borde y encoge el área;
+//! - una **Bar** `autohide`, un **Dock** y un **Panel** *no* reservan: flotan
+//!   sobre el escritorio (su rect se calcula, pero el área de trabajo no cambia).
+
+use alloc::vec::Vec;
+
+use crate::config::{Anchor, Config, SurfaceKind};
+
+/// Un rectángulo en píxeles de pantalla. Origen `(0,0)` arriba-izquierda; `x`
+/// crece a la derecha, `y` hacia abajo. Propio de `pata` —no depende de
+/// `mirada`— para que el marco sea independiente del compositor.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Rect {
+    pub x: i32,
+    pub y: i32,
+    pub w: i32,
+    pub h: i32,
+}
+
+impl Rect {
+    pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
+        Self { x, y, w, h }
+    }
+
+    /// `true` si tiene ancho y alto positivos.
+    pub fn es_visible(&self) -> bool {
+        self.w > 0 && self.h > 0
+    }
+}
+
+/// Una superficie ya colocada: su índice en `config.surfaces` y su rect.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Placed {
+    /// Índice dentro de [`Config::surfaces`], para recuperar sus widgets.
+    pub index: usize,
+    /// Rectángulo en píxeles donde va la superficie.
+    pub rect: Rect,
+    /// `true` si reservó franja (encogió el área de trabajo).
+    pub reserva: bool,
+}
+
+/// El resultado de resolver el marco: las superficies colocadas y el área que
+/// queda para las ventanas.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Frame {
+    /// Superficies en el mismo orden que `config.surfaces`.
+    pub surfaces: Vec<Placed>,
+    /// Lo que queda libre tras reservar las barras sólidas — donde el
+    /// compositor tesela las ventanas.
+    pub work_area: Rect,
+}
+
+/// La franja de grosor `t` pegada al borde `anchor` de `area`.
+fn strip(area: Rect, anchor: Anchor, t: i32) -> Rect {
+    let t = t.max(0);
+    match anchor {
+        Anchor::Top => Rect::new(area.x, area.y, area.w, t.min(area.h)),
+        Anchor::Bottom => {
+            let t = t.min(area.h);
+            Rect::new(area.x, area.y + area.h - t, area.w, t)
+        }
+        Anchor::Left => Rect::new(area.x, area.y, t.min(area.w), area.h),
+        Anchor::Right => {
+            let t = t.min(area.w);
+            Rect::new(area.x + area.w - t, area.y, t, area.h)
+        }
+    }
+}
+
+/// `area` tras descontarle la franja de grosor `t` del borde `anchor`.
+fn shrink(area: Rect, anchor: Anchor, t: i32) -> Rect {
+    let t = t.max(0);
+    match anchor {
+        Anchor::Top => {
+            let t = t.min(area.h);
+            Rect::new(area.x, area.y + t, area.w, area.h - t)
+        }
+        Anchor::Bottom => Rect::new(area.x, area.y, area.w, (area.h - t).max(0)),
+        Anchor::Left => {
+            let t = t.min(area.w);
+            Rect::new(area.x + t, area.y, area.w - t, area.h)
+        }
+        Anchor::Right => Rect::new(area.x, area.y, (area.w - t).max(0), area.h),
+    }
+}
+
+/// Resuelve el marco sobre una pantalla. Recorre las superficies en orden: las
+/// barras sólidas se apilan reservando franja (la segunda barra del mismo borde
+/// va pegada a la primera); las `autohide`, docks y paneles flotan sin reservar.
+pub fn resolve(config: &Config, screen: Rect) -> Frame {
+    let mut work = screen;
+    let mut surfaces = Vec::with_capacity(config.surfaces.len());
+
+    for (index, s) in config.surfaces.iter().enumerate() {
+        let t = s.thickness as i32;
+        let (rect, reserva) = match s.kind {
+            // Una barra y el rail de un sidebar reservan igual: su grosor pegado
+            // al borde, salvo que sean autohide (entonces flotan sin reservar).
+            // El panel que despliega un diente del sidebar no es parte de
+            // `resolve` —flota sobre el área de trabajo como un drawer de
+            // launcher, lo maneja el frontend—.
+            SurfaceKind::Bar | SurfaceKind::Sidebar => {
+                let r = strip(work, s.anchor, t);
+                if s.autohide {
+                    (r, false)
+                } else {
+                    work = shrink(work, s.anchor, t);
+                    (r, true)
+                }
+            }
+            // Dock: franja pegada al borde del área actual, sin reservar.
+            SurfaceKind::Dock => (strip(work, s.anchor, t), false),
+            // Panel: ocupa el área libre como lienzo de sus tarjetas, sin reservar.
+            SurfaceKind::Panel => (work, false),
+        };
+        surfaces.push(Placed {
+            index,
+            rect,
+            reserva,
+        });
+    }
+
+    Frame {
+        surfaces,
+        work_area: work,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{Surface, WidgetSpec};
+
+    fn pantalla() -> Rect {
+        Rect::new(0, 0, 1920, 1080)
+    }
+
+    #[test]
+    fn barra_top_reserva_su_franja() {
+        let mut cfg = Config::default();
+        let mut top = Surface::bar(Anchor::Top);
+        top.thickness = 32.0;
+        cfg.surfaces.push(top);
+
+        let f = resolve(&cfg, pantalla());
+        assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 1920, 32));
+        assert!(f.surfaces[0].reserva);
+        // El área de trabajo arranca 32px más abajo.
+        assert_eq!(f.work_area, Rect::new(0, 32, 1920, 1048));
+    }
+
+    #[test]
+    fn barra_autohide_no_reserva() {
+        let mut cfg = Config::default();
+        let mut shell = Surface::bar(Anchor::Bottom);
+        shell.thickness = 40.0;
+        shell.autohide = true;
+        cfg.surfaces.push(shell);
+
+        let f = resolve(&cfg, pantalla());
+        // El rect de la barra existe, pegado al pie…
+        assert_eq!(f.surfaces[0].rect, Rect::new(0, 1080 - 40, 1920, 40));
+        assert!(!f.surfaces[0].reserva);
+        // …pero el área de trabajo es la pantalla entera (flota encima).
+        assert_eq!(f.work_area, pantalla());
+    }
+
+    #[test]
+    fn top_solida_mas_shell_autohide_solo_reserva_la_top() {
+        // El caso del preset: barra top sólida + shell inferior autohide.
+        let cfg = Config::preset();
+        let f = resolve(&cfg, pantalla());
+        // top reserva 32; shell no reserva.
+        assert!(f.surfaces[0].reserva);
+        assert!(!f.surfaces[1].reserva);
+        assert_eq!(f.work_area, Rect::new(0, 32, 1920, 1048));
+    }
+
+    #[test]
+    fn dos_barras_top_se_apilan() {
+        let mut cfg = Config::default();
+        let mut a = Surface::bar(Anchor::Top);
+        a.thickness = 24.0;
+        let mut b = Surface::bar(Anchor::Top);
+        b.thickness = 30.0;
+        cfg.surfaces.push(a);
+        cfg.surfaces.push(b);
+
+        let f = resolve(&cfg, pantalla());
+        assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 1920, 24));
+        // La segunda va pegada bajo la primera.
+        assert_eq!(f.surfaces[1].rect, Rect::new(0, 24, 1920, 30));
+        assert_eq!(f.work_area, Rect::new(0, 54, 1920, 1080 - 54));
+    }
+
+    #[test]
+    fn barras_verticales_reservan_ancho() {
+        let mut cfg = Config::default();
+        let mut left = Surface::bar(Anchor::Left);
+        left.thickness = 48.0;
+        cfg.surfaces.push(left);
+
+        let f = resolve(&cfg, pantalla());
+        assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 48, 1080));
+        assert_eq!(f.work_area, Rect::new(48, 0, 1920 - 48, 1080));
+    }
+
+    #[test]
+    fn dock_no_reserva_y_se_pega_al_borde() {
+        let mut cfg = Config::default();
+        cfg.surfaces.push({
+            let mut d = Surface::dock(Anchor::Bottom);
+            d.thickness = 64.0;
+            d
+        });
+        let f = resolve(&cfg, pantalla());
+        assert_eq!(f.surfaces[0].rect, Rect::new(0, 1080 - 64, 1920, 64));
+        assert!(!f.surfaces[0].reserva);
+        assert_eq!(f.work_area, pantalla());
+    }
+
+    #[test]
+    fn panel_ocupa_el_area_libre_sin_reservar() {
+        let mut cfg = Config::default();
+        // Una barra top sólida + un panel: el panel toma el área ya descontada.
+        let mut top = Surface::bar(Anchor::Top);
+        top.thickness = 32.0;
+        cfg.surfaces.push(top);
+        let mut panel = Surface::default();
+        panel.kind = SurfaceKind::Panel;
+        panel.center.push(WidgetSpec::new("ram_meter"));
+        cfg.surfaces.push(panel);
+
+        let f = resolve(&cfg, pantalla());
+        assert_eq!(f.surfaces[1].rect, Rect::new(0, 32, 1920, 1048));
+        assert!(!f.surfaces[1].reserva);
+        assert_eq!(f.work_area, Rect::new(0, 32, 1920, 1048));
+    }
+
+    #[test]
+    fn sidebar_reserva_su_rail_como_una_barra_vertical() {
+        let mut cfg = Config::default();
+        let mut sb = Surface::sidebar(Anchor::Left);
+        sb.thickness = 44.0;
+        cfg.surfaces.push(sb);
+
+        let f = resolve(&cfg, pantalla());
+        // El rail toma una franja vertical fina pegada a la izquierda…
+        assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 44, 1080));
+        assert!(f.surfaces[0].reserva);
+        // …y el área de trabajo arranca 44px a la derecha (el panel desplegado
+        // flota encima, no entra en `resolve`).
+        assert_eq!(f.work_area, Rect::new(44, 0, 1920 - 44, 1080));
+    }
+
+    #[test]
+    fn sidebar_autohide_no_reserva() {
+        let mut cfg = Config::default();
+        let mut sb = Surface::sidebar(Anchor::Right);
+        sb.thickness = 44.0;
+        sb.autohide = true;
+        cfg.surfaces.push(sb);
+
+        let f = resolve(&cfg, pantalla());
+        assert_eq!(f.surfaces[0].rect, Rect::new(1920 - 44, 0, 44, 1080));
+        assert!(!f.surfaces[0].reserva);
+        assert_eq!(f.work_area, pantalla());
+    }
+
+    #[test]
+    fn sin_superficies_el_area_es_la_pantalla() {
+        let f = resolve(&Config::default(), pantalla());
+        assert!(f.surfaces.is_empty());
+        assert_eq!(f.work_area, pantalla());
+    }
+}
diff --git a/02_ruway/pata/pata-core/src/lib.rs b/02_ruway/pata/pata-core/src/lib.rs
new file mode 100644
index 0000000..9f1c848
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/lib.rs
@@ -0,0 +1,47 @@
+//! `pata-core` — el modelo del marco del escritorio.
+//!
+//! `pata` (quechua: borde, repisa, andén) es la capa que dibuja el *marco* del
+//! escritorio: las **barras**, los **paneles** y el **dock** que rodean a las
+//! ventanas, y los **widgets** que viven dentro. No es el compositor (eso es
+//! `mirada`) ni el shell (eso es `shuma`): es el chrome configurable que ambos
+//! mundos comparten.
+//!
+//! Este crate es sólo el **modelo**, deliberadamente tonto y portable:
+//!
+//! - [`config`] — el esquema declarativo. Un [`Config`] es una lista de
+//!   [`Surface`]s (bar/panel/dock), cada una anclada a un borde y con widgets
+//!   colocables en sus slots. Es lo que un archivo de config (TOML en Linux,
+//!   akasha en wawa) materializa.
+//! - [`layout`] — la geometría: resuelve un `Config` + pantalla en superficies
+//!   colocadas en píxeles + el área de trabajo que queda para las ventanas.
+//! - [`widget`] — la lógica de datos de cada widget: un [`config::WidgetSpec`]
+//!   se materializa en un [`widget::Widget`] vivo que, alimentado por un
+//!   snapshot del sistema, emite un view-model agnóstico del pincel.
+//!
+//! No pinta, no toca el SO, no sabe quién lo renderiza. Por eso es `no_std` +
+//! `alloc`: el mismo modelo sirve al frontend Llimphi sobre Linux y al kernel
+//! launcher de wawa (`x86_64-unknown-none`), que aporta su propio allocator.
+//! La regla del repo: si un tipo se comparte entre kernel y userspace, vive
+//! sin `std`.
+
+#![cfg_attr(not(test), no_std)]
+#![forbid(unsafe_code)]
+
+extern crate alloc;
+
+pub mod config;
+pub mod layout;
+pub mod widget;
+/// Espejo postcard-safe del modelo, para el cruce a wawa por akasha. Sólo con la
+/// feature `serde` (el kernel la activa; el camino TOML de Linux no lo necesita).
+#[cfg(feature = "serde")]
+pub mod wire;
+
+pub use config::{
+    Anchor, Config, FloatingCard, General, Prop, SidebarTab, Surface, SurfaceKind, WidgetSpec,
+};
+pub use layout::{resolve, Frame, Placed, Rect};
+pub use widget::{
+    build, build_all, Astro, Clock, ClockReading, Meter, MeterSource, Placeholder, StartButton,
+    Widget, WidgetCtx, WidgetView,
+};
diff --git a/02_ruway/pata/pata-core/src/widget.rs b/02_ruway/pata/pata-core/src/widget.rs
new file mode 100644
index 0000000..9911dd8
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/widget.rs
@@ -0,0 +1,663 @@
+//! El modelo de widget de `pata`: **lógica de datos sin pincel**.
+//!
+//! Un [`WidgetSpec`] (lo que el config declara) se materializa en un objeto
+//! [`Widget`] vivo. El widget no sabe dibujar: cada `tick` refresca su estado a
+//! partir de un [`WidgetCtx`] —un snapshot agnóstico del sistema que el host
+//! muestrea (reloj, CPU, RAM, volumen, brillo…)— y `view` emite un
+//! [`WidgetView`], un view-model que describe *qué* mostrar (texto, medidor,
+//! placeholder) sin decir *cómo*. El frontend (Llimphi en Linux, framebuffer en
+//! wawa) traduce ese view-model a su pincel.
+//!
+//! La frontera está donde tiene que estar: el core es `no_std` y determinista,
+//! así que **no lee el reloj ni los contadores del kernel** —eso son syscalls—.
+//! El host los muestrea y los entrega en el [`WidgetCtx`]; el core sólo formatea
+//! y compone. Misma lógica de datos para los dos mundos; cada uno aporta su
+//! sampler y su pincel.
+
+use alloc::boxed::Box;
+use alloc::format;
+use alloc::string::{String, ToString};
+use alloc::vec::Vec;
+
+use crate::config::WidgetSpec;
+
+/// Lectura del reloj descompuesta. El host la rellena desde su fuente de tiempo
+/// (en Linux, la zona horaria de `general.timezone`); el core sólo la formatea.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct ClockReading {
+    /// Año con siglo (p. ej. `2026`).
+    pub year: u16,
+    /// Mes `1..=12`.
+    pub month: u8,
+    /// Día del mes `1..=31`.
+    pub day: u8,
+    /// Día de la semana, `0` = domingo … `6` = sábado.
+    pub weekday: u8,
+    /// Hora `0..=23`.
+    pub hour: u8,
+    /// Minuto `0..=59`.
+    pub minute: u8,
+    /// Segundo `0..=59`.
+    pub second: u8,
+}
+
+/// El snapshot del sistema que alimenta a los widgets en cada `tick`. El host
+/// lo muestrea (vía sysfs/PulseAudio en Linux, vía el kernel en wawa) y lo pasa
+/// por valor: el core no toca el SO. Todos los campos arrancan en cero, así que
+/// un frontend puede llenar sólo lo que le importe.
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub struct WidgetCtx {
+    /// Hora actual ya descompuesta.
+    pub clock: ClockReading,
+    /// Uso de CPU, fracción `0.0..=1.0`.
+    pub cpu: f32,
+    /// Uso de RAM, fracción `0.0..=1.0`.
+    pub ram: f32,
+    /// RAM usada en MiB (para la leyenda del medidor).
+    pub ram_used_mb: u32,
+    /// RAM total en MiB.
+    pub ram_total_mb: u32,
+    /// Volumen, fracción `0.0..=1.0`.
+    pub volume: f32,
+    /// `true` si el audio está silenciado.
+    pub muted: bool,
+    /// Brillo de pantalla, fracción `0.0..=1.0`.
+    pub brightness: f32,
+    /// Longitud eclíptica del Sol en grados `0..360` — la posición zodiacal. El
+    /// host la computa (en Linux, vía una efeméride; ver `pata-llimphi::sampler`);
+    /// el widget [`Astro`] la mapea a signo + grado.
+    pub sun_longitude_deg: f32,
+    /// Fase lunar como fracción del ciclo sinódico `0.0..=1.0`: `0` = luna nueva,
+    /// `0.5` = llena, de vuelta a `1` = nueva.
+    pub moon_phase: f32,
+}
+
+/// El view-model que un widget emite: describe qué pintar sin atarse a ningún
+/// pincel. El frontend hace el match y lo traduce a su backend gráfico.
+#[derive(Debug, Clone, PartialEq)]
+pub enum WidgetView {
+    /// Nada que pintar (un widget que aún no tiene datos).
+    Empty,
+    /// Una línea de texto: el reloj, una etiqueta.
+    Text(String),
+    /// Un medidor: `fraction` en `0.0..=1.0`, una `caption` ya formateada y una
+    /// `label` opcional (el nombre corto, p. ej. `"CPU"`).
+    Meter {
+        /// Etiqueta corta, o `None` si el widget la oculta.
+        label: Option<String>,
+        /// Fracción `0.0..=1.0` que el frontend pinta como barra/arco.
+        fraction: f32,
+        /// Leyenda ya formateada (`"42%"`, `"3.2G"`, `"muted"`).
+        caption: String,
+    },
+    /// Un widget cuyo `kind` el core no implementa todavía: el frontend pinta un
+    /// chip tenue con este nombre. Permite encodear la visión completa del marco
+    /// (start_button, tray, astro…) antes de que cada widget exista.
+    Placeholder(String),
+}
+
+/// Un widget vivo: refresca su estado en cada `tick` y emite su view-model en
+/// `view`. La lógica de datos vive acá; el dibujo, en el frontend.
+pub trait Widget {
+    /// Refresca el estado interno con el snapshot del sistema.
+    fn tick(&mut self, ctx: &WidgetCtx);
+    /// El view-model actual.
+    fn view(&self) -> WidgetView;
+}
+
+/// Reloj: formatea [`ClockReading`] según una cadena estilo `strftime` reducida.
+///
+/// Tokens soportados (suficientes para una barra): `%H %M %S` (hora/min/seg a
+/// dos dígitos), `%I %p` (12h + AM/PM), `%d %m %Y %y` (día/mes/año), `%%`
+/// (porcentaje literal). Cualquier otro carácter pasa tal cual. No es un
+/// `strftime` completo a propósito: nombres de mes/día localizados los resuelve
+/// `rimay-localize` aguas arriba, no este core.
+#[derive(Debug, Clone)]
+pub struct Clock {
+    format: String,
+    text: String,
+}
+
+impl Clock {
+    /// Construye desde el spec leyendo la prop `format` (default `"%H:%M"`).
+    pub fn from_spec(spec: &WidgetSpec) -> Self {
+        Self {
+            format: spec.str_prop("format", "%H:%M").to_string(),
+            text: String::new(),
+        }
+    }
+}
+
+impl Widget for Clock {
+    fn tick(&mut self, ctx: &WidgetCtx) {
+        self.text = format_time(&self.format, &ctx.clock);
+    }
+
+    fn view(&self) -> WidgetView {
+        if self.text.is_empty() {
+            WidgetView::Empty
+        } else {
+            WidgetView::Text(self.text.clone())
+        }
+    }
+}
+
+/// De dónde saca su valor un [`Meter`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MeterSource {
+    /// Uso de CPU.
+    Cpu,
+    /// Uso de RAM (leyenda en GiB usados/total).
+    Ram,
+    /// Volumen de audio (leyenda `"muted"` si está silenciado).
+    Volume,
+    /// Brillo de pantalla.
+    Brightness,
+}
+
+impl MeterSource {
+    /// La etiqueta corta por defecto de la fuente.
+    fn label_por_defecto(&self) -> &'static str {
+        match self {
+            MeterSource::Cpu => "CPU",
+            MeterSource::Ram => "RAM",
+            MeterSource::Volume => "VOL",
+            MeterSource::Brightness => "BRI",
+        }
+    }
+}
+
+/// Medidor genérico: lee una fracción `0..1` del [`WidgetCtx`] según su
+/// [`MeterSource`] y arma una leyenda. Cubre cpu/ram/volumen/brillo con la
+/// misma lógica; el frontend decide si lo pinta como barra, arco o ícono.
+#[derive(Debug, Clone)]
+pub struct Meter {
+    source: MeterSource,
+    label: Option<String>,
+    fraction: f32,
+    caption: String,
+}
+
+impl Meter {
+    /// Construye un medidor de `source` leyendo del spec:
+    /// - `label` (string): override de la etiqueta corta;
+    /// - `show_label` (bool, default `true`): si es `false`, oculta la etiqueta.
+    pub fn from_spec(source: MeterSource, spec: &WidgetSpec) -> Self {
+        let label = if spec.bool_prop("show_label", true) {
+            Some(
+                spec.str_prop("label", source.label_por_defecto())
+                    .to_string(),
+            )
+        } else {
+            None
+        };
+        Self {
+            source,
+            label,
+            fraction: 0.0,
+            caption: String::new(),
+        }
+    }
+}
+
+impl Widget for Meter {
+    fn tick(&mut self, ctx: &WidgetCtx) {
+        match self.source {
+            MeterSource::Cpu => {
+                self.fraction = ctx.cpu;
+                self.caption = porcentaje(ctx.cpu);
+            }
+            MeterSource::Ram => {
+                self.fraction = ctx.ram;
+                self.caption = leyenda_memoria(ctx.ram_used_mb, ctx.ram_total_mb);
+            }
+            MeterSource::Volume => {
+                self.fraction = ctx.volume;
+                self.caption = if ctx.muted {
+                    "muted".to_string()
+                } else {
+                    porcentaje(ctx.volume)
+                };
+            }
+            MeterSource::Brightness => {
+                self.fraction = ctx.brightness;
+                self.caption = porcentaje(ctx.brightness);
+            }
+        }
+    }
+
+    fn view(&self) -> WidgetView {
+        WidgetView::Meter {
+            label: self.label.clone(),
+            fraction: self.fraction.clamp(0.0, 1.0),
+            caption: self.caption.clone(),
+        }
+    }
+}
+
+/// Los doce signos del zodíaco con su glifo, en orden desde Aries (0°). Los
+/// nombres van en español (regla del repo); el glifo es el símbolo astrológico.
+const SIGNOS: [(&str, &str); 12] = [
+    ("Aries", "♈"),
+    ("Tauro", "♉"),
+    ("Géminis", "♊"),
+    ("Cáncer", "♋"),
+    ("Leo", "♌"),
+    ("Virgo", "♍"),
+    ("Libra", "♎"),
+    ("Escorpio", "♏"),
+    ("Sagitario", "♐"),
+    ("Capricornio", "♑"),
+    ("Acuario", "♒"),
+    ("Piscis", "♓"),
+];
+
+/// Las ocho fases lunares con su glifo, en orden desde la nueva. El índice se
+/// saca de la fracción del ciclo (`moon_phase * 8`, redondeado).
+const FASES_LUNA: [(&str, &str); 8] = [
+    ("Nueva", "🌑"),
+    ("Creciente", "🌒"),
+    ("Cuarto creciente", "🌓"),
+    ("Gibosa creciente", "🌔"),
+    ("Llena", "🌕"),
+    ("Gibosa menguante", "🌖"),
+    ("Cuarto menguante", "🌗"),
+    ("Menguante", "🌘"),
+];
+
+/// Widget astral: la posición zodiacal del Sol (signo + grado) y, opcionalmente,
+/// la fase lunar — el aporte que distingue al marco de gioser. La efeméride la
+/// resuelve el host y la entrega en el [`WidgetCtx`]; este widget sólo mapea
+/// grados a signo y fracción a fase, con aritmética entera (`core` no tiene
+/// `floor`/`round` de punto flotante).
+///
+/// Props:
+/// - `degree` (bool, default `true`): muestra el grado dentro del signo.
+/// - `moon` (bool, default `true`): añade el glifo de la fase lunar.
+/// - `name` (bool, default `true`): muestra el nombre del signo (si no, sólo el
+///   glifo).
+#[derive(Debug, Clone)]
+pub struct Astro {
+    show_degree: bool,
+    show_moon: bool,
+    show_name: bool,
+    text: String,
+}
+
+impl Astro {
+    /// Construye desde el spec leyendo `degree` / `moon` / `name`.
+    pub fn from_spec(spec: &WidgetSpec) -> Self {
+        Self {
+            show_degree: spec.bool_prop("degree", true),
+            show_moon: spec.bool_prop("moon", true),
+            show_name: spec.bool_prop("name", true),
+            text: String::new(),
+        }
+    }
+}
+
+impl Widget for Astro {
+    fn tick(&mut self, ctx: &WidgetCtx) {
+        // Grados enteros normalizados a 0..360 sin usar floor (no_std).
+        let lon = ((ctx.sun_longitude_deg as i32) % 360 + 360) % 360;
+        let (nombre, glifo) = SIGNOS[(lon / 30) as usize % 12];
+        let grado = lon % 30;
+
+        let mut s = String::new();
+        s.push_str(glifo);
+        if self.show_name {
+            s.push(' ');
+            s.push_str(nombre);
+        }
+        if self.show_degree {
+            s.push_str(&format!(" {grado}°"));
+        }
+        if self.show_moon {
+            // Índice 0..7: redondeo entero de moon_phase*8 (la fracción es ≥0).
+            let frac = ctx.moon_phase.clamp(0.0, 1.0);
+            let idx = ((frac * 8.0 + 0.5) as usize) % 8;
+            let (_, luna) = FASES_LUNA[idx];
+            s.push_str(" · ");
+            s.push_str(luna);
+        }
+        self.text = s;
+    }
+
+    fn view(&self) -> WidgetView {
+        if self.text.is_empty() {
+            WidgetView::Empty
+        } else {
+            WidgetView::Text(self.text.clone())
+        }
+    }
+}
+
+/// Botón de inicio: el ancla del menú de aplicaciones. Por ahora sólo muestra su
+/// etiqueta (`label`, default `"⊞"`); cablear su acción —abrir el lanzador—
+/// llega cuando el marco rutee clicks (Fase 7, junto al Quake de shuma).
+#[derive(Debug, Clone)]
+pub struct StartButton {
+    label: String,
+}
+
+impl StartButton {
+    /// Construye desde el spec leyendo la prop `label`.
+    pub fn from_spec(spec: &WidgetSpec) -> Self {
+        Self {
+            label: spec.str_prop("label", "⊞").to_string(),
+        }
+    }
+}
+
+impl Widget for StartButton {
+    fn tick(&mut self, _ctx: &WidgetCtx) {}
+
+    fn view(&self) -> WidgetView {
+        WidgetView::Text(self.label.clone())
+    }
+}
+
+/// Widget de relleno para un `kind` que el core no implementa todavía. Su `view`
+/// es siempre un [`WidgetView::Placeholder`] con el nombre del kind.
+#[derive(Debug, Clone)]
+pub struct Placeholder {
+    kind: String,
+}
+
+impl Placeholder {
+    /// Un placeholder que muestra `kind`.
+    pub fn new(kind: impl Into<String>) -> Self {
+        Self { kind: kind.into() }
+    }
+}
+
+impl Widget for Placeholder {
+    fn tick(&mut self, _ctx: &WidgetCtx) {}
+
+    fn view(&self) -> WidgetView {
+        WidgetView::Placeholder(self.kind.clone())
+    }
+}
+
+/// Materializa un [`WidgetSpec`] en un [`Widget`] vivo. Los `kind`s que el core
+/// ya implementa (reloj y medidores) se construyen con su lógica; el resto cae a
+/// un [`Placeholder`] —el conjunto de kinds es abierto, así que esto nunca
+/// falla—. Los widgets que dependen de IPC o crates externos (`window_list`,
+/// `astro`, `tray`, `shuma_input`) llegan en fases posteriores.
+pub fn build(spec: &WidgetSpec) -> Box<dyn Widget> {
+    match spec.kind.as_str() {
+        "clock" => Box::new(Clock::from_spec(spec)),
+        "cpu_meter" => Box::new(Meter::from_spec(MeterSource::Cpu, spec)),
+        "ram_meter" => Box::new(Meter::from_spec(MeterSource::Ram, spec)),
+        "volume" => Box::new(Meter::from_spec(MeterSource::Volume, spec)),
+        "brightness" => Box::new(Meter::from_spec(MeterSource::Brightness, spec)),
+        "astro" => Box::new(Astro::from_spec(spec)),
+        "start_button" => Box::new(StartButton::from_spec(spec)),
+        _ => Box::new(Placeholder::new(&spec.kind)),
+    }
+}
+
+/// Materializa una lista de specs (un slot completo) de una pasada.
+pub fn build_all(specs: &[WidgetSpec]) -> Vec<Box<dyn Widget>> {
+    specs.iter().map(build).collect()
+}
+
+/// Formatea un [`ClockReading`] con la cadena `fmt` (subconjunto de `strftime`,
+/// ver [`Clock`]).
+fn format_time(fmt: &str, t: &ClockReading) -> String {
+    let mut out = String::with_capacity(fmt.len() + 4);
+    let mut chars = fmt.chars();
+    while let Some(c) = chars.next() {
+        if c != '%' {
+            out.push(c);
+            continue;
+        }
+        match chars.next() {
+            Some('H') => empuja_dos(&mut out, t.hour),
+            Some('M') => empuja_dos(&mut out, t.minute),
+            Some('S') => empuja_dos(&mut out, t.second),
+            Some('I') => {
+                let h12 = match t.hour % 12 {
+                    0 => 12,
+                    h => h,
+                };
+                empuja_dos(&mut out, h12);
+            }
+            Some('p') => out.push_str(if t.hour < 12 { "AM" } else { "PM" }),
+            Some('d') => empuja_dos(&mut out, t.day),
+            Some('m') => empuja_dos(&mut out, t.month),
+            Some('Y') => out.push_str(&format!("{:04}", t.year)),
+            Some('y') => empuja_dos(&mut out, (t.year % 100) as u8),
+            Some('%') => out.push('%'),
+            // Token desconocido: lo dejamos literal (`%` + el carácter).
+            Some(other) => {
+                out.push('%');
+                out.push(other);
+            }
+            None => out.push('%'),
+        }
+    }
+    out
+}
+
+/// Empuja `n` como dos dígitos con cero a la izquierda.
+fn empuja_dos(out: &mut String, n: u8) {
+    out.push_str(&format!("{:02}", n));
+}
+
+/// Una fracción `0..1` como porcentaje entero: `0.42 → "42%"`.
+fn porcentaje(frac: f32) -> String {
+    // `f32::round` vive en `std`; acá (no_std) redondeamos a mano. El valor es
+    // siempre ≥ 0 (fracción clampeada), así que `+ 0.5` y truncar basta.
+    let pct = (frac.clamp(0.0, 1.0) * 100.0 + 0.5) as i32;
+    format!("{}%", pct)
+}
+
+/// Leyenda de memoria `"usado/total"` en GiB con un decimal: `"3.2/15.5G"`.
+fn leyenda_memoria(used_mb: u32, total_mb: u32) -> String {
+    let used = used_mb as f32 / 1024.0;
+    let total = total_mb as f32 / 1024.0;
+    format!("{:.1}/{:.1}G", used, total)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::Prop;
+
+    fn ctx() -> WidgetCtx {
+        WidgetCtx {
+            clock: ClockReading {
+                year: 2026,
+                month: 6,
+                day: 1,
+                weekday: 1,
+                hour: 14,
+                minute: 7,
+                second: 9,
+            },
+            cpu: 0.42,
+            ram: 0.5,
+            ram_used_mb: 3277, // ~3.2 GiB
+            ram_total_mb: 15872,
+            volume: 0.75,
+            muted: false,
+            brightness: 0.3,
+            ..WidgetCtx::default()
+        }
+    }
+
+    #[test]
+    fn reloj_formatea_default_y_segundos() {
+        let mut c = Clock::from_spec(&WidgetSpec::new("clock"));
+        c.tick(&ctx());
+        assert_eq!(c.view(), WidgetView::Text("14:07".to_string()));
+
+        let mut con_seg = Clock::from_spec(
+            &WidgetSpec::new("clock").with("format", Prop::Str("%H:%M:%S".to_string())),
+        );
+        con_seg.tick(&ctx());
+        assert_eq!(con_seg.view(), WidgetView::Text("14:07:09".to_string()));
+    }
+
+    #[test]
+    fn reloj_12h_fecha_y_literales() {
+        let spec = WidgetSpec::new("clock")
+            .with("format", Prop::Str("%d/%m/%Y %I:%M %p".to_string()));
+        let mut c = Clock::from_spec(&spec);
+        c.tick(&ctx());
+        assert_eq!(c.view(), WidgetView::Text("01/06/2026 02:07 PM".to_string()));
+
+        // %% literal y token desconocido pasa tal cual.
+        let mut raro = Clock::from_spec(
+            &WidgetSpec::new("clock").with("format", Prop::Str("%H%% %q".to_string())),
+        );
+        raro.tick(&ctx());
+        assert_eq!(raro.view(), WidgetView::Text("14% %q".to_string()));
+    }
+
+    #[test]
+    fn medianoche_en_12h_es_las_12() {
+        let mut t = ctx();
+        t.clock.hour = 0;
+        let mut c = Clock::from_spec(
+            &WidgetSpec::new("clock").with("format", Prop::Str("%I %p".to_string())),
+        );
+        c.tick(&t);
+        assert_eq!(c.view(), WidgetView::Text("12 AM".to_string()));
+    }
+
+    #[test]
+    fn cpu_meter_emite_fraccion_y_porcentaje() {
+        let mut m = Meter::from_spec(MeterSource::Cpu, &WidgetSpec::new("cpu_meter"));
+        m.tick(&ctx());
+        assert_eq!(
+            m.view(),
+            WidgetView::Meter {
+                label: Some("CPU".to_string()),
+                fraction: 0.42,
+                caption: "42%".to_string(),
+            }
+        );
+    }
+
+    #[test]
+    fn ram_meter_leyenda_en_gib() {
+        let mut m = Meter::from_spec(MeterSource::Ram, &WidgetSpec::new("ram_meter"));
+        m.tick(&ctx());
+        match m.view() {
+            WidgetView::Meter { caption, .. } => assert_eq!(caption, "3.2/15.5G"),
+            v => panic!("esperaba Meter, vino {v:?}"),
+        }
+    }
+
+    #[test]
+    fn volumen_muteado_dice_muted() {
+        let mut t = ctx();
+        t.muted = true;
+        let mut m = Meter::from_spec(MeterSource::Volume, &WidgetSpec::new("volume"));
+        m.tick(&t);
+        match m.view() {
+            WidgetView::Meter { caption, .. } => assert_eq!(caption, "muted"),
+            v => panic!("esperaba Meter, vino {v:?}"),
+        }
+    }
+
+    #[test]
+    fn meter_oculta_label_con_show_label_false() {
+        let spec = WidgetSpec::new("cpu_meter").with("show_label", Prop::Bool(false));
+        let mut m = Meter::from_spec(MeterSource::Cpu, &spec);
+        m.tick(&ctx());
+        match m.view() {
+            WidgetView::Meter { label, .. } => assert_eq!(label, None),
+            v => panic!("esperaba Meter, vino {v:?}"),
+        }
+    }
+
+    #[test]
+    fn meter_label_override() {
+        let spec = WidgetSpec::new("cpu_meter").with("label", Prop::Str("Proc".to_string()));
+        let m = Meter::from_spec(MeterSource::Cpu, &spec);
+        match m.view() {
+            WidgetView::Meter { label, .. } => assert_eq!(label, Some("Proc".to_string())),
+            v => panic!("esperaba Meter, vino {v:?}"),
+        }
+    }
+
+    #[test]
+    fn kind_desconocido_cae_a_placeholder() {
+        // window_list/tray/shuma_input todavía no son builtin (IPC/shell pendiente).
+        let w = build(&WidgetSpec::new("window_list"));
+        assert_eq!(w.view(), WidgetView::Placeholder("window_list".to_string()));
+        assert_eq!(
+            build(&WidgetSpec::new("tray")).view(),
+            WidgetView::Placeholder("tray".to_string())
+        );
+    }
+
+    #[test]
+    fn astro_mapea_longitud_a_signo_y_grado() {
+        let mut ctx = ctx();
+        // 132° → 132/30 = 4 → Leo; 132 % 30 = 12°.
+        ctx.sun_longitude_deg = 132.0;
+        ctx.moon_phase = 0.0; // nueva
+        let mut a = Astro::from_spec(&WidgetSpec::new("astro"));
+        a.tick(&ctx);
+        assert_eq!(a.view(), WidgetView::Text("♌ Leo 12° · 🌑".to_string()));
+    }
+
+    #[test]
+    fn astro_normaliza_longitud_negativa_y_redondea_luna() {
+        let mut ctx = ctx();
+        // -30° normaliza a 330° → Piscis (330/30 = 11), grado 0.
+        ctx.sun_longitude_deg = -30.0;
+        ctx.moon_phase = 0.5; // llena → idx 4
+        let mut a = Astro::from_spec(&WidgetSpec::new("astro"));
+        a.tick(&ctx);
+        assert_eq!(a.view(), WidgetView::Text("♓ Piscis 0° · 🌕".to_string()));
+    }
+
+    #[test]
+    fn astro_respeta_props_degree_moon_name() {
+        let mut ctx = ctx();
+        ctx.sun_longitude_deg = 5.0; // Aries 5°
+        let spec = WidgetSpec::new("astro")
+            .with("degree", Prop::Bool(false))
+            .with("moon", Prop::Bool(false))
+            .with("name", Prop::Bool(false));
+        let mut a = Astro::from_spec(&spec);
+        a.tick(&ctx);
+        // Sólo el glifo del signo.
+        assert_eq!(a.view(), WidgetView::Text("♈".to_string()));
+    }
+
+    #[test]
+    fn start_button_muestra_label_default_y_override() {
+        let def = build(&WidgetSpec::new("start_button"));
+        assert_eq!(def.view(), WidgetView::Text("⊞".to_string()));
+        let custom = StartButton::from_spec(
+            &WidgetSpec::new("start_button").with("label", Prop::Str("Inicio".to_string())),
+        );
+        assert_eq!(custom.view(), WidgetView::Text("Inicio".to_string()));
+    }
+
+    #[test]
+    fn build_despacha_los_builtins() {
+        let specs = [
+            WidgetSpec::new("clock"),
+            WidgetSpec::new("cpu_meter"),
+            WidgetSpec::new("ram_meter"),
+            WidgetSpec::new("volume"),
+            WidgetSpec::new("brightness"),
+        ];
+        let mut widgets = build_all(&specs);
+        for w in widgets.iter_mut() {
+            w.tick(&ctx());
+        }
+        // El reloj da texto; los cuatro medidores dan Meter.
+        assert!(matches!(widgets[0].view(), WidgetView::Text(_)));
+        for w in &widgets[1..] {
+            assert!(matches!(w.view(), WidgetView::Meter { .. }));
+        }
+    }
+}
diff --git a/02_ruway/pata/pata-core/src/wire.rs b/02_ruway/pata/pata-core/src/wire.rs
new file mode 100644
index 0000000..cdfbce6
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/wire.rs
@@ -0,0 +1,320 @@
+//! Representación **postcard-safe** del marco para akasha (wawa).
+//!
+//! El modelo de [`Config`](crate::Config) está afinado para el loader TOML de
+//! Linux: `WidgetSpec.props` usa `#[serde(flatten)]` (para que las props vivan al
+//! nivel del `kind`) y [`Prop`] es `#[serde(untagged)]` (para que TOML infiera el
+//! tipo). Ambos atributos **rompen postcard** —el codec de akasha—, que no es
+//! auto-descriptivo y no soporta `deserialize_any`.
+//!
+//! Este módulo define un espejo *plano y etiquetado* del modelo: [`WireConfig`]
+//! es exactamente la misma información, pero serializable con cualquier formato
+//! binario no auto-descriptivo. El kernel de wawa serializa el `WireConfig` con
+//! postcard, lo guarda en el grafo direccionado por contenido y lo lee de vuelta
+//! —el config del marco viaja por akasha como cualquier otro objeto—. En Linux
+//! el camino TOML sigue usando `Config` directo; este espejo es sólo para el
+//! cruce a wawa.
+//!
+//! Las conversiones son **sin pérdida**: `Config → WireConfig → Config` devuelve
+//! el mismo modelo (lo fija un test).
+
+use alloc::string::String;
+use alloc::vec::Vec;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::config::{
+    Anchor, Config, FloatingCard, General, Prop, SidebarTab, Surface, SurfaceKind, WidgetSpec,
+};
+
+/// Valor de propiedad **etiquetado** (a diferencia de [`Prop`], que es
+/// `untagged`): así postcard puede deserializarlo sin auto-descripción.
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub enum WireProp {
+    Bool(bool),
+    Int(i64),
+    Num(f64),
+    Str(String),
+}
+
+impl From<&Prop> for WireProp {
+    fn from(p: &Prop) -> Self {
+        match p {
+            Prop::Bool(b) => WireProp::Bool(*b),
+            Prop::Int(i) => WireProp::Int(*i),
+            Prop::Num(n) => WireProp::Num(*n),
+            Prop::Str(s) => WireProp::Str(s.clone()),
+        }
+    }
+}
+
+impl From<WireProp> for Prop {
+    fn from(p: WireProp) -> Self {
+        match p {
+            WireProp::Bool(b) => Prop::Bool(b),
+            WireProp::Int(i) => Prop::Int(i),
+            WireProp::Num(n) => Prop::Num(n),
+            WireProp::Str(s) => Prop::Str(s),
+        }
+    }
+}
+
+/// Espejo de [`WidgetSpec`] con las props como **lista ordenada** (no `flatten`).
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct WireWidget {
+    pub kind: String,
+    pub props: Vec<(String, WireProp)>,
+}
+
+impl From<&WidgetSpec> for WireWidget {
+    fn from(w: &WidgetSpec) -> Self {
+        Self {
+            kind: w.kind.clone(),
+            props: w.props.iter().map(|(k, v)| (k.clone(), WireProp::from(v))).collect(),
+        }
+    }
+}
+
+impl From<WireWidget> for WidgetSpec {
+    fn from(w: WireWidget) -> Self {
+        let mut spec = WidgetSpec::new(w.kind);
+        for (k, v) in w.props {
+            spec = spec.with(k, Prop::from(v));
+        }
+        spec
+    }
+}
+
+fn a_wire(specs: &[WidgetSpec]) -> Vec<WireWidget> {
+    specs.iter().map(WireWidget::from).collect()
+}
+
+fn de_wire(wires: Vec<WireWidget>) -> Vec<WidgetSpec> {
+    wires.into_iter().map(WidgetSpec::from).collect()
+}
+
+/// Espejo de [`FloatingCard`].
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct WireCard {
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+    pub title: Option<String>,
+    pub widgets: Vec<WireWidget>,
+}
+
+impl From<&FloatingCard> for WireCard {
+    fn from(c: &FloatingCard) -> Self {
+        Self {
+            x: c.x,
+            y: c.y,
+            w: c.w,
+            h: c.h,
+            title: c.title.clone(),
+            widgets: a_wire(&c.widgets),
+        }
+    }
+}
+
+impl From<WireCard> for FloatingCard {
+    fn from(c: WireCard) -> Self {
+        FloatingCard {
+            x: c.x,
+            y: c.y,
+            w: c.w,
+            h: c.h,
+            title: c.title,
+            widgets: de_wire(c.widgets),
+        }
+    }
+}
+
+/// Espejo de [`SidebarTab`].
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct WireTab {
+    pub icon: String,
+    pub label: String,
+    pub content: WireWidget,
+}
+
+impl From<&SidebarTab> for WireTab {
+    fn from(t: &SidebarTab) -> Self {
+        Self {
+            icon: t.icon.clone(),
+            label: t.label.clone(),
+            content: WireWidget::from(&t.content),
+        }
+    }
+}
+
+impl From<WireTab> for SidebarTab {
+    fn from(t: WireTab) -> Self {
+        SidebarTab {
+            icon: t.icon,
+            label: t.label,
+            content: WidgetSpec::from(t.content),
+        }
+    }
+}
+
+/// Espejo de [`Surface`].
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct WireSurface {
+    pub kind: SurfaceKind,
+    pub anchor: Anchor,
+    pub thickness: f32,
+    pub autohide: bool,
+    pub padding: f32,
+    pub gap: f32,
+    pub start: Vec<WireWidget>,
+    pub center: Vec<WireWidget>,
+    pub end: Vec<WireWidget>,
+    pub cards: Vec<WireCard>,
+    pub output: String,
+    pub tabs: Vec<WireTab>,
+    pub panel_width: f32,
+}
+
+impl From<&Surface> for WireSurface {
+    fn from(s: &Surface) -> Self {
+        Self {
+            kind: s.kind,
+            anchor: s.anchor,
+            thickness: s.thickness,
+            autohide: s.autohide,
+            padding: s.padding,
+            gap: s.gap,
+            start: a_wire(&s.start),
+            center: a_wire(&s.center),
+            end: a_wire(&s.end),
+            cards: s.cards.iter().map(WireCard::from).collect(),
+            output: s.output.clone(),
+            tabs: s.tabs.iter().map(WireTab::from).collect(),
+            panel_width: s.panel_width,
+        }
+    }
+}
+
+impl From<WireSurface> for Surface {
+    fn from(s: WireSurface) -> Self {
+        Surface {
+            kind: s.kind,
+            anchor: s.anchor,
+            thickness: s.thickness,
+            autohide: s.autohide,
+            padding: s.padding,
+            gap: s.gap,
+            start: de_wire(s.start),
+            center: de_wire(s.center),
+            end: de_wire(s.end),
+            cards: s.cards.into_iter().map(FloatingCard::from).collect(),
+            output: s.output,
+            tabs: s.tabs.into_iter().map(SidebarTab::from).collect(),
+            panel_width: s.panel_width,
+        }
+    }
+}
+
+/// Espejo postcard-safe de [`Config`]: la misma información, sin `flatten` ni
+/// `untagged`. Es lo que viaja por akasha en wawa.
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct WireConfig {
+    pub general: General,
+    pub surfaces: Vec<WireSurface>,
+}
+
+impl From<&Config> for WireConfig {
+    fn from(c: &Config) -> Self {
+        Self {
+            general: c.general.clone(),
+            surfaces: c.surfaces.iter().map(WireSurface::from).collect(),
+        }
+    }
+}
+
+impl From<WireConfig> for Config {
+    fn from(c: WireConfig) -> Self {
+        Config {
+            general: c.general,
+            surfaces: c.surfaces.into_iter().map(Surface::from).collect(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{Prop, SidebarTab};
+    use crate::{Config, Surface, WidgetSpec};
+    use alloc::string::ToString;
+
+    /// Un config con todos los rincones poblados (props de cada tipo, una
+    /// tarjeta flotante) para que el round-trip cubra el caso difícil.
+    fn config_rico() -> Config {
+        let mut top = Surface::bar(Anchor::Top);
+        top.thickness = 32.0;
+        top.start = alloc::vec![WidgetSpec::new("start_button").with("label", Prop::Str("⊞".to_string()))];
+        top.center = alloc::vec![WidgetSpec::new("clock")
+            .with("format", Prop::Str("%H:%M".to_string()))
+            .with("size", Prop::Int(14))
+            .with("ratio", Prop::Num(0.5))
+            .with("flag", Prop::Bool(true))];
+        top.end = alloc::vec![WidgetSpec::new("ram_meter")];
+
+        let mut panel = Surface::default();
+        panel.kind = SurfaceKind::Panel;
+        panel.cards = alloc::vec![FloatingCard {
+            x: 40.0,
+            y: 80.0,
+            w: 220.0,
+            h: 110.0,
+            title: Some("sistema".to_string()),
+            widgets: alloc::vec![WidgetSpec::new("cpu_meter"), WidgetSpec::new("ram_meter")],
+        }];
+
+        let mut sidebar = Surface::sidebar(Anchor::Left);
+        sidebar.panel_width = 300.0;
+        sidebar.tabs = alloc::vec![
+            SidebarTab::new(
+                "monads",
+                "Mónadas",
+                WidgetSpec::new("navigator").with("source", Prop::Str("nouser".to_string())),
+            ),
+            SidebarTab::new("files", "Archivos", WidgetSpec::new("navigator")),
+        ];
+
+        let mut cfg = Config::default();
+        cfg.surfaces.push(top);
+        cfg.surfaces.push(panel);
+        cfg.surfaces.push(sidebar);
+        cfg
+    }
+
+    #[test]
+    fn round_trip_sin_perdida() {
+        let cfg = config_rico();
+        let wire = WireConfig::from(&cfg);
+        let vuelta: Config = wire.into();
+        assert_eq!(cfg, vuelta);
+    }
+
+    #[test]
+    fn round_trip_postcard() {
+        // El caso real de akasha: serializar con postcard (no auto-descriptivo,
+        // sin soporte de flatten/untagged) y volver. Falla con `Config` directo;
+        // funciona con `WireConfig`.
+        let cfg = config_rico();
+        let wire = WireConfig::from(&cfg);
+        let bytes = postcard::to_allocvec(&wire).expect("postcard serializa el wire");
+        let wire2: WireConfig = postcard::from_bytes(&bytes).expect("postcard deserializa el wire");
+        let vuelta: Config = wire2.into();
+        assert_eq!(cfg, vuelta);
+    }
+}
diff --git a/02_ruway/pata/pata-core/tests/toml_contract.rs b/02_ruway/pata/pata-core/tests/toml_contract.rs
new file mode 100644
index 0000000..108b729
--- /dev/null
+++ b/02_ruway/pata/pata-core/tests/toml_contract.rs
@@ -0,0 +1,129 @@
+//! El contrato del config en disco: un TOML real debe deserializar al modelo
+//! de `pata-core` sin pérdida. Linux carga TOML; este test fija que el esquema
+//! (superficies múltiples, slots, props `flatten` + `Prop` untagged) sobrevive
+//! el viaje. Vive como test de integración porque `toml` es `std` y dev-only.
+
+use pata_core::{Anchor, Config, Prop, SurfaceKind};
+
+#[test]
+fn deserializa_un_marco_completo_desde_toml() {
+    let src = r#"
+        [general]
+        timezone = "America/Lima"
+
+        [[surfaces]]
+        kind = "bar"
+        anchor = "top"
+        thickness = 28
+
+        [[surfaces.start]]
+        kind = "start_button"
+
+        [[surfaces.start]]
+        kind = "clock"
+        format = "%H:%M"
+        size = 14
+
+        [[surfaces.end]]
+        kind = "astro"
+        moon = true
+        lat = -12.04
+
+        [[surfaces]]
+        kind = "bar"
+        anchor = "bottom"
+        autohide = true
+
+        [[surfaces.center]]
+        kind = "shuma_input"
+        hotkey = "F12"
+    "#;
+
+    let cfg: Config = toml::from_str(src).expect("el TOML debe parsear al modelo");
+
+    assert_eq!(cfg.general.timezone, "America/Lima");
+    assert_eq!(cfg.surfaces.len(), 2);
+
+    let top = &cfg.surfaces[0];
+    assert_eq!(top.kind, SurfaceKind::Bar);
+    assert_eq!(top.anchor, Anchor::Top);
+    assert_eq!(top.thickness, 28.0);
+    assert_eq!(top.start.len(), 2);
+    assert_eq!(top.start[0].kind, "start_button");
+    // Props heterogéneas (string + int) llegan por flatten/untagged.
+    assert_eq!(top.start[1].str_prop("format", "?"), "%H:%M");
+    assert_eq!(top.start[1].num_prop("size", 0.0), 14.0);
+
+    let astro = &top.end[0];
+    assert_eq!(astro.kind, "astro");
+    assert!(astro.bool_prop("moon", false));
+    assert_eq!(astro.num_prop("lat", 0.0), -12.04);
+
+    let shell = &cfg.surfaces[1];
+    assert_eq!(shell.anchor, Anchor::Bottom);
+    assert!(shell.autohide);
+    assert_eq!(shell.center[0].kind, "shuma_input");
+    assert_eq!(shell.center[0].str_prop("hotkey", "?"), "F12");
+}
+
+#[test]
+fn props_desconocidas_se_conservan() {
+    let src = r#"
+        [[surfaces]]
+        anchor = "right"
+
+        [[surfaces.start]]
+        kind = "custom"
+        color = "rebeccapurple"
+        ratio = 0.42
+        veces = 3
+    "#;
+    let cfg: Config = toml::from_str(src).unwrap();
+    let w = &cfg.surfaces[0].start[0];
+    assert_eq!(w.str_prop("color", "?"), "rebeccapurple");
+    assert_eq!(w.num_prop("ratio", 0.0), 0.42);
+    assert_eq!(w.num_prop("veces", 0.0), 3.0);
+    // anchor sin kind de superficie cae al default Bar.
+    assert_eq!(cfg.surfaces[0].kind, SurfaceKind::Bar);
+}
+
+#[test]
+fn deserializa_un_sidebar_con_dientes_navegador() {
+    // El rail (Fase 11): un SurfaceKind::Sidebar con `tabs` cuyo `content` es un
+    // WidgetSpec con props flatten (la fuente de datos del navegador).
+    let src = r#"
+        [[surfaces]]
+        kind = "sidebar"
+        anchor = "left"
+        thickness = 44
+        panel_width = 300
+
+        [[surfaces.tabs]]
+        icon = "monads"
+        label = "Mónadas"
+        content = { kind = "navigator", source = "nouser" }
+    "#;
+    let cfg: Config = toml::from_str(src).expect("el sidebar debe parsear");
+    let sb = &cfg.surfaces[0];
+    assert_eq!(sb.kind, SurfaceKind::Sidebar);
+    assert_eq!(sb.anchor, Anchor::Left);
+    assert_eq!(sb.thickness, 44.0);
+    assert_eq!(sb.panel_width, 300.0);
+    assert_eq!(sb.tabs.len(), 1);
+    assert_eq!(sb.tabs[0].icon, "monads");
+    assert_eq!(sb.tabs[0].label, "Mónadas");
+    assert_eq!(sb.tabs[0].content.kind, "navigator");
+    // La prop del contenido llega por flatten/untagged.
+    assert_eq!(sb.tabs[0].content.str_prop("source", "?"), "nouser");
+}
+
+#[test]
+fn marco_minimo_y_vacio() {
+    // Sin superficies declaradas: config válido y vacío.
+    let cfg: Config = toml::from_str("").unwrap();
+    assert!(cfg.surfaces.is_empty());
+    assert_eq!(cfg.general.timezone, "auto");
+
+    // Un Prop entero no se confunde con float al volver a leerse.
+    let _ = Prop::Int(1);
+}
diff --git a/02_ruway/pata/pata-host/Cargo.toml b/02_ruway/pata/pata-host/Cargo.toml
new file mode 100644
index 0000000..a7e6462
--- /dev/null
+++ b/02_ruway/pata/pata-host/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "pata-host"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata-host — protocolo del rail hospedado: una app le presta sus dientes (sidebar) al marco pata cuando tiene foco, y recibe de vuelta qué diente se activó. Socket Unix dedicado + marco postcard con prefijo de longitud. Lado app (HostClient) + lado shell (HostServer)."
+
+[dependencies]
+serde = { workspace = true }
+postcard = { workspace = true }
diff --git a/02_ruway/pata/pata-host/src/client.rs b/02_ruway/pata/pata-host/src/client.rs
new file mode 100644
index 0000000..376c676
--- /dev/null
+++ b/02_ruway/pata/pata-host/src/client.rs
@@ -0,0 +1,65 @@
+//! Lado **app** del rail hospedado: la app se conecta, registra sus dientes y
+//! recibe las activaciones por un callback.
+
+use std::os::unix::net::UnixStream;
+
+use crate::{read_frame, socket_path, write_frame, AppMsg, HostedTooth, ShellMsg};
+
+/// Cliente del rail hospedado, vive en la app. Mantiene la conexión a pata; un
+/// hilo lector entrega las activaciones por el callback dado en [`HostClient::connect`].
+/// Al soltarse (`Drop`) manda `Bye`.
+pub struct HostClient {
+    write: UnixStream,
+}
+
+impl HostClient {
+    /// Se conecta al socket de pata, registra `app_id`/`title`/`teeth` y arranca el
+    /// hilo lector. `on_activate(tooth)` se invoca (en ese hilo) cada vez que el
+    /// usuario clickea un diente en el rail de pata. `None` si pata no está
+    /// escuchando — la app sigue andando con su propio rail.
+    ///
+    /// `app_id` **debe** ser el mismo que el compositor reporta para la ventana de
+    /// la app (el `app_id()` del trait `App` de llimphi), para que pata correlacione
+    /// foco ↔ dientes.
+    pub fn connect<F>(
+        app_id: impl Into<String>,
+        title: impl Into<String>,
+        teeth: Vec<HostedTooth>,
+        on_activate: F,
+    ) -> Option<HostClient>
+    where
+        F: Fn(u32) + Send + 'static,
+    {
+        let mut stream = UnixStream::connect(socket_path()).ok()?;
+        write_frame(
+            &mut stream,
+            &AppMsg::Register {
+                app_id: app_id.into(),
+                title: title.into(),
+                teeth,
+            },
+        )
+        .ok()?;
+
+        let mut read = stream.try_clone().ok()?;
+        std::thread::spawn(move || loop {
+            match read_frame::<ShellMsg>(&mut read) {
+                Ok(ShellMsg::Activate { tooth }) => on_activate(tooth),
+                Err(_) => break, // pata cerró o error: se acabó el hilo lector
+            }
+        });
+
+        Some(HostClient { write: stream })
+    }
+
+    /// Actualiza los dientes publicados (p. ej. si cambian dinámicamente).
+    pub fn update(&mut self, teeth: Vec<HostedTooth>) {
+        let _ = write_frame(&mut self.write, &AppMsg::Update { teeth });
+    }
+}
+
+impl Drop for HostClient {
+    fn drop(&mut self) {
+        let _ = write_frame(&mut self.write, &AppMsg::Bye);
+    }
+}
diff --git a/02_ruway/pata/pata-host/src/lib.rs b/02_ruway/pata/pata-host/src/lib.rs
new file mode 100644
index 0000000..5a46de2
--- /dev/null
+++ b/02_ruway/pata/pata-host/src/lib.rs
@@ -0,0 +1,179 @@
+//! `pata-host` — el **rail hospedado**: el protocolo por el que una app le presta
+//! sus "dientes" (su sidebar) al marco `pata` mientras tiene foco, y recibe de
+//! vuelta qué diente activó el usuario.
+//!
+//! La idea (visión del autor): una app como `cosmos` puede dejar de pintar su
+//! propio rail y quedar como **puro lienzo**; sus herramientas aparecen en el rail
+//! global de pata cuando la app está enfocada. Al clickear un diente en pata, el
+//! comando vuelve a la app, que muestra ese panel sobre su propio canvas. pata
+//! sólo hospeda el **rail** (los dientes) — no los paneles ricos de la app.
+//!
+//! ## Transporte
+//!
+//! Un socket Unix dedicado ([`socket_path`], default
+//! `$XDG_RUNTIME_DIR/pata-sidebar.sock`). pata escucha ([`HostServer`]); las apps
+//! se conectan ([`HostClient`]). Cada conexión es un stream con marco
+//! **prefijo-de-longitud + postcard** (igual que `mirada-link`):
+//!
+//! ```text
+//! app  → shell:  [u32 LE len][postcard AppMsg]…   (Register, Update, Bye)
+//! shell → app:   [u32 LE len][postcard ShellMsg]… (Activate)
+//! ```
+//!
+//! pata correlaciona el `app_id` que la app declara en `Register` con el `app_id`
+//! del toplevel enfocado (que ya conoce vía wlr-foreign-toplevel). Cuando coinciden,
+//! pinta los dientes de esa app; al clickear uno, le manda `Activate{tooth}`.
+
+#![forbid(unsafe_code)]
+
+use std::io::{self, Read, Write};
+use std::path::PathBuf;
+
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
+
+// =====================================================================
+// Wire types
+// =====================================================================
+
+/// Un diente que la app presta al rail de pata: id opaco (asignado por la app),
+/// nombre de icono (mismo vocabulario abierto que `pata_core::SidebarTab::icon`)
+/// y etiqueta (tooltip).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct HostedTooth {
+    /// Identificador del diente, opaco para pata; vuelve tal cual en [`ShellMsg::Activate`].
+    pub id: u32,
+    /// Nombre del icono (p. ej. `"folder"`, `"tools"`, `"astro"`).
+    pub icon: String,
+    /// Etiqueta corta (tooltip del diente).
+    pub label: String,
+}
+
+impl HostedTooth {
+    pub fn new(id: u32, icon: impl Into<String>, label: impl Into<String>) -> Self {
+        Self {
+            id,
+            icon: icon.into(),
+            label: label.into(),
+        }
+    }
+}
+
+/// Mensaje de la **app hacia el shell**.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum AppMsg {
+    /// Alta: la app se presenta con su `app_id` (el mismo que reporta el
+    /// compositor para su ventana), un título y sus dientes.
+    Register {
+        app_id: String,
+        title: String,
+        teeth: Vec<HostedTooth>,
+    },
+    /// Sus dientes cambiaron (mismo `app_id` implícito por la conexión).
+    Update { teeth: Vec<HostedTooth> },
+    /// Baja explícita (también se infiere al cerrarse la conexión).
+    Bye,
+}
+
+/// Mensaje del **shell hacia la app**.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ShellMsg {
+    /// El usuario clickeó el diente `tooth` (el `id` que la app declaró). La app
+    /// decide la semántica (típicamente: togglear ese panel sobre su canvas).
+    Activate { tooth: u32 },
+}
+
+// =====================================================================
+// Transporte
+// =====================================================================
+
+/// Variable de entorno para sobreescribir la ruta del socket.
+pub const SOCKET_ENV: &str = "PATA_SIDEBAR_SOCKET";
+/// Nombre por defecto del socket.
+pub const SOCKET_NAME: &str = "pata-sidebar.sock";
+
+/// Ruta canónica del socket del rail hospedado. Honra [`SOCKET_ENV`]; si no,
+/// arma sobre `$XDG_RUNTIME_DIR` (con fallback al directorio temporal).
+pub fn socket_path() -> PathBuf {
+    if let Ok(p) = std::env::var(SOCKET_ENV) {
+        return PathBuf::from(p);
+    }
+    std::env::var_os("XDG_RUNTIME_DIR")
+        .map(PathBuf::from)
+        .unwrap_or_else(std::env::temp_dir)
+        .join(SOCKET_NAME)
+}
+
+fn postcard_err(e: postcard::Error) -> io::Error {
+    io::Error::new(io::ErrorKind::InvalidData, e)
+}
+
+/// Escribe un mensaje con marco `[u32 LE len][postcard]`.
+pub fn write_frame<T: Serialize>(w: &mut impl Write, msg: &T) -> io::Result<()> {
+    let bytes = postcard::to_stdvec(msg).map_err(postcard_err)?;
+    w.write_all(&(bytes.len() as u32).to_le_bytes())?;
+    w.write_all(&bytes)?;
+    w.flush()
+}
+
+/// Lee un mensaje con marco `[u32 LE len][postcard]`. `Err(UnexpectedEof)` al
+/// cerrarse la conexión limpiamente.
+pub fn read_frame<T: DeserializeOwned>(r: &mut impl Read) -> io::Result<T> {
+    let mut len = [0u8; 4];
+    r.read_exact(&mut len)?;
+    let n = u32::from_le_bytes(len) as usize;
+    let mut buf = vec![0u8; n];
+    r.read_exact(&mut buf)?;
+    postcard::from_bytes(&buf).map_err(postcard_err)
+}
+
+#[cfg(unix)]
+mod server;
+#[cfg(unix)]
+pub use server::HostServer;
+
+#[cfg(unix)]
+mod client;
+#[cfg(unix)]
+pub use client::HostClient;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn wire_roundtrip_appmsg() {
+        let m = AppMsg::Register {
+            app_id: "gioser.cosmos".into(),
+            title: "Cosmos".into(),
+            teeth: vec![HostedTooth::new(1, "folder", "Árbol"), HostedTooth::new(2, "tools", "Herramientas")],
+        };
+        let bytes = postcard::to_stdvec(&m).unwrap();
+        let back: AppMsg = postcard::from_bytes(&bytes).unwrap();
+        assert_eq!(back, m);
+    }
+
+    #[test]
+    fn wire_roundtrip_shellmsg() {
+        let m = ShellMsg::Activate { tooth: 7 };
+        let bytes = postcard::to_stdvec(&m).unwrap();
+        assert_eq!(postcard::from_bytes::<ShellMsg>(&bytes).unwrap(), m);
+    }
+
+    #[test]
+    fn frame_roundtrip_sobre_buffer() {
+        let mut buf: Vec<u8> = Vec::new();
+        let m = ShellMsg::Activate { tooth: 3 };
+        write_frame(&mut buf, &m).unwrap();
+        let mut cur = std::io::Cursor::new(buf);
+        let back: ShellMsg = read_frame(&mut cur).unwrap();
+        assert_eq!(back, m);
+    }
+
+    #[test]
+    fn socket_path_honra_env() {
+        // No tocamos el entorno global del proceso de forma persistente; sólo
+        // verificamos la forma del fallback.
+        let p = socket_path();
+        assert!(p.ends_with(SOCKET_NAME) || std::env::var(SOCKET_ENV).is_ok());
+    }
+}
diff --git a/02_ruway/pata/pata-host/src/server.rs b/02_ruway/pata/pata-host/src/server.rs
new file mode 100644
index 0000000..cf215a3
--- /dev/null
+++ b/02_ruway/pata/pata-host/src/server.rs
@@ -0,0 +1,157 @@
+//! Lado **shell** del rail hospedado: pata escucha el socket, acumula los dientes
+//! que registran las apps y les reenvía las activaciones.
+
+use std::collections::HashMap;
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+
+use crate::{read_frame, socket_path, write_frame, AppMsg, HostedTooth, ShellMsg};
+
+/// Una app registrada: su título, sus dientes y la mitad de escritura de su
+/// conexión (para mandarle `Activate`).
+struct AppReg {
+    title: String,
+    teeth: Vec<HostedTooth>,
+    write: UnixStream,
+}
+
+/// El estado compartido entre el hilo aceptador/lectores y el bucle de UI.
+struct Shared {
+    apps: Mutex<HashMap<String, AppReg>>,
+    /// Se incrementa en cada cambio (alta/baja/update) para que el host detecte
+    /// "hay algo nuevo que pintar" sin difear el mapa.
+    revision: AtomicU64,
+}
+
+/// El servidor del rail hospedado. Vive en pata; arranca su hilo aceptador en
+/// [`HostServer::spawn`] y se consulta desde el bucle de UI.
+pub struct HostServer {
+    shared: Arc<Shared>,
+}
+
+impl HostServer {
+    /// Bindea el socket y arranca el hilo aceptador. `None` si no se puede bindear
+    /// (otro pata ya escucha, o sin permisos) — el rail hospedado queda inactivo
+    /// sin romper el resto del marco.
+    pub fn spawn() -> Option<HostServer> {
+        let path = socket_path();
+        // Limpia un socket viejo de un pata que murió (best-effort). Si hay otro
+        // pata vivo, el bind de abajo fallará y devolvemos None.
+        let _ = std::fs::remove_file(&path);
+        let listener = match UnixListener::bind(&path) {
+            Ok(l) => l,
+            Err(e) => {
+                eprintln!("pata host · no pude bindear {}: {e}", path.display());
+                return None;
+            }
+        };
+
+        let shared = Arc::new(Shared {
+            apps: Mutex::new(HashMap::new()),
+            revision: AtomicU64::new(0),
+        });
+
+        let shared_accept = shared.clone();
+        std::thread::spawn(move || {
+            for stream in listener.incoming() {
+                let Ok(stream) = stream else { continue };
+                let shared = shared_accept.clone();
+                // Un lector por conexión.
+                std::thread::spawn(move || handle_conn(stream, shared));
+            }
+        });
+
+        Some(HostServer { shared })
+    }
+
+    /// Snapshot (clonado) del título + dientes de `app_id`, si está registrada.
+    /// Para que el host lo pinte sin retener el lock.
+    pub fn snapshot(&self, app_id: &str) -> Option<(String, Vec<HostedTooth>)> {
+        let apps = self.shared.apps.lock().ok()?;
+        apps.get(app_id).map(|r| (r.title.clone(), r.teeth.clone()))
+    }
+
+    /// `true` si alguna app tiene dientes registrados (para saber si vale la pena
+    /// mirar el foco).
+    pub fn any_registered(&self) -> bool {
+        self.shared
+            .apps
+            .lock()
+            .map(|a| !a.is_empty())
+            .unwrap_or(false)
+    }
+
+    /// Le manda `Activate{tooth}` a `app_id`. `true` si se escribió.
+    pub fn activate(&self, app_id: &str, tooth: u32) -> bool {
+        let Ok(mut apps) = self.shared.apps.lock() else {
+            return false;
+        };
+        let Some(reg) = apps.get_mut(app_id) else {
+            return false;
+        };
+        write_frame(&mut reg.write, &ShellMsg::Activate { tooth }).is_ok()
+    }
+
+    /// Contador de revisión: cambia cuando un alta/baja/update tocó el mapa.
+    pub fn revision(&self) -> u64 {
+        self.shared.revision.load(Ordering::Relaxed)
+    }
+}
+
+/// Atiende una conexión: lee `AppMsg`s y mantiene el mapa. Al cerrarse el stream
+/// (EOF) o recibir `Bye`, da de baja la app.
+fn handle_conn(stream: UnixStream, shared: Arc<Shared>) {
+    // La mitad de escritura (clonada) viaja al mapa para mandar `Activate`; la de
+    // lectura se queda en este hilo.
+    let write = match stream.try_clone() {
+        Ok(w) => w,
+        Err(_) => return,
+    };
+    let mut read = stream;
+    let mut my_app_id: Option<String> = None;
+
+    loop {
+        match read_frame::<AppMsg>(&mut read) {
+            Ok(AppMsg::Register {
+                app_id,
+                title,
+                teeth,
+            }) => {
+                let write = match write.try_clone() {
+                    Ok(w) => w,
+                    Err(_) => return,
+                };
+                if let Ok(mut apps) = shared.apps.lock() {
+                    apps.insert(app_id.clone(), AppReg { title, teeth, write });
+                }
+                my_app_id = Some(app_id);
+                bump(&shared);
+            }
+            Ok(AppMsg::Update { teeth }) => {
+                if let Some(id) = &my_app_id {
+                    if let Ok(mut apps) = shared.apps.lock() {
+                        if let Some(reg) = apps.get_mut(id) {
+                            reg.teeth = teeth;
+                        }
+                    }
+                    bump(&shared);
+                }
+            }
+            Ok(AppMsg::Bye) | Err(_) => {
+                // Bye explícito o EOF/error: damos de baja y salimos.
+                if let Some(id) = &my_app_id {
+                    if let Ok(mut apps) = shared.apps.lock() {
+                        apps.remove(id);
+                    }
+                    bump(&shared);
+                }
+                return;
+            }
+        }
+    }
+}
+
+fn bump(shared: &Arc<Shared>) {
+    shared.revision.fetch_add(1, Ordering::Relaxed);
+}
diff --git a/02_ruway/pata/pata-host/tests/roundtrip.rs b/02_ruway/pata/pata-host/tests/roundtrip.rs
new file mode 100644
index 0000000..25326bd
--- /dev/null
+++ b/02_ruway/pata/pata-host/tests/roundtrip.rs
@@ -0,0 +1,68 @@
+//! Integración server↔client sobre un socket Unix real: el registro de dientes
+//! llega al server, una activación vuelve a la app, y la baja se infiere al
+//! soltar el cliente.
+//!
+//! Un solo `#[test]` a propósito: el path del socket se fija por env (proceso-
+//! global), así que dos tests en paralelo competirían por él.
+
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+use pata_host::{HostClient, HostServer, HostedTooth};
+
+/// Reintenta `f` hasta que devuelva `Some` o venza `dur`.
+fn esperar<T>(dur: Duration, mut f: impl FnMut() -> Option<T>) -> Option<T> {
+    let fin = Instant::now() + dur;
+    loop {
+        if let Some(v) = f() {
+            return Some(v);
+        }
+        if Instant::now() >= fin {
+            return None;
+        }
+        std::thread::sleep(Duration::from_millis(10));
+    }
+}
+
+#[test]
+fn registro_activacion_y_baja() {
+    let sock = std::env::temp_dir().join(format!("pata-host-test-{}.sock", std::process::id()));
+    std::env::set_var(pata_host::SOCKET_ENV, &sock);
+    let _ = std::fs::remove_file(&sock);
+
+    let server = HostServer::spawn().expect("server bindea");
+
+    let teeth = vec![
+        HostedTooth::new(1, "folder", "Árbol"),
+        HostedTooth::new(2, "tools", "Herramientas"),
+    ];
+
+    // --- Registro + activación ida y vuelta ---
+    let (tx, rx) = mpsc::channel::<u32>();
+    let client = HostClient::connect("gioser.test", "Test", teeth.clone(), move |t| {
+        let _ = tx.send(t);
+    })
+    .expect("client conecta");
+
+    let snap = esperar(Duration::from_secs(2), || server.snapshot("gioser.test"))
+        .expect("el server registró la app");
+    assert_eq!(snap.0, "Test");
+    assert_eq!(snap.1, teeth);
+    assert!(server.any_registered());
+
+    assert!(server.activate("gioser.test", 2));
+    let got = rx.recv_timeout(Duration::from_secs(2)).expect("llega la activación");
+    assert_eq!(got, 2);
+
+    // Una app desconocida no recibe nada.
+    assert!(!server.activate("otra.app", 1));
+
+    // --- Baja: soltar el cliente da de baja la app ---
+    drop(client);
+    let ido = esperar(Duration::from_secs(2), || {
+        server.snapshot("gioser.test").is_none().then_some(())
+    });
+    assert!(ido.is_some(), "la app debe darse de baja al soltar el cliente");
+
+    let _ = std::fs::remove_file(&sock);
+}
diff --git a/02_ruway/pata/pata-llimphi/Cargo.toml b/02_ruway/pata/pata-llimphi/Cargo.toml
new file mode 100644
index 0000000..b9d3618
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/Cargo.toml
@@ -0,0 +1,73 @@
+[package]
+name = "pata-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata — el frontend Linux: monta el modelo de pata-core sobre Llimphi. Resuelve la geometría de las superficies (barras/paneles/dock) y pinta cada widget traduciendo su view-model agnóstico al backend gráfico. Muestrea el sistema (reloj, CPU, RAM, volumen, brillo) en un WidgetCtx que alimenta a los widgets. Corre sobre el compositor mirada."
+
+[[bin]]
+name = "pata-llimphi"
+path = "src/main.rs"
+
+[lib]
+path = "src/lib.rs"
+
+[dependencies]
+pata-core = { path = "../pata-core" }
+pata-config = { path = "../pata-config" }
+# Rail hospedado (sidebar delegado): pata hospeda los dientes de la app enfocada
+# y le reenvía las activaciones. Lado servidor del protocolo.
+pata-host = { path = "../pata-host" }
+# El puente pata→shuma (SDD §5): el marco provee el borde, shuma la ejecución.
+# El drawer Quake corre los comandos por el ejecutor real de shuma (captura
+# acotada, streaming) en vez de un `sh -c` pelado.
+shuma-exec = { workspace = true }
+# El cerebro de la línea de shuma (agnóstico de GUI): descompone el comando en
+# etapas de pipe para las chips clickeables del drawer.
+shuma-line = { workspace = true }
+# IA en el drawer Quake: el submit sin prefijo `!`/`$` va al LLM (fachada
+# transparente de pluma; cae a Mock sin credenciales). Paridad con el quake de
+# mirada-launcher, que pata reemplaza (SDD Fase 10).
+pluma-llm = { workspace = true }
+# Registro de apps para el menú del botón de inicio (descubre ~/.config/gioser/
+# apps/*.toml). El menú nativo reemplaza a depender de wofi/rofi externos.
+app-bus = { workspace = true }
+llimphi-ui = { workspace = true }
+# Área de scroll del historial del drawer (clip + offset + barra arrastrable).
+llimphi-widget-scroll = { workspace = true }
+# Sidebars acoplables (Fase 11c): rail de dientes + navegador de Mónadas/archivos
+# + toggle Árbol/Grafo. El plano de datos lo provee nouser vía chasqui-card.
+llimphi-widget-dock-rail = { workspace = true }
+llimphi-widget-navigator = { workspace = true }
+llimphi-widget-segmented = { workspace = true }
+# Protocolo de query de nouser (list_monads / resolve_monad) — fuente
+# autoritativa de qué archivos componen una Mónada. Importamos sólo los wire
+# types + cliente blocking, sin arrastrar el engine (notify/sled/blake3).
+chasqui-card = { workspace = true }
+# Descubrimiento del socket del daemon vía broker brahman (Card consumer +
+# await_provider_blocking), con fallback al default path. Mismo patrón que
+# chasqui-explorer-llimphi.
+card-sidecar = { workspace = true }
+# MonadId = Ulid: derivamos NavId deterministas de sus bytes; los tests crean ids.
+ulid = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-motion = { workspace = true }
+chrono = { workspace = true }
+# Backend wlr-layer-shell (Linux/Wayland): hace que pata se siente al nivel de
+# eww/waybar — layer surface anclada con exclusive zone, no una ventana cliente.
+smithay-client-toolkit = "0.19"
+wayland-client = "0.31"
+# Lista de ventanas (widget `window_list`): wlr-foreign-toplevel-management, el
+# protocolo que usan waybar/eww para enumerar y activar toplevels en wlroots.
+wayland-protocols-wlr = { version = "0.3", features = ["client"] }
+# Bandeja del sistema (widget `tray`): StatusNotifierItem sobre D-Bus. pata corre
+# como watcher+host en un hilo aparte con zbus async sobre un runtime tokio
+# current-thread (el workspace fija zbus con la feature `tokio`, no la blocking).
+zbus = { workspace = true }
+tokio = { workspace = true }
+# Decodificar PNG de íconos del tray resueltos por nombre (IconName).
+image = { workspace = true }
+raw-window-handle = { workspace = true }
+pollster = { workspace = true }
diff --git a/02_ruway/pata/pata-llimphi/src/keys.rs b/02_ruway/pata/pata-llimphi/src/keys.rs
new file mode 100644
index 0000000..712afae
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/keys.rs
@@ -0,0 +1,74 @@
+//! Parser mínimo `string → Key` para los hotkeys declarados en el config.
+//!
+//! Cubre teclas únicas: `F1..F12`, `Escape/Esc`, `Enter/Return`, `Tab`,
+//! `Space`, `Backspace`, o un carácter suelto (`a`, `/`). Sin modifiers
+//! (`Ctrl+Space`) por ahora — falla cerrado si no parsea.
+
+use llimphi_ui::{Key, NamedKey};
+
+/// Convierte una etiqueta tipo `"F12"` al `Key` correspondiente.
+pub fn parse(label: &str) -> Option<Key> {
+    let s = label.trim();
+    if s.is_empty() {
+        return None;
+    }
+    let named = match s {
+        "F1" => NamedKey::F1,
+        "F2" => NamedKey::F2,
+        "F3" => NamedKey::F3,
+        "F4" => NamedKey::F4,
+        "F5" => NamedKey::F5,
+        "F6" => NamedKey::F6,
+        "F7" => NamedKey::F7,
+        "F8" => NamedKey::F8,
+        "F9" => NamedKey::F9,
+        "F10" => NamedKey::F10,
+        "F11" => NamedKey::F11,
+        "F12" => NamedKey::F12,
+        "Escape" | "Esc" => NamedKey::Escape,
+        "Enter" | "Return" => NamedKey::Enter,
+        "Tab" => NamedKey::Tab,
+        "Space" => NamedKey::Space,
+        "Backspace" => NamedKey::Backspace,
+        _ => {
+            if s.chars().count() == 1 {
+                return Some(Key::Character(s.into()));
+            }
+            return None;
+        }
+    };
+    Some(Key::Named(named))
+}
+
+/// `true` si la tecla del evento corresponde a la etiqueta. Falla cerrado.
+pub fn matches(label: &str, event_key: &Key) -> bool {
+    match parse(label) {
+        Some(parsed) => &parsed == event_key,
+        None => false,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parsea_teclas_de_funcion_y_caracter() {
+        assert_eq!(parse("F12"), Some(Key::Named(NamedKey::F12)));
+        assert_eq!(parse("Esc"), Some(Key::Named(NamedKey::Escape)));
+        assert_eq!(parse("/"), Some(Key::Character("/".into())));
+    }
+
+    #[test]
+    fn etiqueta_vacia_o_desconocida_es_none() {
+        assert_eq!(parse("  "), None);
+        assert_eq!(parse("Ctrl+Space"), None);
+    }
+
+    #[test]
+    fn matches_falla_cerrado() {
+        assert!(matches("F12", &Key::Named(NamedKey::F12)));
+        assert!(!matches("", &Key::Named(NamedKey::F12)));
+        assert!(!matches("F1", &Key::Named(NamedKey::F12)));
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/layer.rs b/02_ruway/pata/pata-llimphi/src/layer.rs
new file mode 100644
index 0000000..3902436
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/layer.rs
@@ -0,0 +1,1761 @@
+//! Backend `wlr-layer-shell`: hace que `pata` se siente **al nivel de eww/
+//! waybar** en cualquier compositor wlroots (Hyprland, Sway, river…), no como
+//! una ventana cliente.
+//!
+//! Una *layer surface* se ancla a un borde y declara una *exclusive zone* —el
+//! compositor le reserva esa franja y tesela el resto alrededor—, igual que eww.
+//! Aquí: nos conectamos a Wayland con `smithay-client-toolkit`, creamos **una
+//! layer surface por cada superficie `Bar`** de la config (cada una anclada a su
+//! borde con su exclusive zone), sacamos su `wgpu::Surface` de los punteros raw
+//! del `wl_surface`/`wl_display` (envuelta en [`RawSurface`]) y la pintamos
+//! reusando el pipeline de Llimphi (`mount → compute → paint → render`).
+//!
+//! **Estado**: pinta todas las barras de la config (varios bordes a la vez),
+//! con input (teclado/clicks), drawer Quake, window_list, clipboard y tray.
+//! Verificado en Hyprland. No se verifica headless: se itera en un compositor
+//! real.
+//!
+//! **Gotcha Vulkan WSI + smithay (mirada):** NO reconfigurar el swapchain por
+//! cuadro. [`Self::draw`] llama a `surface.resize(w, h)` cada frame; por eso
+//! `RawSurface::resize` es no-op cuando el tamaño no cambia. Reconfigurar el
+//! swapchain reconstruye el `wl_buffer` y destruye el recién presentado antes de
+//! que el compositor lo componga — wlroots lo tolera, smithay no (la barra queda
+//! negra, el compositor ve `buffer=None`). Ver commit `b8747b90`.
+
+use std::error::Error;
+use std::ffi::c_void;
+use std::ptr::NonNull;
+
+use raw_window_handle::{
+    RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
+};
+use smithay_client_toolkit::{
+    compositor::{CompositorHandler, CompositorState, Region},
+    delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_pointer,
+    delegate_registry, delegate_seat,
+    output::{OutputHandler, OutputState},
+    registry::{ProvidesRegistryState, RegistryState},
+    registry_handlers,
+    seat::{
+        keyboard::{KeyEvent as KbEvent, KeyboardHandler, Keysym, Modifiers},
+        pointer::{PointerEvent, PointerEventKind, PointerHandler, BTN_LEFT, BTN_RIGHT},
+        Capability, SeatHandler, SeatState,
+    },
+    shell::{
+        wlr_layer::{
+            Anchor as LayerAnchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler,
+            LayerSurface, LayerSurfaceConfigure,
+        },
+        WaylandSurface,
+    },
+};
+use wayland_client::{
+    event_created_child,
+    globals::registry_queue_init,
+    protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
+    Connection, Dispatch, Proxy, QueueHandle,
+};
+use wayland_protocols_wlr::foreign_toplevel::v1::client::{
+    zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1},
+    zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1, EVT_TOPLEVEL_OPCODE},
+};
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_compositor::{
+    hit_test_click, hit_test_hover, hit_test_scroll, measure_text_node, mount, paint, DragFn,
+    DragPhase, Mounted,
+};
+use llimphi_ui::llimphi_hal::{wgpu, Hal, RawSurface, Surface as _};
+use llimphi_ui::llimphi_layout::{taffy, ComputedLayout, LayoutTree};
+use llimphi_ui::llimphi_raster::{peniko::color::palette, vello, Renderer};
+use llimphi_ui::llimphi_text::Typesetter;
+
+use pata_core::config::FloatingCard;
+use pata_core::widget::{Widget, WidgetCtx};
+use pata_core::{Anchor, Config, SurfaceKind};
+
+use crate::nouser::{self, MembersOutcome, NavState, PollOutcome};
+use crate::sampler::SamplerHandle;
+use pata_host::HostServer;
+use crate::toplevel::{Toplevel, WindowEntry};
+use crate::tray::TrayHandle;
+use crate::{render, Model, Msg};
+
+use std::sync::mpsc::{Receiver, Sender};
+
+/// Traza de diagnóstico gateada por `PATA_DIAG` (cualquier valor la enciende).
+/// Para depurar el camino layer-shell en hardware sin recompilar A/B:
+/// `PATA_DIAG=1 pata-llimphi 2>&1 | tee /tmp/pata.log`.
+macro_rules! diag {
+    ($($a:tt)*) => {
+        if std::env::var_os("PATA_DIAG").is_some() {
+            eprintln!($($a)*);
+        }
+    };
+}
+
+/// El estado wgpu de **una** layer surface (una barra). El `Hal` (instancia +
+/// device de wgpu) se comparte entre todas las barras, en [`LayerApp::hal`].
+struct PanelGpu {
+    surface: RawSurface,
+    renderer: Renderer,
+    typesetter: Typesetter,
+    scene: vello::Scene,
+    layout: LayoutTree,
+}
+
+/// El árbol pintado en el último frame de un panel, para hacer hit-test de los
+/// clicks (qué nodo está bajo el puntero y qué `on_click` dispara).
+struct RenderCache {
+    mounted: Mounted<Msg>,
+    computed: ComputedLayout,
+}
+
+/// Un arrastre en curso sobre un nodo arrastrable (p. ej. un nodo del grafo del
+/// navegador, que selecciona al soltar). El backend layer-shell rastrea el press→
+/// move→release mínimo para invocar el handler `draggable` del nodo —el bucle
+/// winit lo hace nativo; acá lo replicamos a mano para que el modo grafo
+/// seleccione también bajo Wayland.
+struct LayerDrag {
+    /// El handler del nodo: `Fn(DragPhase, dx, dy) -> Option<Msg>`.
+    handler: DragFn<Msg>,
+    /// Última posición del puntero, para el delta de cada `Move`.
+    last: (f32, f32),
+}
+
+/// El estado de una tarjeta flotante (estilo conky) montada como su propia layer
+/// surface: la spec (título/tamaño) + sus widgets vivos. La diferencia con una
+/// barra es que vive en `Layer::Bottom` sobre el escritorio, no reserva franja y
+/// no toma teclado.
+struct CardState {
+    spec: FloatingCard,
+    widgets: Vec<Box<dyn Widget>>,
+}
+
+/// Una layer surface de pata: o una **barra** anclada a un borde (con sus tres
+/// slots), o una **tarjeta flotante** (`card`). En ambos casos lleva su propio
+/// estado wgpu y su cache de hit-test.
+struct Panel {
+    /// Índice de su superficie en `cfg.surfaces` (la barra, o el `Panel` dueño de
+    /// la tarjeta).
+    idx: usize,
+    /// `Some` si esta surface es una tarjeta flotante; `None` si es una barra.
+    card: Option<CardState>,
+    layer: LayerSurface,
+    /// El árbol del último frame (para hit-test de clicks).
+    cache: Option<RenderCache>,
+    width: u32,
+    height: u32,
+    /// `true` cuando hay algo nuevo que pintar (cambió el muestreo o el tamaño).
+    dirty: bool,
+    /// Nodo bajo el puntero en este panel (para `hover_fill` y, a futuro,
+    /// tooltips). `None` si el puntero no está sobre ningún nodo hovereable.
+    hover_idx: Option<usize>,
+    gpu: Option<PanelGpu>,
+}
+
+/// Alto del drawer Quake cuando se despliega (px). El compositor lo clampa a la
+/// salida; la barra crece hacia arriba hasta este alto.
+const DRAWER_H: u32 = 420;
+
+/// Alto de la barra superior cuando despliega el menú de inicio (px): crece hacia
+/// abajo hasta este alto, manteniendo su exclusive zone en el grosor de la barra.
+const MENU_H: u32 = 480;
+
+/// El cliente Wayland del backend layer-shell.
+struct LayerApp {
+    registry_state: RegistryState,
+    output_state: OutputState,
+    seat_state: SeatState,
+    conn: Connection,
+    /// `Hal` compartido (una instancia/device de wgpu para todas las barras).
+    hal: Option<Hal>,
+    keyboard: Option<wl_keyboard::WlKeyboard>,
+    pointer: Option<wl_pointer::WlPointer>,
+    /// El seat (para activar ventanas: `activate(seat)` lo exige).
+    seat: Option<wl_seat::WlSeat>,
+    /// El manager de wlr-foreign-toplevel, si el compositor lo expone. `None` en
+    /// compositores sin el protocolo: el `window_list` queda vacío, sin romper.
+    /// Se guarda para mantener vivo el binding (de él cuelgan los eventos de cada
+    /// toplevel), aunque no se vuelva a leer.
+    #[allow(dead_code)]
+    toplevel_mgr: Option<ZwlrForeignToplevelManagerV1>,
+    /// Las ventanas abiertas que reporta el compositor.
+    toplevels: Vec<Toplevel>,
+    /// Contador para asignar [`Toplevel::id`] estables.
+    next_toplevel_id: u32,
+    /// Texto del portapapeles (una línea), para el widget `clipboard`. Se
+    /// re-muestrea con el resto del sistema (~1Hz) vía `wl-paste`.
+    clipboard: Option<String>,
+    /// La bandeja del sistema (StatusNotifierItem), corriendo en su propio hilo.
+    /// `None` si la config no tiene ningún widget `tray`.
+    tray: Option<TrayHandle>,
+    theme: Theme,
+    cfg: Config,
+    surfaces: Vec<crate::SurfaceWidgets>,
+    shuma: crate::shuma::ShumaState,
+    /// Índice (en `panels`) de la barra que hospeda el `shuma_input`, si hay.
+    shuma_panel: Option<usize>,
+    /// Grosor original (px) de esa barra — al que vuelve al replegar el drawer.
+    shuma_bar_px: u32,
+    /// Registro de apps para el menú de inicio (descubierto del dir canónico).
+    registry: app_bus::AppRegistry,
+    /// `true` cuando el menú de inicio está desplegado.
+    menu_open: bool,
+    /// Índice (en `panels`) de la barra que hospeda el `start_button`, si hay.
+    menu_panel: Option<usize>,
+    /// Grosor original (px) de esa barra — al que vuelve al replegar el menú.
+    menu_bar_px: u32,
+    /// Muestreador del sistema en su propio hilo (subprocesos wpctl/wl-paste sin
+    /// tocar el bucle de UI). Publica un snapshot ~1Hz; `maybe_sample` lo recoge.
+    sampler: SamplerHandle,
+    /// Último snapshot del sistema recogido del hilo de muestreo.
+    ctx: WidgetCtx,
+    /// Comando del Quake corriendo en un hilo: su resultado llega por aquí. El
+    /// latido del frame-callback lo sondea (`try_recv`) sin bloquear el loop.
+    exec_rx: Option<std::sync::mpsc::Receiver<crate::shuma::RunResult>>,
+    /// Estado del sidebar navegador (Mónadas de nouser). Vacío si la config no
+    /// declara un navegador.
+    nav: NavState,
+    /// Canal por donde el hilo de poll de `list_monads` entrega resultados (~2s).
+    /// `None` si la config no tiene navegador (no se arranca el hilo).
+    nav_rx: Option<Receiver<PollOutcome>>,
+    /// Canal para que los hilos one-shot de `resolve_monad` entreguen miembros.
+    members_tx: Sender<MembersOutcome>,
+    members_rx: Receiver<MembersOutcome>,
+    /// Arrastre en curso (selección de nodo del grafo). `None` si no se arrastra.
+    drag: Option<LayerDrag>,
+    /// Servidor del rail hospedado: apps que prestan sus dientes a pata mientras
+    /// tienen foco. `None` si la config no tiene ningún sidebar donde alojarlos.
+    host: Option<HostServer>,
+    /// Última revisión vista del `host` (para detectar altas/bajas/updates y
+    /// re-pintar los sidebars).
+    last_host_rev: u64,
+    /// Una layer surface por cada barra de la config.
+    panels: Vec<Panel>,
+    /// Índice (en `panels`) de la surface del **tooltip flotante**: una layer
+    /// surface en `Overlay`, reubicada al hover. `None` si no se creó.
+    tooltip_pi: Option<usize>,
+    /// Texto del tooltip actualmente visible (`None` = oculto).
+    tooltip_text: Option<String>,
+    exit: bool,
+}
+
+/// El anclaje sctk + el tamaño `(w, h)` pedido para un borde y grosor. El eje
+/// libre va en 0 → el compositor lo estira al ancho/alto de la salida.
+fn anchor_y_size(anchor: Anchor, thickness: u32) -> (LayerAnchor, (u32, u32)) {
+    match anchor {
+        Anchor::Top => (
+            LayerAnchor::TOP | LayerAnchor::LEFT | LayerAnchor::RIGHT,
+            (0, thickness),
+        ),
+        Anchor::Bottom => (
+            LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT,
+            (0, thickness),
+        ),
+        Anchor::Left => (
+            LayerAnchor::LEFT | LayerAnchor::TOP | LayerAnchor::BOTTOM,
+            (thickness, 0),
+        ),
+        Anchor::Right => (
+            LayerAnchor::RIGHT | LayerAnchor::TOP | LayerAnchor::BOTTOM,
+            (thickness, 0),
+        ),
+    }
+}
+
+/// Levanta el backend layer-shell. Devuelve error si no hay sesión Wayland o el
+/// compositor no expone `wlr-layer-shell` (en ese caso el caller cae a winit).
+pub fn run() -> Result<(), Box<dyn Error>> {
+    let cfg = pata_config::load();
+    let bars: Vec<usize> = cfg
+        .surfaces
+        .iter()
+        .enumerate()
+        .filter(|(_, s)| s.kind == SurfaceKind::Bar)
+        .map(|(i, _)| i)
+        .collect();
+    // Los sidebars (Fase 11) también se anclan como layer surfaces propias.
+    let sidebars: Vec<usize> = cfg
+        .surfaces
+        .iter()
+        .enumerate()
+        .filter(|(_, s)| s.kind == SurfaceKind::Sidebar)
+        .map(|(i, _)| i)
+        .collect();
+    if bars.is_empty() && sidebars.is_empty() {
+        return Err("pata · la config no tiene ninguna superficie anclable (bar/sidebar)".into());
+    }
+    diag!(
+        "pata diag · backend LAYER-SHELL arranca · {} barra(s) + {} sidebar(s)",
+        bars.len(),
+        sidebars.len()
+    );
+
+    let conn = Connection::connect_to_env()?;
+    let (globals, mut event_queue) = registry_queue_init(&conn)?;
+    let qh: QueueHandle<LayerApp> = event_queue.handle();
+
+    let compositor = CompositorState::bind(&globals, &qh)?;
+    let layer_shell = LayerShell::bind(&globals, &qh)?;
+
+    // El manager de ventanas (window_list): opcional. Si el compositor no lo
+    // expone, el widget queda vacío en vez de fallar el arranque.
+    let toplevel_mgr = globals
+        .bind::<ZwlrForeignToplevelManagerV1, _, _>(&qh, 1..=3, ())
+        .ok();
+    if toplevel_mgr.is_none() {
+        eprintln!("pata layer · el compositor no expone wlr-foreign-toplevel; window_list vacío");
+    }
+
+    // El tray sólo arranca (y toma el nombre del watcher) si la config lo pide.
+    let tray = crate::config_tiene_widget(&cfg, "tray")
+        .then(TrayHandle::spawn)
+        .flatten();
+
+    // Plano de datos del sidebar: un hilo que poolea `list_monads` cada ~2s y
+    // entrega por canal (mismo patrón que el sampler/exec — el bucle Wayland lo
+    // sondea sin bloquear). Sólo arranca si la config declara un navegador.
+    let nav_rx = crate::config_tiene_navigator(&cfg).then(|| {
+        let (tx, rx) = std::sync::mpsc::channel::<PollOutcome>();
+        std::thread::spawn(move || {
+            let mut socket = None;
+            loop {
+                let outcome = nouser::poll(socket.clone());
+                socket = match &outcome {
+                    PollOutcome::Ok { socket: s, .. } => Some(s.clone()),
+                    PollOutcome::Failed(_) => None,
+                };
+                if tx.send(outcome).is_err() {
+                    break; // el bucle de UI terminó
+                }
+                std::thread::sleep(nouser::REFRESH_INTERVAL);
+            }
+        });
+        rx
+    });
+    let (members_tx, members_rx) = std::sync::mpsc::channel::<MembersOutcome>();
+
+    let (surfaces, shuma) = Model::construir(&cfg);
+    // El sampler en UTC si la config lo pide (se lee antes de mover `cfg` al app).
+    let utc = crate::usa_utc(&cfg);
+    let mut app = LayerApp {
+        registry_state: RegistryState::new(&globals),
+        output_state: OutputState::new(&globals, &qh),
+        seat_state: SeatState::new(&globals, &qh),
+        conn,
+        hal: None,
+        keyboard: None,
+        pointer: None,
+        seat: None,
+        toplevel_mgr,
+        toplevels: Vec::new(),
+        next_toplevel_id: 0,
+        clipboard: None,
+        tray,
+        theme: Theme::dark(),
+        cfg,
+        surfaces,
+        shuma,
+        // Se calculan después, una vez creados los panels.
+        shuma_panel: None,
+        shuma_bar_px: 40,
+        registry: app_bus::AppRegistry::discover(),
+        menu_open: false,
+        menu_panel: None,
+        menu_bar_px: 32,
+        sampler: SamplerHandle::spawn(utc),
+        ctx: WidgetCtx::default(),
+        exec_rx: None,
+        nav: NavState::default(),
+        nav_rx,
+        members_tx,
+        members_rx,
+        drag: None,
+        // El rail hospedado sólo tiene sentido si hay un sidebar donde alojar los
+        // dientes de la app enfocada.
+        host: (!sidebars.is_empty()).then(HostServer::spawn).flatten(),
+        last_host_rev: 0,
+        panels: Vec::new(),
+        tooltip_pi: None,
+        tooltip_text: None,
+        exit: false,
+    };
+
+    // Roundtrip para que `OutputState` reciba `wl_output.geometry` + el
+    // `xdg_output.name` de cada monitor: lo necesitamos para resolver
+    // `Surface::output` (nombre del conector) a un `wl_output` real antes
+    // de pedir cada layer surface. SCTK publica el nombre en el segundo
+    // roundtrip (xdg-output llega después del wl_output base).
+    event_queue.roundtrip(&mut app)?;
+    event_queue.roundtrip(&mut app)?;
+
+    // Mapa `nombre del conector → wl_output` (ej. `"HDMI-A-1" → WlOutput`).
+    // Sin nombre (compositor sin xdg-output) la entrada se omite — esa
+    // salida sólo es alcanzable con `output: ""` (= primario, sin hint).
+    let mut outputs_by_name: std::collections::HashMap<String, wl_output::WlOutput> =
+        std::collections::HashMap::new();
+    for out in app.output_state.outputs() {
+        if let Some(info) = app.output_state.info(&out) {
+            if let Some(name) = info.name {
+                outputs_by_name.insert(name, out);
+            }
+        }
+    }
+    diag!("pata diag · outputs descubiertos: {:?}", outputs_by_name.keys().collect::<Vec<_>>());
+
+    // Resuelve `output: String` de la config a `Option<&wl_output>`. Vacío =
+    // None (el compositor decide). Nombre desconocido = None + aviso (la
+    // surface cae al primario en vez de fallar el arranque).
+    let resolve_output =
+        |name: &str| -> Option<wl_output::WlOutput> {
+            if name.is_empty() {
+                return None;
+            }
+            if let Some(o) = outputs_by_name.get(name) {
+                return Some(o.clone());
+            }
+            eprintln!("pata layer · output «{name}» no conectado; cae al primario");
+            None
+        };
+
+    // Una layer surface por barra: anclada a su borde, con su exclusive zone.
+    for &idx in &bars {
+        let s = &app.cfg.surfaces[idx];
+        let thickness = s.thickness.max(1.0) as u32;
+        let (sctk_anchor, size) = anchor_y_size(s.anchor, thickness);
+        let wl_surface = compositor.create_surface(&qh);
+        let layer = layer_shell.create_layer_surface(
+            &qh,
+            wl_surface,
+            Layer::Top,
+            Some("pata".to_string()),
+            resolve_output(&s.output).as_ref(),
+        );
+        layer.set_anchor(sctk_anchor);
+        layer.set_size(size.0, size.1);
+        layer.set_exclusive_zone(thickness as i32);
+        layer.commit();
+        app.panels.push(Panel {
+            idx,
+            card: None,
+            layer,
+            cache: None,
+            width: size.0.max(1),
+            height: thickness,
+            dirty: true,
+            hover_idx: None,
+            gpu: None,
+        });
+    }
+
+    // Una layer surface por sidebar (Fase 11): rail anclado al borde vertical con
+    // exclusive zone = su grosor. Colapsado mide `thickness`; al abrir un diente
+    // la surface CRECE en ancho (a `thickness + panel_width`) manteniendo la
+    // exclusive zone, así el panel flota sobre el área de trabajo sin recolocar el
+    // teselado — el mismo truco del drawer de shuma, pero en el eje horizontal.
+    for &idx in &sidebars {
+        let s = &app.cfg.surfaces[idx];
+        let thickness = s.thickness.max(1.0) as u32;
+        let (sctk_anchor, size) = anchor_y_size(s.anchor, thickness);
+        let wl_surface = compositor.create_surface(&qh);
+        let layer = layer_shell.create_layer_surface(
+            &qh,
+            wl_surface,
+            Layer::Top,
+            Some("pata-sidebar".to_string()),
+            resolve_output(&s.output).as_ref(),
+        );
+        layer.set_anchor(sctk_anchor);
+        layer.set_size(size.0, size.1);
+        layer.set_exclusive_zone(thickness as i32);
+        // Sin teclado: la navegación es por clic (como las barras). Se cierra el
+        // panel volviendo a clickear el diente.
+        layer.set_keyboard_interactivity(KeyboardInteractivity::None);
+        layer.commit();
+        app.panels.push(Panel {
+            idx,
+            card: None,
+            layer,
+            cache: None,
+            width: thickness,
+            height: size.1.max(1),
+            dirty: true,
+            hover_idx: None,
+            gpu: None,
+        });
+    }
+
+    // Tarjetas flotantes (estilo conky): cada `card` de una superficie `Panel`
+    // es su propia layer surface en `Layer::Bottom` (sobre el escritorio,
+    // debajo de las ventanas), anclada a la esquina superior-izquierda con
+    // margen (x, y) y del tamaño (w, h) de la tarjeta. No reserva franja ni
+    // toma teclado. Heredan el `output` del Panel padre.
+    for (idx, s) in app.cfg.surfaces.iter().enumerate() {
+        if s.kind != SurfaceKind::Panel {
+            continue;
+        }
+        let panel_output = resolve_output(&s.output);
+        for card in &s.cards {
+            let (cw, ch) = (card.w.max(1.0) as u32, card.h.max(1.0) as u32);
+            let wl_surface = compositor.create_surface(&qh);
+            let layer = layer_shell.create_layer_surface(
+                &qh,
+                wl_surface,
+                Layer::Bottom,
+                Some("pata-card".to_string()),
+                panel_output.as_ref(),
+            );
+            layer.set_anchor(LayerAnchor::TOP | LayerAnchor::LEFT);
+            layer.set_size(cw, ch);
+            // Margen: (top, right, bottom, left). (x, y) desde la esquina sup-izq.
+            layer.set_margin(card.y as i32, 0, 0, card.x as i32);
+            layer.set_exclusive_zone(0);
+            layer.set_keyboard_interactivity(KeyboardInteractivity::None);
+            layer.commit();
+            let widgets = card.widgets.iter().map(pata_core::widget::build).collect();
+            app.panels.push(Panel {
+                idx,
+                card: Some(CardState { spec: card.clone(), widgets }),
+                layer,
+                cache: None,
+                width: cw,
+                height: ch,
+                dirty: true,
+                hover_idx: None,
+                gpu: None,
+            });
+        }
+    }
+
+    // ¿Qué barra hospeda el shuma_input? Esa recibe foco de teclado al clickearla
+    // (OnDemand) para poder desplegar el Quake y escribir.
+    app.shuma_panel = app.panels.iter().position(|p| {
+        let s = &app.cfg.surfaces[p.idx];
+        s.start
+            .iter()
+            .chain(&s.center)
+            .chain(&s.end)
+            .any(|w| w.kind == "shuma_input")
+    });
+    app.shuma_bar_px = app
+        .shuma_panel
+        .map(|pi| app.cfg.surfaces[app.panels[pi].idx].thickness.max(1.0) as u32)
+        .unwrap_or(40);
+    if let Some(pi) = app.shuma_panel {
+        // Barra cerrada: NO pide teclado. Con `OnDemand` el compositor
+        // consumía el primer click para darle foco (de ahí «dos clicks para
+        // desplegar»); con `None` el click va directo a togglear.
+        app.panels[pi]
+            .layer
+            .set_keyboard_interactivity(KeyboardInteractivity::None);
+        app.panels[pi].layer.commit();
+    }
+
+    // La surface del tooltip flotante: una layer surface en Overlay (sobre todo),
+    // anclada arriba-izquierda, sin teclado ni zona exclusiva y con **región de
+    // input vacía** (no roba clicks ni hover). Arranca 1×1 fuera de vista; al
+    // hover se redimensiona y reubica con `set_margin`. Sin buffer hasta el primer
+    // tooltip → no se mapea (invisible). Sin output específico: la pone el
+    // compositor donde caiga el puntero.
+    app.tooltip_pi = {
+        let wl_surface = compositor.create_surface(&qh);
+        if let Ok(region) = Region::new(&compositor) {
+            // Región vacía (sin add): el tooltip nunca intercepta el puntero.
+            wl_surface.set_input_region(Some(region.wl_region()));
+        }
+        let layer = layer_shell.create_layer_surface(
+            &qh,
+            wl_surface,
+            Layer::Overlay,
+            Some("pata-tooltip".to_string()),
+            None,
+        );
+        layer.set_anchor(LayerAnchor::TOP | LayerAnchor::LEFT);
+        layer.set_size(1, 1);
+        layer.set_margin(100_000, 0, 0, 0); // fuera de vista
+        layer.set_exclusive_zone(0);
+        layer.set_keyboard_interactivity(KeyboardInteractivity::None);
+        layer.commit();
+        app.panels.push(Panel {
+            idx: 0,
+            card: None,
+            layer,
+            cache: None,
+            width: 1,
+            height: 1,
+            dirty: false,
+            hover_idx: None,
+            gpu: None,
+        });
+        Some(app.panels.len() - 1)
+    };
+
+    // ¿Qué barra hospeda el start_button? Esa crece hacia abajo al desplegar el
+    // menú de inicio (mismo truco que shuma, hacia el otro lado).
+    app.menu_panel = app.panels.iter().position(|p| {
+        let s = &app.cfg.surfaces[p.idx];
+        s.start
+            .iter()
+            .chain(&s.center)
+            .chain(&s.end)
+            .any(|w| w.kind == "start_button")
+    });
+    app.menu_bar_px = app
+        .menu_panel
+        .map(|pi| app.cfg.surfaces[app.panels[pi].idx].thickness.max(1.0) as u32)
+        .unwrap_or(32);
+
+    while !app.exit {
+        if let Err(e) = event_queue.blocking_dispatch(&mut app) {
+            // El compositor cerró la conexión (se apagó / Ctrl+Alt+Backspace):
+            // es una salida normal, no una falla del backend. Devolvemos Ok para
+            // que el caller NO caiga a la ventana winit (que paniquearía al no
+            // encontrar compositor). La caída a winit es sólo para cuando el
+            // layer-shell no arranca de entrada (X11, sin wlr-layer-shell).
+            eprintln!("pata layer · el compositor cerró la conexión: {e}");
+            break;
+        }
+    }
+    Ok(())
+}
+
+impl LayerApp {
+    /// Índice del panel cuya layer surface es `surface`.
+    fn panel_de(&self, surface: &wl_surface::WlSurface) -> Option<usize> {
+        self.panels
+            .iter()
+            .position(|p| p.layer.wl_surface() == surface)
+    }
+
+    /// Marca la barra de shuma para re-pintar (tras teclear, etc.).
+    fn marcar_shuma_dirty(&mut self) {
+        if let Some(pi) = self.shuma_panel {
+            self.panels[pi].dirty = true;
+        }
+    }
+
+    /// Marca todas las barras para re-pintar (p. ej. cambió la lista de ventanas).
+    fn marcar_todo_dirty(&mut self) {
+        for p in &mut self.panels {
+            p.dirty = true;
+        }
+    }
+
+    /// La lista de ventanas para el render del `window_list`, desde los toplevels
+    /// que reporta el compositor.
+    fn window_entries(&self) -> Vec<WindowEntry> {
+        self.toplevels
+            .iter()
+            .map(|t| WindowEntry {
+                id: t.id,
+                label: t.etiqueta(),
+                app_id: t.app_id.clone(),
+                active: t.activated,
+                minimized: t.minimized,
+            })
+            .collect()
+    }
+
+    /// El toplevel con ese `id`, si sigue abierto.
+    fn toplevel_por_id(&self, id: u32) -> Option<&Toplevel> {
+        self.toplevels.iter().find(|t| t.id == id)
+    }
+
+    /// Despliega o repliega el drawer Quake: agranda/encoge la layer surface de
+    /// la barra de shuma hacia arriba (su exclusive zone queda en el grosor de la
+    /// barra, así no recoloca el teselado) y toma/suelta el foco de teclado.
+    fn set_shuma_open(&mut self, open: bool) {
+        let Some(pi) = self.shuma_panel else { return };
+        if self.shuma.open == open {
+            return;
+        }
+        self.shuma.open = open;
+        let h = if open { DRAWER_H } else { self.shuma_bar_px };
+        let layer = &self.panels[pi].layer;
+        layer.set_size(0, h);
+        // Abierto: foco Exclusive para escribir. Cerrado: `None` — no
+        // retiene el teclado, así una app lanzada (kitty) lo recibe.
+        layer.set_keyboard_interactivity(if open {
+            KeyboardInteractivity::Exclusive
+        } else {
+            KeyboardInteractivity::None
+        });
+        layer.commit();
+        // El cache de hit-test es del layout viejo; invalidarlo evita que el
+        // click siguiente pegue contra el árbol previo («no se guarda»). Se
+        // re-arma en el próximo frame con la geometría nueva.
+        self.panels[pi].cache = None;
+        self.panels[pi].dirty = true;
+    }
+
+    /// Despliega/repliega el menú de inicio: agranda/encoge hacia abajo la layer
+    /// surface de la barra del `start_button` (su exclusive zone queda en el
+    /// grosor de la barra, así no recoloca el teselado). No toma teclado (clics).
+    fn set_menu_open(&mut self, open: bool) {
+        let Some(pi) = self.menu_panel else { return };
+        if self.menu_open == open {
+            return;
+        }
+        self.menu_open = open;
+        let h = if open { MENU_H } else { self.menu_bar_px };
+        let layer = &self.panels[pi].layer;
+        layer.set_size(0, h);
+        layer.commit();
+        // Invalida el cache de hit-test (geometría vieja) — igual que shuma.
+        self.panels[pi].cache = None;
+        self.panels[pi].dirty = true;
+    }
+
+    /// Actualiza el tooltip flotante para el nodo `node_idx` bajo el cursor en el
+    /// panel `pi`: si ese nodo tiene texto de tooltip, redimensiona y reubica la
+    /// surface del tooltip bajo el widget y la marca para pintar; si no, la
+    /// oculta. Reposiciona sólo al cambiar de nodo (no sigue al cursor). Pinta de
+    /// inmediato (`draw`) porque los eventos llegan por OTRA surface.
+    fn update_tooltip(&mut self, pi: usize, node_idx: Option<usize>, qh: &QueueHandle<Self>) {
+        let Some(tpi) = self.tooltip_pi else { return };
+        if pi == tpi {
+            return;
+        }
+        // Texto + rect del nodo hovereado, desde el cache de hit-test del panel.
+        let info = node_idx.and_then(|i| {
+            let c = self.panels[pi].cache.as_ref()?;
+            let node = c.mounted.nodes.get(i)?;
+            let text = node.tooltip.clone()?;
+            let rect = c.computed.get(node.id)?;
+            Some((text, rect))
+        });
+        match info {
+            Some((text, rect)) => {
+                // Posición: bajo el widget. La barra superior (la única con
+                // widgets hovereables) está en y=0, así que su alto da el offset.
+                let x = rect.x.max(0.0) as i32;
+                let y = self.panels[pi].height as i32 + 4;
+                // Tamaño estimado (no medimos texto acá): ~8px/glifo + padding.
+                let w = (text.chars().count() as u32 * 8 + 16).clamp(24, 600);
+                let h = 24u32;
+                self.tooltip_text = Some(text);
+                {
+                    let layer = &self.panels[tpi].layer;
+                    layer.set_size(w, h);
+                    layer.set_margin(y, 0, 0, x);
+                    layer.commit();
+                }
+                self.panels[tpi].width = w;
+                self.panels[tpi].height = h;
+                self.panels[tpi].dirty = true;
+                self.draw(tpi, qh);
+            }
+            None => self.hide_tooltip(qh),
+        }
+    }
+
+    /// Oculta el tooltip: lo empuja fuera de vista (1×1 con margen enorme) y lo
+    /// re-pinta una vez ahí. No hace nada si ya está oculto.
+    fn hide_tooltip(&mut self, qh: &QueueHandle<Self>) {
+        let Some(tpi) = self.tooltip_pi else { return };
+        if self.tooltip_text.is_none() {
+            return;
+        }
+        self.tooltip_text = None;
+        {
+            let layer = &self.panels[tpi].layer;
+            layer.set_size(1, 1);
+            layer.set_margin(100_000, 0, 0, 0);
+            layer.commit();
+        }
+        self.panels[tpi].width = 1;
+        self.panels[tpi].height = 1;
+        self.panels[tpi].dirty = true;
+        self.draw(tpi, qh);
+    }
+
+    /// Lanza una app del menú por su `id` y cierra el menú. Sólo `Exec` spawnea;
+    /// `Action`/`Wasm` los despacharía un host con chasis (acá no aplica).
+    fn lanzar_app(&mut self, id: String) {
+        if let Some(app) = self.registry.get(&id) {
+            let _ = app.spawn();
+        }
+        self.set_menu_open(false);
+    }
+
+    /// Enter en el drawer: corre el comando por el **ejecutor real de shuma**
+    /// (`shuma::ejecutar`, sobre `shuma-exec`) en un hilo de fondo y muestra su
+    /// salida en el drawer — el puente pata→shuma del SDD §5. El hilo manda el
+    /// `Result` por un canal que `poll_exec` sondea cada frame; la UI no se
+    /// bloquea. El drawer **queda abierto** (sigue Exclusive, podés encadenar
+    /// comandos y leer la salida); se cierra con Esc. Para lanzar una app GUI y
+    /// olvidarte, está el launcher (clic en un ítem) o `Super+p`.
+    fn shuma_submit(&mut self) {
+        let cmd = std::mem::take(&mut self.shuma.buffer);
+        if cmd.trim().is_empty() {
+            self.marcar_shuma_dirty();
+            return;
+        }
+        self.shuma.push_pending(cmd.clone());
+        let (tx, rx) = std::sync::mpsc::channel();
+        std::thread::spawn(move || {
+            let _ = tx.send(crate::shuma::ejecutar(&cmd));
+        });
+        self.exec_rx = Some(rx);
+        self.marcar_shuma_dirty();
+    }
+
+    /// Sondea (sin bloquear) si el comando del Quake terminó; si sí, guarda su
+    /// salida y re-pinta. Se llama en cada frame (el latido del shuma corre a
+    /// ~60fps, así que el resultado aparece a los ~16ms de terminar).
+    fn poll_exec(&mut self) {
+        let got = self.exec_rx.as_ref().and_then(|rx| rx.try_recv().ok());
+        if let Some(res) = got {
+            self.shuma.finish_last(res);
+            self.exec_rx = None;
+            self.marcar_shuma_dirty();
+        }
+    }
+
+    /// Sondea (sin bloquear) el plano de datos del sidebar: aplica el último poll
+    /// de `list_monads` y cualquier `resolve_monad` que haya terminado. Si cambió
+    /// algo, marca las superficies sidebar para re-pintar. Se llama en cada frame.
+    fn poll_nav(&mut self) {
+        let mut cambios = false;
+        // Drena el poll de Mónadas (nos quedamos con el último si hay varios).
+        if let Some(rx) = self.nav_rx.as_ref() {
+            let mut ultimo = None;
+            while let Ok(o) = rx.try_recv() {
+                ultimo = Some(o);
+            }
+            if let Some(outcome) = ultimo {
+                match outcome {
+                    PollOutcome::Ok { socket, resp } => {
+                        self.nav.socket = Some(socket);
+                        self.nav.apply_monads(*resp);
+                    }
+                    PollOutcome::Failed(e) => {
+                        self.nav.socket = None;
+                        self.nav.error = Some(e);
+                    }
+                }
+                cambios = true;
+            }
+        }
+        // Drena los miembros resueltos.
+        while let Ok(outcome) = self.members_rx.try_recv() {
+            match outcome {
+                MembersOutcome::Ok { monad, members } => self.nav.apply_members(monad, members),
+                MembersOutcome::Failed(e) => self.nav.error = Some(e),
+            }
+            cambios = true;
+        }
+        if cambios {
+            self.marcar_sidebars_dirty();
+        }
+    }
+
+    /// El `app_id` del toplevel que tiene foco ahora, si hay alguno.
+    fn focused_app_id(&self) -> Option<&str> {
+        self.toplevels
+            .iter()
+            .find(|t| t.activated)
+            .map(|t| t.app_id.as_str())
+    }
+
+    /// Sondea el rail hospedado: si cambió su revisión (un alta/baja/update de
+    /// dientes de alguna app), re-pinta los sidebars. El cambio de foco ya re-pinta
+    /// vía `marcar_todo_dirty` (eventos de toplevel).
+    fn poll_host(&mut self) {
+        let Some(h) = &self.host else { return };
+        let rev = h.revision();
+        if rev != self.last_host_rev {
+            self.last_host_rev = rev;
+            self.marcar_sidebars_dirty();
+        }
+    }
+
+    /// Marca todas las superficies sidebar para re-pintar.
+    fn marcar_sidebars_dirty(&mut self) {
+        for p in &mut self.panels {
+            if p.card.is_none() && self.cfg.surfaces[p.idx].kind == SurfaceKind::Sidebar {
+                p.dirty = true;
+            }
+        }
+    }
+
+    /// Índice (en `panels`) de la layer surface del sidebar `si`.
+    fn sidebar_panel_de(&self, si: usize) -> Option<usize> {
+        self.panels.iter().position(|p| p.idx == si && p.card.is_none())
+    }
+
+    /// Activa/repliega el diente `(si, ti)`: actualiza el estado y **redimensiona**
+    /// la layer surface del sidebar (crece a `thickness + panel_width` al abrir,
+    /// vuelve a `thickness` al cerrar). La exclusive zone no cambia, así el panel
+    /// flota sobre el área de trabajo (drawer horizontal).
+    fn set_sidebar_open(&mut self, si: usize, ti: usize) {
+        self.nav.toggle_tab(si, ti);
+        let Some(pi) = self.sidebar_panel_de(si) else {
+            return;
+        };
+        let s = &self.cfg.surfaces[si];
+        let thickness = s.thickness.max(1.0) as u32;
+        let abierto = matches!(self.nav.open, Some((s2, _)) if s2 == si);
+        let w = if abierto {
+            thickness + s.panel_width.max(1.0) as u32
+        } else {
+            thickness
+        };
+        {
+            let layer = &self.panels[pi].layer;
+            layer.set_size(w, 0);
+            layer.commit();
+        }
+        // El cache de hit-test es del layout viejo; invalidarlo (igual que shuma).
+        self.panels[pi].cache = None;
+        self.panels[pi].dirty = true;
+    }
+
+    /// Cierra el panel del sidebar (si alguno está abierto) y encoge su surface.
+    fn cerrar_sidebar(&mut self) {
+        if let Some((si, ti)) = self.nav.open {
+            self.set_sidebar_open(si, ti); // toggle del abierto = cerrar
+        }
+    }
+
+    /// Expande/colapsa un nodo del navegador; al abrir una Mónada sin miembros
+    /// resueltos lanza su `resolve_monad` en un hilo one-shot (entrega por canal).
+    fn nav_toggle(&mut self, id: u64) {
+        if self.nav.expanded.contains(&id) {
+            self.nav.expanded.remove(&id);
+        } else {
+            self.nav.expanded.insert(id);
+            if let (Some(mid), Some(sock)) =
+                (self.nav.needs_resolve(id), self.nav.socket.clone())
+            {
+                let tx = self.members_tx.clone();
+                std::thread::spawn(move || {
+                    let _ = tx.send(nouser::resolve(sock, mid));
+                });
+            }
+        }
+        self.marcar_sidebars_dirty();
+    }
+
+    /// Recoge el último snapshot del hilo de muestreo (no bloquea). Si llegó uno
+    /// nuevo, `tick`ea los widgets y marca todas las barras para re-pintar. El
+    /// muestreo en sí (subprocesos que pueden colgarse) vive en `SamplerHandle`,
+    /// nunca acá: el bucle de UI no se bloquea aunque wpctl/wl-paste se cuelguen.
+    fn maybe_sample(&mut self) {
+        let Some((ctx, clipboard)) = self.sampler.latest() else {
+            return;
+        };
+        self.ctx = ctx;
+        self.clipboard = clipboard;
+        for sw in &mut self.surfaces {
+            for w in sw.core_mut() {
+                w.tick(&ctx);
+            }
+        }
+        // Las tarjetas flotantes tienen sus widgets en su propio Panel.
+        for p in &mut self.panels {
+            if let Some(c) = p.card.as_mut() {
+                for w in &mut c.widgets {
+                    w.tick(&ctx);
+                }
+            }
+            p.dirty = true;
+        }
+    }
+
+    /// Crea el estado wgpu de un panel sobre los punteros raw de Wayland
+    /// (`wl_display` + `wl_surface`). El `Hal` se comparte; lo crea el primer panel
+    /// **eligiendo el adaptador compatible con su surface** (el dispositivo que
+    /// mirada compone) — clave en multi-GPU/Optimus. Los paneles siguientes reusan
+    /// ese `Hal` (mismo compositor → mismo dispositivo, el adaptador ya sirve).
+    fn ensure_gpu(&mut self, pi: usize) {
+        if self.panels[pi].gpu.is_some() {
+            return;
+        }
+        let display_ptr = self.conn.backend().display_ptr() as *mut c_void;
+        let surface_ptr = self.panels[pi].layer.wl_surface().id().as_ptr() as *mut c_void;
+        let (w, h) = (self.panels[pi].width, self.panels[pi].height);
+        let display_handle = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(
+            NonNull::new(display_ptr).expect("wl_display ptr"),
+        ));
+        let window_handle = RawWindowHandle::Wayland(WaylandWindowHandle::new(
+            NonNull::new(surface_ptr).expect("wl_surface ptr"),
+        ));
+        // SAFETY: los handles apuntan a objetos Wayland que `self` mantiene vivos
+        // (la conexión y la layer surface) durante toda la vida de la surface.
+        let make_target = || wgpu::SurfaceTargetUnsafe::RawHandle {
+            raw_display_handle: display_handle,
+            raw_window_handle: window_handle,
+        };
+
+        let surface = if self.hal.is_none() {
+            // Primer panel: crea el Hal pidiendo el adaptador compatible con ESTA
+            // surface (no `HighPerformance` a ciegas, que en Optimus agarraría la
+            // GPU equivocada → 0 formatos).
+            match pollster::block_on(unsafe { Hal::new_for_raw_surface(make_target, w, h) }) {
+                Ok((hal, surface)) => {
+                    self.hal = Some(hal);
+                    surface
+                }
+                Err(e) => {
+                    eprintln!("pata layer · panel {pi} sin gpu: {e}");
+                    return;
+                }
+            }
+        } else {
+            // Paneles siguientes: reusan el Hal ya creado.
+            let hal = self.hal.as_ref().expect("hal");
+            let wgpu_surface = match unsafe { hal.instance.create_surface_unsafe(make_target()) } {
+                Ok(s) => s,
+                Err(e) => {
+                    eprintln!("pata layer · panel {pi} sin gpu: {e}");
+                    return;
+                }
+            };
+            // Sin formatos la WSI no soporta esta surface: en vez de paniquear,
+            // dejamos el panel sin gpu (no pinta) y seguimos — un panel roto no
+            // tira todo el marco. Reintenta en el próximo `draw`.
+            match RawSurface::from_surface(hal, wgpu_surface, w, h) {
+                Ok(s) => s,
+                Err(e) => {
+                    eprintln!("pata layer · panel {pi} sin gpu: {e}");
+                    return;
+                }
+            }
+        };
+        let hal = self.hal.as_ref().expect("hal");
+        diag!(
+            "pata diag · panel {pi} surface creada {w}x{h} · backend={:?} format={:?}",
+            hal.adapter.get_info().backend,
+            surface.format(),
+        );
+        let renderer = Renderer::new(hal).expect("renderer");
+        self.panels[pi].gpu = Some(PanelGpu {
+            surface,
+            renderer,
+            typesetter: Typesetter::new(),
+            scene: vello::Scene::new(),
+            layout: LayoutTree::new(),
+        });
+    }
+
+    /// Mantiene vivo el latido de un panel: pide su siguiente frame-callback.
+    fn latido(&self, pi: usize, qh: &QueueHandle<Self>) {
+        let surface = self.panels[pi].layer.wl_surface();
+        surface.frame(qh, surface.clone());
+        surface.commit();
+    }
+
+    /// Avanza el frame de un panel: re-muestrea ~1Hz (compartido) y pinta sólo si
+    /// hay algo nuevo; entre cambios sólo mantiene el latido.
+    fn draw(&mut self, pi: usize, qh: &QueueHandle<Self>) {
+        self.maybe_sample();
+        self.poll_exec();
+        self.poll_nav();
+        self.poll_host();
+        self.ensure_gpu(pi);
+
+        if !self.panels[pi].dirty {
+            self.latido(pi, qh);
+            return;
+        }
+
+        let idx = self.panels[pi].idx;
+        let (w, h) = (self.panels[pi].width, self.panels[pi].height);
+        let windows = self.window_entries();
+        let tray_items = self.tray.as_ref().map(|t| t.items()).unwrap_or_default();
+        let data = render::BarData {
+            windows: &windows,
+            clipboard: self.clipboard.as_deref(),
+            tray: &tray_items,
+        };
+        // Una tarjeta flotante pinta su contenido (relleno de su surface); la
+        // barra de shuma desplegada pinta el drawer (cuerpo + cabezal); el resto
+        // pinta su barra normal.
+        let view = if self.tooltip_pi == Some(pi) {
+            // La surface del tooltip: pinta la cajita con el texto actual (o vacía
+            // cuando está oculta fuera de vista).
+            render::tooltip_view(self.tooltip_text.as_deref().unwrap_or(""), &self.theme)
+        } else if let Some(c) = self.panels[pi].card.as_ref() {
+            render::card_view(&c.spec, &c.widgets, &self.theme)
+        } else if self.menu_panel == Some(pi) && self.menu_open {
+            render::start_menu_view(
+                &self.cfg.surfaces[idx],
+                &self.surfaces[idx],
+                &self.shuma,
+                &data,
+                &self.theme,
+                self.menu_bar_px as f32,
+                self.registry.all(),
+            )
+        } else if self.shuma_panel == Some(pi) && self.shuma.open {
+            // Viewport del historial: la surface menos la barra, la línea de
+            // input y los paddings. Lo cacheamos para que el clamp del scroll
+            // en `update` (rueda/arrastre) sea exacto.
+            let vh = (h as f32 - self.shuma_bar_px as f32 - 60.0).max(40.0);
+            self.shuma.viewport_h = vh;
+            render::shuma_open_view(
+                &self.cfg.surfaces[idx],
+                &self.surfaces[idx],
+                &self.shuma,
+                &data,
+                &self.theme,
+                self.shuma_bar_px as f32,
+                vh,
+            )
+        } else if self.cfg.surfaces[idx].kind == SurfaceKind::Sidebar {
+            // Dientes hospedados de la app enfocada (si registró alguno en el host).
+            let hosted = {
+                let app = self.focused_app_id().map(|s| s.to_string());
+                match (app, self.host.as_ref()) {
+                    (Some(id), Some(h)) => h.snapshot(&id).map(|(_, teeth)| (id, teeth)),
+                    _ => None,
+                }
+            };
+            let (hosted_app, hosted_teeth): (&str, &[pata_host::HostedTooth]) = match &hosted {
+                Some((id, teeth)) => (id.as_str(), teeth.as_slice()),
+                None => ("", &[]),
+            };
+            render::sidebar_surface_view(
+                &self.cfg.surfaces[idx],
+                idx,
+                w as f32,
+                h as f32,
+                &self.nav,
+                hosted_teeth,
+                hosted_app,
+                &self.shuma,
+                &self.theme,
+            )
+        } else {
+            render::bar_view(
+                &self.cfg.surfaces[idx],
+                &self.surfaces[idx],
+                &self.shuma,
+                &data,
+                &self.theme,
+            )
+        };
+
+        let hover_idx = self.panels[pi].hover_idx;
+        let hal = self.hal.as_ref().expect("hal");
+        let gpu = match self.panels[pi].gpu.as_mut() {
+            Some(g) => g,
+            None => {
+                self.latido(pi, qh);
+                return;
+            }
+        };
+        gpu.surface.resize(w, h);
+        let frame = match gpu.surface.acquire() {
+            Ok(f) => f,
+            Err(_) => {
+                // Soltamos el préstamo mutable de `gpu` antes de tocar `self`.
+                let _ = gpu;
+                self.latido(pi, qh);
+                return;
+            }
+        };
+        gpu.layout.clear();
+        let mounted: Mounted<Msg> = mount(&mut gpu.layout, view);
+        let computed = {
+            let ts = &mut gpu.typesetter;
+            let tmap = &mounted.text_measures;
+            gpu.layout
+                .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
+                    match tmap.get(&nid) {
+                        Some(tm) => measure_text_node(ts, tm, known, avail),
+                        None => taffy::Size::ZERO,
+                    }
+                })
+                .expect("layout")
+        };
+        gpu.scene.reset();
+        paint(&mut gpu.scene, &mounted, &computed, &mut gpu.typesetter, hover_idx, None);
+        if let Err(e) = gpu.renderer.render(hal, &gpu.scene, &frame, palette::css::BLACK) {
+            eprintln!("pata layer · render: {e}");
+        }
+        gpu.surface.present(frame, hal);
+        diag!("pata diag · present panel {pi} {w}x{h}");
+
+        // Recién con el cuadro presentado damos el panel por limpio: si la
+        // adquisición hubiera fallado, `dirty` sigue puesto y el próximo
+        // frame-callback reintenta (no esperamos al re-muestreo de 1 Hz).
+        self.panels[pi].dirty = false;
+        // Guarda el árbol pintado para el hit-test de los clicks.
+        self.panels[pi].cache = Some(RenderCache { mounted, computed });
+        self.latido(pi, qh);
+    }
+
+    /// Aplica el `Msg` que produjo un click: togglear shuma (su cabezal) o lanzar
+    /// el comando de un widget con `exec`. El resto no sale de un click.
+    fn handle_msg(&mut self, msg: Msg) {
+        match msg {
+            Msg::ShumaToggle => self.set_shuma_open(!self.shuma.open),
+            // Clic en una etapa de pipe de una card: re-ejecuta la línea
+            // truncada en el drawer (lo abre si estaba cerrado).
+            Msg::ShumaRunLine(line) => {
+                if !line.trim().is_empty() {
+                    if !self.shuma.open {
+                        self.set_shuma_open(true);
+                    }
+                    self.shuma.buffer = line;
+                    self.shuma_submit();
+                }
+            }
+            // Clic en el `$` de una card: la pliega/despliega.
+            Msg::ShumaCollapse(idx) => {
+                if let Some(b) = self.shuma.blocks.get_mut(idx) {
+                    b.collapsed = !b.collapsed;
+                    self.marcar_shuma_dirty();
+                }
+            }
+            Msg::ShumaScroll(delta) => {
+                self.shuma.scroll_by(delta);
+                self.marcar_shuma_dirty();
+            }
+            Msg::Spawn(cmd) => crate::spawn_cmd(&cmd),
+            Msg::StartToggle => self.set_menu_open(!self.menu_open),
+            Msg::LaunchApp(id) => self.lanzar_app(id),
+            Msg::ActivateWindow(id) => self.activar_ventana(id),
+            Msg::CloseWindow(id) => self.cerrar_ventana(id),
+            Msg::TrayActivate(key) => {
+                if let Some(t) = &self.tray {
+                    t.activate(key);
+                }
+            }
+            // --- Sidebar navegador (Fase 11c-layer) ---
+            Msg::NavTabActivate(si, ti) => self.set_sidebar_open(si, ti),
+            Msg::NavClosePanel => self.cerrar_sidebar(),
+            Msg::NavSetMode(m) => {
+                self.nav.mode = m;
+                self.marcar_sidebars_dirty();
+            }
+            Msg::NavSelect(id) => {
+                self.nav.selected = Some(id);
+                self.marcar_sidebars_dirty();
+            }
+            Msg::NavToggle(id) => self.nav_toggle(id),
+            Msg::NavContextMenu(id) => {
+                // Fase 11d-extra: right-click sobre archivo abre el menú "Abrir con…".
+                if let Some(path) = self.nav.file_path(id).map(str::to_owned) {
+                    let opts = crate::open::handlers_for_path(&self.registry, &path);
+                    self.nav.open_menu(id, opts);
+                    self.marcar_sidebars_dirty();
+                }
+            }
+            Msg::NavOpenWith(id, app_id) => {
+                if let Some(path) = self.nav.file_path(id).map(str::to_owned) {
+                    match app_id {
+                        Some(aid) => {
+                            let _ = crate::open::open_with_id(&self.registry, &aid, &path);
+                        }
+                        None => {
+                            let _ = crate::open::open_system(&path);
+                        }
+                    }
+                }
+                self.nav.close_menu();
+                self.marcar_sidebars_dirty();
+            }
+            Msg::NavMenuCancel => {
+                self.nav.close_menu();
+                self.marcar_sidebars_dirty();
+            }
+            Msg::HostToothActivate(app_id, tooth) => {
+                // Reenvía el clic del diente hospedado a la app enfocada; ella
+                // muestra ese panel sobre su propio canvas.
+                if let Some(h) = &self.host {
+                    h.activate(&app_id, tooth);
+                }
+            }
+            Msg::NavScroll(delta) => {
+                self.nav.scroll = (self.nav.scroll + delta).max(0.0);
+                self.marcar_sidebars_dirty();
+            }
+            Msg::Quit => self.exit = true,
+            _ => {}
+        }
+    }
+
+    /// Click en una ventana del task manager (estilo KDE): si ya está activa, la
+    /// **minimiza**; si no, la trae al frente (y la desminimiza). Sin seat (raro)
+    /// no hace nada. El compositor responde con `state`/`done` que actualiza el
+    /// resaltado y el atenuado.
+    fn activar_ventana(&mut self, id: u32) {
+        let Some(seat) = self.seat.clone() else { return };
+        if let Some(t) = self.toplevel_por_id(id) {
+            if t.activated {
+                t.handle.set_minimized();
+            } else {
+                t.handle.unset_minimized();
+                t.handle.activate(&seat);
+            }
+        }
+    }
+
+    /// Cierra la ventana `id` (clic derecho en su chip del task manager). El
+    /// compositor manda `closed` que la retira de la lista.
+    fn cerrar_ventana(&mut self, id: u32) {
+        if let Some(t) = self.toplevel_por_id(id) {
+            t.handle.close();
+        }
+    }
+}
+
+impl CompositorHandler for LayerApp {
+    fn scale_factor_changed(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_surface::WlSurface,
+        _: i32,
+    ) {
+    }
+
+    fn transform_changed(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_surface::WlSurface,
+        _: wl_output::Transform,
+    ) {
+    }
+
+    fn frame(
+        &mut self,
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+        surface: &wl_surface::WlSurface,
+        _: u32,
+    ) {
+        if let Some(pi) = self.panel_de(surface) {
+            self.draw(pi, qh);
+        }
+    }
+
+    fn surface_enter(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_surface::WlSurface,
+        _: &wl_output::WlOutput,
+    ) {
+    }
+
+    fn surface_leave(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_surface::WlSurface,
+        _: &wl_output::WlOutput,
+    ) {
+    }
+}
+
+impl LayerShellHandler for LayerApp {
+    fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {
+        // Cerrar cualquier barra cierra el marco entero.
+        self.exit = true;
+    }
+
+    fn configure(
+        &mut self,
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+        layer: &LayerSurface,
+        configure: LayerSurfaceConfigure,
+        _: u32,
+    ) {
+        let (cw, ch) = configure.new_size;
+        let Some(pi) = self.panel_de(layer.wl_surface()) else {
+            return;
+        };
+        diag!("pata diag · configure panel {pi} new_size={cw}x{ch}");
+        // El compositor nos da el tamaño definitivo (el eje libre ya resuelto).
+        if cw > 0 {
+            self.panels[pi].width = cw;
+        }
+        if ch > 0 {
+            self.panels[pi].height = ch;
+        }
+        self.panels[pi].dirty = true; // tamaño nuevo → re-pintar
+        self.draw(pi, qh);
+    }
+}
+
+impl OutputHandler for LayerApp {
+    fn output_state(&mut self) -> &mut OutputState {
+        &mut self.output_state
+    }
+    fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+    fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+    fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+}
+
+impl SeatHandler for LayerApp {
+    fn seat_state(&mut self) -> &mut SeatState {
+        &mut self.seat_state
+    }
+    fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
+        // Guardamos el seat para poder activar ventanas (`activate(seat)`).
+        if self.seat.is_none() {
+            self.seat = Some(seat);
+        }
+    }
+
+    fn new_capability(
+        &mut self,
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+        seat: wl_seat::WlSeat,
+        capability: Capability,
+    ) {
+        match capability {
+            Capability::Keyboard if self.keyboard.is_none() => {
+                if let Ok(kbd) = self.seat_state.get_keyboard(qh, &seat, None) {
+                    self.keyboard = Some(kbd);
+                }
+            }
+            Capability::Pointer if self.pointer.is_none() => {
+                if let Ok(ptr) = self.seat_state.get_pointer(qh, &seat) {
+                    self.pointer = Some(ptr);
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn remove_capability(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: wl_seat::WlSeat,
+        capability: Capability,
+    ) {
+        match capability {
+            Capability::Keyboard => {
+                if let Some(k) = self.keyboard.take() {
+                    k.release();
+                }
+            }
+            Capability::Pointer => {
+                if let Some(p) = self.pointer.take() {
+                    p.release();
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
+}
+
+impl KeyboardHandler for LayerApp {
+    fn enter(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_keyboard::WlKeyboard,
+        _: &wl_surface::WlSurface,
+        _: u32,
+        _: &[u32],
+        _: &[Keysym],
+    ) {
+    }
+
+    fn leave(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_keyboard::WlKeyboard,
+        _: &wl_surface::WlSurface,
+        _: u32,
+    ) {
+    }
+
+    fn press_key(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_keyboard::WlKeyboard,
+        _: u32,
+        event: KbEvent,
+    ) {
+        // El teclado sólo nos importa con el drawer abierto (foco Exclusive).
+        if !self.shuma.open {
+            return;
+        }
+        match event.keysym {
+            Keysym::Escape => self.set_shuma_open(false),
+            Keysym::BackSpace => {
+                self.shuma.buffer.pop();
+                self.marcar_shuma_dirty();
+            }
+            Keysym::Return | Keysym::KP_Enter => self.shuma_submit(),
+            _ => {
+                if let Some(txt) = event.utf8 {
+                    if !txt.is_empty() && !txt.chars().any(|c| c.is_control()) {
+                        self.shuma.buffer.push_str(&txt);
+                        self.marcar_shuma_dirty();
+                    }
+                }
+            }
+        }
+    }
+
+    fn release_key(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_keyboard::WlKeyboard,
+        _: u32,
+        _: KbEvent,
+    ) {
+    }
+
+    fn update_modifiers(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &wl_keyboard::WlKeyboard,
+        _: u32,
+        _: Modifiers,
+        _: u32,
+    ) {
+    }
+}
+
+impl PointerHandler for LayerApp {
+    fn pointer_frame(
+        &mut self,
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+        _: &wl_pointer::WlPointer,
+        events: &[PointerEvent],
+    ) {
+        for e in events {
+            // Hover: el nodo bajo el puntero da feedback (`hover_fill`) y, si
+            // tiene texto de tooltip, lo muestra en la surface flotante. El
+            // layer-shell no trackeaba hover (pasaba `None` a `paint`), así que el
+            // realce estaba muerto en todas las barras.
+            match e.kind {
+                PointerEventKind::Motion { .. } => {
+                    // Drag en curso: el delta va al handler del nodo (Move). El
+                    // nodegraph del navegador no reposiciona (devuelve None en
+                    // Move); selecciona al soltar (End).
+                    if self.drag.is_some() {
+                        let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+                        let (handler, last) = {
+                            let d = self.drag.as_ref().unwrap();
+                            (d.handler.clone(), d.last)
+                        };
+                        if let Some(d) = self.drag.as_mut() {
+                            d.last = (px, py);
+                        }
+                        if let Some(msg) = (handler)(DragPhase::Move, px - last.0, py - last.1) {
+                            self.handle_msg(msg);
+                        }
+                        continue;
+                    }
+                    if let Some(pi) = self.panel_de(&e.surface) {
+                        let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+                        let nuevo = self.panels[pi]
+                            .cache
+                            .as_ref()
+                            .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py));
+                        if self.panels[pi].hover_idx != nuevo {
+                            self.panels[pi].hover_idx = nuevo;
+                            self.panels[pi].dirty = true;
+                            self.update_tooltip(pi, nuevo, qh);
+                        }
+                    }
+                    continue;
+                }
+                PointerEventKind::Leave { .. } => {
+                    if let Some(pi) = self.panel_de(&e.surface) {
+                        if self.panels[pi].hover_idx.is_some() {
+                            self.panels[pi].hover_idx = None;
+                            self.panels[pi].dirty = true;
+                        }
+                    }
+                    self.hide_tooltip(qh);
+                    continue;
+                }
+                _ => {}
+            }
+            // Rueda sobre el historial del drawer: el nodo de scroll bajo el
+            // cursor consume el delta y emite `ShumaScroll`. Convención de
+            // signo igual que llimphi-ui (wayland y winit traen el eje y con
+            // signos opuestos, así que acá NO se niega).
+            if let PointerEventKind::Axis { vertical, .. } = e.kind {
+                let dy = if vertical.discrete != 0 {
+                    vertical.discrete as f32
+                } else {
+                    vertical.absolute as f32 / 20.0
+                };
+                if dy != 0.0 {
+                    let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+                    if let Some(pi) = self.panel_de(&e.surface) {
+                        let msg = self.panels[pi].cache.as_ref().and_then(|c| {
+                            hit_test_scroll(&c.mounted, &c.computed, px, py)
+                                .and_then(|i| c.mounted.nodes.get(i))
+                                .and_then(|n| n.on_scroll.as_ref().and_then(|h| h(0.0, dy)))
+                        });
+                        if let Some(msg) = msg {
+                            self.handle_msg(msg);
+                        }
+                    }
+                }
+                continue;
+            }
+            // Soltar el botón izquierdo termina un drag en curso: el handler del
+            // nodo recibe `End` y emite su Msg (p. ej. seleccionar el nodo del
+            // grafo). Los deltas en End los ignoran los consumidores.
+            if let PointerEventKind::Release { button, .. } = e.kind {
+                if button == BTN_LEFT {
+                    if let Some(d) = self.drag.take() {
+                        if let Some(msg) = (d.handler)(DragPhase::End, 0.0, 0.0) {
+                            self.handle_msg(msg);
+                        }
+                    }
+                }
+                continue;
+            }
+            if let PointerEventKind::Press { button, .. } = e.kind {
+                if button != BTN_LEFT && button != BTN_RIGHT {
+                    continue;
+                }
+                // Hit-test: qué nodo está bajo el puntero y qué handler dispara.
+                // El izquierdo usa `on_click` (cabezal de shuma, activar ventana,
+                // lanzar exec); el derecho `on_right_click` (cerrar ventana del
+                // task manager). El click ya dio foco de teclado.
+                let Some(pi) = self.panel_de(&e.surface) else {
+                    continue;
+                };
+                let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+                let derecho = button == BTN_RIGHT;
+                // Nodo arrastrable bajo el press (izquierdo): arranca un drag y NO
+                // lo tratamos como click (el nodegraph selecciona al soltar).
+                if !derecho {
+                    let handler = self.panels[pi].cache.as_ref().and_then(|c| {
+                        let i = hit_test_click(&c.mounted, &c.computed, px, py)?;
+                        c.mounted.nodes.get(i)?.drag.clone()
+                    });
+                    if let Some(handler) = handler {
+                        self.drag = Some(LayerDrag { handler, last: (px, py) });
+                        continue;
+                    }
+                }
+                let msg = self.panels[pi].cache.as_ref().and_then(|c| {
+                    let i = hit_test_click(&c.mounted, &c.computed, px, py)?;
+                    let n = c.mounted.nodes.get(i)?;
+                    // Primero el handler simple (`on_click`/`on_right_click`); si no
+                    // hay, el `*_at` (coords locales al nodo) — lo usan widgets que
+                    // coexisten con drag, como los dientes del rail. Paridad con el
+                    // bucle winit, que también prioriza así.
+                    if derecho {
+                        if let Some(m) = n.on_right_click.clone() {
+                            return Some(m);
+                        }
+                        let at = n.on_right_click_at.as_ref()?;
+                        let r = c.computed.get(n.id)?;
+                        at(px - r.x, py - r.y, r.w, r.h)
+                    } else {
+                        if let Some(m) = n.on_click.clone() {
+                            return Some(m);
+                        }
+                        let at = n.on_click_at.as_ref()?;
+                        let r = c.computed.get(n.id)?;
+                        at(px - r.x, py - r.y, r.w, r.h)
+                    }
+                });
+                if let Some(msg) = msg {
+                    self.handle_msg(msg);
+                }
+            }
+        }
+    }
+}
+
+impl ProvidesRegistryState for LayerApp {
+    fn registry(&mut self) -> &mut RegistryState {
+        &mut self.registry_state
+    }
+    registry_handlers![OutputState, SeatState];
+}
+
+/// El manager de ventanas: anuncia un toplevel nuevo (creando su handle hijo) y
+/// el fin del servicio. `event_created_child!` declara cómo enrutar el handle que
+/// nace en el evento `toplevel` (sin esto, wayland-client paniquea al recibirlo).
+impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for LayerApp {
+    fn event(
+        state: &mut Self,
+        _mgr: &ZwlrForeignToplevelManagerV1,
+        event: zwlr_foreign_toplevel_manager_v1::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        use zwlr_foreign_toplevel_manager_v1::Event;
+        match event {
+            Event::Toplevel { toplevel } => {
+                let id = state.next_toplevel_id;
+                state.next_toplevel_id = state.next_toplevel_id.wrapping_add(1);
+                state.toplevels.push(Toplevel::new(id, toplevel));
+            }
+            Event::Finished => {
+                state.toplevels.clear();
+                state.marcar_todo_dirty();
+            }
+            _ => {}
+        }
+    }
+
+    event_created_child!(LayerApp, ZwlrForeignToplevelManagerV1, [
+        EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()),
+    ]);
+}
+
+/// Un handle de toplevel: el compositor le manda título / app_id / estado en
+/// eventos sueltos y los confirma con `done`; `closed` lo retira. Acumulamos en
+/// el [`Toplevel`] y aplicamos en `done` para no pintar estados a medias.
+impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for LayerApp {
+    fn event(
+        state: &mut Self,
+        handle: &ZwlrForeignToplevelHandleV1,
+        event: zwlr_foreign_toplevel_handle_v1::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        use zwlr_foreign_toplevel_handle_v1::Event;
+        let pos = state.toplevels.iter().position(|t| &t.handle == handle);
+        let Some(i) = pos else { return };
+        match event {
+            Event::Title { title } => state.toplevels[i].set_title(title),
+            Event::AppId { app_id } => state.toplevels[i].set_app_id(app_id),
+            Event::State { state: estados } => state.toplevels[i].set_state(&estados),
+            Event::Done => {
+                if state.toplevels[i].confirmar() {
+                    state.marcar_todo_dirty();
+                }
+            }
+            Event::Closed => {
+                let t = state.toplevels.remove(i);
+                t.handle.destroy();
+                state.marcar_todo_dirty();
+            }
+            _ => {}
+        }
+    }
+}
+
+delegate_compositor!(LayerApp);
+delegate_output!(LayerApp);
+delegate_layer!(LayerApp);
+delegate_seat!(LayerApp);
+delegate_keyboard!(LayerApp);
+delegate_pointer!(LayerApp);
+delegate_registry!(LayerApp);
diff --git a/02_ruway/pata/pata-llimphi/src/lib.rs b/02_ruway/pata/pata-llimphi/src/lib.rs
new file mode 100644
index 0000000..d8bbdde
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/lib.rs
@@ -0,0 +1,652 @@
+//! `pata-llimphi` — el frontend Linux del marco.
+//!
+//! Monta el modelo agnóstico de [`pata_core`] sobre Llimphi. El reparto de
+//! responsabilidades es la regla dura del repo (UIs intercambiables sobre un
+//! `*-core` agnóstico):
+//!
+//! - **`pata-core`** decide *qué* mostrar: resuelve la geometría
+//!   ([`pata_core::layout::resolve`]) y, por cada [`WidgetSpec`], materializa un
+//!   [`Widget`] que emite un view-model ([`WidgetView`]) en cada `tick`.
+//! - **este crate** decide *cómo*: muestrea el sistema en un
+//!   [`WidgetCtx`](pata_core::widget::WidgetCtx) (ver [`sampler`]) y traduce el
+//!   view-model a `View<Msg>` de Llimphi (ver [`render`]).
+//!
+//! El `shuma_input` es la excepción: es **interacción**, no modelo de dominio,
+//! así que lo intercepta el frontend (ver [`shuma`]) en lugar de pasar por el
+//! `build` agnóstico —igual que `mirada-launcher` trata su shuma_bar—.
+//!
+//! Hoy todas las superficies se pintan en una sola ventana, en los rects que el
+//! layout resolvió. Cuando el compositor `mirada` reconozca superficies `pata`
+//! (Fase 8), cada una será su propia ventana acoplada.
+
+pub mod keys;
+pub mod layer;
+pub mod nouser;
+pub mod open;
+pub mod render;
+pub mod sampler;
+pub mod shuma;
+pub mod toplevel;
+pub mod tray;
+
+use std::time::Duration;
+
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_theme::Theme;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+
+use llimphi_widget_navigator::{NavId, NavMode};
+
+use pata_core::config::{FloatingCard, SurfaceKind};
+use pata_core::widget::{build, Widget, WidgetCtx};
+use pata_core::{Config, Frame, Rect};
+
+use nouser::{MembersOutcome, NavState, PollOutcome};
+use sampler::Sampler;
+use shuma::ShumaState;
+use tray::TrayHandle;
+
+/// Los mensajes de la app.
+#[derive(Clone, Debug)]
+pub enum Msg {
+    /// Refresh periódico (1 Hz): re-muestrea el sistema y `tick`ea los widgets.
+    Tick,
+    /// Desplegar/replegar el drawer de shuma.
+    ShumaToggle,
+    /// Carácter al input de shuma.
+    ShumaChar(char),
+    /// Backspace en el input de shuma.
+    ShumaBackspace,
+    /// Enter en el input de shuma — ejecuta el comando.
+    ShumaSubmit,
+    /// Resultado estructurado del comando (líneas + código) para la card.
+    ShumaResult(shuma::RunResult),
+    /// Re-ejecutar una línea (clic en el comando de una card sin pipe).
+    ShumaRunLine(String),
+    /// Revelar/ocultar la salida capturada (tee) de una etapa intermedia de la
+    /// card `idx`: `(idx_card, idx_etapa)`.
+    ShumaStageToggle(usize, usize),
+    /// Plegar/desplegar la card `idx` del historial.
+    ShumaCollapse(usize),
+    /// Desplazar el historial del drawer `delta` px (rueda / arrastre de barra).
+    ShumaScroll(f32),
+    /// Tick de la animación de despliegue (sólo re-render).
+    ShumaAnim,
+    /// Lanzar un programa (click sobre un widget con prop `exec`).
+    Spawn(String),
+    /// Desplegar/replegar el menú del botón de inicio.
+    StartToggle,
+    /// Lanzar una app del menú de inicio por su `id` en el [`app_bus::AppRegistry`].
+    LaunchApp(String),
+    /// Activar una ventana del `window_list` (traerla al frente, o minimizarla si
+    /// ya está activa — estilo KDE). El `u32` es el [`toplevel::Toplevel::id`];
+    /// sólo el backend layer-shell sabe resolverlo.
+    ActivateWindow(u32),
+    /// Cerrar una ventana del task manager (clic derecho). El `u32` es el
+    /// [`toplevel::Toplevel::id`]; sólo el backend layer-shell sabe resolverlo.
+    CloseWindow(u32),
+    /// Activar un item del `tray` (click). El `String` es la `key` del
+    /// [`tray::TrayItem`]; sólo el backend layer-shell sabe resolverlo.
+    TrayActivate(String),
+    // --- Sidebar navegador (Fase 11c) ---
+    /// Clic en un diente del rail `(surface_idx, tab_idx)`: despliega/repliega su
+    /// panel navegador.
+    NavTabActivate(usize, usize),
+    /// Cerrar el panel navegador desplegado (Esc / clic fuera).
+    NavClosePanel,
+    /// Cambiar el modo del navegador (árbol/grafo).
+    NavSetMode(NavMode),
+    /// Seleccionar un nodo del navegador.
+    NavSelect(NavId),
+    /// Expandir/colapsar un nodo rama; al expandir una Mónada sin miembros
+    /// resueltos dispara su `resolve_monad`.
+    NavToggle(NavId),
+    /// Right-click sobre un nodo: si es un archivo, abre el menú "Abrir con…"
+    /// (precomputa sus apps); si no, no-op.
+    NavContextMenu(NavId),
+    /// Elegir cómo abrir el archivo del menú: `Some(app_id)` con esa app nativa,
+    /// `None` con el handler del sistema (`xdg-open`).
+    NavOpenWith(NavId, Option<String>),
+    /// Cerrar el menú "Abrir con…" sin abrir nada.
+    NavMenuCancel,
+    /// Clic en un diente **hospedado** (de la app enfocada) en el rail de pata:
+    /// `(app_id, tooth_id)`. Se reenvía a la app por el rail hospedado. Sólo el
+    /// backend layer-shell (que conoce el foco y corre el `HostServer`) lo resuelve.
+    HostToothActivate(String, u32),
+    /// Desplazar el panel navegador `delta` px.
+    NavScroll(f32),
+    /// Disparo periódico del poll de Mónadas (`list_monads`).
+    NavTick,
+    /// Resultado del poll de Mónadas.
+    NavPoll(PollOutcome),
+    /// Resultado de resolver los miembros de una Mónada.
+    NavMembers(MembersOutcome),
+    /// Cerrar la app.
+    Quit,
+}
+
+/// Un widget dentro de un slot: o un widget de `pata-core` (que emite un
+/// view-model), o el `shuma_input` —interacción que pinta el frontend—.
+pub enum SlotWidget {
+    /// Un widget builtin de `pata-core`. `exec` es el comando que lanza al
+    /// clickearlo (de la prop `exec` del spec), o `None` si no es clickeable.
+    Core {
+        widget: Box<dyn Widget>,
+        exec: Option<String>,
+    },
+    /// El botón de inicio: muestra su `label` y, al clickearlo, despliega el
+    /// menú nativo de apps (o lanza `exec` si la config lo fija, override estilo
+    /// waybar). Es interacción, no view-model de core.
+    Start {
+        /// Texto/ícono del botón (prop `label`, default `⊞`).
+        label: String,
+        /// Comando a lanzar en vez de abrir el menú, si la config lo fija.
+        exec: Option<String>,
+    },
+    /// El cabezal del shell; su estado vive en [`Model::shuma`].
+    Shuma,
+    /// La lista de ventanas abiertas. Es interacción + IPC (igual que `Shuma`):
+    /// los datos los provee el backend (vía wlr-foreign-toplevel en layer-shell)
+    /// y se pasan al render aparte, no por el view-model de core.
+    WindowList,
+    /// El portapapeles: muestra el texto copiado actual. Dato del host (vía
+    /// `wl-paste`), no del view-model de core. `exec` (opcional) es el comando a
+    /// lanzar al clickearlo — típicamente un selector de historial (cliphist).
+    Clipboard {
+        /// Comando del selector de historial, o `None` si no es clickeable.
+        exec: Option<String>,
+    },
+    /// La bandeja del sistema (StatusNotifierItem). Dato del host (vía D-Bus, ver
+    /// [`tray`]), no del view-model de core. Cada item se activa al clickearlo.
+    Tray,
+}
+
+/// `true` si la config pide el reloj en **UTC** (`general.timezone = "UTC"`).
+/// Cualquier otro valor (incluido `"auto"`) usa la hora local. Paridad con el
+/// `TzMode` de mirada-launcher (que sólo distinguía auto/UTC). Compartido por
+/// ambos backends para construir el sampler.
+pub fn usa_utc(cfg: &Config) -> bool {
+    cfg.general.timezone.trim().eq_ignore_ascii_case("utc")
+}
+
+/// Lanza `cmd` por `sh -c` como proceso hijo, sin esperarlo (no bloquea). Lo
+/// usan ambos backends al recibir [`Msg::Spawn`].
+pub fn spawn_cmd(cmd: &str) {
+    let _ = std::process::Command::new("sh").arg("-c").arg(cmd).spawn();
+}
+
+/// Envuelve `s` en comillas simples para `sh -c`, escapando comillas internas.
+/// Para pasar rutas con espacios al stand-in de apertura (Fase 11d).
+pub fn shell_quote(s: &str) -> String {
+    format!("'{}'", s.replace('\'', "'\\''"))
+}
+
+/// `true` si la config declara al menos un widget de ese `kind` en cualquier slot
+/// de cualquier superficie. Lo usan ambos backends para arrancar servicios caros
+/// (el tray, que toma el nombre del watcher) sólo si hacen falta.
+pub fn config_tiene_widget(cfg: &Config, kind: &str) -> bool {
+    cfg.surfaces.iter().any(|s| {
+        s.start
+            .iter()
+            .chain(&s.center)
+            .chain(&s.end)
+            .any(|w| w.kind == kind)
+    })
+}
+
+/// `true` si la config declara al menos un `SurfaceKind::Sidebar` con un diente
+/// cuyo contenido es un navegador (`kind = "navigator"`). Sólo entonces arranca
+/// el plano de datos de nouser (el poll periódico de Mónadas).
+pub fn config_tiene_navigator(cfg: &Config) -> bool {
+    cfg.surfaces
+        .iter()
+        .filter(|s| s.kind == SurfaceKind::Sidebar)
+        .flat_map(|s| s.tabs.iter())
+        .any(|t| t.content.kind == "navigator")
+}
+
+/// Los widgets vivos de una superficie, repartidos por slot.
+pub struct SurfaceWidgets {
+    /// Slot inicial (izquierda / arriba).
+    pub start: Vec<SlotWidget>,
+    /// Slot central.
+    pub center: Vec<SlotWidget>,
+    /// Slot final (derecha / abajo).
+    pub end: Vec<SlotWidget>,
+}
+
+impl SurfaceWidgets {
+    /// Itera los widgets de core de la superficie (los que se `tick`ean).
+    fn core_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Widget>> {
+        self.start
+            .iter_mut()
+            .chain(self.center.iter_mut())
+            .chain(self.end.iter_mut())
+            .filter_map(|sw| match sw {
+                SlotWidget::Core { widget, .. } => Some(widget),
+                SlotWidget::Start { .. }
+                | SlotWidget::Shuma
+                | SlotWidget::WindowList
+                | SlotWidget::Clipboard { .. }
+                | SlotWidget::Tray => None,
+            })
+    }
+}
+
+/// El estado de la app: config + geometría resuelta + widgets vivos + sampler.
+pub struct Model {
+    /// Paleta de Llimphi.
+    pub theme: Theme,
+    /// El marco declarado.
+    pub cfg: Config,
+    /// La geometría resuelta sobre la pantalla.
+    pub frame: Frame,
+    /// Widgets vivos, en el mismo orden que `cfg.surfaces`.
+    pub surfaces: Vec<SurfaceWidgets>,
+    /// Tarjetas flotantes (estilo conky) de las superficies `Panel`, cada una con
+    /// sus widgets vivos. En layer-shell cada tarjeta es su propia surface; en el
+    /// path winit se pintan en absoluto sobre la ventana única.
+    pub cards: Vec<(FloatingCard, Vec<Box<dyn Widget>>)>,
+    /// Estado del cabezal del shell y su drawer Quake.
+    pub shuma: ShumaState,
+    /// Registro de apps para el menú del botón de inicio.
+    pub registry: app_bus::AppRegistry,
+    /// `true` cuando el menú de inicio está desplegado.
+    pub menu_open: bool,
+    /// Muestreador del sistema (con estado para el delta de CPU).
+    pub sampler: Sampler,
+    /// Texto del portapapeles (una línea), para el widget `clipboard`. Se
+    /// re-muestrea cada tick vía `wl-paste`.
+    pub clipboard: Option<String>,
+    /// La bandeja del sistema, corriendo en su propio hilo. `None` si la config no
+    /// declara ningún widget `tray`.
+    pub tray: Option<TrayHandle>,
+    /// Estado del sidebar navegador (Mónadas de nouser). Vacío si la config no
+    /// declara ningún `SurfaceKind::Sidebar` con un navegador.
+    pub nav: NavState,
+    /// Tamaño de la pantalla en píxeles.
+    pub screen: (i32, i32),
+}
+
+impl Model {
+    /// Construye los widgets de cada superficie y el estado de shuma desde la
+    /// config. El primer `shuma_input` que aparece define el cabezal.
+    fn construir(cfg: &Config) -> (Vec<SurfaceWidgets>, ShumaState) {
+        let mut shuma = ShumaState::default();
+        let mut build_slot = |specs: &[pata_core::WidgetSpec]| -> Vec<SlotWidget> {
+            specs
+                .iter()
+                .map(|spec| {
+                    if spec.kind == "start_button" {
+                        let exec = spec.str_prop("exec", "");
+                        SlotWidget::Start {
+                            label: spec.str_prop("label", "⊞").to_string(),
+                            exec: (!exec.is_empty()).then(|| exec.to_string()),
+                        }
+                    } else if spec.kind == "shuma_input" {
+                        if !shuma.present {
+                            shuma = ShumaState::from_spec(spec);
+                        }
+                        SlotWidget::Shuma
+                    } else if spec.kind == "window_list" {
+                        SlotWidget::WindowList
+                    } else if spec.kind == "clipboard" {
+                        let exec = spec.str_prop("exec", "");
+                        SlotWidget::Clipboard {
+                            exec: (!exec.is_empty()).then(|| exec.to_string()),
+                        }
+                    } else if spec.kind == "tray" {
+                        SlotWidget::Tray
+                    } else {
+                        let exec = spec.str_prop("exec", "");
+                        SlotWidget::Core {
+                            widget: build(spec),
+                            exec: (!exec.is_empty()).then(|| exec.to_string()),
+                        }
+                    }
+                })
+                .collect()
+        };
+        let surfaces = cfg
+            .surfaces
+            .iter()
+            .map(|s| SurfaceWidgets {
+                start: build_slot(&s.start),
+                center: build_slot(&s.center),
+                end: build_slot(&s.end),
+            })
+            .collect();
+        (surfaces, shuma)
+    }
+
+    /// Construye las tarjetas flotantes de todas las superficies `Panel` con sus
+    /// widgets vivos. Compartido por el path winit ([`PataApp::init`]) y el
+    /// layer-shell ([`crate::layer`]): el modelo se escribe una vez.
+    pub fn construir_cards(cfg: &Config) -> Vec<(FloatingCard, Vec<Box<dyn Widget>>)> {
+        cfg.surfaces
+            .iter()
+            .filter(|s| s.kind == SurfaceKind::Panel)
+            .flat_map(|s| s.cards.iter())
+            .map(|card| {
+                let ws = card.widgets.iter().map(build).collect();
+                (card.clone(), ws)
+            })
+            .collect()
+    }
+
+    /// `tick`ea todos los widgets de core (barras y tarjetas) con el contexto dado.
+    fn tick_widgets(&mut self, ctx: &WidgetCtx) {
+        for sw in &mut self.surfaces {
+            for w in sw.core_mut() {
+                w.tick(ctx);
+            }
+        }
+        for (_, ws) in &mut self.cards {
+            for w in ws {
+                w.tick(ctx);
+            }
+        }
+    }
+
+    /// Arranca la animación del drawer hacia `destino` (0 = replegado, 1 =
+    /// desplegado) y dispara el bucle de `ShumaAnim`.
+    fn animar_shuma(&mut self, destino: f32, handle: &Handle<Msg>) {
+        let desde = self.shuma.anim.value();
+        self.shuma.anim = Tween::new(desde, destino, motion::FAST, motion::ease_out_cubic);
+        animate(handle, motion::FAST, || Msg::ShumaAnim);
+    }
+}
+
+/// Tamaño inicial de la ventana. Cuando mirada acople las superficies (Fase 8)
+/// esto lo fijará el compositor; por ahora cubrimos un 1080p.
+const PANTALLA: (i32, i32) = (1920, 1080);
+
+/// La app Llimphi del marco.
+pub struct PataApp;
+
+impl App for PataApp {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "pata"
+    }
+
+    fn app_id() -> Option<&'static str> {
+        Some("gioser.pata")
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (PANTALLA.0 as u32, PANTALLA.1 as u32)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        let cfg = pata_config::load();
+        let screen = PANTALLA;
+        let frame = pata_core::resolve(&cfg, Rect::new(0, 0, screen.0, screen.1));
+        let (surfaces, shuma) = Model::construir(&cfg);
+        let cards = Model::construir_cards(&cfg);
+        let mut sampler = Sampler::with_utc(usa_utc(&cfg));
+        let ctx = sampler.sample();
+        let clipboard = crate::sampler::leer_clipboard();
+        let tray = config_tiene_widget(&cfg, "tray")
+            .then(TrayHandle::spawn)
+            .flatten();
+
+        let mut model = Model {
+            theme: Theme::dark(),
+            cfg,
+            frame,
+            surfaces,
+            cards,
+            shuma,
+            registry: app_bus::AppRegistry::discover(),
+            menu_open: false,
+            sampler,
+            clipboard,
+            tray,
+            nav: NavState::default(),
+            screen,
+        };
+        // Primer tick para que los widgets arranquen con datos.
+        model.tick_widgets(&ctx);
+
+        handle.spawn_periodic(Duration::from_secs(1), || Msg::Tick);
+        // Plano de datos del sidebar: poll de Mónadas a nouser, sólo si la config
+        // declara un navegador (no molestar al broker si no hace falta).
+        if config_tiene_navigator(&model.cfg) {
+            handle.dispatch(Msg::NavTick);
+            handle.spawn_periodic(nouser::REFRESH_INTERVAL, || Msg::NavTick);
+        }
+        model
+    }
+
+    fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+        match msg {
+            Msg::Tick => {
+                let ctx = model.sampler.sample();
+                model.tick_widgets(&ctx);
+                model.clipboard = crate::sampler::leer_clipboard();
+            }
+            Msg::Quit => handle.quit(),
+            Msg::ShumaToggle => {
+                if model.shuma.present {
+                    model.shuma.open = !model.shuma.open;
+                    let destino = if model.shuma.open { 1.0 } else { 0.0 };
+                    model.animar_shuma(destino, handle);
+                }
+            }
+            Msg::ShumaChar(c) => {
+                if model.shuma.open {
+                    model.shuma.buffer.push(c);
+                }
+            }
+            Msg::ShumaBackspace => {
+                if model.shuma.open {
+                    model.shuma.buffer.pop();
+                }
+            }
+            Msg::ShumaSubmit => {
+                if model.shuma.open {
+                    // El buffer sin prefijo `!`/`$` va a la IA; con prefijo, al
+                    // shell (paridad con el quake de mirada-launcher).
+                    let buffer = std::mem::take(&mut model.shuma.buffer);
+                    match shuma::classify(&buffer) {
+                        shuma::SubmitKind::Empty => {}
+                        shuma::SubmitKind::Shell(cmd) => {
+                            let cmd = cmd.to_string();
+                            model.shuma.push_pending(cmd.clone());
+                            handle.spawn(move || Msg::ShumaResult(shuma::ejecutar(&cmd)));
+                        }
+                        shuma::SubmitKind::Ia(prompt) => {
+                            let prompt = prompt.to_string();
+                            model.shuma.push_pending_ia(prompt.clone());
+                            handle.spawn(move || Msg::ShumaResult(shuma::preguntar_ia(&prompt)));
+                        }
+                    }
+                }
+            }
+            Msg::ShumaResult(res) => model.shuma.finish_last(res),
+            Msg::ShumaRunLine(line) => {
+                if model.shuma.open && !line.trim().is_empty() {
+                    model.shuma.push_pending(line.clone());
+                    handle.spawn(move || Msg::ShumaResult(shuma::ejecutar(&line)));
+                }
+            }
+            Msg::ShumaStageToggle(idx, stage) => {
+                if let Some(b) = model.shuma.blocks.get_mut(idx) {
+                    b.expanded_stage = if b.expanded_stage == Some(stage) {
+                        None
+                    } else {
+                        Some(stage)
+                    };
+                }
+            }
+            Msg::ShumaCollapse(idx) => {
+                if let Some(b) = model.shuma.blocks.get_mut(idx) {
+                    b.collapsed = !b.collapsed;
+                }
+            }
+            Msg::ShumaScroll(delta) => model.shuma.scroll_by(delta),
+            Msg::ShumaAnim => {}
+            Msg::Spawn(cmd) => spawn_cmd(&cmd),
+            Msg::StartToggle => model.menu_open = !model.menu_open,
+            Msg::LaunchApp(id) => {
+                if let Some(app) = model.registry.get(&id) {
+                    let _ = app.spawn();
+                }
+                model.menu_open = false;
+            }
+            Msg::TrayActivate(key) => {
+                if let Some(t) = &model.tray {
+                    t.activate(key);
+                }
+            }
+            // El window_list necesita el cliente foreign-toplevel del backend
+            // layer-shell; bajo el compositor mirada llegará por su IPC. No-op acá.
+            Msg::ActivateWindow(_) => {}
+            Msg::CloseWindow(_) => {}
+            // --- Sidebar navegador (Fase 11c) ---
+            Msg::NavTabActivate(si, ti) => model.nav.toggle_tab(si, ti),
+            Msg::NavClosePanel => model.nav.open = None,
+            Msg::NavSetMode(m) => model.nav.mode = m,
+            Msg::NavSelect(id) => model.nav.selected = Some(id),
+            Msg::NavToggle(id) => {
+                if model.nav.expanded.contains(&id) {
+                    model.nav.expanded.remove(&id);
+                } else {
+                    model.nav.expanded.insert(id);
+                    // Carga perezosa: al abrir una Mónada sin miembros, pídelos.
+                    if let (Some(mid), Some(sock)) =
+                        (model.nav.needs_resolve(id), model.nav.socket.clone())
+                    {
+                        handle.spawn(move || Msg::NavMembers(nouser::resolve(sock, mid)));
+                    }
+                }
+            }
+            Msg::NavContextMenu(id) => {
+                // Fase 11d-extra: right-click sobre un archivo abre el menú "Abrir
+                // con…". Precomputamos sus apps acá (con el registro) para que el
+                // render no lo toque.
+                if let Some(path) = model.nav.file_path(id).map(str::to_owned) {
+                    let opts = open::handlers_for_path(&model.registry, &path);
+                    model.nav.open_menu(id, opts);
+                }
+            }
+            Msg::NavOpenWith(id, app_id) => {
+                if let Some(path) = model.nav.file_path(id).map(str::to_owned) {
+                    match app_id {
+                        Some(aid) => {
+                            let _ = open::open_with_id(&model.registry, &aid, &path);
+                        }
+                        None => {
+                            let _ = open::open_system(&path);
+                        }
+                    }
+                }
+                model.nav.close_menu();
+            }
+            Msg::NavMenuCancel => model.nav.close_menu(),
+            // El rail hospedado vive en el backend layer-shell (conoce el foco y
+            // corre el HostServer). En winit no hay toplevels: no-op.
+            Msg::HostToothActivate(_, _) => {}
+            Msg::NavScroll(delta) => {
+                model.nav.scroll = (model.nav.scroll + delta).max(0.0);
+            }
+            Msg::NavTick => {
+                let sock = model.nav.socket.clone();
+                handle.spawn(move || Msg::NavPoll(nouser::poll(sock)));
+            }
+            Msg::NavPoll(outcome) => match outcome {
+                PollOutcome::Ok { socket, resp } => {
+                    model.nav.socket = Some(socket);
+                    model.nav.apply_monads(*resp);
+                }
+                PollOutcome::Failed(e) => {
+                    // Invalida el socket cacheado para re-descubrir en el próximo poll.
+                    model.nav.socket = None;
+                    model.nav.error = Some(e);
+                }
+            },
+            Msg::NavMembers(outcome) => match outcome {
+                MembersOutcome::Ok { monad, members } => model.nav.apply_members(monad, members),
+                MembersOutcome::Failed(e) => model.nav.error = Some(e),
+            },
+        }
+        model
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        render::root(model)
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        // El drawer Quake tiene prioridad; si no, el menú de inicio.
+        if let Some(d) = shuma::drawer_overlay(&model.shuma, model.screen, &model.theme) {
+            return Some(d);
+        }
+        if model.menu_open {
+            // Lo ofrecemos bajo la barra superior que hospeda el start_button.
+            let bar_h = model
+                .cfg
+                .surfaces
+                .iter()
+                .find(|s| {
+                    s.start
+                        .iter()
+                        .chain(&s.center)
+                        .chain(&s.end)
+                        .any(|w| w.kind == "start_button")
+                })
+                .map(|s| s.thickness)
+                .unwrap_or(32.0);
+            return Some(render::start_menu_overlay(
+                model.registry.all(),
+                bar_h,
+                &model.theme,
+            ));
+        }
+        None
+    }
+
+    fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
+        if event.state != KeyState::Pressed {
+            return None;
+        }
+        // 1) El hotkey del shuma_input abre/cierra el drawer (prioridad).
+        if model.shuma.present {
+            if let Some(hk) = &model.shuma.hotkey {
+                if keys::matches(hk, &event.key) {
+                    return Some(Msg::ShumaToggle);
+                }
+            }
+        }
+        // 2) Con el drawer abierto, el teclado va al input.
+        if model.shuma.open {
+            return match &event.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::ShumaToggle),
+                Key::Named(NamedKey::Backspace) => Some(Msg::ShumaBackspace),
+                Key::Named(NamedKey::Enter) => Some(Msg::ShumaSubmit),
+                Key::Character(s) => s.chars().next().map(Msg::ShumaChar),
+                _ => None,
+            };
+        }
+        // 3) Con el menú "Abrir con…" abierto, Esc lo cierra primero.
+        if model.nav.menu.is_some() {
+            if let Key::Named(NamedKey::Escape) = &event.key {
+                return Some(Msg::NavMenuCancel);
+            }
+        }
+        // 4) Con el panel navegador desplegado, Esc lo cierra (no la app).
+        if model.nav.open.is_some() {
+            if let Key::Named(NamedKey::Escape) = &event.key {
+                return Some(Msg::NavClosePanel);
+            }
+        }
+        // 5) Sin nada abierto, Esc cierra la app.
+        match &event.key {
+            Key::Named(NamedKey::Escape) => Some(Msg::Quit),
+            _ => None,
+        }
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/main.rs b/02_ruway/pata/pata-llimphi/src/main.rs
new file mode 100644
index 0000000..0808a1f
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/main.rs
@@ -0,0 +1,28 @@
+//! Binario del frontend `pata`: levanta el marco.
+//!
+//! Elige el backend de windowing:
+//! - **`wlr-layer-shell`** (default en Wayland): pata se ancla como una *layer
+//!   surface* al nivel de eww/waybar, con exclusive zone — el compositor le
+//!   reserva su franja. Es lo que querés en Hyprland/Sway/river.
+//! - **winit** (fallback): una ventana normal. Sirve en X11, o si el compositor
+//!   no expone `wlr-layer-shell`, o forzándolo con `PATA_BACKEND=winit`.
+//!
+//! ```sh
+//! cargo run -p pata-llimphi --release            # layer-shell si hay Wayland
+//! PATA_BACKEND=winit cargo run -p pata-llimphi   # fuerza ventana winit
+//! ```
+
+fn main() {
+    let forzar_winit = std::env::var("PATA_BACKEND").as_deref() == Ok("winit");
+    let hay_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
+
+    if !forzar_winit && hay_wayland {
+        match pata_llimphi::layer::run() {
+            Ok(()) => return,
+            Err(e) => {
+                eprintln!("pata · backend layer-shell falló ({e}); caigo a ventana winit.");
+            }
+        }
+    }
+    llimphi_ui::run::<pata_llimphi::PataApp>();
+}
diff --git a/02_ruway/pata/pata-llimphi/src/nouser.rs b/02_ruway/pata/pata-llimphi/src/nouser.rs
new file mode 100644
index 0000000..a43c75d
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/nouser.rs
@@ -0,0 +1,454 @@
+//! `nouser` — el **plano de datos** del sidebar navegador (Fase 11c).
+//!
+//! El sidebar de `pata` muestra las **Mónadas** de nouser y sus archivos en un
+//! navegador conmutable árbol/grafo ([`llimphi_widget_navigator`]). nouser es la
+//! **fuente autoritativa** de qué archivos componen una Mónada (no el filesystem
+//! por su cuenta — decisión del autor); por eso el nivel de archivos se resuelve
+//! por el query de nouser (`chasqui_card::query`) y no leyendo directorios.
+//!
+//! Este módulo:
+//! - descubre el socket del daemon (broker brahman → fallback al default path),
+//!   igual que `chasqui-explorer-llimphi`;
+//! - consulta `list_monads` (poll liviano) y `resolve_monad` (miembros bajo
+//!   demanda al expandir una Mónada);
+//! - construye el bosque de [`NavNode`]s que el widget pinta, manteniendo el
+//!   estado de UI (modo, selección, expansión, diente desplegado) en el caller.
+//!
+//! La asignación de [`NavId`] es **determinista** (hash del `MonadId`/path) para
+//! que la expansión y la selección sobrevivan a un re-poll sin parpadear.
+
+use std::collections::{HashMap, HashSet};
+use std::path::PathBuf;
+use std::time::Duration;
+
+use card_sidecar::{await_provider_blocking, build_consumer_card};
+use chasqui_card::query::client as qclient;
+use chasqui_card::query::{
+    transport, FileView, ListMonadsResponse, MonadView, FLOW_MONAD_LIST, FLOW_TYPE_NAME,
+};
+use chasqui_card::MonadId;
+use llimphi_widget_navigator::{NavId, NavKind, NavMode, NavNode};
+
+/// Timeout para descubrir el provider por el broker.
+const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
+/// Timeout de un query single-shot al daemon.
+const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
+/// Cada cuánto se repolea `list_monads`.
+pub const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
+
+// =====================================================================
+// Mapeo NavId → qué representa
+// =====================================================================
+
+/// Qué representa un nodo del navegador, para resolver miembros (al expandir una
+/// Mónada) y para abrir con la app que corresponda (Fase 11d).
+#[derive(Debug, Clone)]
+pub enum NavTarget {
+    /// Una Mónada de nouser, por su id.
+    Monad(MonadId),
+    /// Un archivo miembro, por su ruta.
+    File(String),
+}
+
+/// Hash FNV-1a de 64 bits — determinista y sin dependencias, suficiente para
+/// derivar un [`NavId`] estable de un identificador opaco.
+fn fnv1a(tag: u8, bytes: &[u8]) -> u64 {
+    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+    h ^= tag as u64;
+    h = h.wrapping_mul(0x0000_0100_0000_01b3);
+    for &b in bytes {
+        h ^= b as u64;
+        h = h.wrapping_mul(0x0000_0100_0000_01b3);
+    }
+    h
+}
+
+/// [`NavId`] de una Mónada (tag 1).
+fn monad_nav_id(id: &MonadId) -> NavId {
+    fnv1a(1, &id.to_bytes())
+}
+
+/// [`NavId`] del placeholder "cargando…" de una Mónada aún no resuelta (tag 2).
+fn placeholder_nav_id(id: &MonadId) -> NavId {
+    fnv1a(2, &id.to_bytes())
+}
+
+/// [`NavId`] de un archivo, por su ruta (tag 3).
+fn file_nav_id(path: &str) -> NavId {
+    fnv1a(3, path.as_bytes())
+}
+
+/// El último componente de una ruta (su "nombre"), o la ruta entera si no tiene
+/// separadores. Para la etiqueta de la fila — el path completo va al tooltip /
+/// al abrir.
+fn file_label(path: &str) -> String {
+    path.rsplit('/').next().filter(|s| !s.is_empty()).unwrap_or(path).to_string()
+}
+
+// =====================================================================
+// Estado del navegador
+// =====================================================================
+
+/// Estado del sidebar navegador. Vive en el `Model` del frontend; el widget es
+/// render-only y lo consulta cada `view`.
+pub struct NavState {
+    /// Diente desplegado: `(surface_idx, tab_idx)`. `None` = rail colapsado, sin
+    /// panel.
+    pub open: Option<(usize, usize)>,
+    /// Modo de visualización activo (compartido entre dientes).
+    pub mode: NavMode,
+    /// Nodo seleccionado (resaltado).
+    pub selected: Option<NavId>,
+    /// Nodos rama expandidos.
+    pub expanded: HashSet<NavId>,
+    /// Offset de scroll del panel (px).
+    pub scroll: f32,
+    /// El bosque a pintar (Mónadas como raíces, archivos como hijos).
+    pub roots: Vec<NavNode>,
+    /// Qué representa cada [`NavId`] (para resolver/abrir).
+    pub targets: HashMap<NavId, NavTarget>,
+    /// Mónadas vivas del último poll (vista slim).
+    monads: Vec<MonadView>,
+    /// Miembros ya resueltos por Mónada (cache, llenado bajo demanda).
+    members: HashMap<MonadId, Vec<FileView>>,
+    /// Socket del daemon, cacheado entre polls (`None` fuerza re-descubrimiento).
+    pub socket: Option<PathBuf>,
+    /// Último error de descubrimiento/query (para mostrar en el panel).
+    pub error: Option<String>,
+    /// Menú "Abrir con…" abierto sobre un archivo (su [`NavId`]). `None` = sin
+    /// menú. Las opciones se precomputan al abrirlo ([`NavState::open_menu`]) para
+    /// que el render no toque el registro de apps.
+    pub menu: Option<NavId>,
+    /// Apps nativas que ofrece el menú abierto: `(app_id, label)`. El render las
+    /// pinta como filas "Abrir con <label>"; siempre se les suma "el sistema".
+    pub menu_options: Vec<(String, String)>,
+}
+
+impl Default for NavState {
+    fn default() -> Self {
+        Self {
+            open: None,
+            mode: NavMode::Tree,
+            selected: None,
+            expanded: HashSet::new(),
+            scroll: 0.0,
+            roots: Vec::new(),
+            targets: HashMap::new(),
+            monads: Vec::new(),
+            members: HashMap::new(),
+            socket: None,
+            error: None,
+            menu: None,
+            menu_options: Vec::new(),
+        }
+    }
+}
+
+impl NavState {
+    /// `true` si el diente `(si, ti)` está desplegado ahora.
+    pub fn is_open(&self, si: usize, ti: usize) -> bool {
+        self.open == Some((si, ti))
+    }
+
+    /// Activa/repliega el diente `(si, ti)`: si ya estaba abierto lo cierra, si
+    /// no, lo abre (cerrando cualquier otro).
+    pub fn toggle_tab(&mut self, si: usize, ti: usize) {
+        self.close_menu(); // un cambio de diente descarta el menú "Abrir con…"
+        if self.open == Some((si, ti)) {
+            self.open = None;
+        } else {
+            self.open = Some((si, ti));
+            self.scroll = 0.0;
+        }
+    }
+
+    /// La ruta del archivo que representa `id`, si es un archivo. `None` para
+    /// Mónadas (no tienen una ruta única).
+    pub fn file_path(&self, id: NavId) -> Option<&str> {
+        match self.targets.get(&id) {
+            Some(NavTarget::File(p)) => Some(p.as_str()),
+            _ => None,
+        }
+    }
+
+    /// Abre el menú "Abrir con…" sobre `id` con las `options` (app_id, label) ya
+    /// resueltas por el caller (que tiene el registro de apps).
+    pub fn open_menu(&mut self, id: NavId, options: Vec<(String, String)>) {
+        self.menu = Some(id);
+        self.menu_options = options;
+    }
+
+    /// Cierra el menú "Abrir con…".
+    pub fn close_menu(&mut self) {
+        self.menu = None;
+        self.menu_options.clear();
+    }
+
+    /// Si `id` es una Mónada todavía sin miembros resueltos, devuelve su id para
+    /// que el caller dispare el `resolve_monad`. `None` en caso contrario.
+    pub fn needs_resolve(&self, id: NavId) -> Option<MonadId> {
+        match self.targets.get(&id) {
+            Some(NavTarget::Monad(mid)) if !self.members.contains_key(mid) => Some(*mid),
+            _ => None,
+        }
+    }
+
+    /// Aplica una respuesta de `list_monads`: reemplaza la lista de Mónadas y
+    /// reconstruye el bosque (preservando miembros ya resueltos).
+    pub fn apply_monads(&mut self, resp: ListMonadsResponse) {
+        self.monads = resp.monads;
+        // Descarta del cache las Mónadas que ya no existen, para no acumular.
+        let vivos: HashSet<MonadId> = self.monads.iter().map(|m| m.id).collect();
+        self.members.retain(|id, _| vivos.contains(id));
+        self.error = None;
+        self.rebuild();
+    }
+
+    /// Aplica los miembros resueltos de una Mónada y reconstruye el bosque.
+    pub fn apply_members(&mut self, monad: MonadId, members: Vec<FileView>) {
+        self.members.insert(monad, members);
+        self.rebuild();
+    }
+
+    /// Reconstruye `roots` + `targets` desde `monads` + `members`. Una Mónada con
+    /// `cardinality > 0` aún no resuelta lleva un hijo placeholder "…" para que
+    /// muestre el chevron y se pueda expandir (carga perezosa).
+    fn rebuild(&mut self) {
+        let mut roots = Vec::with_capacity(self.monads.len());
+        let mut targets = HashMap::new();
+        for mv in &self.monads {
+            let mid = monad_nav_id(&mv.id);
+            targets.insert(mid, NavTarget::Monad(mv.id));
+            let children = if let Some(files) = self.members.get(&mv.id) {
+                files
+                    .iter()
+                    .map(|f| {
+                        let fid = file_nav_id(&f.path);
+                        targets.insert(fid, NavTarget::File(f.path.clone()));
+                        NavNode::leaf(fid, file_label(&f.path), NavKind::File)
+                    })
+                    .collect()
+            } else if mv.cardinality > 0 {
+                vec![NavNode::leaf(placeholder_nav_id(&mv.id), "…", NavKind::Other)]
+            } else {
+                Vec::new()
+            };
+            let label = if mv.label.is_empty() {
+                "(sin nombre)".to_string()
+            } else {
+                mv.label.clone()
+            };
+            roots.push(NavNode::branch(mid, label, NavKind::Monad, children));
+        }
+        self.roots = roots;
+        self.targets = targets;
+    }
+}
+
+// =====================================================================
+// Queries (corren en un thread vía Handle::spawn, no bloquean el UI)
+// =====================================================================
+
+/// Resultado de un poll de `list_monads`.
+#[derive(Clone, Debug)]
+pub enum PollOutcome {
+    /// El daemon respondió: socket usado + Mónadas.
+    Ok {
+        socket: PathBuf,
+        resp: Box<ListMonadsResponse>,
+    },
+    /// No se pudo descubrir/consultar; mensaje para el panel.
+    Failed(String),
+}
+
+/// Descubre el socket (broker → fallback default path) y pide `list_monads`.
+/// Reusa `prior_socket` si está cacheado (evita re-descubrir cada poll).
+pub fn poll(prior_socket: Option<PathBuf>) -> PollOutcome {
+    let socket = match prior_socket {
+        Some(p) => p,
+        None => match resolve_socket() {
+            Ok(p) => p,
+            Err(e) => return PollOutcome::Failed(e),
+        },
+    };
+    match qclient::list_monads(&socket, QUERY_TIMEOUT) {
+        Ok(resp) => PollOutcome::Ok {
+            socket,
+            resp: Box::new(resp),
+        },
+        Err(e) => PollOutcome::Failed(format!("query a {}: {e}", socket.display())),
+    }
+}
+
+/// Resultado de resolver los miembros de una Mónada.
+#[derive(Clone, Debug)]
+pub enum MembersOutcome {
+    Ok {
+        monad: MonadId,
+        members: Vec<FileView>,
+    },
+    Failed(String),
+}
+
+/// Pide los archivos miembros de `monad` al daemon en `socket`.
+pub fn resolve(socket: PathBuf, monad: MonadId) -> MembersOutcome {
+    match qclient::resolve_monad(&socket, monad, QUERY_TIMEOUT) {
+        Ok(resp) => MembersOutcome::Ok {
+            monad,
+            members: resp.members,
+        },
+        Err(e) => MembersOutcome::Failed(format!("resolve_monad: {e}")),
+    }
+}
+
+/// Resuelve el socket del daemon: primero el broker brahman (Card consumer +
+/// `await_provider_blocking`), luego el default path si el broker no responde.
+/// Idéntico a `chasqui-explorer-llimphi`.
+fn resolve_socket() -> Result<PathBuf, String> {
+    let card = build_consumer_card("pata-llimphi", FLOW_MONAD_LIST, FLOW_TYPE_NAME);
+    match await_provider_blocking(card, DISCOVERY_TIMEOUT) {
+        Ok(p) => Ok(p),
+        Err(broker_err) => {
+            let fallback = transport::default_socket_path();
+            if fallback.exists() {
+                Ok(fallback)
+            } else {
+                Err(format!(
+                    "broker: {broker_err}; fallback {} no existe",
+                    fallback.display()
+                ))
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chasqui_card::query::EngineInfo;
+    use ulid::Ulid;
+
+    fn monad_view(label: &str, cardinality: u32) -> MonadView {
+        MonadView {
+            id: Ulid::new(),
+            label: label.into(),
+            summary: String::new(),
+            keywords: Vec::new(),
+            cardinality,
+            entropy: 0.0,
+            dominant_lens: Default::default(),
+            path_hint: None,
+            centroid_model: None,
+        }
+    }
+
+    fn list_resp(monads: Vec<MonadView>) -> ListMonadsResponse {
+        ListMonadsResponse {
+            engine: EngineInfo {
+                id: Ulid::new(),
+                label: "test".into(),
+                watching: None,
+            },
+            monads,
+        }
+    }
+
+    #[test]
+    fn nav_id_determinista_y_separado_por_tag() {
+        let id = Ulid::new();
+        assert_eq!(monad_nav_id(&id), monad_nav_id(&id));
+        // El placeholder y la Mónada no colisionan (tags distintos).
+        assert_ne!(monad_nav_id(&id), placeholder_nav_id(&id));
+        // Dos rutas distintas → ids distintos.
+        assert_ne!(file_nav_id("/a/x.rs"), file_nav_id("/a/y.rs"));
+    }
+
+    #[test]
+    fn file_label_toma_el_ultimo_componente() {
+        assert_eq!(file_label("/proj/src/lib.rs"), "lib.rs");
+        assert_eq!(file_label("solo.txt"), "solo.txt");
+        assert_eq!(file_label("/dir/"), "/dir/");
+    }
+
+    #[test]
+    fn apply_monads_construye_raices_con_placeholder_si_hay_cardinalidad() {
+        let mut st = NavState::default();
+        let m = monad_view("src", 3);
+        let mid = m.id;
+        st.apply_monads(list_resp(vec![m]));
+        assert_eq!(st.roots.len(), 1);
+        // Aún sin resolver: tiene un hijo placeholder → chevron visible.
+        assert!(st.roots[0].has_children());
+        assert_eq!(st.roots[0].children.len(), 1);
+        // La Mónada necesita resolverse al expandir.
+        let nav = monad_nav_id(&mid);
+        assert_eq!(st.needs_resolve(nav), Some(mid));
+    }
+
+    #[test]
+    fn monada_vacia_no_tiene_chevron() {
+        let mut st = NavState::default();
+        st.apply_monads(list_resp(vec![monad_view("vacia", 0)]));
+        assert!(!st.roots[0].has_children());
+    }
+
+    #[test]
+    fn apply_members_reemplaza_placeholder_por_archivos() {
+        let mut st = NavState::default();
+        let m = monad_view("src", 2);
+        let mid = m.id;
+        st.apply_monads(list_resp(vec![m]));
+        let files = vec![
+            FileView {
+                id: Ulid::new(),
+                path: "/p/lib.rs".into(),
+                size: 1,
+                extension: Some("rs".into()),
+                mtime_ms: 0,
+            },
+            FileView {
+                id: Ulid::new(),
+                path: "/p/main.rs".into(),
+                size: 1,
+                extension: Some("rs".into()),
+                mtime_ms: 0,
+            },
+        ];
+        st.apply_members(mid, files);
+        assert_eq!(st.roots[0].children.len(), 2);
+        assert_eq!(st.roots[0].children[0].label, "lib.rs");
+        // Ya resuelta: no vuelve a pedir.
+        assert_eq!(st.needs_resolve(monad_nav_id(&mid)), None);
+        // El target del archivo apunta a su ruta completa.
+        let fid = file_nav_id("/p/lib.rs");
+        matches!(st.targets.get(&fid), Some(NavTarget::File(p)) if p == "/p/lib.rs");
+    }
+
+    #[test]
+    fn apply_monads_descarta_miembros_de_monadas_muertas() {
+        let mut st = NavState::default();
+        let m = monad_view("a", 1);
+        let mid = m.id;
+        st.apply_monads(list_resp(vec![m]));
+        st.apply_members(mid, vec![]);
+        assert!(st.members.contains_key(&mid));
+        // Re-poll sin esa Mónada: su cache se purga.
+        st.apply_monads(list_resp(vec![monad_view("b", 1)]));
+        assert!(!st.members.contains_key(&mid));
+    }
+
+    #[test]
+    fn toggle_tab_abre_y_cierra() {
+        let mut st = NavState::default();
+        assert!(!st.is_open(0, 0));
+        st.toggle_tab(0, 0);
+        assert!(st.is_open(0, 0));
+        // Abrir otro diente cierra el anterior.
+        st.toggle_tab(0, 1);
+        assert!(st.is_open(0, 1));
+        assert!(!st.is_open(0, 0));
+        // Re-clic en el abierto lo cierra.
+        st.toggle_tab(0, 1);
+        assert_eq!(st.open, None);
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/open.rs b/02_ruway/pata/pata-llimphi/src/open.rs
new file mode 100644
index 0000000..3b83c69
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/open.rs
@@ -0,0 +1,214 @@
+//! Apertura de archivos del navegador con la app que corresponda (Fase 11d).
+//!
+//! El navegador del sidebar lista archivos miembros de una Mónada (su ruta real
+//! en disco, resuelta por nouser). Al abrir uno (right-click), enrutamos a la app
+//! adecuada en dos pasos:
+//!
+//! 1. **Apps nativas de gioser** (`app-bus`): si alguna app declara manejar el
+//!    mime del archivo (`handles = ["…"]` en su manifiesto), la lanzamos con la
+//!    ruta como argumento (sustitución freedesktop `%f`/`%u` vía
+//!    [`app_bus::AppEntry::open`]). Las apps de la suite tienen prioridad.
+//! 2. **Fallback del sistema** (`xdg-open`): si ninguna app nativa lo maneja,
+//!    delegamos en las asociaciones del escritorio.
+//!
+//! El mime se deriva de la **extensión** con una tabla acotada (sin leer el
+//! archivo — la UI no hace I/O de disco). Lo desconocido cae directo a `xdg-open`,
+//! que de todas formas respeta las asociaciones del usuario. (El discernimiento
+//! por contenido de `shuma-discern` sería el upgrade, a costa de leer una muestra.)
+
+use app_bus::AppRegistry;
+
+/// Mapea la extensión de `path` (lo que va tras el último punto, en minúsculas) a
+/// un mime canónico. `None` si no hay extensión o no está en la tabla — el caller
+/// cae a `xdg-open`.
+pub fn mime_for_path(path: &str) -> Option<&'static str> {
+    // La extensión es el segmento tras el último punto del último componente.
+    let name = path.rsplit('/').next().unwrap_or(path);
+    let ext = name.rsplit_once('.').map(|(_, e)| e)?.to_ascii_lowercase();
+    Some(match ext.as_str() {
+        // Código y texto estructurado.
+        "rs" => "text/x-rust",
+        "py" => "text/x-python",
+        "js" | "mjs" | "cjs" => "text/javascript",
+        "ts" | "tsx" => "text/typescript",
+        "c" | "h" => "text/x-c",
+        "cpp" | "cc" | "cxx" | "hpp" => "text/x-c++",
+        "go" => "text/x-go",
+        "java" => "text/x-java",
+        "rb" => "text/x-ruby",
+        "sh" | "bash" | "zsh" => "application/x-shellscript",
+        "toml" => "application/toml",
+        "json" => "application/json",
+        "yaml" | "yml" => "application/yaml",
+        "xml" => "application/xml",
+        "html" | "htm" => "text/html",
+        "css" => "text/css",
+        // Texto plano y documentos.
+        "md" | "markdown" => "text/markdown",
+        "rst" => "text/x-rst",
+        "txt" | "log" | "ron" => "text/plain",
+        "csv" => "text/csv",
+        "pdf" => "application/pdf",
+        // Imágenes.
+        "png" => "image/png",
+        "jpg" | "jpeg" => "image/jpeg",
+        "gif" => "image/gif",
+        "webp" => "image/webp",
+        "svg" => "image/svg+xml",
+        "bmp" => "image/bmp",
+        "ico" => "image/x-icon",
+        // Audio.
+        "mp3" => "audio/mpeg",
+        "flac" => "audio/flac",
+        "wav" => "audio/wav",
+        "ogg" | "oga" => "audio/ogg",
+        "opus" => "audio/opus",
+        // Video.
+        "mp4" | "m4v" => "video/mp4",
+        "mkv" => "video/x-matroska",
+        "webm" => "video/webm",
+        "avi" => "video/x-msvideo",
+        "mov" => "video/quicktime",
+        // Archivos comprimidos.
+        "zip" => "application/zip",
+        "tar" => "application/x-tar",
+        "gz" | "tgz" => "application/gzip",
+        "7z" => "application/x-7z-compressed",
+        _ => return None,
+    })
+}
+
+/// Qué hizo [`open_file`], para log/diagnóstico.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Opened {
+    /// Una app nativa de gioser abrió el archivo (su `label`).
+    NativeApp(String),
+    /// Se delegó en `xdg-open`.
+    SystemDefault,
+}
+
+/// La app nativa que abriría `path`: la primera del registro (orden por label)
+/// que declare manejar el mime de su extensión. `None` si no se conoce el mime o
+/// ninguna app lo maneja (→ el caller cae a `xdg-open`). Función **pura**: decide
+/// el handler sin spawnear nada — testeable sin tocar el sistema.
+pub fn handler_for<'a>(registry: &'a AppRegistry, path: &str) -> Option<&'a app_bus::AppEntry> {
+    let mime = mime_for_path(path)?;
+    registry.handlers_for(mime).into_iter().next()
+}
+
+/// Todas las apps nativas que declaran manejar el mime de `path`, como pares
+/// `(app_id, label)` — para poblar el menú "Abrir con…". Vacío si no hay mime
+/// conocido o ningún handler. Función **pura**.
+pub fn handlers_for_path(registry: &AppRegistry, path: &str) -> Vec<(String, String)> {
+    let Some(mime) = mime_for_path(path) else {
+        return Vec::new();
+    };
+    registry
+        .handlers_for(mime)
+        .into_iter()
+        .map(|a| (a.id.clone(), a.label.clone()))
+        .collect()
+}
+
+/// Abre `path` con la app de id `app_id`; si no existe o falla el spawn, cae a
+/// `xdg-open`. Para la elección explícita del menú "Abrir con…".
+pub fn open_with_id(registry: &AppRegistry, app_id: &str, path: &str) -> Opened {
+    if let Some(app) = registry.get(app_id) {
+        match app.open(path) {
+            Ok(_) => return Opened::NativeApp(app.label.clone()),
+            Err(e) => eprintln!("pata · {} no pudo abrir {path}: {e}; uso xdg-open", app.label),
+        }
+    }
+    open_system(path)
+}
+
+/// Abre `path` con el handler del escritorio (`xdg-open`).
+pub fn open_system(path: &str) -> Opened {
+    crate::spawn_cmd(&format!("xdg-open {}", crate::shell_quote(path)));
+    Opened::SystemDefault
+}
+
+/// Abre `path` con la app del registro que declare su mime; si ninguna, cae a
+/// `xdg-open`. No bloquea (spawnea y olvida). Devuelve qué ruta tomó.
+pub fn open_file(registry: &AppRegistry, path: &str) -> Opened {
+    if let Some(app) = handler_for(registry, path) {
+        match app.open(path) {
+            Ok(_) => return Opened::NativeApp(app.label.clone()),
+            Err(e) => {
+                eprintln!("pata · {} no pudo abrir {path}: {e}; uso xdg-open", app.label);
+            }
+        }
+    }
+    open_system(path)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn mime_por_extension_comun() {
+        assert_eq!(mime_for_path("/proj/src/lib.rs"), Some("text/x-rust"));
+        assert_eq!(mime_for_path("foto.PNG"), Some("image/png")); // case-insensitive
+        assert_eq!(mime_for_path("a/b/notas.md"), Some("text/markdown"));
+        assert_eq!(mime_for_path("clip.mp4"), Some("video/mp4"));
+        assert_eq!(mime_for_path("data.json"), Some("application/json"));
+    }
+
+    #[test]
+    fn sin_extension_o_desconocida_es_none() {
+        assert_eq!(mime_for_path("README"), None);
+        assert_eq!(mime_for_path("/etc/hosts"), None);
+        assert_eq!(mime_for_path("archivo.xyzqux"), None);
+    }
+
+    #[test]
+    fn extension_de_un_punto_en_directorio_no_confunde() {
+        // El punto está en un componente de directorio, no en el archivo.
+        assert_eq!(mime_for_path("/home/.config/Makefile"), None);
+        // Pero un dotfile con extensión real sí.
+        assert_eq!(mime_for_path("/home/.bashrc.md"), Some("text/markdown"));
+    }
+
+    /// Un registro con las apps de ejemplo (media + nada), construido en memoria
+    /// desde el mismo formato TOML de `~/.config/gioser/apps/`.
+    fn registro_ejemplo() -> AppRegistry {
+        let media = app_bus::parse_entry(
+            "id='media'\nlabel='Media'\nhandles=['video/mp4','audio/mpeg']\n[launch]\nexec='media-app'\nargs=['%f']",
+        )
+        .unwrap();
+        let nada = app_bus::parse_entry(
+            "id='nada'\nlabel='Nada'\nhandles=['text/x-rust','text/markdown','text/plain']\n[launch]\nexec='nada'\nargs=['%f']",
+        )
+        .unwrap();
+        AppRegistry::new(vec![media, nada])
+    }
+
+    #[test]
+    fn rutea_a_la_app_nativa_por_mime() {
+        let reg = registro_ejemplo();
+        // Un .mp4 va a media; un .rs y un .md a nada.
+        assert_eq!(handler_for(&reg, "/v/clip.mp4").map(|a| a.id.as_str()), Some("media"));
+        assert_eq!(handler_for(&reg, "/s/lib.rs").map(|a| a.id.as_str()), Some("nada"));
+        assert_eq!(handler_for(&reg, "/d/README.md").map(|a| a.id.as_str()), Some("nada"));
+    }
+
+    #[test]
+    fn sin_handler_nativo_cae_al_sistema() {
+        let reg = registro_ejemplo();
+        // .pdf tiene mime conocido pero ninguna app de ejemplo lo declara.
+        assert!(handler_for(&reg, "/d/manual.pdf").is_none());
+        // Sin extensión, ni siquiera hay mime.
+        assert!(handler_for(&reg, "/etc/hosts").is_none());
+    }
+
+    #[test]
+    fn handlers_for_path_lista_opciones_del_menu() {
+        let reg = registro_ejemplo();
+        // Un .rs ofrece "nada" como handler nativo.
+        let opts = handlers_for_path(&reg, "/s/lib.rs");
+        assert_eq!(opts, vec![("nada".to_string(), "Nada".to_string())]);
+        // Un .pdf no tiene handlers nativos → lista vacía (sólo "sistema" en UI).
+        assert!(handlers_for_path(&reg, "/d/x.pdf").is_empty());
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/render.rs b/02_ruway/pata/pata-llimphi/src/render.rs
new file mode 100644
index 0000000..bb9fc85
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/render.rs
@@ -0,0 +1,996 @@
+//! El pincel: traduce el modelo resuelto de `pata-core` a `View<Msg>` de
+//! Llimphi.
+//!
+//! Dos niveles:
+//! - [`widget_view`] traduce un [`WidgetView`] —el view-model agnóstico que un
+//!   widget emite— a un `View<Msg>` concreto (texto, medidor con barra,
+//!   placeholder tenue).
+//! - [`root`] coloca cada superficie en el rect que [`pata_core::layout`]
+//!   resolvió (posición absoluta, en píxeles de pantalla) y reparte sus widgets
+//!   en los slots start/center/end según el eje del anclaje.
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{
+        auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
+    },
+    Rect as TaffyRect,
+};
+use llimphi_ui::llimphi_raster::peniko::{Blob, Image, ImageFormat};
+use llimphi_ui::View;
+
+use app_bus::AppEntry;
+use pata_core::config::{FloatingCard, Surface, SurfaceKind};
+use pata_core::layout::Rect;
+use pata_core::widget::{Widget, WidgetView};
+
+use crate::shuma::{self, ShumaState};
+use crate::toplevel::WindowEntry;
+use crate::tray::{TrayIcon, TrayItem};
+use crate::{Model, Msg, SlotWidget, SurfaceWidgets};
+
+mod sidebar;
+pub use sidebar::{nav_panel_view, sidebar_rail_view, sidebar_surface_view};
+
+/// Largo máximo de la etiqueta de una ventana en el `window_list` antes de
+/// recortar con `…`. Evita que un título largo empuje el resto de la barra.
+const WINDOW_LABEL_MAX: usize = 22;
+
+/// Largo máximo del preview del portapapeles antes de recortar con `…`.
+const CLIPBOARD_PREVIEW_MAX: usize = 28;
+
+/// Largo máximo de la etiqueta de un item del tray antes de recortar con `…`.
+const TRAY_LABEL_MAX: usize = 14;
+
+/// Los datos del host que el render necesita además del view-model de los
+/// widgets de core: lo dinámico que vive en el backend (ventanas abiertas,
+/// portapapeles) y se pasa aparte. Agrupado para no inflar cada firma a medida
+/// que se suman widgets de este tipo (mañana, el tray).
+#[derive(Default)]
+pub struct BarData<'a> {
+    /// Las ventanas abiertas, para el `window_list`.
+    pub windows: &'a [WindowEntry],
+    /// El texto del portapapeles (ya en una línea), para el `clipboard`.
+    pub clipboard: Option<&'a str>,
+    /// Los items de la bandeja del sistema, para el `tray`.
+    pub tray: &'a [TrayItem],
+}
+
+/// Ancho de la barrita de un medidor, en píxeles.
+const BARRA_W: f32 = 48.0;
+
+/// Ancho fijo de la leyenda de un medidor (px). Cabe `"10.5/15.5G"` (RAM), la
+/// más ancha; evita que el cambio de dígitos reacomode la barra.
+const CAPTION_W: f32 = 72.0;
+
+/// El texto de tooltip de un widget de core, derivado de su view-model: la
+/// lectura completa (medidor con su etiqueta + leyenda, texto tal cual). `None`
+/// para los vacíos. Lo muestra el tooltip flotante al posar el cursor.
+pub fn widget_tooltip(v: &WidgetView) -> Option<String> {
+    match v {
+        WidgetView::Empty => None,
+        WidgetView::Text(t) if t.trim().is_empty() => None,
+        WidgetView::Text(t) => Some(t.clone()),
+        WidgetView::Meter { label, caption, .. } => {
+            let l = label.as_deref().unwrap_or("").trim();
+            let c = caption.trim();
+            let s = format!("{l} {c}");
+            let s = s.trim().to_string();
+            (!s.is_empty()).then_some(s)
+        }
+        WidgetView::Placeholder(kind) => Some(kind.clone()),
+    }
+}
+
+/// El cuerpo del **tooltip flotante**: una cajita opaca con el texto, rellenando
+/// su contenedor (en layer-shell, la propia surface popup). Opaca a propósito —
+/// así no depende de transparencia de la surface (que en algún compositor podría
+/// fallar y ennegrecer todo).
+pub fn tooltip_view(text: &str, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: TaffyRect {
+            left: length(8.0_f32),
+            right: length(8.0_f32),
+            top: length(4.0_f32),
+            bottom: length(4.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .radius(6.0)
+    .text(text.to_string(), 12.0, theme.fg_text)
+}
+
+/// Traduce el view-model de un widget al `View<Msg>` que lo pinta.
+pub fn widget_view(v: &WidgetView, theme: &Theme) -> View<Msg> {
+    match v {
+        WidgetView::Empty => View::new(Style {
+            size: Size {
+                width: length(0.0_f32),
+                height: length(0.0_f32),
+            },
+            ..Default::default()
+        }),
+        WidgetView::Text(t) => chip(theme).text(t.clone(), 13.0, theme.fg_text),
+        WidgetView::Meter {
+            label,
+            fraction,
+            caption,
+        } => meter_view(label.as_deref(), *fraction, caption, theme),
+        WidgetView::Placeholder(kind) => chip(theme)
+            .fill(theme.bg_panel)
+            .radius(6.0)
+            .text(kind.clone(), 12.0, theme.fg_muted),
+    }
+}
+
+/// Un contenedor compacto, centrado, con padding horizontal — la base de
+/// cualquier widget de barra.
+fn chip(_theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: auto(),
+            height: length(22.0_f32),
+        },
+        padding: TaffyRect {
+            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()
+    })
+}
+
+/// Aclara un color hacia el blanco en `amount` (`0.0` = igual, `1.0` = blanco).
+/// Para el extremo claro del gradiente de los medidores.
+fn aclarar(c: llimphi_theme::Color, amount: f32) -> llimphi_theme::Color {
+    use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
+    let [r, g, b, a] = c.components;
+    let m = amount.clamp(0.0, 1.0);
+    AlphaColor::new([r + (1.0 - r) * m, g + (1.0 - g) * m, b + (1.0 - b) * m, a])
+}
+
+/// Un medidor: etiqueta opcional + barrita proporcional + leyenda. La barra de
+/// relleno lleva un **gradiente** horizontal del acento (izquierda) a un acento
+/// aclarado (derecha), pintado a mano con `paint_with` (Llimphi no tiene fill de
+/// brush, sólo color sólido).
+fn meter_view(label: Option<&str>, fraction: f32, caption: &str, theme: &Theme) -> View<Msg> {
+    let frac = fraction.clamp(0.0, 1.0);
+    let c0 = theme.accent;
+    let c1 = aclarar(theme.accent, 0.5);
+    let relleno = View::new(Style {
+        size: Size {
+            width: length(BARRA_W * frac),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .paint_with(move |scene, _ts, rect| {
+        use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
+        use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
+        if rect.w <= 0.0 || rect.h <= 0.0 {
+            return;
+        }
+        let (x0, y0) = (rect.x as f64, rect.y as f64);
+        let (x1, y1) = ((rect.x + rect.w) as f64, (rect.y + rect.h) as f64);
+        let rr = RoundedRect::new(x0, y0, x1, y1, 2.0);
+        let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
+            .with_stops([c0, c1].as_slice());
+        scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
+    });
+    let barra = View::new(Style {
+        size: Size {
+            width: length(BARRA_W),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .radius(2.0)
+    .children(vec![relleno]);
+
+    let mut hijos: Vec<View<Msg>> = Vec::new();
+    if let Some(l) = label {
+        hijos.push(etiqueta(l, theme));
+    }
+    hijos.push(barra);
+    if !caption.is_empty() {
+        // Ancho FIJO: la leyenda cambia de dígitos cada tick ("7%"→"42%"→
+        // "100%") y, con ancho automático, eso reflota toda la barra. Una caja
+        // fija mantiene el layout quieto.
+        hijos.push(caption_fija(caption, theme));
+    }
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: auto(),
+            height: length(22.0_f32),
+        },
+        padding: TaffyRect {
+            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),
+        justify_content: Some(JustifyContent::Center),
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(hijos)
+}
+
+/// La leyenda de un medidor en una caja de **ancho fijo**: como el texto cambia
+/// de dígitos a cada tick, una caja fija evita que el medidor (y con él toda la
+/// barra) se reacomode. Cabe la más ancha (`"10.5/15.5G"` de la RAM).
+fn caption_fija(t: &str, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: length(CAPTION_W),
+            height: length(22.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::FlexStart),
+        ..Default::default()
+    })
+    .text(t.to_string(), 12.0, theme.fg_muted)
+}
+
+/// Un texto corto en color tenue (etiqueta o leyenda de un medidor).
+fn etiqueta(t: &str, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: auto(),
+            height: length(22.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .text(t.to_string(), 12.0, theme.fg_muted)
+}
+
+/// El **interior** de una tarjeta flotante (estilo conky): título opcional +
+/// widgets apilados, rellenando su contenedor (100%×100%). Lo usa el backend
+/// layer-shell, donde la propia layer surface ya tiene el tamaño de la tarjeta.
+/// Para el path winit, [`card_view_absolute`] lo posiciona en (x, y).
+pub fn card_view(card: &FloatingCard, widgets: &[Box<dyn Widget>], theme: &Theme) -> View<Msg> {
+    let mut hijos: Vec<View<Msg>> = Vec::new();
+    if let Some(t) = &card.title {
+        hijos.push(
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(20.0_f32),
+                },
+                align_items: Some(AlignItems::Center),
+                justify_content: Some(JustifyContent::FlexStart),
+                ..Default::default()
+            })
+            .text(t.clone(), 12.0, theme.fg_muted),
+        );
+    }
+    for w in widgets {
+        hijos.push(widget_view(&w.view(), theme));
+    }
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: TaffyRect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(10.0_f32),
+            bottom: length(10.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .radius(10.0)
+    .children(hijos)
+}
+
+/// Una tarjeta flotante posicionada en **absoluto** en (x, y) con tamaño (w, h)
+/// — para el path winit, donde todas las superficies viven en una sola ventana.
+fn card_view_absolute(card: &FloatingCard, widgets: &[Box<dyn Widget>], theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(card.x),
+            top: length(card.y),
+            right: auto(),
+            bottom: auto(),
+        },
+        size: Size {
+            width: length(card.w),
+            height: length(card.h),
+        },
+        ..Default::default()
+    })
+    .children(vec![card_view(card, widgets, theme)])
+}
+
+/// El `View` raíz: cubre la pantalla y coloca cada superficie en su rect.
+pub fn root(model: &Model) -> View<Msg> {
+    let (sw, sh) = model.screen;
+    let mut superficies: Vec<View<Msg>> = Vec::new();
+
+    // Datos del host muestreados por el Model: portapapeles y tray ya funcionan en
+    // este path winit. El `window_list` queda vacío hasta que el compositor mirada
+    // exponga sus toplevels por IPC (en layer-shell sí se llena).
+    let tray_items = model.tray.as_ref().map(|t| t.items()).unwrap_or_default();
+    let data = BarData {
+        windows: &[],
+        clipboard: model.clipboard.as_deref(),
+        tray: &tray_items,
+    };
+
+    for placed in &model.frame.surfaces {
+        let surface = &model.cfg.surfaces[placed.index];
+        let widgets = &model.surfaces[placed.index];
+        if !placed.rect.es_visible() {
+            continue;
+        }
+        // Un Sidebar no tiene slots: pinta el rail de dientes a partir de
+        // `surface.tabs` (su panel flota aparte, después, para quedar encima).
+        if surface.kind == SurfaceKind::Sidebar {
+            superficies.push(sidebar_rail_view(
+                surface,
+                placed.index,
+                placed.rect,
+                &model.nav,
+                &model.shuma,
+                &model.theme,
+            ));
+            continue;
+        }
+        superficies.push(surface_view(
+            surface,
+            placed.rect,
+            widgets,
+            &model.shuma,
+            &data,
+            &model.theme,
+        ));
+    }
+
+    // El panel del diente desplegado flota sobre el área de trabajo, junto al
+    // rail (no entra en el layout — lo maneja el frontend, como un drawer).
+    if let Some((si, ti)) = model.nav.open {
+        if let Some(placed) = model.frame.surfaces.iter().find(|p| p.index == si) {
+            if let Some(surface) = model.cfg.surfaces.get(si) {
+                if surface.kind == SurfaceKind::Sidebar {
+                    superficies.push(nav_panel_view(
+                        surface,
+                        ti,
+                        placed.rect,
+                        (sw, sh),
+                        &model.nav,
+                        &model.theme,
+                    ));
+                }
+            }
+        }
+    }
+
+    // Tarjetas flotantes (estilo conky), posicionadas en absoluto sobre la
+    // pantalla. En layer-shell cada una es su propia surface; acá (winit) viven
+    // en la ventana única.
+    for (card, ws) in &model.cards {
+        superficies.push(card_view_absolute(card, ws, &model.theme));
+    }
+
+    View::new(Style {
+        size: Size {
+            width: length(sw as f32),
+            height: length(sh as f32),
+        },
+        ..Default::default()
+    })
+    .children(superficies)
+}
+
+/// Una superficie colocada: rectángulo absoluto con los tres slots repartidos
+/// a lo largo de su eje (fila si el anclaje es horizontal, columna si vertical).
+fn surface_view(
+    surface: &Surface,
+    rect: Rect,
+    widgets: &SurfaceWidgets,
+    shuma_state: &ShumaState,
+    data: &BarData,
+    theme: &Theme,
+) -> View<Msg> {
+    let dir = if surface.anchor.es_horizontal() {
+        FlexDirection::Row
+    } else {
+        FlexDirection::Column
+    };
+
+    View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(rect.x as f32),
+            top: length(rect.y as f32),
+            right: auto(),
+            bottom: auto(),
+        },
+        size: Size {
+            width: length(rect.w as f32),
+            height: length(rect.h as f32),
+        },
+        flex_direction: dir,
+        padding: TaffyRect {
+            left: length(surface.padding),
+            right: length(surface.padding),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::SpaceBetween),
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .children(slots_de(surface, widgets, shuma_state, data, theme, dir))
+}
+
+/// La barra de shuma **desplegada**: la propia layer surface creció hacia
+/// arriba, así que pintamos el cuerpo del drawer (input + salida) llenando lo
+/// alto y la barra (su cabezal) abajo, con su grosor original `bar_px`. Asume
+/// anclaje inferior (el caso del preset).
+pub fn shuma_open_view(
+    surface: &Surface,
+    widgets: &SurfaceWidgets,
+    shuma_state: &ShumaState,
+    data: &BarData,
+    theme: &Theme,
+    bar_px: f32,
+    viewport_h: f32,
+) -> View<Msg> {
+    // El cuerpo del drawer ocupa todo lo que sobra por encima de la barra.
+    let mut body_style = Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    };
+    body_style.flex_grow = 1.0;
+    let body =
+        View::new(body_style).children(vec![shuma::drawer_body_view(shuma_state, theme, viewport_h)]);
+
+    let bar = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(bar_px),
+        },
+        ..Default::default()
+    })
+    .children(vec![bar_view(surface, widgets, shuma_state, data, theme)]);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![body, bar])
+}
+
+/// Construye los tres slots (start/center/end) de una superficie a lo largo de
+/// su eje. Compartido por [`surface_view`] (una superficie colocada en su rect
+/// dentro de una ventana grande) y [`bar_view`] (la barra llenando su propia
+/// layer surface de Wayland).
+fn slots_de(
+    surface: &Surface,
+    widgets: &SurfaceWidgets,
+    shuma_state: &ShumaState,
+    data: &BarData,
+    theme: &Theme,
+    dir: FlexDirection,
+) -> Vec<View<Msg>> {
+    let slot = |ws: &[SlotWidget], justify: JustifyContent| -> View<Msg> {
+        let items: Vec<View<Msg>> = ws
+            .iter()
+            .map(|sw| match sw {
+                SlotWidget::Core { widget, exec } => {
+                    // Realce al hover en todos los widgets (feedback de "estoy
+                    // encima") + tooltip con su lectura completa; los que tienen
+                    // `exec` además lanzan su comando.
+                    let wv = widget.view();
+                    let mut v = widget_view(&wv, theme)
+                        .radius(6.0)
+                        .hover_fill(theme.bg_button_hover);
+                    if let Some(tip) = widget_tooltip(&wv) {
+                        v = v.tooltip(tip);
+                    }
+                    match exec {
+                        Some(cmd) => v.on_click(Msg::Spawn(cmd.clone())),
+                        None => v,
+                    }
+                }
+                SlotWidget::Start { label, exec } => start_button_view(label, exec.as_deref(), theme),
+                SlotWidget::Shuma => shuma::headline_view(shuma_state, theme),
+                SlotWidget::WindowList => window_list_view(data.windows, surface.gap, dir, theme),
+                SlotWidget::Clipboard { exec } => {
+                    clipboard_view(data.clipboard, exec.as_deref(), theme)
+                }
+                SlotWidget::Tray => tray_view(data.tray, surface.gap, dir, theme),
+            })
+            .collect();
+        let mut style = Style {
+            flex_direction: dir,
+            align_items: Some(AlignItems::Center),
+            justify_content: Some(justify),
+            gap: Size {
+                width: length(surface.gap),
+                height: length(surface.gap),
+            },
+            ..Default::default()
+        };
+        style.flex_grow = 1.0;
+        View::new(style).children(items)
+    };
+    vec![
+        slot(&widgets.start, JustifyContent::FlexStart),
+        slot(&widgets.center, JustifyContent::Center),
+        slot(&widgets.end, JustifyContent::FlexEnd),
+    ]
+}
+
+/// Lado del ícono-badge (cuadrado) de una ventana en el task manager, en px.
+const WIN_BADGE_PX: f32 = 18.0;
+
+/// El **task manager** (estilo KDE): un botón por ventana abierta con un
+/// ícono-badge (la inicial del `app_id`) + el título. La activa va resaltada con
+/// fondo de panel y badge en acento; las minimizadas, atenuadas. Clic izquierdo
+/// → [`Msg::ActivateWindow`] (activa, o minimiza si ya estaba activa); clic
+/// derecho → [`Msg::CloseWindow`]. Los botones siguen el eje de la barra.
+fn window_list_view(
+    windows: &[WindowEntry],
+    gap: f32,
+    dir: FlexDirection,
+    theme: &Theme,
+) -> View<Msg> {
+    let chips: Vec<View<Msg>> = windows.iter().map(|w| window_button(w, theme)).collect();
+
+    View::new(Style {
+        flex_direction: dir,
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(gap),
+            height: length(gap),
+        },
+        ..Default::default()
+    })
+    .children(chips)
+}
+
+/// Un botón de ventana del task manager: badge (inicial) + título recortado.
+fn window_button(w: &WindowEntry, theme: &Theme) -> View<Msg> {
+    // Activa: fondo de panel, texto pleno, badge en acento. Inactiva: tenue.
+    // Minimizada: aún más atenuada (texto y badge en muted).
+    let (fg, fill, badge_bg, badge_fg) = if w.active {
+        (theme.fg_text, theme.bg_panel, theme.accent, theme.bg_panel)
+    } else if w.minimized {
+        (theme.fg_muted, theme.bg_panel_alt, theme.bg_panel, theme.fg_muted)
+    } else {
+        (theme.fg_text, theme.bg_panel_alt, theme.bg_panel, theme.fg_muted)
+    };
+
+    let badge = View::new(Style {
+        size: Size {
+            width: length(WIN_BADGE_PX),
+            height: length(WIN_BADGE_PX),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .fill(badge_bg)
+    .radius(4.0)
+    .text(w.inicial(), 11.0, badge_fg);
+
+    let titulo = View::new(Style {
+        size: Size {
+            width: auto(),
+            height: length(22.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .text(recortar(&w.label, WINDOW_LABEL_MAX), 12.0, fg);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: auto(),
+            height: length(24.0_f32),
+        },
+        padding: TaffyRect {
+            left: length(6.0_f32),
+            right: length(10.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(fill)
+    .radius(6.0)
+    .hover_fill(theme.bg_button_hover)
+    .tooltip(w.label.clone())
+    .on_click(Msg::ActivateWindow(w.id))
+    .on_right_click(Msg::CloseWindow(w.id))
+    .children(vec![badge, titulo])
+}
+
+/// El **botón de inicio**: un chip con su label/ícono. Clic → despliega el menú
+/// nativo de apps ([`Msg::StartToggle`]), salvo que la config fije `exec` (en
+/// cuyo caso lanza ese comando, override estilo waybar).
+fn start_button_view(label: &str, exec: Option<&str>, theme: &Theme) -> View<Msg> {
+    let click = match exec {
+        Some(cmd) => Msg::Spawn(cmd.to_string()),
+        None => Msg::StartToggle,
+    };
+    chip(theme)
+        .fill(theme.bg_panel)
+        .radius(6.0)
+        .hover_fill(theme.bg_button_hover)
+        .tooltip(if exec.is_some() { "Lanzar" } else { "Menú de inicio" })
+        .on_click(click)
+        .text(label.to_string(), 14.0, theme.accent)
+}
+
+/// Ancho del menú de inicio desplegado, en px.
+const START_MENU_W: f32 = 280.0;
+
+/// El **menú de inicio** desplegado bajo la barra superior: un scrim que cierra
+/// al click + un panel a la izquierda con una fila por app del registro. Pensado
+/// para llenar el área que la barra superior libera al crecer hacia abajo (mismo
+/// truco que el drawer Quake, pero hacia abajo). Cada fila lanza su app
+/// ([`Msg::LaunchApp`]); si el registro está vacío, una pista.
+pub fn start_menu_body(apps: &[AppEntry], theme: &Theme) -> View<Msg> {
+    let filas: Vec<View<Msg>> = if apps.is_empty() {
+        vec![View::new(Style {
+            size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+            align_items: Some(AlignItems::Center),
+            ..Default::default()
+        })
+        .text(
+            "sin apps en ~/.config/gioser/apps/".to_string(),
+            12.0,
+            theme.fg_muted,
+        )]
+    } else {
+        apps.iter().map(|a| app_row(a, theme)).collect()
+    };
+
+    let panel = View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(0.0_f32),
+            top: length(0.0_f32),
+            right: auto(),
+            bottom: auto(),
+        },
+        size: Size {
+            width: length(START_MENU_W),
+            height: auto(),
+        },
+        flex_direction: FlexDirection::Column,
+        padding: TaffyRect {
+            left: length(8.0_f32),
+            right: length(8.0_f32),
+            top: length(8.0_f32),
+            bottom: length(8.0_f32),
+        },
+        gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .radius(10.0)
+    .children(filas);
+
+    // Scrim a ancho completo del área: cierra al click fuera del panel.
+    View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            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) },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .alpha(0.45)
+    .on_click(Msg::StartToggle)
+    .children(vec![panel])
+}
+
+/// Una fila del menú de inicio: ícono (glyph) + label, clickeable.
+fn app_row(a: &AppEntry, theme: &Theme) -> View<Msg> {
+    let icono = a.icon.clone().unwrap_or_else(|| "▸".to_string());
+    let badge = View::new(Style {
+        size: Size { width: length(22.0_f32), height: length(22.0_f32) },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .text(icono, 14.0, theme.accent);
+    let nombre = View::new(Style {
+        size: Size { width: auto(), height: length(28.0_f32) },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text(a.label.clone(), 13.0, theme.fg_text);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+        padding: TaffyRect {
+            left: length(6.0_f32),
+            right: length(6.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
+        ..Default::default()
+    })
+    .radius(6.0)
+    .hover_fill(theme.bg_button_hover)
+    .on_click(Msg::LaunchApp(a.id.clone()))
+    .children(vec![badge, nombre])
+}
+
+/// El menú de inicio como **overlay** para el path winit: un contenedor a
+/// pantalla completa desplazado `bar_h` px hacia abajo (para que el panel caiga
+/// bajo la barra superior) que aloja [`start_menu_body`]. El scrim del body
+/// cierra al click.
+pub fn start_menu_overlay(apps: &[AppEntry], bar_h: f32, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(0.0_f32),
+            top: length(bar_h),
+            right: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        size: Size {
+            width: percent(1.0_f32),
+            height: auto(),
+        },
+        ..Default::default()
+    })
+    .children(vec![start_menu_body(apps, theme)])
+}
+
+/// La barra superior con el menú de inicio **desplegado** hacia abajo: la barra
+/// arriba (su grosor original) y el menú llenando lo que queda. Espeja
+/// [`shuma_open_view`] pero hacia abajo (anclaje superior). El compositor ya
+/// creció la layer surface a [`crate::layer`]'s alto de menú.
+pub fn start_menu_view(
+    surface: &Surface,
+    widgets: &SurfaceWidgets,
+    shuma_state: &ShumaState,
+    data: &BarData,
+    theme: &Theme,
+    bar_px: f32,
+    apps: &[AppEntry],
+) -> View<Msg> {
+    let bar = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(bar_px),
+        },
+        ..Default::default()
+    })
+    .children(vec![bar_view(surface, widgets, shuma_state, data, theme)]);
+
+    let mut body_style = Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    };
+    body_style.flex_grow = 1.0;
+    let body = View::new(body_style).children(vec![start_menu_body(apps, theme)]);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![bar, body])
+}
+
+/// El `clipboard`: un chip con el ícono 📋 y un preview del texto copiado
+/// (recortado). Si `exec` está, clickearlo lanza ese comando —típicamente un
+/// selector de historial (cliphist)— con realce al hover. Sin texto copiado
+/// muestra sólo el ícono tenue.
+fn clipboard_view(text: Option<&str>, exec: Option<&str>, theme: &Theme) -> View<Msg> {
+    let (etiqueta, fg) = match text {
+        Some(t) if !t.is_empty() => (format!("📋 {}", recortar(t, CLIPBOARD_PREVIEW_MAX)), theme.fg_text),
+        _ => ("📋".to_string(), theme.fg_muted),
+    };
+    // Tooltip: el texto copiado completo (sin recortar), útil cuando el preview
+    // de la barra lo trunca.
+    let v = chip(theme)
+        .hover_fill(theme.bg_button_hover)
+        .radius(6.0)
+        .text(etiqueta, 12.0, fg);
+    let v = match text {
+        Some(t) if !t.is_empty() => v.tooltip(t.to_string()),
+        _ => v,
+    };
+    match exec {
+        Some(cmd) => v.on_click(Msg::Spawn(cmd.to_string())),
+        None => v,
+    }
+}
+
+/// Tamaño del ícono del tray en la barra (px).
+const TRAY_ICON_PX: f32 = 18.0;
+
+/// El `tray`: un chip clickeable por item de la bandeja, resaltando los que
+/// piden atención (`NeedsAttention`). Click → [`Msg::TrayActivate`] con su `key`;
+/// el backend activa el item por D-Bus. Pinta el ícono si la app lo proveyó (pixmap
+/// o PNG por nombre); si no, cae a la etiqueta de texto. Los chips siguen el eje.
+fn tray_view(items: &[TrayItem], gap: f32, dir: FlexDirection, theme: &Theme) -> View<Msg> {
+    let chips: Vec<View<Msg>> = items
+        .iter()
+        .map(|it| {
+            let tip = if it.label.trim().is_empty() {
+                it.key.clone()
+            } else {
+                it.label.clone()
+            };
+            let base = chip(theme)
+                .fill(theme.bg_panel_alt)
+                .radius(6.0)
+                .hover_fill(theme.bg_button_hover)
+                .tooltip(tip)
+                .on_click(Msg::TrayActivate(it.key.clone()));
+            match &it.icon {
+                Some(icon) => base.children(vec![tray_icon_node(icon)]),
+                None => {
+                    // NeedsAttention: acento; el resto, normal.
+                    let fg = if it.status == "NeedsAttention" {
+                        theme.accent
+                    } else {
+                        theme.fg_text
+                    };
+                    base.text(recortar(&it.label, TRAY_LABEL_MAX), 12.0, fg)
+                }
+            }
+        })
+        .collect();
+
+    View::new(Style {
+        flex_direction: dir,
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(gap),
+            height: length(gap),
+        },
+        ..Default::default()
+    })
+    .children(chips)
+}
+
+/// Un nodo cuadrado de [`TRAY_ICON_PX`] con el ícono del item (aspect-fit). Arma la
+/// `peniko::Image` desde los bytes RGBA que el hilo del tray ya decodificó.
+fn tray_icon_node(icon: &TrayIcon) -> View<Msg> {
+    let blob = Blob::from(icon.rgba.clone());
+    let img = Image::new(blob, ImageFormat::Rgba8, icon.width, icon.height);
+    View::new(Style {
+        size: Size {
+            width: length(TRAY_ICON_PX),
+            height: length(TRAY_ICON_PX),
+        },
+        ..Default::default()
+    })
+    .image(img)
+}
+
+/// Recorta una cadena a `max` caracteres, agregando `…` si sobró.
+fn recortar(s: &str, max: usize) -> String {
+    if s.chars().count() <= max {
+        s.to_string()
+    } else {
+        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
+        out.push('…');
+        out
+    }
+}
+
+/// La barra de **una** superficie llenando su contenedor (100%×100%): la raíz
+/// que pinta el backend `wlr-layer-shell`, donde el compositor ya dimensionó y
+/// ancló la layer surface al borde — no hace falta posicionarla en absoluto.
+pub fn bar_view(
+    surface: &Surface,
+    widgets: &SurfaceWidgets,
+    shuma_state: &ShumaState,
+    data: &BarData,
+    theme: &Theme,
+) -> View<Msg> {
+    let dir = if surface.anchor.es_horizontal() {
+        FlexDirection::Row
+    } else {
+        FlexDirection::Column
+    };
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        flex_direction: dir,
+        padding: TaffyRect {
+            left: length(surface.padding),
+            right: length(surface.padding),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::SpaceBetween),
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .children(slots_de(surface, widgets, shuma_state, data, theme, dir))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::widget_tooltip;
+    use pata_core::widget::WidgetView;
+
+    #[test]
+    fn tooltip_de_un_medidor_junta_etiqueta_y_leyenda() {
+        let v = WidgetView::Meter {
+            label: Some("CPU".into()),
+            fraction: 0.42,
+            caption: "42%".into(),
+        };
+        assert_eq!(widget_tooltip(&v).as_deref(), Some("CPU 42%"));
+    }
+
+    #[test]
+    fn tooltip_de_texto_y_vacio() {
+        assert_eq!(widget_tooltip(&WidgetView::Text("14:05".into())).as_deref(), Some("14:05"));
+        assert_eq!(widget_tooltip(&WidgetView::Text("  ".into())), None);
+        assert_eq!(widget_tooltip(&WidgetView::Empty), None);
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/render/sidebar.rs b/02_ruway/pata/pata-llimphi/src/render/sidebar.rs
new file mode 100644
index 0000000..4e2d3d8
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/render/sidebar.rs
@@ -0,0 +1,622 @@
+//! Render del **sidebar navegador** (Fase 11c): el rail de dientes pegado al
+//! borde y, cuando un diente está activo, el panel con el navegador de
+//! Mónadas/archivos.
+//!
+//! - El **rail** reusa [`llimphi_widget_dock_rail`]: una franja vertical con un
+//!   diente por `SidebarTab`. El diente activo (su panel desplegado) va
+//!   resaltado. Clic → [`Msg::NavTabActivate`].
+//! - El **panel** ([`panel_inner`]) lleva un cabezal con el toggle Árbol/Grafo +
+//!   el navegador ([`llimphi_widget_navigator`]) dentro de un área de scroll. El
+//!   plano de datos lo provee [`crate::nouser`].
+//!
+//! Dos backends montan estas piezas distinto:
+//! - **winit** ([`sidebar_rail_view`] + [`nav_panel_view`]): cada superficie vive
+//!   en la ventana única, posicionada en absoluto sobre la pantalla; el panel
+//!   flota como un drawer.
+//! - **layer-shell** ([`sidebar_surface_view`]): el rail es su propia layer
+//!   surface anclada al borde; al abrir un diente la surface **crece** en ancho y
+//!   el panel se pinta junto al rail (el eje libre lo estira el compositor).
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
+    Rect as TaffyRect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::View;
+
+use llimphi_widget_dock_rail::{dock_rail_view, DockRailItem, DockRailPalette};
+use pata_host::HostedTooth;
+use llimphi_widget_navigator::{
+    navigator_view, NavId, NavKind, NavMode, NavNode, NavPalette, NavSpec,
+};
+use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
+use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
+
+use std::collections::HashSet;
+
+use pata_core::config::{Anchor, Surface};
+use pata_core::layout::Rect;
+
+use crate::nouser::NavState;
+use crate::shuma::ShumaState;
+use crate::Msg;
+
+/// Alto del cabezal del panel (título + toggle de modo), en px.
+const HEADER_H: f32 = 40.0;
+/// Padding interno del panel, en px.
+const PAD: f32 = 8.0;
+/// Alto estimado de una fila del navegador en modo árbol (igual al `ROW_H`
+/// interno del widget). Para dimensionar el scroll.
+const TREE_ROW_H: f32 = 24.0;
+/// Alto estimado de un nodo del navegador en modo grafo (nodo + separación).
+const GRAPH_ROW_H: f32 = 60.0;
+
+// =====================================================================
+// Piezas compartidas por ambos backends
+// =====================================================================
+
+/// El rail de dientes (sin fondo de franja): un diente por `SidebarTab`. `si`
+/// identifica la superficie para el `Msg` del clic.
+fn rail_widget(surface: &Surface, si: usize, width: f32, nav: &NavState, theme: &Theme) -> View<Msg> {
+    let items: Vec<DockRailItem> = surface
+        .tabs
+        .iter()
+        .enumerate()
+        .map(|(ti, _)| DockRailItem {
+            id: ti as u64,
+            active: nav.is_open(si, ti),
+        })
+        .collect();
+    let icons: Vec<String> = surface.tabs.iter().map(|t| t.icon.clone()).collect();
+    dock_rail_view(
+        &items,
+        width,
+        &DockRailPalette::from_theme(theme),
+        move |id, size, color| {
+            let name = icons.get(id as usize).map(|s| s.as_str()).unwrap_or("");
+            tooth_icon(name, size, color)
+        },
+        move |id| Msg::NavTabActivate(si, id as usize),
+        // Mover un diente de un rail a otro: Fase futura (drop entre sidebars).
+        |_| None,
+    )
+}
+
+/// El rail de **dientes hospedados** de la app enfocada (`app_id`): un diente por
+/// [`HostedTooth`]. Al clickear, manda `HostToothActivate(app_id, id)` (la app lo
+/// resuelve sobre su propio canvas). No tienen estado "activo" en pata (lo lleva
+/// la app), así que van todos inactivos.
+fn hosted_rail(app_id: &str, teeth: &[HostedTooth], width: f32, theme: &Theme) -> View<Msg> {
+    let items: Vec<DockRailItem> = teeth
+        .iter()
+        .map(|t| DockRailItem {
+            id: t.id as u64,
+            active: false,
+        })
+        .collect();
+    let icons: Vec<String> = teeth.iter().map(|t| t.icon.clone()).collect();
+    let app = app_id.to_string();
+    dock_rail_view(
+        &items,
+        width,
+        &DockRailPalette::from_theme(theme),
+        move |id, size, color| {
+            let name = icons.get(id as usize).map(|s| s.as_str()).unwrap_or("");
+            tooth_icon(name, size, color)
+        },
+        move |id| Msg::HostToothActivate(app.clone(), id as u32),
+        |_| None,
+    )
+}
+
+/// El diente **in-process** de shuma: cuando el marco hospeda un `shuma_input`
+/// ([`ShumaState::present`]), el rail muestra un diente que despliega/repliega el
+/// drawer Quake. A diferencia de los dientes hospedados (estado en la app remota,
+/// vía socket), shuma vive en el **propio proceso** de pata, así que el diente
+/// refleja su estado real (`active = open`) y el clic va directo a
+/// [`Msg::ShumaToggle`]. Por eso no depende del foco: aparece igual en winit y en
+/// layer-shell mientras la config declare un `shuma_input`.
+fn shuma_rail(open: bool, width: f32, theme: &Theme) -> View<Msg> {
+    let items = vec![DockRailItem { id: 0, active: open }];
+    dock_rail_view(
+        &items,
+        width,
+        &DockRailPalette::from_theme(theme),
+        |_id, size, color| tooth_icon("shell", size, color),
+        |_id| Msg::ShumaToggle,
+        |_| None,
+    )
+}
+
+/// Un separador tenue horizontal entre grupos de dientes del rail.
+fn rail_separator(thickness: f32, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: length(thickness * 0.5),
+            height: length(1.0_f32),
+        },
+        margin: TaffyRect {
+            left: length(thickness * 0.25),
+            right: length(thickness * 0.25),
+            top: length(6.0_f32),
+            bottom: length(6.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.fg_muted)
+}
+
+/// Una franja de rail que **llena su alto**: fondo de panel + el rail de dientes
+/// de la config arriba, debajo los dientes **hospedados** de la app enfocada (si
+/// los hay) y, al fondo, el diente **in-process** de shuma (si el marco hospeda un
+/// `shuma_input`). La usan ambos backends (en winit dentro del rect absoluto —sin
+/// dientes hospedados, que dependen del foco, pero sí con el de shuma—, en
+/// layer-shell como columna de ancho `thickness` dentro de la surface).
+fn rail_strip(
+    surface: &Surface,
+    si: usize,
+    thickness: f32,
+    nav: &NavState,
+    hosted: &[HostedTooth],
+    hosted_app: &str,
+    shuma: &ShumaState,
+    theme: &Theme,
+) -> View<Msg> {
+    let mut hijos = vec![rail_widget(surface, si, thickness, nav, theme)];
+    if !hosted.is_empty() {
+        hijos.push(rail_separator(thickness, theme));
+        hijos.push(hosted_rail(hosted_app, hosted, thickness, theme));
+    }
+    if shuma.present {
+        hijos.push(rail_separator(thickness, theme));
+        hijos.push(shuma_rail(shuma.open, thickness, theme));
+    }
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: length(thickness),
+            height: percent(1.0_f32),
+        },
+        flex_shrink: 0.0,
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .children(hijos)
+}
+
+/// El contenido del panel (cabezal con toggle de modo + navegador con scroll),
+/// dimensionado para llenar su contenedor de alto `panel_h` px. Trae su propio
+/// fondo y padding. `ti` es el diente desplegado.
+fn panel_inner(surface: &Surface, ti: usize, panel_h: f32, nav: &NavState, theme: &Theme) -> View<Msg> {
+    // --- Cabezal: título del diente + toggle Árbol/Grafo ---
+    let titulo = surface
+        .tabs
+        .get(ti)
+        .map(|t| t.label.clone())
+        .unwrap_or_default();
+    let titulo_view = View::new(Style {
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(0.0_f32),
+            height: length(HEADER_H),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text(titulo, 13.0, theme.fg_text);
+
+    let toggle = View::new(Style {
+        size: Size {
+            width: length(140.0_f32),
+            height: length(HEADER_H),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .children(vec![segmented_view(
+        &NavMode::LABELS,
+        nav.mode.index(),
+        |i| Msg::NavSetMode(NavMode::from_index(i)),
+        &SegmentedPalette::from_theme(theme),
+    )]);
+
+    let header = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(HEADER_H),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::SpaceBetween),
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![titulo_view, toggle]);
+
+    // --- Cuerpo: el menú "Abrir con…" si está abierto, si no el navegador (o un
+    // aviso si no hay datos) ---
+    let viewport = (panel_h - HEADER_H - PAD * 2.0).max(0.0);
+    let cuerpo = if let Some(mid) = nav.menu {
+        open_with_menu(nav, mid, theme)
+    } else if nav.roots.is_empty() {
+        aviso_view(nav, theme, viewport)
+    } else {
+        let row_h = match nav.mode {
+            NavMode::Tree => TREE_ROW_H,
+            NavMode::Graph => GRAPH_ROW_H,
+        };
+        let visibles = count_visible(&nav.roots, &nav.expanded);
+        let content_len = visibles as f32 * row_h + 16.0;
+        let offset = clamp_offset(nav.scroll, content_len, viewport);
+
+        let navv = navigator_view(
+            NavSpec {
+                roots: &nav.roots,
+                mode: nav.mode,
+                selected: nav.selected,
+                palette: NavPalette::from_theme(theme),
+                guides: true,
+            },
+            |id| nav.expanded.contains(&id),
+            Msg::NavToggle,
+            Msg::NavSelect,
+            // Right-click sobre un archivo → menú "Abrir con…".
+            Some(Msg::NavContextMenu),
+        );
+
+        scroll_y(
+            offset,
+            content_len,
+            viewport,
+            navv,
+            Msg::NavScroll,
+            &ScrollPalette::from_theme(theme),
+        )
+    };
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: TaffyRect {
+            left: length(PAD),
+            right: length(PAD),
+            top: length(PAD),
+            bottom: length(PAD),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(PAD),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children(vec![header, cuerpo])
+}
+
+// =====================================================================
+// Backend winit: superficies en absoluto sobre la ventana única
+// =====================================================================
+
+/// El rail de un `SurfaceKind::Sidebar`, posicionado en el rect que el layout
+/// reservó para él (path winit). `si` es el índice de la superficie.
+pub fn sidebar_rail_view(
+    surface: &Surface,
+    si: usize,
+    rect: Rect,
+    nav: &NavState,
+    shuma: &ShumaState,
+    theme: &Theme,
+) -> View<Msg> {
+    View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(rect.x as f32),
+            top: length(rect.y as f32),
+            right: auto(),
+            bottom: auto(),
+        },
+        size: Size {
+            width: length(rect.w as f32),
+            height: length(rect.h as f32),
+        },
+        ..Default::default()
+    })
+    // El path winit no conoce el foco (no hay toplevels) → sin dientes hospedados,
+    // pero el diente de shuma es in-process y sí aparece.
+    .children(vec![rail_strip(surface, si, rect.w as f32, nav, &[], "", shuma, theme)])
+}
+
+/// El panel flotante del diente `ti` desplegado (path winit): flota junto al
+/// `rail_rect` (a su derecha si el sidebar está a la izquierda, a su izquierda si
+/// está a la derecha).
+pub fn nav_panel_view(
+    surface: &Surface,
+    ti: usize,
+    rail_rect: Rect,
+    screen: (i32, i32),
+    nav: &NavState,
+    theme: &Theme,
+) -> View<Msg> {
+    let pw = surface.panel_width;
+    let (_, sh) = screen;
+    let h = (rail_rect.h as f32).min(sh as f32);
+    let y = rail_rect.y as f32;
+    let x = match surface.anchor {
+        Anchor::Right => (rail_rect.x as f32 - pw).max(0.0),
+        _ => (rail_rect.x + rail_rect.w) as f32,
+    };
+
+    View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(x),
+            top: length(y),
+            right: auto(),
+            bottom: auto(),
+        },
+        size: Size {
+            width: length(pw),
+            height: length(h),
+        },
+        ..Default::default()
+    })
+    .children(vec![panel_inner(surface, ti, h, nav, theme)])
+}
+
+// =====================================================================
+// Backend layer-shell: la surface del rail crece para alojar el panel
+// =====================================================================
+
+/// La vista que llena la layer surface de un `SurfaceKind::Sidebar` de tamaño
+/// `(w, h)` px. Colapsada es sólo el rail (`w == thickness`); con un diente
+/// abierto la surface creció a `thickness + panel_width` y se pinta rail + panel
+/// (el orden depende del anclaje: el rail siempre pegado a su borde). `si` es el
+/// índice de la superficie.
+pub fn sidebar_surface_view(
+    surface: &Surface,
+    si: usize,
+    w: f32,
+    h: f32,
+    nav: &NavState,
+    hosted: &[HostedTooth],
+    hosted_app: &str,
+    shuma: &ShumaState,
+    theme: &Theme,
+) -> View<Msg> {
+    let thickness = surface.thickness;
+    let rail = rail_strip(surface, si, thickness, nav, hosted, hosted_app, shuma, theme);
+
+    let open_ti = match nav.open {
+        Some((s, ti)) if s == si => Some(ti),
+        _ => None,
+    };
+
+    let children = if let Some(ti) = open_ti {
+        let pw = (w - thickness).max(0.0);
+        let panel = View::new(Style {
+            size: Size {
+                width: length(pw),
+                height: percent(1.0_f32),
+            },
+            flex_shrink: 0.0,
+            ..Default::default()
+        })
+        .children(vec![panel_inner(surface, ti, h, nav, theme)]);
+        // El rail va pegado a su borde: a la izquierda del panel si el sidebar
+        // está anclado a la izquierda; a la derecha si está a la derecha.
+        match surface.anchor {
+            Anchor::Right => vec![panel, rail],
+            _ => vec![rail, panel],
+        }
+    } else {
+        vec![rail]
+    };
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: length(w),
+            height: length(h),
+        },
+        ..Default::default()
+    })
+    .children(children)
+}
+
+// =====================================================================
+// Auxiliares
+// =====================================================================
+
+/// El menú "Abrir con…" sobre el archivo `id`: una fila por app nativa que
+/// declare su mime (de `nav.menu_options`), más "el sistema" (`xdg-open`) y
+/// "Cancelar". Se pinta en el cuerpo del panel (sin overlay flotante: así
+/// funciona idéntico en winit y layer-shell sin necesitar coords del cursor).
+fn open_with_menu(nav: &NavState, id: NavId, theme: &Theme) -> View<Msg> {
+    let path = nav.file_path(id).unwrap_or("");
+    let name = path.rsplit('/').next().filter(|s| !s.is_empty()).unwrap_or(path);
+
+    let mut rows: Vec<View<Msg>> = Vec::new();
+    rows.push(
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(28.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            ..Default::default()
+        })
+        .text(format!("Abrir «{name}» con:"), 12.0, theme.fg_muted),
+    );
+    for (app_id, label) in &nav.menu_options {
+        let aid = app_id.clone();
+        rows.push(menu_button(label, theme).on_click(Msg::NavOpenWith(id, Some(aid))));
+    }
+    rows.push(menu_button("El sistema (xdg-open)", theme).on_click(Msg::NavOpenWith(id, None)));
+    rows.push(menu_button("Cancelar", theme).on_click(Msg::NavMenuCancel));
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: auto(),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(4.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(rows)
+}
+
+/// Una fila clickeable del menú "Abrir con…". El caller le cuelga el `on_click`.
+fn menu_button(label: &str, theme: &Theme) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(30.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        padding: TaffyRect {
+            left: length(10.0_f32),
+            right: length(10.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .hover_fill(theme.bg_button_hover)
+    .radius(6.0)
+    .text(label.to_string(), 13.0, theme.fg_text)
+}
+
+/// Un aviso centrado cuando no hay Mónadas que mostrar (conectando, o error).
+fn aviso_view(nav: &NavState, theme: &Theme, viewport: f32) -> View<Msg> {
+    let (texto, color) = match &nav.error {
+        Some(e) => (e.clone(), theme.fg_muted),
+        None => ("Conectando con nouser…".to_string(), theme.fg_muted),
+    };
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(viewport.max(40.0)),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .text(texto, 12.0, color)
+}
+
+/// Cuenta los nodos visibles del bosque dado el conjunto de expandidos — para
+/// dimensionar el alto del contenido del scroll.
+fn count_visible(roots: &[NavNode], expanded: &HashSet<u64>) -> usize {
+    fn walk(node: &NavNode, expanded: &HashSet<u64>, acc: &mut usize) {
+        *acc += 1;
+        if node.has_children() && expanded.contains(&node.id) {
+            for c in &node.children {
+                walk(c, expanded, acc);
+            }
+        }
+    }
+    let mut acc = 0;
+    for r in roots {
+        walk(r, expanded, &mut acc);
+    }
+    acc
+}
+
+/// El icono de un diente del rail: un glifo vectorial según el nombre declarado
+/// en el `SidebarTab` (`monads` → diamante, `files` → cuadrado, otro → círculo),
+/// con el color que el rail ya resolvió (acento si activo, atenuado si no).
+fn tooth_icon(name: &str, size: f32, color: Color) -> View<Msg> {
+    // Reusamos la semántica de iconos del navegador para coherencia visual.
+    let kind = match name {
+        "monads" | "monadas" | "monad" | "astro" => NavKind::Monad,
+        "files" | "archivos" | "file" | "dir" | "folder" | "tree" => NavKind::Dir,
+        "tools" | "group" | "settings" | "system" | "shell" | "terminal" => NavKind::Group,
+        _ => NavKind::Other,
+    };
+    View::new(Style {
+        size: Size {
+            width: length(size),
+            height: length(size),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .paint_with(move |scene, _ts, rect| {
+        use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, RoundedRect};
+        use llimphi_ui::llimphi_raster::peniko::Fill;
+        if rect.w <= 0.0 || rect.h <= 0.0 {
+            return;
+        }
+        let cx = (rect.x + rect.w * 0.5) as f64;
+        let cy = (rect.y + rect.h * 0.5) as f64;
+        let r = (rect.w.min(rect.h) as f64 * 0.38).max(2.0);
+        match kind {
+            NavKind::Monad => {
+                let mut p = BezPath::new();
+                p.move_to(Point::new(cx, cy - r));
+                p.line_to(Point::new(cx + r, cy));
+                p.line_to(Point::new(cx, cy + r));
+                p.line_to(Point::new(cx - r, cy));
+                p.close_path();
+                scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p);
+            }
+            NavKind::Group | NavKind::Dir => {
+                let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0);
+                scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq);
+            }
+            NavKind::File | NavKind::Other => {
+                scene.fill(
+                    Fill::NonZero,
+                    Affine::IDENTITY,
+                    color,
+                    None,
+                    &Circle::new((cx, cy), r * 0.7),
+                );
+            }
+        }
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use llimphi_widget_navigator::NavNode;
+
+    fn forest() -> Vec<NavNode> {
+        vec![
+            NavNode::branch(
+                1,
+                "m1",
+                NavKind::Monad,
+                vec![NavNode::leaf(11, "a", NavKind::File), NavNode::leaf(12, "b", NavKind::File)],
+            ),
+            NavNode::leaf(2, "m2", NavKind::Monad),
+        ]
+    }
+
+    #[test]
+    fn count_visible_respeta_expansion() {
+        let roots = forest();
+        // Colapsado: sólo las 2 raíces.
+        let none = HashSet::new();
+        assert_eq!(count_visible(&roots, &none), 2);
+        // Expandida la primera: 2 raíces + 2 hijos.
+        let mut exp = HashSet::new();
+        exp.insert(1u64);
+        assert_eq!(count_visible(&roots, &exp), 4);
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/sampler.rs b/02_ruway/pata/pata-llimphi/src/sampler.rs
new file mode 100644
index 0000000..59180ac
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/sampler.rs
@@ -0,0 +1,446 @@
+//! El muestreador del sistema en Linux: arma el [`WidgetCtx`] que alimenta a
+//! los widgets de `pata-core` en cada tick.
+//!
+//! La frontera de la Fase 4: el core no toca el SO; este es el sampler que cada
+//! plataforma aporta. En Linux leemos `chrono` para el reloj, `/proc/stat` para
+//! la CPU (necesita dos lecturas, por eso es un struct con estado), `/proc/
+//! meminfo` para la RAM y `/sys/class/backlight` para el brillo. El volumen
+//! (PulseAudio/PipeWire) queda diferido —el medidor sale en 0% hasta entonces—.
+
+use std::io::Read;
+use std::time::{Duration, Instant};
+
+use chrono::{Datelike, Local, Timelike, Utc};
+
+use pata_core::widget::{ClockReading, WidgetCtx};
+
+/// Duración del mes sinódico (de luna nueva a luna nueva), en días.
+const MES_SINODICO: f64 = 29.530588853;
+/// Época de referencia de luna nueva: 2000-01-06 18:14 UTC, en días julianos.
+const LUNA_NUEVA_REF_JD: f64 = 2451550.1;
+
+/// Muestreador con estado: guarda la última lectura de `/proc/stat` para poder
+/// calcular el uso de CPU como delta entre ticks.
+#[derive(Default)]
+pub struct Sampler {
+    /// `(total, idle)` de la lectura anterior de `/proc/stat`, o `None` al inicio.
+    cpu_prev: Option<(u64, u64)>,
+    /// Si `true`, el reloj se arma en UTC en vez de la hora local (de
+    /// `general.timezone = "UTC"`). Paridad con el `TzMode` de mirada-launcher.
+    utc: bool,
+}
+
+impl Sampler {
+    /// Un sampler nuevo, sin lecturas previas (hora local).
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Un sampler que arma el reloj en UTC si `utc`, o local si no.
+    pub fn with_utc(utc: bool) -> Self {
+        Self { utc, ..Self::default() }
+    }
+
+    /// Toma un snapshot completo del sistema.
+    pub fn sample(&mut self) -> WidgetCtx {
+        let (ram, ram_used_mb, ram_total_mb) = sample_ram();
+        let (sun_longitude_deg, moon_phase) = astro_from_jd(jd_from_unix(Utc::now().timestamp()));
+        let (volume, muted) = sample_volume().unwrap_or((0.0, false));
+        WidgetCtx {
+            clock: sample_clock(self.utc),
+            cpu: self.sample_cpu(),
+            ram,
+            ram_used_mb,
+            ram_total_mb,
+            volume,
+            muted,
+            brightness: sample_brightness().unwrap_or(0.0),
+            sun_longitude_deg,
+            moon_phase,
+        }
+    }
+
+    /// Uso de CPU `0..1` como `1 - idle_delta/total_delta`. La primera vez no
+    /// hay delta, así que devuelve 0 y guarda la base para el siguiente tick.
+    fn sample_cpu(&mut self) -> f32 {
+        let Some((total, idle)) = read_proc_stat() else {
+            return 0.0;
+        };
+        let usage = match self.cpu_prev {
+            Some((pt, pi)) => {
+                let dt = total.saturating_sub(pt);
+                let di = idle.saturating_sub(pi);
+                if dt > 0 {
+                    (1.0 - di as f32 / dt as f32).clamp(0.0, 1.0)
+                } else {
+                    0.0
+                }
+            }
+            None => 0.0,
+        };
+        self.cpu_prev = Some((total, idle));
+        usage
+    }
+}
+
+/// Descompone la hora actual (local, o UTC si `utc`) en [`ClockReading`].
+fn sample_clock(utc: bool) -> ClockReading {
+    if utc {
+        clock_de(Utc::now())
+    } else {
+        clock_de(Local::now())
+    }
+}
+
+/// Arma el [`ClockReading`] desde cualquier `DateTime` con timezone.
+fn clock_de<Tz: chrono::TimeZone>(now: chrono::DateTime<Tz>) -> ClockReading {
+    ClockReading {
+        year: now.year() as u16,
+        month: now.month() as u8,
+        day: now.day() as u8,
+        weekday: now.weekday().num_days_from_sunday() as u8,
+        hour: now.hour() as u8,
+        minute: now.minute() as u8,
+        second: now.second() as u8,
+    }
+}
+
+/// `(fracción_usada, usada_mb, total_mb)` desde `/proc/meminfo`. Si no se puede
+/// leer (no-Linux), devuelve ceros.
+fn sample_ram() -> (f32, u32, u32) {
+    let Some((total_kb, avail_kb)) = read_meminfo() else {
+        return (0.0, 0, 0);
+    };
+    let used_kb = total_kb.saturating_sub(avail_kb);
+    let frac = if total_kb > 0 {
+        used_kb as f32 / total_kb as f32
+    } else {
+        0.0
+    };
+    (frac, (used_kb / 1024) as u32, (total_kb / 1024) as u32)
+}
+
+/// `(total_kb, available_kb)` desde `/proc/meminfo`.
+fn read_meminfo() -> Option<(u64, u64)> {
+    let text = std::fs::read_to_string("/proc/meminfo").ok()?;
+    parse_meminfo(&text)
+}
+
+/// Extrae `(MemTotal, MemAvailable)` en kB del texto de `/proc/meminfo`.
+fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
+    let mut total = None;
+    let mut avail = None;
+    for line in text.lines() {
+        let mut parts = line.split_whitespace();
+        match parts.next()? {
+            "MemTotal:" => total = parts.next()?.parse::<u64>().ok(),
+            "MemAvailable:" => avail = parts.next()?.parse::<u64>().ok(),
+            _ => {}
+        }
+        if total.is_some() && avail.is_some() {
+            break;
+        }
+    }
+    Some((total?, avail?))
+}
+
+/// `(total_jiffies, idle_jiffies)` de la primera línea `cpu` de `/proc/stat`.
+/// `idle` incluye `iowait` (4º campo). `None` si no se puede leer.
+fn read_proc_stat() -> Option<(u64, u64)> {
+    let text = std::fs::read_to_string("/proc/stat").ok()?;
+    parse_proc_stat(&text)
+}
+
+/// Extrae `(total, idle+iowait)` en jiffies de la primera línea `cpu` de
+/// `/proc/stat`.
+fn parse_proc_stat(text: &str) -> Option<(u64, u64)> {
+    let line = text.lines().next()?;
+    let mut parts = line.split_whitespace();
+    if parts.next()? != "cpu" {
+        return None;
+    }
+    let vals: Vec<u64> = parts.filter_map(|p| p.parse::<u64>().ok()).collect();
+    if vals.len() < 4 {
+        return None;
+    }
+    let total: u64 = vals.iter().sum();
+    // idle = idle (índice 3) + iowait (índice 4, si está).
+    let idle = vals[3] + vals.get(4).copied().unwrap_or(0);
+    Some((total, idle))
+}
+
+/// Día juliano a partir de un timestamp Unix (segundos UTC). El día juliano
+/// 2440587.5 corresponde a la época Unix (1970-01-01 00:00 UTC).
+fn jd_from_unix(secs: i64) -> f64 {
+    secs as f64 / 86_400.0 + 2_440_587.5
+}
+
+/// `(longitud_eclíptica_sol_deg, fase_lunar)` para un día juliano dado.
+///
+/// La longitud del Sol usa la fórmula de baja precisión del *Astronomical
+/// Almanac* (exacta a ~0.01°, de sobra para el signo zodiacal). La fase lunar
+/// es la edad sinódica media desde una luna nueva de referencia, como fracción
+/// `0..1` (0 = nueva, 0.5 = llena). No es astronomía de alta precisión —para eso
+/// está `cosmos-ephemeris`, que puede sustituir a este sampler— pero alcanza
+/// para un widget de barra.
+fn astro_from_jd(jd: f64) -> (f32, f32) {
+    let n = jd - 2_451_545.0; // días desde J2000.0
+    // Anomalía media del Sol (grados → radianes para los senos).
+    let g = (357.528 + 0.985_600_3 * n).to_radians();
+    // Longitud media + ecuación del centro.
+    let mut lambda = 280.460 + 0.985_647_4 * n + 1.915 * g.sin() + 0.020 * (2.0 * g).sin();
+    lambda = lambda.rem_euclid(360.0);
+
+    // Edad lunar como fracción del ciclo sinódico.
+    let edad = (jd - LUNA_NUEVA_REF_JD).rem_euclid(MES_SINODICO);
+    let fase = (edad / MES_SINODICO) as f32;
+
+    (lambda as f32, fase)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn jd_de_epoca_unix_es_la_referencia() {
+        assert!((jd_from_unix(0) - 2_440_587.5).abs() < 1e-9);
+    }
+
+    #[test]
+    fn parse_wpctl_lee_fraccion_y_mute() {
+        assert_eq!(parse_wpctl("Volume: 0.65"), Some((0.65, false)));
+        assert_eq!(parse_wpctl("Volume: 0.30 [MUTED]"), Some((0.30, true)));
+        assert_eq!(parse_wpctl("nada"), None);
+    }
+
+    #[test]
+    fn preview_clipboard_colapsa_a_una_linea() {
+        assert_eq!(preview_clipboard("  hola\n  mundo\t!  "), "hola mundo !");
+        assert_eq!(preview_clipboard("una sola"), "una sola");
+        assert_eq!(preview_clipboard("   \n\t  "), "");
+    }
+
+    #[test]
+    fn parse_pactl_pct_toma_el_primer_porcentaje() {
+        let s = "Volume: front-left: 42598 / 65% / -9.58 dB,   front-right: 42598 / 65% / -9.58 dB";
+        assert_eq!(parse_pactl_pct(s), Some(0.65));
+        assert_eq!(parse_pactl_pct("Volume: 0 / 0% / -inf dB"), Some(0.0));
+        assert_eq!(parse_pactl_pct("sin porcentaje"), None);
+    }
+
+    #[test]
+    fn sol_en_equinoccio_de_marzo_esta_cerca_de_aries_0() {
+        // 2025-03-20 ~09:01 UTC fue el equinoccio: el Sol cruza 0° (Aries).
+        // timestamp del 2025-03-20 09:01:00 UTC = 1742461260.
+        let (lon, _) = astro_from_jd(jd_from_unix(1_742_461_260));
+        // Cerca de 0°/360°: aceptamos un margen de 1°.
+        let dist = lon.min(360.0 - lon);
+        assert!(dist < 1.0, "longitud {lon} no está cerca de 0°");
+    }
+
+    #[test]
+    fn fase_lunar_esta_en_rango() {
+        let (_, fase) = astro_from_jd(jd_from_unix(1_742_461_260));
+        assert!((0.0..=1.0).contains(&fase));
+    }
+
+    #[test]
+    fn parse_meminfo_extrae_total_y_disponible() {
+        let txt = "MemTotal:       16252000 kB\n\
+                   MemFree:         1000000 kB\n\
+                   MemAvailable:    8126000 kB\n";
+        assert_eq!(parse_meminfo(txt), Some((16252000, 8126000)));
+    }
+
+    #[test]
+    fn parse_meminfo_sin_claves_es_none() {
+        assert_eq!(parse_meminfo("Foo: 1 kB\n"), None);
+    }
+
+    #[test]
+    fn parse_proc_stat_suma_total_e_idle_con_iowait() {
+        // cpu user nice system idle iowait irq softirq …
+        let txt = "cpu  100 0 50 800 50 0 0 0\ncpu0 ...\n";
+        // total = 100+0+50+800+50 = 1000 ; idle = 800+50 = 850
+        assert_eq!(parse_proc_stat(txt), Some((1000, 850)));
+    }
+
+    #[test]
+    fn parse_proc_stat_otra_primera_linea_es_none() {
+        assert_eq!(parse_proc_stat("intr 1 2 3\n"), None);
+    }
+
+    #[test]
+    fn sampler_nuevo_no_tiene_lectura_previa_de_cpu() {
+        // El primer tick no puede calcular delta (sin base): arranca en None.
+        assert_eq!(Sampler::new().cpu_prev, None);
+    }
+
+    #[test]
+    fn delta_de_cpu_da_el_uso_esperado() {
+        // Base (total=1000, idle=900) → (1100, 950): dt=100, di=50 → 1-0.5 = 0.5.
+        let (dt, di) = (1100u64 - 1000, 950u64 - 900);
+        let uso = 1.0 - di as f32 / dt as f32;
+        assert!((uso - 0.5).abs() < 1e-6);
+    }
+}
+
+/// Brillo `0..1` desde el primer dispositivo en `/sys/class/backlight`. `None`
+/// si no hay backlight (escritorio, VM).
+fn sample_brightness() -> Option<f32> {
+    let dir = std::fs::read_dir("/sys/class/backlight").ok()?;
+    for entry in dir.flatten() {
+        let base = entry.path();
+        let cur = std::fs::read_to_string(base.join("brightness"))
+            .ok()
+            .and_then(|s| s.trim().parse::<f32>().ok());
+        let max = std::fs::read_to_string(base.join("max_brightness"))
+            .ok()
+            .and_then(|s| s.trim().parse::<f32>().ok());
+        if let (Some(c), Some(m)) = (cur, max) {
+            if m > 0.0 {
+                return Some((c / m).clamp(0.0, 1.0));
+            }
+        }
+    }
+    None
+}
+
+/// `(fracción_volumen, muteado)` del sink por defecto. Prueba PipeWire (`wpctl`)
+/// y cae a PulseAudio (`pactl`). `None` si ninguno está. Corre un subproceso por
+/// muestreo (~1Hz) — barato a esa frecuencia.
+fn sample_volume() -> Option<(f32, bool)> {
+    if let Some(out) = run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]) {
+        if let Some(r) = parse_wpctl(&out) {
+            return Some(r);
+        }
+    }
+    let vol = run("pactl", &["get-sink-volume", "@DEFAULT_SINK@"]).and_then(|o| parse_pactl_pct(&o))?;
+    let muted = run("pactl", &["get-sink-mute", "@DEFAULT_SINK@"])
+        .map(|o| o.contains("yes"))
+        .unwrap_or(false);
+    Some((vol, muted))
+}
+
+/// El texto del portapapeles vía `wl-paste` (wl-clipboard), ya colapsado a una
+/// línea. `None` si `wl-paste` no está, si el portapapeles está vacío o no es
+/// texto (p. ej. una imagen). Corre un subproceso por muestreo (~1Hz), como el
+/// volumen — barato a esa frecuencia.
+pub fn leer_clipboard() -> Option<String> {
+    // `--no-newline`: sin salto final. `--type text/plain`: sólo texto, así una
+    // imagen en el portapapeles no entra (wl-paste falla y devolvemos None).
+    let raw = run("wl-paste", &["--no-newline", "--type", "text/plain"])?;
+    let prev = preview_clipboard(&raw);
+    (!prev.is_empty()).then_some(prev)
+}
+
+/// Colapsa el texto del portapapeles a una sola línea para la barra: saltos y
+/// tabs pasan a espacios y los espacios repetidos se comprimen. No trunca —de eso
+/// se encarga el render con su `recortar`—.
+pub fn preview_clipboard(raw: &str) -> String {
+    raw.split_whitespace().collect::<Vec<_>>().join(" ")
+}
+
+/// Corre `cmd args` con un **tope de tiempo** y devuelve su stdout si salió bien
+/// dentro del plazo; si se pasa, mata el proceso y devuelve `None`.
+///
+/// El tope es la diferencia entre "anda" y "se cuelga": herramientas como
+/// `wl-paste` (sin `wlr-data-control` en el compositor) o `wpctl`/`pactl` (sin
+/// PipeWire/Pulse corriendo, típico en una sesión recién abierta) **bloquean
+/// indefinidamente**. Sin timeout, eso congelaba el muestreo —y con él el primer
+/// frame del marco— para siempre (pata no llegaba ni a crear su surface GPU).
+fn run(cmd: &str, args: &[&str]) -> Option<String> {
+    const PLAZO: Duration = Duration::from_millis(500);
+    let mut child = std::process::Command::new(cmd)
+        .args(args)
+        .stdin(std::process::Stdio::null())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::null())
+        .spawn()
+        .ok()?;
+    let inicio = Instant::now();
+    loop {
+        match child.try_wait() {
+            Ok(Some(status)) => {
+                if !status.success() {
+                    return None;
+                }
+                let mut buf = String::new();
+                child.stdout.take()?.read_to_string(&mut buf).ok()?;
+                return Some(buf);
+            }
+            Ok(None) => {
+                if inicio.elapsed() >= PLAZO {
+                    let _ = child.kill();
+                    let _ = child.wait();
+                    return None;
+                }
+                std::thread::sleep(Duration::from_millis(10));
+            }
+            Err(_) => return None,
+        }
+    }
+}
+
+/// Muestrea el sistema en un **hilo aparte** y publica el último snapshot por un
+/// canal. Los subprocesos (wpctl/pactl/wl-paste) corren ahí, **nunca en el hilo
+/// del bucle de UI**: si uno se cuelga o tarda, el marco sigue pintando y
+/// refrescando lo demás. Mismo patrón que el `TrayHandle`.
+pub struct SamplerHandle {
+    rx: std::sync::mpsc::Receiver<Snapshot>,
+}
+
+/// Lo que el hilo de muestreo publica cada ~1 s: el contexto de widgets + el
+/// preview del portapapeles.
+pub type Snapshot = (WidgetCtx, Option<String>);
+
+impl SamplerHandle {
+    /// Arranca el hilo de muestreo. Toma una muestra al toque y luego cada ~1 s.
+    /// `utc` arma el reloj en UTC (de `general.timezone = "UTC"`).
+    pub fn spawn(utc: bool) -> Self {
+        let (tx, rx) = std::sync::mpsc::channel();
+        std::thread::spawn(move || {
+            let mut sampler = Sampler::with_utc(utc);
+            loop {
+                let snapshot = (sampler.sample(), leer_clipboard());
+                if tx.send(snapshot).is_err() {
+                    break; // la app se fue: cortamos el hilo
+                }
+                std::thread::sleep(Duration::from_secs(1));
+            }
+        });
+        Self { rx }
+    }
+
+    /// El snapshot más reciente (drena la cola), o `None` si no llegó nada nuevo
+    /// desde la última vez. **No bloquea** — pensado para llamar por frame.
+    pub fn latest(&self) -> Option<Snapshot> {
+        let mut last = None;
+        while let Ok(snapshot) = self.rx.try_recv() {
+            last = Some(snapshot);
+        }
+        last
+    }
+}
+
+/// Parsea `wpctl get-volume`: `"Volume: 0.65"` o `"Volume: 0.65 [MUTED]"`.
+fn parse_wpctl(s: &str) -> Option<(f32, bool)> {
+    let rest = s.trim().strip_prefix("Volume:")?;
+    let muted = rest.contains("MUTED");
+    let frac = rest.split_whitespace().next()?.parse::<f32>().ok()?;
+    Some((frac.clamp(0.0, 1.0), muted))
+}
+
+/// Parsea el primer porcentaje de `pactl get-sink-volume`
+/// (`"Volume: front-left: 42598 / 65% / -9.58 dB ..."`) como fracción `0..1`.
+fn parse_pactl_pct(s: &str) -> Option<f32> {
+    for tok in s.split_whitespace() {
+        if let Some(num) = tok.strip_suffix('%') {
+            if let Ok(p) = num.parse::<f32>() {
+                return Some((p / 100.0).clamp(0.0, 1.0));
+            }
+        }
+    }
+    None
+}
diff --git a/02_ruway/pata/pata-llimphi/src/shuma.rs b/02_ruway/pata/pata-llimphi/src/shuma.rs
new file mode 100644
index 0000000..7e7bf74
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/shuma.rs
@@ -0,0 +1,971 @@
+//! El `shuma_input` y su despliegue **Quake**.
+//!
+//! La frontera del SDD §5: el marco (`pata`) provee el borde; `shuma` provee el
+//! contenido. `shuma_input` es el cabezal que vive en una barra; al activarlo
+//! (click o hotkey) el frontend **despliega un drawer** estilo Quake sobre el
+//! escritorio, con un input que captura el teclado. Repliega al cerrar.
+//!
+//! La ejecución del comando es, estrictamente, trabajo de `shuma` (no de
+//! `pata`). El puente del SDD §5 ya existe: [`ejecutar`] corre el comando por
+//! el **ejecutor real de shuma** (`shuma-exec`) —captura acotada, eventos en
+//! streaming— en vez de un `sh -c` pelado. El mecanismo del drawer no cambia.
+
+use llimphi_motion::Tween;
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
+    Rect as TaffyRect,
+};
+use llimphi_ui::View;
+
+use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
+use pata_core::WidgetSpec;
+use shuma_exec::StageSpec;
+use shuma_line::{split_pipeline, tokenize, Dialect, TokenKind};
+
+use crate::Msg;
+
+/// Alto máximo del drawer, como fracción de la pantalla.
+const DRAWER_FRAC: f32 = 0.45;
+
+/// Una línea de salida con su naturaleza, para colorearla en la card.
+#[derive(Clone, Debug)]
+pub struct OutLine {
+    /// `true` si vino por stderr (se pinta en color de error).
+    pub err: bool,
+    pub text: String,
+}
+
+/// Una *card* del drawer: un comando ejecutado con sus etapas de pipe, su
+/// salida y su código de salida. Es el modelo de paridad con el shell de
+/// shuma (cards + etapas clickeables), que el render del marco pinta.
+#[derive(Clone, Debug)]
+pub struct DrawerBlock {
+    /// La línea tal cual se tecleó.
+    pub cmd: String,
+    /// Etiquetas de cada etapa del pipe (de `shuma-line`) — chips clickeables
+    /// que revelan la salida capturada (tee) de esa etapa intermedia.
+    pub stages: Vec<String>,
+    /// Líneas de salida (stdout/stderr entremezcladas en orden de llegada).
+    /// Es la salida de la **última** etapa del pipe.
+    pub lines: Vec<OutLine>,
+    /// Salida capturada de cada etapa **intermedia** del pipe (tee), indexada
+    /// por etapa. La última etapa no tiene entrada propia: su stdout es
+    /// `lines`. Vacío si la línea no corrió como pipe directo simple.
+    pub stage_lines: Vec<Vec<OutLine>>,
+    /// Etapa cuya salida capturada está revelada inline (`None` = ninguna).
+    pub expanded_stage: Option<usize>,
+    /// Código de salida; `None` mientras el comando sigue corriendo.
+    pub exit: Option<i32>,
+    /// `true` si la card está plegada (sólo se ve el encabezado).
+    pub collapsed: bool,
+    /// `true` si la card es una consulta a la **IA** (no un comando shell): el
+    /// encabezado muestra `✦ <prompt>` sin chips de etapa y el cuerpo es la
+    /// respuesta del modelo. Lo setea [`ShumaState::push_pending_ia`].
+    pub is_ia: bool,
+}
+
+/// El resultado estructurado de una corrida — lo que un hilo de fondo manda de
+/// vuelta para rellenar la card pendiente.
+#[derive(Clone, Debug)]
+pub struct RunResult {
+    pub lines: Vec<OutLine>,
+    pub exit: Option<i32>,
+    /// Salida capturada por etapa intermedia (tee), indexada por etapa. Vacío
+    /// si la línea no corrió como pipe directo simple (cayó a `sh -c`).
+    pub stages: Vec<Vec<OutLine>>,
+}
+
+/// Las etiquetas de las etapas de pipe de una línea (vía `shuma-line`): el
+/// `comando` de cada etapa, o el texto crudo si no se reconoció. Vacío si la
+/// línea no tiene pipe (una sola etapa no amerita chips).
+pub fn stage_labels(cmd: &str) -> Vec<String> {
+    let pipeline = split_pipeline(&tokenize(cmd, Dialect::Bash));
+    if pipeline.stages.len() < 2 {
+        return Vec::new();
+    }
+    pipeline
+        .stages
+        .iter()
+        .map(|s| {
+            s.command.clone().unwrap_or_else(|| {
+                // Sin comando reconocido: el primer argumento, o «·».
+                s.args.first().cloned().unwrap_or_else(|| "·".into())
+            })
+        })
+        .collect()
+}
+
+/// Si `cmd` es un pipe «simple» (sólo comandos/args/flags y `|`, sin comillas,
+/// variables, redirecciones, globs ni `~`), lo descompone en [`StageSpec`] para
+/// correrlo por `Exec::Direct` con **captura por etapa** (tee). Si no, `None`
+/// (cae a `sh -c`, sin tee). Espeja `shuma-module-shell::simple_pipe_stages`:
+/// cualquier sintaxis que el modo directo no absorbe va al shell.
+pub fn simple_pipe_stages(cmd: &str) -> Option<Vec<StageSpec>> {
+    let tokens = tokenize(cmd, Dialect::Bash);
+    let simple = !tokens.is_empty()
+        && tokens.iter().all(|t| {
+            matches!(
+                t.kind,
+                TokenKind::Command
+                    | TokenKind::Argument
+                    | TokenKind::Flag
+                    | TokenKind::Pipe
+                    | TokenKind::Whitespace
+            ) && !t.text.contains(['*', '?', '[', ']', '{', '}'])
+                && !t.text.starts_with('~')
+        });
+    if !simple {
+        return None;
+    }
+    let pipeline = split_pipeline(&tokens);
+    if pipeline.stages.len() < 2 {
+        return None;
+    }
+    let mut stages = Vec::with_capacity(pipeline.stages.len());
+    for st in &pipeline.stages {
+        // Una etapa sin comando (línea incompleta, p. ej. termina en `|`)
+        // → al shell, que reporta el error de sintaxis como toca.
+        let program = st.command.clone()?;
+        stages.push(StageSpec { program, args: st.args.clone() });
+    }
+    Some(stages)
+}
+
+/// El estado del cabezal del shell y su drawer. Vive en el `Model` del frontend
+/// —es interacción, no modelo de dominio—, no en `pata-core`.
+pub struct ShumaState {
+    /// `true` cuando el drawer está desplegado.
+    pub open: bool,
+    /// El comando que se está escribiendo.
+    pub buffer: String,
+    /// Historial de comandos del drawer, uno por *card* — paridad con el
+    /// shell de shuma (cada `$ cmd` con sus etapas de pipe, su salida y su
+    /// código). El más reciente va al final.
+    pub blocks: Vec<DrawerBlock>,
+    /// `true` mientras el comando corre en segundo plano.
+    pub pending: bool,
+    /// Desplazamiento del historial, en px desde arriba (0 = la card más
+    /// vieja al tope; el máximo deja la más nueva pegada al fondo). El render
+    /// lo acota; al lanzar/terminar un comando salta al fondo.
+    pub scroll: f32,
+    /// Alto del viewport del historial en px — lo cachea el backend en cada
+    /// render para que el clamp del scroll (en `update`) sea exacto.
+    pub viewport_h: f32,
+    /// Hotkey que abre/cierra el drawer (de la prop `hotkey`), o `None`.
+    pub hotkey: Option<String>,
+    /// Prompt al frente del input (`›`, `$`, …).
+    pub prompt: String,
+    /// Placeholder cuando el buffer está vacío.
+    pub placeholder: String,
+    /// Animación de despliegue `0..1` (0 = replegado, 1 = desplegado).
+    pub anim: Tween<f32>,
+    /// `true` si el config declaró algún `shuma_input` (si no, no hay cabezal
+    /// ni drawer).
+    pub present: bool,
+}
+
+/// Métricas de layout del historial, en px — deben seguir al render
+/// (`card_view`) para que el alto estimado sea fiel y el scroll acote bien.
+const HEADER_H: f32 = 22.0;
+const LINE_H: f32 = 18.0;
+const CARD_GAP: f32 = 10.0;
+const BODY_GAP: f32 = 2.0;
+
+impl ShumaState {
+    /// Tope de cards en el historial — más allá, las viejas se descartan.
+    const MAX_BLOCKS: usize = 12;
+
+    /// Alto total estimado del historial, en px — base del clamp del scroll y
+    /// de la barra. Estimado desde el modelo con [`HEADER_H`]/[`LINE_H`] (el
+    /// render no devuelve medidas); fiel mientras `card_view` no cambie.
+    pub fn content_height(&self) -> f32 {
+        if self.blocks.is_empty() {
+            return LINE_H; // la pista de uso
+        }
+        let mut h = 0.0;
+        for b in &self.blocks {
+            h += HEADER_H;
+            if !b.collapsed {
+                let mut lines = b.lines.len();
+                if b.exit.is_none() {
+                    lines += 1; // la línea "…"
+                }
+                if matches!(b.exit, Some(c) if c != 0) {
+                    lines += 1; // el footer "exit N"
+                }
+                // La etapa revelada (tee) agrega su salida capturada, o una
+                // línea de aviso si no capturó nada.
+                if let Some(si) = b.expanded_stage {
+                    let n = b.stage_lines.get(si).map(|l| l.len()).unwrap_or(0);
+                    lines += n.max(1);
+                }
+                h += lines as f32 * LINE_H + BODY_GAP;
+            }
+            h += CARD_GAP;
+        }
+        h
+    }
+
+    /// Suma `delta` px al scroll y lo acota al viewport cacheado. Lo llama el
+    /// `update` al recibir la rueda o el arrastre de la barra.
+    pub fn scroll_by(&mut self, delta: f32) {
+        self.scroll = clamp_offset(self.scroll + delta, self.content_height(), self.viewport_h);
+    }
+
+    /// Pega el scroll al fondo (la card más nueva). Se llama al lanzar y al
+    /// terminar un comando para que la salida fresca quede a la vista; el
+    /// render lo acota al máximo real.
+    fn pin_bottom(&mut self) {
+        self.scroll = self.content_height();
+    }
+
+    /// Empuja una card nueva en estado «corriendo» para `cmd` (con sus etapas
+    /// de pipe ya resueltas) y marca el drawer como pendiente. Acota el
+    /// historial a [`MAX_BLOCKS`]. Lo usan ambos backends al lanzar.
+    pub fn push_pending(&mut self, cmd: String) {
+        let stages = stage_labels(&cmd);
+        self.blocks.push(DrawerBlock {
+            cmd,
+            stages,
+            lines: Vec::new(),
+            stage_lines: Vec::new(),
+            expanded_stage: None,
+            exit: None,
+            collapsed: false,
+            is_ia: false,
+        });
+        self.trim_and_pin();
+    }
+
+    /// Empuja una card de consulta a la **IA** para `prompt` y marca el drawer
+    /// como pendiente. Sin chips de etapa (un prompt no es un pipe). El
+    /// resultado llega por [`finish_last`], igual que un comando shell.
+    pub fn push_pending_ia(&mut self, prompt: String) {
+        self.blocks.push(DrawerBlock {
+            cmd: prompt,
+            stages: Vec::new(),
+            lines: Vec::new(),
+            stage_lines: Vec::new(),
+            expanded_stage: None,
+            exit: None,
+            collapsed: false,
+            is_ia: true,
+        });
+        self.trim_and_pin();
+    }
+
+    /// Acota el historial a [`MAX_BLOCKS`] (descarta las cards más viejas),
+    /// marca pendiente y pega el scroll al fondo. Compartido por los dos
+    /// `push_pending*`.
+    fn trim_and_pin(&mut self) {
+        if self.blocks.len() > Self::MAX_BLOCKS {
+            let drop = self.blocks.len() - Self::MAX_BLOCKS;
+            self.blocks.drain(0..drop);
+        }
+        self.pending = true;
+        self.pin_bottom();
+    }
+
+    /// Rellena la última card (la pendiente) con el resultado de la corrida.
+    pub fn finish_last(&mut self, res: RunResult) {
+        self.pending = false;
+        if let Some(b) = self.blocks.last_mut() {
+            b.lines = res.lines;
+            b.stage_lines = res.stages;
+            b.exit = res.exit;
+        }
+        self.pin_bottom();
+    }
+}
+
+impl Default for ShumaState {
+    fn default() -> Self {
+        Self {
+            open: false,
+            buffer: String::new(),
+            blocks: Vec::new(),
+            pending: false,
+            scroll: 0.0,
+            viewport_h: 300.0,
+            hotkey: None,
+            prompt: "›".into(),
+            placeholder: "shuma".into(),
+            anim: Tween::idle(0.0),
+            present: false,
+        }
+    }
+}
+
+impl ShumaState {
+    /// Construye el estado desde la spec del `shuma_input` (prompt/placeholder/
+    /// hotkey). Marca `present = true`.
+    pub fn from_spec(spec: &WidgetSpec) -> Self {
+        let hotkey = spec.str_prop("hotkey", "");
+        Self {
+            prompt: spec.str_prop("prompt", "›").to_string(),
+            placeholder: spec.str_prop("placeholder", "shuma").to_string(),
+            hotkey: if hotkey.is_empty() {
+                None
+            } else {
+                Some(hotkey.to_string())
+            },
+            present: true,
+            ..Self::default()
+        }
+    }
+
+    /// `true` si el drawer debe pintarse (abierto o aún animando el cierre).
+    pub fn visible(&self) -> bool {
+        self.open || self.anim.value() > 0.01
+    }
+}
+
+/// El cabezal clicable que va en la barra: prompt + buffer/placeholder. Un click
+/// despliega el drawer.
+pub fn headline_view(state: &ShumaState, theme: &Theme) -> View<Msg> {
+    let texto = if state.buffer.is_empty() {
+        state.placeholder.clone()
+    } else {
+        state.buffer.clone()
+    };
+    let color = if state.buffer.is_empty() {
+        theme.fg_muted
+    } else {
+        theme.fg_text
+    };
+
+    View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: length(220.0_f32),
+            height: length(24.0_f32),
+        },
+        padding: TaffyRect {
+            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::FlexStart),
+        gap: Size {
+            width: length(6.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .radius(6.0)
+    .hover_fill(theme.bg_button_hover)
+    .on_click(Msg::ShumaToggle)
+    .children(vec![
+        chip_text(&state.prompt, 13.0, theme.accent),
+        chip_text(&texto, 13.0, color),
+    ])
+}
+
+/// El drawer desplegado: scrim que cierra al click + panel inferior con el input
+/// y la salida. `None` si no hay nada que mostrar.
+pub fn drawer_overlay(state: &ShumaState, screen: (i32, i32), theme: &Theme) -> Option<View<Msg>> {
+    if !state.visible() {
+        return None;
+    }
+    let t = state.anim.value().clamp(0.0, 1.0);
+    let (_sw, sh) = screen;
+    let alto = (sh as f32 * DRAWER_FRAC * t).max(1.0);
+
+    // Línea del input: prompt + buffer + cursor.
+    let linea = {
+        let mut s = state.buffer.clone();
+        s.push('▏'); // cursor
+        View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size {
+                width: percent(1.0_f32),
+                height: length(28.0_f32),
+            },
+            align_items: Some(AlignItems::Center),
+            gap: Size {
+                width: length(8.0_f32),
+                height: length(0.0_f32),
+            },
+            ..Default::default()
+        })
+        .children(vec![
+            chip_text(&state.prompt, 16.0, theme.accent),
+            chip_text(&s, 16.0, theme.fg_text),
+        ])
+    };
+
+    // Cuerpo: las cards del historial (paridad con el shell de shuma). El
+    // viewport es el panel menos la línea de input y los paddings.
+    let cuerpo = blocks_view(state, theme, (alto - 76.0).max(40.0));
+
+    let panel = View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: auto(),
+            bottom: length(0.0_f32),
+        },
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(alto),
+        },
+        flex_direction: FlexDirection::Column,
+        padding: TaffyRect {
+            left: length(20.0_f32),
+            right: length(20.0_f32),
+            top: length(16.0_f32),
+            bottom: length(16.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children(vec![linea, cuerpo]);
+
+    // Scrim a pantalla completa: oscurece el fondo y cierra al click.
+    let scrim = View::new(Style {
+        position: Position::Absolute,
+        inset: TaffyRect {
+            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),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_app)
+    .alpha(0.55 * t)
+    .on_click(Msg::ShumaToggle)
+    .children(vec![panel]);
+
+    Some(scrim)
+}
+
+/// El **cuerpo** del drawer (sin scrim ni posición absoluta), pensado para
+/// llenar el contenedor que le da el backend `wlr-layer-shell`: ahí la propia
+/// layer surface ya *es* el panel del Quake (la barra crece hacia arriba), así
+/// que no hace falta scrim ni animación. Línea de input (prompt + buffer +
+/// cursor) arriba, salida del último comando debajo.
+pub fn drawer_body_view(state: &ShumaState, theme: &Theme, viewport_h: f32) -> View<Msg> {
+    let mut buf = state.buffer.clone();
+    buf.push('▏'); // cursor
+    let linea = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(28.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        gap: Size {
+            width: length(8.0_f32),
+            height: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .children(vec![
+        chip_text(&state.prompt, 16.0, theme.accent),
+        chip_text(&buf, 16.0, theme.fg_text),
+    ]);
+
+    let cuerpo = blocks_view(state, theme, viewport_h);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: TaffyRect {
+            left: length(20.0_f32),
+            right: length(20.0_f32),
+            top: length(16.0_f32),
+            bottom: length(16.0_f32),
+        },
+        gap: Size {
+            width: length(0.0_f32),
+            height: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(theme.bg_panel)
+    .children(vec![linea, cuerpo])
+}
+
+/// El cuerpo del drawer: las cards del historial (paridad con el shell de
+/// shuma), o la pista de uso si no hay ninguna. Lo comparten los dos backends
+/// (winit con scrim y layer-shell). Cada card es un `$ cmd` con sus etapas de
+/// pipe clickeables, su salida coloreada por stdout/stderr y su código.
+fn blocks_view(state: &ShumaState, theme: &Theme, viewport_h: f32) -> View<Msg> {
+    let col = Style {
+        flex_direction: FlexDirection::Column,
+        size: Size { width: percent(1.0_f32), height: auto() },
+        gap: Size { width: length(0.0_f32), height: length(CARD_GAP) },
+        ..Default::default()
+    };
+    let content = if state.blocks.is_empty() {
+        View::new(col).text(
+            "Enter pregunta a la IA · prefijo ! o $ corre shell · Esc cierra".to_string(),
+            13.0,
+            theme.fg_muted,
+        )
+    } else {
+        let cards = state
+            .blocks
+            .iter()
+            .enumerate()
+            .map(|(i, b)| card_view(i, b, theme))
+            .collect();
+        View::new(col).children(cards)
+    };
+    // Área de scroll: el contenido se desplaza y se recorta al viewport; la
+    // rueda y el arrastre de la barra emiten `ShumaScroll(delta)`. El offset
+    // se acota acá mismo (el modelo puede guardar uno mayor sin romper nada).
+    let content_h = state.content_height();
+    let offset = clamp_offset(state.scroll, content_h, viewport_h);
+    scroll_y(
+        offset,
+        content_h,
+        viewport_h,
+        content,
+        Msg::ShumaScroll,
+        &ScrollPalette::from_theme(theme),
+    )
+}
+
+/// Una card: encabezado (`$` plegable + etapas de pipe clickeables) y, si no
+/// está plegada, la salida de la etapa revelada (tee), las líneas de salida
+/// final y el código de salida.
+fn card_view(idx: usize, b: &DrawerBlock, theme: &Theme) -> View<Msg> {
+    // Encabezado: el `$` pliega/despliega; las chips de etapa **intermedia**
+    // revelan su salida capturada en vivo (tee). La última etapa no se captura
+    // aparte —su stdout es el cuerpo de la card— así que su chip es pasiva.
+    // Card de IA: marca `✦` + el prompt, sin chips de etapa.
+    if b.is_ia {
+        let header = View::new(Style {
+            flex_direction: FlexDirection::Row,
+            size: Size { width: percent(1.0_f32), height: length(22.0_f32) },
+            align_items: Some(AlignItems::Center),
+            gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
+            ..Default::default()
+        })
+        .children(vec![
+            chip_text("✦", 14.0, theme.accent).on_click(Msg::ShumaCollapse(idx)),
+            chip_text(&b.cmd, 14.0, theme.fg_text).on_click(Msg::ShumaCollapse(idx)),
+        ]);
+        let mut col: Vec<View<Msg>> = vec![header];
+        if !b.collapsed {
+            if b.exit.is_none() {
+                col.push(out_line("…pensando", theme.fg_muted));
+            }
+            for l in &b.lines {
+                let c = if l.err { theme.fg_destructive } else { theme.fg_text };
+                col.push(out_line(&l.text, c));
+            }
+        }
+        return View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size { width: percent(1.0_f32), height: auto() },
+            gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
+            ..Default::default()
+        })
+        .children(col);
+    }
+    let mut head: Vec<View<Msg>> = vec![chip_text("$", 14.0, theme.accent)
+        .on_click(Msg::ShumaCollapse(idx))];
+    if b.stages.is_empty() {
+        // Sin pipe: la línea entera re-ejecuta al clickearla.
+        head.push(chip_text(&b.cmd, 14.0, theme.fg_text).on_click(Msg::ShumaRunLine(b.cmd.clone())));
+    } else {
+        let last = b.stages.len() - 1;
+        for (si, label) in b.stages.iter().enumerate() {
+            if si > 0 {
+                head.push(chip_text("|", 14.0, theme.fg_muted));
+            }
+            // La etapa revelada se resalta (más clara); clic togglea su revelado.
+            let revealed = b.expanded_stage == Some(si);
+            let color = if revealed { theme.fg_text } else { theme.accent };
+            let chip = chip_text(label, 14.0, color);
+            head.push(if si < last {
+                chip.on_click(Msg::ShumaStageToggle(idx, si)).hover_fill(theme.bg_button_hover)
+            } else {
+                chip // la última etapa: su salida es el cuerpo de la card
+            });
+        }
+    }
+    let header = View::new(Style {
+        flex_direction: FlexDirection::Row,
+        size: Size { width: percent(1.0_f32), height: length(22.0_f32) },
+        align_items: Some(AlignItems::Center),
+        gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
+        ..Default::default()
+    })
+    .children(head);
+
+    let mut col: Vec<View<Msg>> = vec![header];
+    if !b.collapsed {
+        // Salida capturada de la etapa revelada (tee), indentada y atenuada.
+        if let Some(si) = b.expanded_stage {
+            match b.stage_lines.get(si) {
+                Some(lines) if !lines.is_empty() => {
+                    for l in lines {
+                        let c = if l.err { theme.fg_destructive } else { theme.fg_muted };
+                        col.push(out_line(&format!("  {}", l.text), c));
+                    }
+                }
+                _ => col.push(out_line("  (sin salida capturada)", theme.fg_muted)),
+            }
+        }
+        if b.exit.is_none() {
+            col.push(out_line("…", theme.fg_muted));
+        }
+        for l in &b.lines {
+            let c = if l.err { theme.fg_destructive } else { theme.fg_text };
+            col.push(out_line(&l.text, c));
+        }
+        if let Some(code) = b.exit {
+            if code != 0 {
+                col.push(out_line(&format!("exit {code}"), theme.fg_destructive));
+            }
+        }
+    }
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        size: Size { width: percent(1.0_f32), height: auto() },
+        gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
+        ..Default::default()
+    })
+    .children(col)
+}
+
+/// Una línea de salida a ancho completo.
+fn out_line(t: &str, color: llimphi_theme::Color) -> View<Msg> {
+    View::new(Style {
+        size: Size { width: percent(1.0_f32), height: auto() },
+        ..Default::default()
+    })
+    .text(t.to_string(), 13.0, color)
+}
+
+/// Un texto suelto, centrado verticalmente.
+fn chip_text(t: &str, size: f32, color: llimphi_theme::Color) -> View<Msg> {
+    View::new(Style {
+        size: Size {
+            width: auto(),
+            height: length(size + 6.0),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    })
+    .text(t.to_string(), size, color)
+}
+
+/// El puente pata→shuma (SDD §5): ejecuta `cmd` por el **ejecutor real de
+/// shuma** (`shuma-exec`) y devuelve su stdout, o el stderr/código como error.
+/// Reúne los eventos en streaming hasta el final; la captura está acotada a
+/// [`CAPTURE_CAP`] (lo que excede se marca como truncado). Bloqueante: se
+/// llama desde un hilo de fondo (`Handle::spawn` o `std::thread`).
+pub fn ejecutar(cmd: &str) -> RunResult {
+    use shuma_exec::{run, CommandSpec, Exec, RunEvent};
+
+    let cwd = std::env::current_dir()
+        .map(|p| p.display().to_string())
+        .unwrap_or_else(|_| "/".to_string());
+
+    // Pipe «simple» → ejecución directa con **captura por etapa** (tee): cada
+    // etapa intermedia emite su stdout en vivo, sin re-ejecutar (paridad con el
+    // shell de shuma). Cualquier otra sintaxis cae a `sh -c`, sin tee.
+    let (spec, n_stages) = match simple_pipe_stages(cmd) {
+        Some(stages) => {
+            let n = stages.len();
+            let spec = CommandSpec {
+                exec: Exec::Direct { stages },
+                cwd,
+                capture_limit: CAPTURE_CAP,
+                spill_path: None,
+                stdin_data: None,
+                capture_stages: true,
+            };
+            (spec, n)
+        }
+        None => (CommandSpec::shell(cmd, cwd).with_limit(CAPTURE_CAP), 0),
+    };
+
+    let mut lines: Vec<OutLine> = Vec::new();
+    let mut exit: Option<i32> = None;
+    let mut stages: Vec<Vec<OutLine>> = vec![Vec::new(); n_stages];
+    for ev in run(&spec).wait_all() {
+        match ev {
+            RunEvent::Stdout(t) => lines.push(OutLine { err: false, text: t }),
+            // Salida de una etapa intermedia (tee): a su balde, para revelarla
+            // al clickear la chip de esa etapa. La última etapa va por Stdout.
+            RunEvent::StageStdout { stage, line } => {
+                if let Some(buf) = stages.get_mut(stage) {
+                    buf.push(OutLine { err: false, text: line });
+                }
+            }
+            RunEvent::Stderr(t) => lines.push(OutLine { err: true, text: t }),
+            RunEvent::Exited(c) => exit = Some(c),
+            RunEvent::Failed(m) => lines.push(OutLine {
+                err: true,
+                text: format!("no pude lanzar: {m}"),
+            }),
+            RunEvent::Truncated => lines.push(OutLine {
+                err: true,
+                text: format!("… (salida truncada a {CAPTURE_CAP} bytes)"),
+            }),
+            RunEvent::Spilled(p) => lines.push(OutLine {
+                err: false,
+                text: format!("… (salida volcada a {p})"),
+            }),
+            // Sólo aparece en modo PTY; el drawer no lo usa.
+            RunEvent::Bytes(_) => {}
+        }
+    }
+    RunResult { lines, exit, stages }
+}
+
+/// Tope de captura del drawer, en bytes — un comando charlatán no infla la RAM.
+const CAPTURE_CAP: usize = 256 * 1024;
+
+/// Cómo se clasifica el buffer al hacer submit en el drawer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SubmitKind<'a> {
+    /// Buffer vacío (o sólo espacios): no se hace nada.
+    Empty,
+    /// Comando shell — el prefijo explícito `!`/`$` lo fuerza.
+    Shell(&'a str),
+    /// Prompt para la IA — el default cuando no hay prefijo.
+    Ia(&'a str),
+}
+
+/// Clasifica el buffer al submit (paridad con el quake de mirada-launcher):
+/// `!cmd` o `$cmd` van al shell; vacío no hace nada; **todo lo demás es un
+/// prompt para la IA**. Así el drawer es a la vez terminal y asistente.
+pub fn classify(buffer: &str) -> SubmitKind<'_> {
+    let trimmed = buffer.trim();
+    if let Some(rest) = trimmed.strip_prefix('!').or_else(|| trimmed.strip_prefix('$')) {
+        SubmitKind::Shell(rest.trim())
+    } else if trimmed.is_empty() {
+        SubmitKind::Empty
+    } else {
+        SubmitKind::Ia(trimmed)
+    }
+}
+
+/// Consulta a la IA bloqueando — pensado para un hilo de fondo (`Handle::spawn`).
+/// `pluma_llm::from_env` autodetecta el backend y cae a Mock sin credenciales
+/// (eco determinista, útil para iterar sin red). Devuelve un [`RunResult`] para
+/// rellenar la card pendiente igual que un comando: la respuesta va a `lines`
+/// (una por renglón), un error va como línea de stderr + `exit 1`.
+pub fn preguntar_ia(prompt: &str) -> RunResult {
+    use pluma_llm::pluma_llm_core::ChatRequest;
+
+    let resultado = (|| -> Result<String, String> {
+        let cli = pluma_llm::from_env().map_err(|e| format!("{e}"))?;
+        let rt = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .map_err(|e| format!("{e}"))?;
+        rt.block_on(async {
+            let req = ChatRequest::una_vuelta(prompt, 256)
+                .con_sistema("Sos un asistente conciso del escritorio. Responde corto.");
+            cli.complete(&req)
+                .await
+                .map(|r| r.content)
+                .map_err(|e| format!("{e}"))
+        })
+    })();
+
+    match resultado {
+        Ok(texto) => RunResult {
+            lines: texto
+                .lines()
+                .map(|l| OutLine { err: false, text: l.to_string() })
+                .collect(),
+            exit: Some(0),
+            stages: Vec::new(),
+        },
+        Err(e) => RunResult {
+            lines: vec![OutLine { err: true, text: e }],
+            exit: Some(1),
+            stages: Vec::new(),
+        },
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn una_linea_sin_pipe_no_tiene_chips() {
+        assert!(stage_labels("ls -la").is_empty());
+        assert!(stage_labels("").is_empty());
+    }
+
+    #[test]
+    fn las_etapas_de_un_pipe_son_los_comandos() {
+        assert_eq!(
+            stage_labels("ls -la | grep foo | sort"),
+            vec!["ls".to_string(), "grep".to_string(), "sort".to_string()]
+        );
+    }
+
+    #[test]
+    fn classify_rutea_prefijo_a_shell_y_resto_a_ia() {
+        assert_eq!(classify(""), SubmitKind::Empty);
+        assert_eq!(classify("   "), SubmitKind::Empty);
+        assert_eq!(classify("!firefox"), SubmitKind::Shell("firefox"));
+        assert_eq!(classify("$ ls -la"), SubmitKind::Shell("ls -la"));
+        assert_eq!(classify("qué hora es?"), SubmitKind::Ia("qué hora es?"));
+        assert_eq!(classify("  hola  "), SubmitKind::Ia("hola"));
+    }
+
+    #[test]
+    fn push_pending_ia_marca_la_card_sin_etapas() {
+        let mut s = ShumaState::default();
+        s.push_pending_ia("explicá el kernel".into());
+        let last = s.blocks.last().unwrap();
+        assert!(last.is_ia);
+        assert!(last.stages.is_empty());
+        assert!(last.exit.is_none() && s.pending);
+    }
+
+    #[test]
+    fn un_pipe_simple_se_descompone_en_etapas() {
+        let stages = simple_pipe_stages("ls -la | grep foo | sort").expect("pipe simple");
+        assert_eq!(stages.len(), 3);
+        assert_eq!(stages[0].program, "ls");
+        assert_eq!(stages[0].args, vec!["-la".to_string()]);
+        assert_eq!(stages[1].program, "grep");
+        assert_eq!(stages[2].program, "sort");
+    }
+
+    #[test]
+    fn sin_pipe_o_con_sintaxis_compleja_no_hay_etapas_directas() {
+        // Una sola etapa: no amerita modo directo.
+        assert!(simple_pipe_stages("ls -la").is_none());
+        // Comillas, variables, redirecciones, globs o `~` → al shell (sin tee).
+        assert!(simple_pipe_stages("echo \"hola\" | cat").is_none());
+        assert!(simple_pipe_stages("cat *.rs | wc -l").is_none());
+        assert!(simple_pipe_stages("echo $HOME | cat").is_none());
+        // Línea incompleta (termina en `|`).
+        assert!(simple_pipe_stages("ls |").is_none());
+    }
+
+    #[test]
+    fn push_pending_resuelve_etapas_y_acota_el_historial() {
+        let mut s = ShumaState::default();
+        s.push_pending("a | b".into());
+        let last = s.blocks.last().unwrap();
+        assert_eq!(last.stages, vec!["a".to_string(), "b".to_string()]);
+        assert!(last.exit.is_none() && s.pending);
+        // Más allá del tope, las viejas se descartan.
+        for _ in 0..ShumaState::MAX_BLOCKS + 5 {
+            s.push_pending("x".into());
+        }
+        assert_eq!(s.blocks.len(), ShumaState::MAX_BLOCKS);
+    }
+
+    #[test]
+    fn finish_last_rellena_la_card_pendiente() {
+        let mut s = ShumaState::default();
+        s.push_pending("echo hi".into());
+        s.finish_last(RunResult {
+            lines: vec![OutLine { err: false, text: "hi".into() }],
+            exit: Some(0),
+            stages: Vec::new(),
+        });
+        let last = s.blocks.last().unwrap();
+        assert_eq!(last.exit, Some(0));
+        assert_eq!(last.lines.len(), 1);
+        assert!(!s.pending);
+    }
+
+    #[test]
+    fn finish_last_guarda_la_salida_por_etapa() {
+        let mut s = ShumaState::default();
+        s.push_pending("seq 3 | sort".into());
+        s.finish_last(RunResult {
+            lines: vec![OutLine { err: false, text: "1".into() }],
+            exit: Some(0),
+            stages: vec![
+                vec![OutLine { err: false, text: "3".into() }],
+                Vec::new(),
+            ],
+        });
+        let last = s.blocks.last().unwrap();
+        assert_eq!(last.stage_lines.len(), 2);
+        assert_eq!(last.stage_lines[0][0].text, "3");
+        // Revelar la etapa 0 agranda el contenido (entra su salida capturada).
+        let antes = s.content_height();
+        s.blocks.last_mut().unwrap().expanded_stage = Some(0);
+        assert!(s.content_height() > antes);
+    }
+}
+
+#[cfg(test)]
+mod scroll_tests {
+    use super::*;
+
+    #[test]
+    fn content_height_crece_con_cards_y_lineas() {
+        let mut s = ShumaState::default();
+        let vacio = s.content_height();
+        s.push_pending("a".into());
+        s.finish_last(RunResult {
+            lines: vec![OutLine { err: false, text: "x".into() }; 3],
+            exit: Some(0),
+            stages: Vec::new(),
+        });
+        assert!(s.content_height() > vacio);
+    }
+
+    #[test]
+    fn scroll_by_acota_a_cero_y_al_maximo() {
+        let mut s = ShumaState::default();
+        s.viewport_h = 50.0;
+        // Historial alto: varias cards con salida.
+        for _ in 0..6 {
+            s.push_pending("cmd".into());
+            s.finish_last(RunResult {
+                lines: vec![OutLine { err: false, text: "l".into() }; 4],
+                exit: Some(0),
+                stages: Vec::new(),
+            });
+        }
+        let max = (s.content_height() - s.viewport_h).max(0.0);
+        s.scroll = 0.0;
+        s.scroll_by(-100.0); // no baja de 0
+        assert_eq!(s.scroll, 0.0);
+        s.scroll_by(1e6); // no pasa del máximo
+        assert!((s.scroll - max).abs() < 0.01);
+    }
+
+    #[test]
+    fn lanzar_un_comando_pega_el_scroll_al_fondo() {
+        let mut s = ShumaState::default();
+        s.viewport_h = 40.0;
+        for _ in 0..6 {
+            s.push_pending("c".into());
+            s.finish_last(RunResult { lines: vec![], exit: Some(0), stages: Vec::new() });
+        }
+        s.scroll = 0.0; // el usuario subió a ver lo viejo
+        s.push_pending("nuevo".into()); // un comando nuevo
+        // El scroll quedó pegado al fondo (>= el máximo tras el clamp del render).
+        assert!(s.scroll >= (s.content_height() - s.viewport_h).max(0.0));
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/toplevel.rs b/02_ruway/pata/pata-llimphi/src/toplevel.rs
new file mode 100644
index 0000000..1655913
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/toplevel.rs
@@ -0,0 +1,187 @@
+//! Seguimiento de ventanas abiertas vía **wlr-foreign-toplevel-management**, el
+//! protocolo que usan waybar/eww para enumerar y activar toplevels en cualquier
+//! compositor wlroots (Hyprland, Sway, river…).
+//!
+//! El compositor anuncia un `zwlr_foreign_toplevel_handle_v1` por cada ventana y
+//! le manda sus atributos (título, app_id, estado) en eventos sueltos que se
+//! confirman con `done`. Aquí acumulamos esos atributos en un [`Toplevel`] y los
+//! aplicamos de golpe al recibir `done`, para no pintar estados a medias. El
+//! cableado Wayland (los `Dispatch`) vive en [`crate::layer`], que es quien tiene
+//! el `QueueHandle`; este módulo sólo modela el dato.
+//!
+//! La activación —traer la ventana al frente— es interacción, igual que el
+//! `shuma_input`: por eso `window_list` no pasa por el `build` agnóstico de
+//! `pata-core`, sino que lo intercepta el frontend (ver [`crate::SlotWidget`]).
+
+use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{
+    State, ZwlrForeignToplevelHandleV1,
+};
+
+/// El bit `activated` del array de estados que manda el evento `state`.
+const ESTADO_ACTIVADO: u32 = State::Activated as u32;
+/// El bit `minimized` del array de estados.
+const ESTADO_MINIMIZADO: u32 = State::Minimized as u32;
+
+/// Una ventana reportada por el compositor. Los campos `p_*` acumulan lo que
+/// llega entre `done`s; [`Toplevel::confirmar`] los vuelca a los definitivos.
+pub struct Toplevel {
+    /// Identificador estable que viaja en [`crate::Msg::ActivateWindow`]. Es un
+    /// contador local (no el ObjectId, que no es `Clone`-friendly para el `Msg`).
+    pub id: u32,
+    /// El handle del protocolo: por él se activa/cierra la ventana.
+    pub handle: ZwlrForeignToplevelHandleV1,
+    /// Título de la ventana, ya confirmado.
+    pub title: String,
+    /// `app_id` (clase de la app), ya confirmado.
+    pub app_id: String,
+    /// `true` si es la ventana activa.
+    pub activated: bool,
+    /// `true` si la ventana está minimizada (para atenuarla en la barra).
+    pub minimized: bool,
+    p_title: Option<String>,
+    p_app_id: Option<String>,
+    p_activated: Option<bool>,
+    p_minimized: Option<bool>,
+}
+
+impl Toplevel {
+    /// Una ventana recién anunciada, todavía sin atributos.
+    pub fn new(id: u32, handle: ZwlrForeignToplevelHandleV1) -> Self {
+        Self {
+            id,
+            handle,
+            title: String::new(),
+            app_id: String::new(),
+            activated: false,
+            minimized: false,
+            p_title: None,
+            p_app_id: None,
+            p_activated: None,
+            p_minimized: None,
+        }
+    }
+
+    /// Guarda el título pendiente (evento `title`).
+    pub fn set_title(&mut self, title: String) {
+        self.p_title = Some(title);
+    }
+
+    /// Guarda el `app_id` pendiente (evento `app_id`).
+    pub fn set_app_id(&mut self, app_id: String) {
+        self.p_app_id = Some(app_id);
+    }
+
+    /// Decodifica el array de estados (`u32` little-endian empaquetados en bytes)
+    /// y registra si la ventana quedó activa (evento `state`).
+    pub fn set_state(&mut self, bytes: &[u8]) {
+        let tiene = |bit: u32| {
+            bytes
+                .chunks_exact(4)
+                .any(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]) == bit)
+        };
+        self.p_activated = Some(tiene(ESTADO_ACTIVADO));
+        self.p_minimized = Some(tiene(ESTADO_MINIMIZADO));
+    }
+
+    /// Aplica lo acumulado (evento `done`). Devuelve `true` si algo cambió, para
+    /// que el caller sepa si tiene que re-pintar.
+    pub fn confirmar(&mut self) -> bool {
+        let mut cambio = false;
+        if let Some(t) = self.p_title.take() {
+            if t != self.title {
+                self.title = t;
+                cambio = true;
+            }
+        }
+        if let Some(a) = self.p_app_id.take() {
+            if a != self.app_id {
+                self.app_id = a;
+                cambio = true;
+            }
+        }
+        if let Some(act) = self.p_activated.take() {
+            if act != self.activated {
+                self.activated = act;
+                cambio = true;
+            }
+        }
+        if let Some(m) = self.p_minimized.take() {
+            if m != self.minimized {
+                self.minimized = m;
+                cambio = true;
+            }
+        }
+        cambio
+    }
+
+    /// La etiqueta a mostrar: el título si lo hay, si no el `app_id`, si no un
+    /// genérico. Nunca vacía (un chip vacío no se podría clickear).
+    pub fn etiqueta(&self) -> String {
+        if !self.title.is_empty() {
+            self.title.clone()
+        } else if !self.app_id.is_empty() {
+            self.app_id.clone()
+        } else {
+            "ventana".to_string()
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::WindowEntry;
+
+    fn entry(app_id: &str, label: &str) -> WindowEntry {
+        WindowEntry {
+            id: 0,
+            label: label.into(),
+            app_id: app_id.into(),
+            active: false,
+            minimized: false,
+        }
+    }
+
+    #[test]
+    fn inicial_toma_la_primera_alfanumerica_del_app_id() {
+        assert_eq!(entry("firefox", "Mozilla").inicial(), "F");
+        assert_eq!(entry("org.kde.konsole", "Konsole").inicial(), "O");
+        // Sin app_id cae al título.
+        assert_eq!(entry("", "Documento").inicial(), "D");
+        // Sin nada alfanumérico, un punto medio.
+        assert_eq!(entry("", "").inicial(), "·");
+        assert_eq!(entry("  ", "—").inicial(), "·");
+    }
+}
+
+/// Lo que el render necesita de cada ventana: el `id` para el click, la etiqueta
+/// y si está activa (para resaltarla). Desacopla el pincel del protocolo Wayland.
+#[derive(Clone, Debug)]
+pub struct WindowEntry {
+    /// Identificador estable (el de [`Toplevel::id`]).
+    pub id: u32,
+    /// Texto a pintar en el chip.
+    pub label: String,
+    /// `app_id` de la ventana — para el ícono-badge del task manager.
+    pub app_id: String,
+    /// `true` si es la ventana activa (chip resaltado).
+    pub active: bool,
+    /// `true` si está minimizada (chip atenuado).
+    pub minimized: bool,
+}
+
+impl WindowEntry {
+    /// La inicial para el ícono-badge: primera letra del `app_id` (o del título
+    /// si no hay `app_id`), en mayúscula. `"·"` si no hay nada.
+    pub fn inicial(&self) -> String {
+        let fuente = if !self.app_id.is_empty() {
+            &self.app_id
+        } else {
+            &self.label
+        };
+        fuente
+            .chars()
+            .find(|c| c.is_alphanumeric())
+            .map(|c| c.to_uppercase().to_string())
+            .unwrap_or_else(|| "·".to_string())
+    }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/tray.rs b/02_ruway/pata/pata-llimphi/src/tray.rs
new file mode 100644
index 0000000..a12c351
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/tray.rs
@@ -0,0 +1,452 @@
+//! Bandeja del sistema (`tray`) vía **StatusNotifierItem**, el protocolo D-Bus de
+//! KDE/freedesktop que usan los applets modernos (nm-applet, blueman, clientes de
+//! chat…).
+//!
+//! pata actúa como **watcher + host**: posee el nombre well-known
+//! `org.kde.StatusNotifierWatcher` y atiende a las apps que registran su item.
+//! Como el bucle de pata es bloqueante (sctk) y zbus es async, el tray corre en su
+//! **propio hilo** con un runtime tokio current-thread (el workspace fija zbus con
+//! la feature `tokio`, no la blocking — mismo patrón que `mirada-portal`). Comparte
+//! el snapshot de items con el bucle por `Arc<Mutex>` y recibe los clicks por un
+//! canal (como el exec asíncrono del Quake).
+//!
+//! Alcance del MVP (todo runtime, no verificable sin un Hyprland real):
+//! - **Enumera** los items y los **activa** al click (`Activate(0,0)`).
+//! - **Íconos**: resuelve el `IconPixmap` (ARGB32 que manda la app por D-Bus) y,
+//!   si no hay, busca el `IconName` como PNG en los directorios estándar de íconos
+//!   (búsqueda acotada: hicolor + pixmaps, sólo PNG, sin parsear `index.theme` ni
+//!   SVG). Si nada resuelve, cae a una etiqueta de texto (título o id).
+//! - **No** emite las señales del watcher (sólo le importan a *otros* hosts) ni
+//!   provee fallback si ya hay un watcher corriendo: si el nombre está tomado, el
+//!   tray queda vacío y se loguea (el caso de pata como barra única no lo tiene).
+
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use tokio::sync::mpsc;
+use zbus::message::Header;
+use zbus::{interface, proxy};
+
+/// Un ícono ya decodificado a RGBA8, listo para que el render lo envuelva en una
+/// `peniko::Image`. El render no toca D-Bus ni decodifica; sólo pinta.
+#[derive(Clone, Debug)]
+pub struct TrayIcon {
+    /// Ancho en píxeles.
+    pub width: u32,
+    /// Alto en píxeles.
+    pub height: u32,
+    /// Píxeles RGBA8 (`width*height*4` bytes).
+    pub rgba: Vec<u8>,
+}
+
+/// Lo que el render necesita de cada item del tray. `key` (`"bus|path"`) rutea la
+/// activación de vuelta al hilo del tray.
+#[derive(Clone, Debug)]
+pub struct TrayItem {
+    /// Clave estable `"bus|path"` para la activación.
+    pub key: String,
+    /// Texto a pintar (título, o id si no hay título).
+    pub label: String,
+    /// Estado SNI (`Active` / `Passive` / `NeedsAttention`).
+    pub status: String,
+    /// Ícono ya decodificado, o `None` si no se pudo resolver (cae a texto).
+    pub icon: Option<TrayIcon>,
+}
+
+/// Estado compartido con la interfaz del watcher: los items registrados como
+/// `(key, bus, path)`. Lo escribe la interfaz (en el runtime del tray) y lo lee el
+/// bucle de refresco; de ahí el `Mutex`.
+#[derive(Default)]
+struct WatcherState {
+    items: Vec<(String, String, String)>,
+}
+
+/// Órdenes del bucle de pata hacia el hilo del tray. (Soltar el [`TrayHandle`]
+/// cierra el canal, lo que termina el hilo: no hace falta una variante de parada.)
+enum TrayCmd {
+    /// Activar el item con esa `key` (click).
+    Activate(String),
+}
+
+/// El asa que el bucle de pata conserva: lee el snapshot de items y manda clicks.
+pub struct TrayHandle {
+    items: Arc<Mutex<Vec<TrayItem>>>,
+    tx: mpsc::UnboundedSender<TrayCmd>,
+}
+
+impl TrayHandle {
+    /// Arranca el hilo del tray. Devuelve `None` sólo si no se pudo lanzar el hilo;
+    /// la conexión D-Bus se intenta dentro (si falla, el hilo termina y el tray
+    /// queda vacío, sin romper la barra).
+    pub fn spawn() -> Option<Self> {
+        let items: Arc<Mutex<Vec<TrayItem>>> = Arc::new(Mutex::new(Vec::new()));
+        let (tx, rx) = mpsc::unbounded_channel::<TrayCmd>();
+        let items_hilo = items.clone();
+        std::thread::Builder::new()
+            .name("pata-tray".into())
+            .spawn(move || run_tray(items_hilo, rx))
+            .ok()?;
+        Some(Self { items, tx })
+    }
+
+    /// El snapshot actual de items para el render.
+    pub fn items(&self) -> Vec<TrayItem> {
+        self.items.lock().map(|g| g.clone()).unwrap_or_default()
+    }
+
+    /// Pide activar el item `key` (no bloquea; el hilo del tray hace la llamada).
+    pub fn activate(&self, key: String) {
+        let _ = self.tx.send(TrayCmd::Activate(key));
+    }
+}
+
+/// La interfaz `org.kde.StatusNotifierWatcher` que pata expone. Las apps llaman a
+/// `RegisterStatusNotifierItem`; guardamos el item normalizado en el estado
+/// compartido. Métodos síncronos: zbus los atiende en el runtime del tray.
+struct Watcher {
+    state: Arc<Mutex<WatcherState>>,
+}
+
+#[interface(name = "org.kde.StatusNotifierWatcher")]
+impl Watcher {
+    /// Una app registra su item. `service` puede ser un nombre de bus, una ruta de
+    /// objeto (con el bus = remitente) o la forma combinada `"bus/path"`.
+    fn register_status_notifier_item(&self, service: &str, #[zbus(header)] hdr: Header<'_>) {
+        let sender = hdr.sender().map(|s| s.to_string());
+        if let Some((bus, path)) = split_service(service, sender.as_deref()) {
+            let key = format!("{bus}|{path}");
+            let mut st = self.state.lock().unwrap();
+            if !st.items.iter().any(|(k, _, _)| *k == key) {
+                st.items.push((key, bus, path));
+            }
+        }
+    }
+
+    /// Otro host se registra. pata es su propio host, así que no hace falta nada.
+    fn register_status_notifier_host(&self, _service: &str) {}
+
+    /// La lista de items registrados, en la forma `"bus/path"` que esperan los
+    /// hosts que consulten el watcher.
+    #[zbus(property)]
+    fn registered_status_notifier_items(&self) -> Vec<String> {
+        self.state
+            .lock()
+            .unwrap()
+            .items
+            .iter()
+            .map(|(_, b, p)| format!("{b}{p}"))
+            .collect()
+    }
+
+    /// Siempre `true`: pata provee el host, así que las apps deben usar SNI.
+    #[zbus(property)]
+    fn is_status_notifier_host_registered(&self) -> bool {
+        true
+    }
+
+    /// Versión del protocolo (0, como la implementación de referencia).
+    #[zbus(property)]
+    fn protocol_version(&self) -> i32 {
+        0
+    }
+}
+
+/// Cliente del item de una app: leemos sus atributos para pintarlo y lo activamos
+/// al click.
+#[proxy(interface = "org.kde.StatusNotifierItem", assume_defaults = false)]
+trait StatusNotifierItem {
+    #[zbus(property)]
+    fn id(&self) -> zbus::Result<String>;
+    #[zbus(property)]
+    fn title(&self) -> zbus::Result<String>;
+    #[zbus(property)]
+    fn icon_name(&self) -> zbus::Result<String>;
+    /// Íconos embebidos: lista de `(ancho, alto, ARGB32)`. La app los manda por el
+    /// bus; no requieren buscar en el tema.
+    #[zbus(property)]
+    fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>;
+    #[zbus(property)]
+    fn status(&self) -> zbus::Result<String>;
+    /// Click primario sobre el item.
+    fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
+}
+
+/// El hilo del tray: levanta un runtime tokio current-thread y corre el bucle
+/// async. Si no hay runtime o D-Bus, termina (tray vacío).
+fn run_tray(items: Arc<Mutex<Vec<TrayItem>>>, rx: mpsc::UnboundedReceiver<TrayCmd>) {
+    let Ok(rt) = tokio::runtime::Builder::new_current_thread()
+        .enable_all()
+        .build()
+    else {
+        return;
+    };
+    rt.block_on(bucle_tray(items, rx));
+}
+
+/// El bucle async: arma el watcher y, hasta que cierren el canal, atiende los
+/// clicks (respuesta inmediata) o refresca el snapshot de items cada ~1s.
+async fn bucle_tray(items: Arc<Mutex<Vec<TrayItem>>>, mut rx: mpsc::UnboundedReceiver<TrayCmd>) {
+    let state = Arc::new(Mutex::new(WatcherState::default()));
+    let Some(conn) = build_watcher(state.clone()).await else {
+        return; // sin D-Bus o n

El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.