gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198630 Definitions

feat(tahuantinsuyu): UX pass — splitter, light wheel, scroll, zoom/pan, dock lateral

Seis fixes derivados de testing real, ordenados por costo:

- Splitter (yahweh-widget-splitter): `flex-basis: 0` por item para que
  el ratio flex-grow se respete sin importar el min-content de los
  hijos. Sin esto, al cambiar el canvas de Empty→Wheel (WHEEL_SIZE
  fijo de 580px) la suma de basis excedía el contenedor y flexbox
  abandonaba el ratio 1:4, aplastando el tree a 0px (síntoma
  reportado: "el tree desaparece al seleccionar carta"). También se
  amplió la hit-zone del divider de 4px a 12px manteniendo una franja
  visual de 4px centrada — la zona de pointer-capture y cursor es
  ahora mucho más generosa, el visual sigue fino.

- Light mode wheel (tahuantinsuyu-canvas + tahuantinsuyu-theme): el
  gradient del fondo del wheel pasa de alphas 0.06/0.03 (invisibles
  contra fondo claro) a 0.18/0.10 cuando el theme es light. Cusps y
  aspectos secundarios del light palette bajan luminancia y suben
  alpha para no lavarse contra blanco.

- Panel scroll (tahuantinsuyu-panel): body del control panel agrega
  `flex_grow + min_h(0) + overflow_y_scroll` para que cuando los
  controles no caben aparezca scroll vertical en lugar de cortarse.

- Canvas zoom + pan (tahuantinsuyu-canvas): nuevo estado
  view_scale / view_pan_x / view_pan_y. Ctrl+wheel zoomea
  multiplicativo (clamp 0.5..3.0); wheel solo paneja. MMB drag para
  pan libre. Hotkey `0` resetea zoom+pan. Hit-tests del jog-dial y
  hover derivan ahora el `r_outer` del width actual del canvas, así
  se autoescalan con el zoom.

- Panel dock lateral (shell.rs): nuevo `PanelDock { Bottom, Right,
  Left }` configurable desde 3 botones en el header (◧ ▭ ◨). Bottom
  mantiene el layout histórico (tree+canvas / panel); las variantes
  laterales colapsan los splitters anidados en uno solo horizontal
  de 3 columnas. El dock se persiste en `layout.panel_dock` y cada
  layout guarda sus flex en una key distinta para no pisarse.
  `load_split_flex_n` / `save_split_flex` generalizados a N hijos.

Tests: 6 pasan (incluye nuevo roundtrip de PanelDock y N-flex).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

e09207b152419ca29accb3d3eb721990e49290ab · by sergio · (5 months ago) · parent d2b6b8b12 · browse the tree at this commit

Semantic delta · 41

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs

  • body changed fn light pub

crates/modules/ui_engine/widgets/splitter/src/lib.rs

5 files · +629 −143

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 34d5f5e..b20010e 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -24,8 +24,8 @@
 use std::collections::HashMap;
 
 use gpui::{
-    Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
-    prelude::*, px,
+    ClickEvent, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
+    Window, div, prelude::*, px,
 };
 
 use tahuantinsuyu_canvas::{
@@ -45,6 +45,35 @@ use yahweh_widget_container_core::ChildSlot;
 use yahweh_widget_splitter::{SplitContainer, SplitEvent};
 use yahweh_widget_theme_switcher::theme_switcher;
 
+/// Posición del panel de control dentro del shell. `Bottom` mantiene
+/// el layout histórico (tree+canvas arriba, panel abajo); las variantes
+/// laterales colapsan los splitters anidados en uno solo de 3 columnas.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PanelDock {
+    Bottom,
+    Right,
+    Left,
+}
+
+impl PanelDock {
+    fn as_setting(&self) -> &'static str {
+        match self {
+            PanelDock::Bottom => "bottom",
+            PanelDock::Right => "right",
+            PanelDock::Left => "left",
+        }
+    }
+
+    fn from_setting(s: &str) -> Option<Self> {
+        match s {
+            "bottom" => Some(PanelDock::Bottom),
+            "right" => Some(PanelDock::Right),
+            "left" => Some(PanelDock::Left),
+            _ => None,
+        }
+    }
+}
+
 /// Status del broker brahman tal como lo vimos en el último ping.
 /// Se refresca cada 30 segundos desde un background task.
 #[derive(Clone, Debug)]
@@ -64,19 +93,24 @@ pub enum BrahmanStatus {
 
 pub struct Shell {
     store: Store,
-    /// El árbol vive como child de `outer_split` (vía AnyView clone),
-    /// pero retenemos el Entity acá para que las subscripciones
-    /// registradas en `new` sigan vivas — al droppear el último handle,
-    /// gpui cancela los suscriptores.
-    #[allow(dead_code)]
+    /// Los tres widgets viven como children de los splitters vía
+    /// AnyView clone; retenemos los Entity acá para que las
+    /// subscripciones sigan vivas y para poder rearmar el layout al
+    /// cambiar `dock` sin recrear los widgets.
     tree: Entity<TahuantinsuyuTree>,
     canvas: Entity<AstrologyCanvas>,
     panel: Entity<ControlPanel>,
-    /// Splitter vertical entre el main_row (arriba — tree + canvas) y
-    /// el panel de control (abajo). El splitter horizontal interno se
-    /// arma en `new` y queda referenciado vía `outer_split` (es uno de
-    /// sus children), sin necesidad de retenerlo aparte.
+    /// Splitter "exterior". En dock=Bottom es vertical con (main_split,
+    /// panel) como hijos; en dock=Right/Left es horizontal y agrupa
+    /// tree+canvas+panel en una sola tira.
     outer_split: Entity<SplitContainer>,
+    /// Splitter horizontal interno con (tree, canvas). Solo se usa
+    /// cuando dock=Bottom; en docks laterales queda vivo pero sin ser
+    /// hijo del árbol activo.
+    main_split: Entity<SplitContainer>,
+    /// Dock activo del panel — determina cómo se arman los splitters
+    /// y cuáles flex se persisten.
+    dock: PanelDock,
     /// Último estado conocido del broker brahman — refrescado cada
     /// 30s desde el background task.
     brahman_status: BrahmanStatus,
@@ -125,90 +159,193 @@ impl Shell {
         })
         .detach();
 
-        // Splitter horizontal: tree + canvas. Defaults (1.0, 4.0) salvo
-        // que tengamos un flex persistido en `settings`.
-        let (main_left, main_right) =
-            load_split_flex(&store, "layout.main_split", 1.0, 4.0);
+        // Splitters vacíos — `apply_dock` los puebla según el layout
+        // activo. Horizontal/Vertical son defaults; cada apply ajusta la
+        // dirección antes de setear children.
         let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
-        main_split.update(cx, |sc, cx| {
-            sc.set_children(
-                vec![
-                    ChildSlot {
-                        id: NodeId::new("tts-tree"),
-                        flex: main_left,
-                        label: None,
-                        view: gpui::AnyView::from(tree.clone()),
-                    },
-                    ChildSlot {
-                        id: NodeId::new("tts-canvas"),
-                        flex: main_right,
-                        label: None,
-                        view: gpui::AnyView::from(canvas.clone()),
-                    },
-                ],
-                cx,
-            );
-        });
-
-        // Splitter vertical: main arriba, panel abajo. Defaults (4.0, 1.0).
-        let (outer_top, outer_bottom) =
-            load_split_flex(&store, "layout.outer_split", 4.0, 1.0);
-        let outer_split = cx.new(|cx| {
-            let mut sc = SplitContainer::new(LayoutDirection::Vertical, cx);
-            sc.set_children(
-                vec![
-                    ChildSlot {
-                        id: NodeId::new("tts-main"),
-                        flex: outer_top,
-                        label: None,
-                        view: gpui::AnyView::from(main_split.clone()),
-                    },
-                    ChildSlot {
-                        id: NodeId::new("tts-panel"),
-                        flex: outer_bottom,
-                        label: None,
-                        view: gpui::AnyView::from(panel.clone()),
-                    },
-                ],
-                cx,
-            );
-            sc
-        });
+        let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx));
 
-        // Persistir flex en `DragEnd`. Capturamos el store por valor
-        // (Store es Clone — comparte el Arc<Mutex<Connection>>).
+        // Persistir flex en `DragEnd`. La key del setting depende del
+        // dock activo, así no se pisan los flexes de un layout con los
+        // de otro al mudarse. Se lee dentro del closure para tomar el
+        // dock actualizado, no el capturado en `new`.
         let store_main = store.clone();
-        cx.subscribe(&main_split, move |_, sc, ev: &SplitEvent, cx| {
+        cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
             if matches!(ev, SplitEvent::DragEnd) {
-                save_split_flex(&store_main, "layout.main_split", sc.read(cx));
+                let key = split_key_main(this.dock);
+                save_split_flex(&store_main, key, sc.read(cx));
             }
         })
         .detach();
         let store_outer = store.clone();
-        cx.subscribe(&outer_split, move |_, sc, ev: &SplitEvent, cx| {
+        cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
             if matches!(ev, SplitEvent::DragEnd) {
-                save_split_flex(&store_outer, "layout.outer_split", sc.read(cx));
+                let key = split_key_outer(this.dock);
+                save_split_flex(&store_outer, key, sc.read(cx));
             }
         })
         .detach();
 
-        let shell = Self {
+        let dock = load_dock(&store).unwrap_or(PanelDock::Bottom);
+
+        let mut shell = Self {
             store,
             tree,
             canvas,
             panel,
             outer_split,
+            main_split,
+            dock,
             brahman_status: BrahmanStatus::Pending,
             current_chart: None,
             current_offset_minutes: 0,
             module_configs: HashMap::new(),
             render_seq: 0,
         };
+        shell.apply_dock(dock, cx);
         shell.refresh_chart_options(cx);
         shell.spawn_brahman_status_loop(cx);
         shell
     }
 
+    /// Arma el árbol de splitters según el dock pedido y persiste la
+    /// elección. Idempotente: llamar con el dock actual reconstruye los
+    /// children con flexes leídos del setting (útil tras `new`).
+    pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context<Self>) {
+        self.dock = dock;
+
+        let tree_view = gpui::AnyView::from(self.tree.clone());
+        let canvas_view = gpui::AnyView::from(self.canvas.clone());
+        let panel_view = gpui::AnyView::from(self.panel.clone());
+        let main_view = gpui::AnyView::from(self.main_split.clone());
+
+        match dock {
+            PanelDock::Bottom => {
+                let flex_main = load_split_flex_n(
+                    &self.store,
+                    split_key_main(dock),
+                    &[1.0, 4.0],
+                );
+                let flex_outer = load_split_flex_n(
+                    &self.store,
+                    split_key_outer(dock),
+                    &[4.0, 1.0],
+                );
+                self.main_split.update(cx, |sc, cx| {
+                    sc.set_direction(LayoutDirection::Horizontal, cx);
+                    sc.set_children(
+                        vec![
+                            ChildSlot {
+                                id: NodeId::new("tts-tree"),
+                                flex: flex_main[0],
+                                label: None,
+                                view: tree_view.clone(),
+                            },
+                            ChildSlot {
+                                id: NodeId::new("tts-canvas"),
+                                flex: flex_main[1],
+                                label: None,
+                                view: canvas_view.clone(),
+                            },
+                        ],
+                        cx,
+                    );
+                });
+                self.outer_split.update(cx, |sc, cx| {
+                    sc.set_direction(LayoutDirection::Vertical, cx);
+                    sc.set_children(
+                        vec![
+                            ChildSlot {
+                                id: NodeId::new("tts-main"),
+                                flex: flex_outer[0],
+                                label: None,
+                                view: main_view,
+                            },
+                            ChildSlot {
+                                id: NodeId::new("tts-panel"),
+                                flex: flex_outer[1],
+                                label: None,
+                                view: panel_view,
+                            },
+                        ],
+                        cx,
+                    );
+                });
+            }
+            PanelDock::Right => {
+                let flex = load_split_flex_n(
+                    &self.store,
+                    split_key_outer(dock),
+                    &[1.0, 4.0, 1.5],
+                );
+                self.outer_split.update(cx, |sc, cx| {
+                    sc.set_direction(LayoutDirection::Horizontal, cx);
+                    sc.set_children(
+                        vec![
+                            ChildSlot {
+                                id: NodeId::new("tts-tree"),
+                                flex: flex[0],
+                                label: None,
+                                view: tree_view,
+                            },
+                            ChildSlot {
+                                id: NodeId::new("tts-canvas"),
+                                flex: flex[1],
+                                label: None,
+                                view: canvas_view,
+                            },
+                            ChildSlot {
+                                id: NodeId::new("tts-panel"),
+                                flex: flex[2],
+                                label: None,
+                                view: panel_view,
+                            },
+                        ],
+                        cx,
+                    );
+                });
+            }
+            PanelDock::Left => {
+                let flex = load_split_flex_n(
+                    &self.store,
+                    split_key_outer(dock),
+                    &[1.5, 1.0, 4.0],
+                );
+                self.outer_split.update(cx, |sc, cx| {
+                    sc.set_direction(LayoutDirection::Horizontal, cx);
+                    sc.set_children(
+                        vec![
+                            ChildSlot {
+                                id: NodeId::new("tts-panel"),
+                                flex: flex[0],
+                                label: None,
+                                view: panel_view,
+                            },
+                            ChildSlot {
+                                id: NodeId::new("tts-tree"),
+                                flex: flex[1],
+                                label: None,
+                                view: tree_view,
+                            },
+                            ChildSlot {
+                                id: NodeId::new("tts-canvas"),
+                                flex: flex[2],
+                                label: None,
+                                view: canvas_view,
+                            },
+                        ],
+                        cx,
+                    );
+                });
+            }
+        }
+
+        if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) {
+            eprintln!("[shell] persist panel_dock: {}", e);
+        }
+        cx.notify();
+    }
+
     /// Loop que cada 30s pregunta al broker la lista de sessions
     /// activas y actualiza `brahman_status`. El cómputo bloqueante
     /// (list_sessions_blocking abre su propio tokio runtime) corre en
@@ -918,38 +1055,125 @@ fn set_module_enabled(
     }
 }
 
-/// Lee del `settings` el flex de un splitter (formato "left,right"). Si
-/// no hay nada persistido o está corrupto, devuelve los defaults.
-fn load_split_flex(store: &Store, key: &str, default_a: f32, default_b: f32) -> (f32, f32) {
+/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y
+/// lo devuelve como `Vec<f32>` con la misma longitud que `defaults`.
+/// Si no hay nada persistido, faltan campos, o algún flex es ≤0, cae a
+/// `defaults`. Validación estricta porque un flex 0 colapsa al panel.
+fn load_split_flex_n(store: &Store, key: &str, defaults: &[f32]) -> Vec<f32> {
     let Ok(Some(raw)) = store.get_setting(key) else {
-        return (default_a, default_b);
+        return defaults.to_vec();
     };
-    let mut parts = raw.split(',');
-    let a = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
-    let b = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
-    match (a, b) {
-        (Some(a), Some(b)) if a > 0.0 && b > 0.0 => (a, b),
-        _ => (default_a, default_b),
+    let parsed: Vec<f32> = raw
+        .split(',')
+        .filter_map(|s| s.trim().parse::<f32>().ok())
+        .collect();
+    if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) {
+        return defaults.to_vec();
     }
+    parsed
 }
 
-/// Persiste los flex actuales de un splitter de 2 children. Si tiene
-/// más children (en el futuro) sólo guarda los dos primeros — ajustar
-/// el formato si se necesita más.
+/// Persiste los flex actuales de un splitter — soporta N children.
 fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
     let children = sc.children();
-    let Some((first, rest)) = children.split_first() else {
-        return;
-    };
-    let Some(second) = rest.first() else {
+    if children.is_empty() {
         return;
-    };
-    let payload = format!("{:.4},{:.4}", first.flex, second.flex);
+    }
+    let payload: String = children
+        .iter()
+        .map(|c| format!("{:.4}", c.flex))
+        .collect::<Vec<_>>()
+        .join(",");
     if let Err(e) = store.set_setting(key, &payload) {
         eprintln!("[shell] save_split_flex {}: {}", key, e);
     }
 }
 
+/// Key del setting donde se persiste el splitter "outer" (el de mayor
+/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks
+/// laterales guarda los flex de las 3 columnas — usamos keys distintas
+/// para no pisar valores entre layouts.
+fn split_key_outer(dock: PanelDock) -> &'static str {
+    match dock {
+        PanelDock::Bottom => "layout.outer_split",
+        PanelDock::Right => "layout.dock_right",
+        PanelDock::Left => "layout.dock_left",
+    }
+}
+
+/// Key del setting del splitter horizontal interno. Solo se usa cuando
+/// dock=Bottom (en docks laterales no hay main_split activo).
+fn split_key_main(dock: PanelDock) -> &'static str {
+    match dock {
+        PanelDock::Bottom => "layout.main_split",
+        // En docks laterales el main_split está dormido — escribir acá
+        // no hace daño pero tampoco se usa al recargar.
+        PanelDock::Right => "layout.main_split_right",
+        PanelDock::Left => "layout.main_split_left",
+    }
+}
+
+fn load_dock(store: &Store) -> Option<PanelDock> {
+    let raw = store.get_setting("layout.panel_dock").ok().flatten()?;
+    PanelDock::from_setting(raw.trim())
+}
+
+impl Shell {
+    /// Tres botones compactos en el header — uno por dock disponible.
+    /// El dock activo se marca con `bg=accent`; los demás van planos.
+    /// Click llama a `apply_dock` que reorganiza splitters y persiste.
+    fn render_dock_switcher(
+        &self,
+        theme: &yahweh_theme::Theme,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let mut row = div()
+            .id("tts-dock-switcher")
+            .flex()
+            .flex_row()
+            .gap(px(2.0))
+            .px(px(2.0))
+            .py(px(2.0))
+            .rounded(px(6.0))
+            .bg(theme.bg_panel_alt.clone())
+            .border_1()
+            .border_color(theme.border);
+
+        for (dock, glyph) in [
+            (PanelDock::Left, "◧"),
+            (PanelDock::Bottom, "▭"),
+            (PanelDock::Right, "◨"),
+        ] {
+            let active = self.dock == dock;
+            let fg = if active { theme.fg_text } else { theme.fg_muted };
+            let id: SharedString = SharedString::from(format!("tts-dock-{}", dock.as_setting()));
+            let mut btn = div()
+                .id(gpui::ElementId::from(id))
+                .w(px(22.0))
+                .h(px(20.0))
+                .flex()
+                .items_center()
+                .justify_center()
+                .rounded(px(4.0))
+                .text_size(px(12.0))
+                .text_color(fg)
+                .hover(|s| s.bg(theme.bg_row_hover))
+                .child(SharedString::from(glyph))
+                .on_click(cx.listener(move |this, _: &ClickEvent, _w, cx| {
+                    if this.dock != dock {
+                        this.apply_dock(dock, cx);
+                    }
+                }));
+            if active {
+                btn = btn.bg(theme.accent);
+            }
+            row = row.child(btn);
+        }
+
+        row
+    }
+}
+
 impl Render for Shell {
     fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let theme = Theme::global(cx).clone();
@@ -999,6 +1223,7 @@ impl Render for Shell {
                     .child("estudio de astrología profesional"),
             )
             .child(div().flex_grow())
+            .child(self.render_dock_switcher(&theme, cx))
             .child(brahman_badge)
             .child(theme_switcher(cx));
 
@@ -1171,23 +1396,55 @@ mod tests {
 
     /// El flex de los splitters persiste entre instancias de Shell que
     /// comparten la misma store (in-memory): primera shell escribe via
-    /// `save_split_flex`, segunda shell lee via `load_split_flex` al
-    /// boot.
+    /// `save_split_flex`, segunda shell lee via `load_split_flex_n` al
+    /// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales).
     #[test]
     fn split_flex_round_trip_via_store() {
         let store = Store::in_memory().expect("store");
-        // No hay nada persistido todavía: defaults.
-        assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0));
+        let defaults_2 = vec![1.0_f32, 4.0];
+        let defaults_3 = vec![1.0_f32, 4.0, 1.5];
+
+        // Sin nada persistido → defaults.
+        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
 
         store.set_setting("layout.x", "2.5,3.5").unwrap();
-        assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (2.5, 3.5));
+        assert_eq!(
+            load_split_flex_n(&store, "layout.x", &defaults_2),
+            vec![2.5_f32, 3.5]
+        );
+
+        store.set_setting("layout.x", "1.0,4.0,2.0").unwrap();
+        assert_eq!(
+            load_split_flex_n(&store, "layout.x", &defaults_3),
+            vec![1.0_f32, 4.0, 2.0]
+        );
 
         // Valor corrupto → defaults.
         store.set_setting("layout.x", "garbage").unwrap();
-        assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0));
+        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
 
-        // Valores ≤ 0 → defaults (los splitters tratan 0 como hidden).
+        // Cantidad incorrecta → defaults.
+        store.set_setting("layout.x", "2,3,4").unwrap();
+        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
+
+        // Valor ≤0 → defaults.
         store.set_setting("layout.x", "0,5").unwrap();
-        assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0));
+        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
+    }
+
+    /// PanelDock roundtrip via store.
+    #[test]
+    fn panel_dock_setting_roundtrip() {
+        assert_eq!(PanelDock::from_setting("bottom"), Some(PanelDock::Bottom));
+        assert_eq!(PanelDock::from_setting("right"), Some(PanelDock::Right));
+        assert_eq!(PanelDock::from_setting("left"), Some(PanelDock::Left));
+        assert_eq!(PanelDock::from_setting("nope"), None);
+
+        let store = Store::in_memory().expect("store");
+        assert_eq!(load_dock(&store), None);
+        store
+            .set_setting("layout.panel_dock", PanelDock::Right.as_setting())
+            .unwrap();
+        assert_eq!(load_dock(&store), Some(PanelDock::Right));
     }
 }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 1f5a720..68deb84 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -36,8 +36,8 @@ use std::f32::consts::PI;
 use gpui::{
     Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
     KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
-    PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla,
-    linear_color_stop, linear_gradient, point, prelude::*, px,
+    PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled,
+    Window, canvas, div, hsla, linear_color_stop, linear_gradient, point, prelude::*, px,
 };
 
 use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
@@ -102,6 +102,15 @@ struct JogDragState {
     accumulated_delta_deg: f32,
 }
 
+/// Drag activo de pan (MMB o LMB con Space). Captura el pan inicial al
+/// hacer mousedown; el move agrega delta_pos a esos valores.
+#[derive(Clone, Debug)]
+struct PanDragState {
+    start_pos: Point<Pixels>,
+    pan_x_start: f32,
+    pan_y_start: f32,
+}
+
 #[derive(Clone, Debug)]
 pub struct CanvasState {
     pub mode: CanvasMode,
@@ -111,14 +120,29 @@ pub struct CanvasState {
     /// Offset acumulado en minutos. Persiste entre drags hasta que el
     /// host lo resetee.
     pub time_offset_minutes: i64,
+    /// Factor de zoom multiplicativo aplicado al wheel. `1.0` = tamaño
+    /// nominal. Clampeado a [VIEW_SCALE_MIN, VIEW_SCALE_MAX].
+    pub view_scale: f32,
+    /// Pan horizontal en px (positivo = desplaza el wheel a la derecha
+    /// desde el centro). Se aplica como margin shift sobre el centrado
+    /// natural del flex parent.
+    pub view_pan_x: f32,
+    /// Pan vertical en px (positivo = abajo).
+    pub view_pan_y: f32,
     /// Por-LayerKind: `true` = visible. Default = todo visible.
     pub layer_visibility: HashMap<LayerKind, bool>,
     /// Planeta hovered actualmente (para tooltip). `None` cuando el
     /// mouse no está sobre ningún cuerpo.
     pub hover: Option<HoverInfo>,
     drag_jog: Option<JogDragState>,
+    drag_pan: Option<PanDragState>,
 }
 
+/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
+/// 3.0 el wheel desborda incluso pantallas grandes.
+pub const VIEW_SCALE_MIN: f32 = 0.5;
+pub const VIEW_SCALE_MAX: f32 = 3.0;
+
 /// Info del elemento bajo el cursor — usado por el render para mostrar
 /// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
 /// y líneas de aspectos.
@@ -187,9 +211,13 @@ impl Default for CanvasState {
             mode: CanvasMode::default(),
             view_rotation_deg: 0.0,
             time_offset_minutes: 0,
+            view_scale: 1.0,
+            view_pan_x: 0.0,
+            view_pan_y: 0.0,
             layer_visibility: HashMap::new(),
             hover: None,
             drag_jog: None,
+            drag_pan: None,
         }
     }
 }
@@ -263,6 +291,48 @@ impl AstrologyCanvas {
         cx.notify();
     }
 
+    /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
+    /// ni time offset — esos son ortogonales y tienen su propio reset.
+    pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
+        if self.state.view_scale != 1.0
+            || self.state.view_pan_x != 0.0
+            || self.state.view_pan_y != 0.0
+        {
+            self.state.view_scale = 1.0;
+            self.state.view_pan_x = 0.0;
+            self.state.view_pan_y = 0.0;
+            cx.notify();
+        }
+    }
+
+    /// Zoom multiplicativo. El nuevo scale es `current * factor`, clamp
+    /// al rango permitido. El zoom es centrado (no rastrea el cursor) —
+    /// para mover el foco después del zoom, el usuario paneja con MMB.
+    fn zoom_by(&mut self, factor: f32, cx: &mut Context<'_, Self>) {
+        let new_scale =
+            (self.state.view_scale * factor).clamp(VIEW_SCALE_MIN, VIEW_SCALE_MAX);
+        if (new_scale - self.state.view_scale).abs() < 1e-4 {
+            return;
+        }
+        // Mantener el centro del wheel anclado al centro de pantalla:
+        // como el pan está en coords de la pantalla y el zoom es desde
+        // el centro del wheel, el pan se escala proporcional al ratio.
+        let ratio = new_scale / self.state.view_scale;
+        self.state.view_pan_x *= ratio;
+        self.state.view_pan_y *= ratio;
+        self.state.view_scale = new_scale;
+        cx.notify();
+    }
+
+    fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) {
+        if dx == 0.0 && dy == 0.0 {
+            return;
+        }
+        self.state.view_pan_x += dx;
+        self.state.view_pan_y += dy;
+        cx.notify();
+    }
+
     // ----- Internos: handlers de jog-dial -----
 
     fn on_jog_down(
@@ -277,7 +347,10 @@ impl AstrologyCanvas {
         let dx = mx - cx_px;
         let dy = my - cy_px;
         let dist = (dx * dx + dy * dy).sqrt();
-        let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
+        // r_outer se deriva del width actual del canvas (que ya
+        // incorpora view_scale), no del WHEEL_SIZE constante. Sin esto,
+        // el jog-dial dejaría de funcionar al hacer zoom.
+        let r_outer = effective_r_outer(bounds);
         let radii = Radii::from_outer(r_outer);
         // Aro de captura un poco más generoso que el anillo del dial.
         if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 {
@@ -340,7 +413,7 @@ impl AstrologyCanvas {
         let my: f32 = position.y.into();
         let ox: f32 = bounds.origin.x.into();
         let oy: f32 = bounds.origin.y.into();
-        let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
+        let r_outer = effective_r_outer(bounds);
         let radii = Radii::from_outer(r_outer);
         let asc = render.ascendant_deg;
         let rot = self.state.view_rotation_deg;
@@ -494,6 +567,57 @@ impl AstrologyCanvas {
         }
     }
 
+    // ----- Internos: pan drag (MMB) -----
+
+    fn on_pan_down(&mut self, position: Point<Pixels>, _cx: &mut Context<'_, Self>) {
+        self.state.drag_pan = Some(PanDragState {
+            start_pos: position,
+            pan_x_start: self.state.view_pan_x,
+            pan_y_start: self.state.view_pan_y,
+        });
+    }
+
+    fn on_pan_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
+        let Some(pan) = self.state.drag_pan.as_ref() else {
+            return;
+        };
+        let dx: f32 = (position.x - pan.start_pos.x).into();
+        let dy: f32 = (position.y - pan.start_pos.y).into();
+        self.state.view_pan_x = pan.pan_x_start + dx;
+        self.state.view_pan_y = pan.pan_y_start + dy;
+        cx.notify();
+    }
+
+    fn on_pan_up(&mut self, cx: &mut Context<'_, Self>) {
+        if self.state.drag_pan.take().is_some() {
+            cx.notify();
+        }
+    }
+
+    fn on_scroll(
+        &mut self,
+        event: &ScrollWheelEvent,
+        _w: &mut Window,
+        cx: &mut Context<'_, Self>,
+    ) {
+        let (dx_px, dy_px) = match event.delta {
+            ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
+            ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0),
+        };
+        // Ctrl + wheel = zoom. wheel solo = pan (contenido sigue al
+        // dedo). El criterio de "modifier" usa el control flag estándar
+        // de gpui (en macOS sería cmd; aceptamos ambos como zoom).
+        let zoom_mod = event.modifiers.control || event.modifiers.platform;
+        if zoom_mod {
+            // Sensibilidad: 100px de scroll ≈ ±20% zoom. exp es suave y
+            // simétrico contra dy negativo (zoom out).
+            let factor = (dy_px * 0.002).exp();
+            self.zoom_by(factor, cx);
+        } else {
+            self.pan_by(dx_px, dy_px, cx);
+        }
+    }
+
     fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
         let Some(jog) = self.state.drag_jog.take() else {
             return;
@@ -528,6 +652,10 @@ impl AstrologyCanvas {
                 self.reset_time_offset(cx);
                 return;
             }
+            "0" => {
+                self.reset_view(cx);
+                return;
+            }
             "s" | "S" => {
                 cx.emit(CanvasEvent::ExportSvgRequested);
                 return;
@@ -553,6 +681,17 @@ fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
     (ox + bw / 2.0, oy + bh / 2.0)
 }
 
+/// Radio del anillo exterior derivado del width *actual* del canvas
+/// (que ya está escalado por view_scale). Mantiene la proporción del
+/// margen contra `WHEEL_SIZE` original, así el hit-test del jog-dial y
+/// las cusps se adapta automáticamente al zoom sin que cada caller
+/// recalcule `view_scale`.
+fn effective_r_outer(bounds: Bounds<Pixels>) -> f32 {
+    let bw: f32 = bounds.size.width.into();
+    let scale = if WHEEL_SIZE > 0.0 { bw / WHEEL_SIZE } else { 1.0 };
+    (bw - WHEEL_MARGIN * scale * 2.0) / 2.0
+}
+
 // =====================================================================
 // Render
 // =====================================================================
@@ -572,6 +711,9 @@ impl Render for AstrologyCanvas {
                 render,
                 self.state.view_rotation_deg,
                 self.state.time_offset_minutes,
+                self.state.view_scale,
+                self.state.view_pan_x,
+                self.state.view_pan_y,
                 &self.state.layer_visibility,
                 self.state.hover.as_ref(),
                 entity,
@@ -590,12 +732,14 @@ impl Render for AstrologyCanvas {
                     w.focus(&this.focus_handle);
                 }),
             )
+            .on_scroll_wheel(cx.listener(Self::on_scroll))
             .size_full()
             .bg(theme.bg_panel.clone())
             .flex()
             .flex_col()
             .items_center()
             .justify_center()
+            .overflow_hidden()
             .child(body)
     }
 }
@@ -669,15 +813,24 @@ fn render_wheel(
     render: &RenderModel,
     view_rotation_deg: f32,
     time_offset_minutes: i64,
+    view_scale: f32,
+    view_pan_x: f32,
+    view_pan_y: f32,
     layer_visibility: &HashMap<LayerKind, bool>,
     hover: Option<&HoverInfo>,
     entity: gpui::Entity<AstrologyCanvas>,
 ) -> gpui::Div {
     let asc = render.ascendant_deg;
     let rot_offset = view_rotation_deg;
-    let cx_center = WHEEL_SIZE / 2.0;
-    let cy_center = WHEEL_SIZE / 2.0;
-    let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
+    // Todo el wheel escala uniforme: el cuadro contenedor y los anillos
+    // crecen con view_scale, así que glifos, líneas y márgenes mantienen
+    // sus proporciones. cx/cy_center vive en coords locales del wheel,
+    // donde el wheel tiene tamaño `wheel_size` (no WHEEL_SIZE).
+    let wheel_size = WHEEL_SIZE * view_scale;
+    let wheel_margin = WHEEL_MARGIN * view_scale;
+    let cx_center = wheel_size / 2.0;
+    let cy_center = wheel_size / 2.0;
+    let r_outer = (wheel_size - wheel_margin * 2.0) / 2.0;
     let radii = Radii::from_outer(r_outer);
 
     let visible = layer_visibility.clone();
@@ -707,22 +860,36 @@ fn render_wheel(
                 &visibility_for_paint,
             );
 
-            // Handlers de mouse para el jog-dial — se registran cada
-            // frame contra el window; GPUI los reemplaza al re-renderear.
+            // Handlers de mouse — se registran cada frame contra el
+            // window; GPUI los reemplaza al re-renderear. Jog-dial (LMB
+            // sobre el anillo de signos) y pan (MMB en cualquier parte
+            // del canvas) coexisten porque consumen botones distintos.
             let entity_d = entity_for_canvas.clone();
             window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
-                if ev.button != MouseButton::Left {
-                    return;
-                }
                 if !bounds.contains(&ev.position) {
                     return;
                 }
-                entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx));
+                match ev.button {
+                    MouseButton::Left => {
+                        entity_d
+                            .update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx));
+                    }
+                    MouseButton::Middle => {
+                        entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx));
+                    }
+                    _ => {}
+                }
             });
             let entity_m = entity_for_canvas.clone();
             window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
                 if ev.dragging() {
-                    entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
+                    entity_m.update(cx, |this, cx| {
+                        if this.state.drag_pan.is_some() {
+                            this.on_pan_move(ev.position, cx);
+                        } else {
+                            this.on_jog_move(ev.position, bounds, cx);
+                        }
+                    });
                 } else if bounds.contains(&ev.position) {
                     // Mouse hover sin drag: hit-test sobre los body
                     // glyphs para el tooltip.
@@ -737,28 +904,42 @@ fn render_wheel(
             });
             let entity_u = entity_for_canvas.clone();
             window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
-                entity_u.update(cx, |this, cx| this.on_jog_up(cx));
+                entity_u.update(cx, |this, cx| {
+                    this.on_pan_up(cx);
+                    this.on_jog_up(cx);
+                });
             });
         },
     )
     .absolute()
-    .w(px(WHEEL_SIZE))
-    .h(px(WHEEL_SIZE));
+    .w(px(wheel_size))
+    .h(px(wheel_size));
 
     // Gradient sutil diagonal en el fondo del wheel — toque "místico
-    // velado": alpha muy baja, así no compite con la geometría pintada
-    // encima pero llena las zonas vacías (esquinas del cuadrado, gaps
-    // entre anillos) con un shimmer mineral.
+    // velado". En dark la alpha es muy baja (el fondo del panel ya es
+    // oscuro, no hace falta tinte fuerte). En light el panel es claro,
+    // así que necesitamos alphas mayores para que el gradient se vea
+    // como un fondo "papel teñido" y no se borre contra blanco.
+    let (a0, a1) = if theme.is_dark {
+        (0.06, 0.03)
+    } else {
+        (0.18, 0.10)
+    };
     let wheel_bg = linear_gradient(
         155.0,
-        linear_color_stop(with_alpha(palette.dial_ring, 0.06), 0.0),
-        linear_color_stop(with_alpha(palette.angle_highlight, 0.03), 1.0),
+        linear_color_stop(with_alpha(palette.dial_ring, a0), 0.0),
+        linear_color_stop(with_alpha(palette.angle_highlight, a1), 1.0),
     );
 
     let mut wheel = div()
         .relative()
-        .w(px(WHEEL_SIZE))
-        .h(px(WHEEL_SIZE))
+        .w(px(wheel_size))
+        .h(px(wheel_size))
+        // El parent del canvas centra con flex; aplicamos el pan como
+        // margin shift desde ese centrado natural. Positivo = a la
+        // derecha / abajo; negativo desplaza al lado opuesto.
+        .ml(px(view_pan_x))
+        .mt(px(view_pan_y))
         .bg(wheel_bg)
         .rounded(px(12.0))
         .child(canvas_element);
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
index 2d5445d..d2a127f 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -400,6 +400,13 @@ impl Render for ControlPanel {
             );
 
         let mut body = div()
+            .id("tts-panel-body")
+            .flex_grow()
+            // `min_h(0)` libera al body de la altura intrínseca de su
+            // contenido — sin esto el flex_col padre lo expandiría hasta
+            // fit-content y el scroll nunca aparecería.
+            .min_h(px(0.0))
+            .overflow_y_scroll()
             .flex()
             .flex_row()
             .flex_wrap()
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs
index b32dd10..0086108 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs
@@ -194,16 +194,24 @@ impl AstroPalette {
             south_node: hsla(35.0 / 360.0, 0.20, 0.30, 1.0),
             lilith: hsla(310.0 / 360.0, 0.50, 0.30, 1.0),
 
-            conjunction: hsla(45.0 / 360.0, 0.65, 0.40, 0.85),
-            sextile: hsla(195.0 / 360.0, 0.60, 0.38, 0.75),
-            square: hsla(8.0 / 360.0, 0.75, 0.40, 0.85),
-            trine: hsla(140.0 / 360.0, 0.55, 0.35, 0.80),
-            opposition: hsla(280.0 / 360.0, 0.55, 0.42, 0.85),
-            minor_aspect: hsla(220.0 / 360.0, 0.20, 0.45, 0.55),
-
-            dial_ring: hsla(40.0 / 360.0, 0.18, 0.32, 0.90),
-            house_cusp: hsla(40.0 / 360.0, 0.10, 0.45, 0.50),
-            angle_highlight: hsla(45.0 / 360.0, 0.85, 0.40, 1.0),
+            // Aspectos en light: alpha alta y luminancia media-baja para
+            // que las líneas tengan presencia contra fondo claro. En dark
+            // las alphas pueden ser más bajas porque el contraste contra
+            // el fondo oscuro ya las hace destacar.
+            conjunction: hsla(45.0 / 360.0, 0.70, 0.38, 0.95),
+            sextile: hsla(195.0 / 360.0, 0.65, 0.36, 0.90),
+            square: hsla(8.0 / 360.0, 0.80, 0.38, 0.95),
+            trine: hsla(140.0 / 360.0, 0.60, 0.32, 0.92),
+            opposition: hsla(280.0 / 360.0, 0.60, 0.40, 0.95),
+            minor_aspect: hsla(220.0 / 360.0, 0.30, 0.38, 0.75),
+
+            // dial_ring: luminancia baja (oscuro sobre blanco) para que
+            // el anillo de signos tenga peso. house_cusp: subimos alpha
+            // y bajamos luminancia para que las cúspides no se laven en
+            // un beige translúcido.
+            dial_ring: hsla(40.0 / 360.0, 0.20, 0.28, 0.95),
+            house_cusp: hsla(40.0 / 360.0, 0.15, 0.32, 0.80),
+            angle_highlight: hsla(38.0 / 360.0, 0.90, 0.38, 1.0),
         }
     }
 
diff --git a/crates/modules/ui_engine/widgets/splitter/src/lib.rs b/crates/modules/ui_engine/widgets/splitter/src/lib.rs
index ac1e48d..b2f4835 100644
--- a/crates/modules/ui_engine/widgets/splitter/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/splitter/src/lib.rs
@@ -119,7 +119,7 @@ impl SplitContainer {
         // Restamos el espacio que ocupan los divisores — son fixed-size en el
         // eje principal, no participan del flex. El "espacio disponible
         // para flex" es lo que importa para convertir delta_px → delta_flex.
-        let dividers_total = px(DIVIDER_THICKNESS) * (self.children.len().saturating_sub(1) as f32);
+        let dividers_total = px(DIVIDER_HIT_ZONE) * (self.children.len().saturating_sub(1) as f32);
         let total_main = raw_main - dividers_total;
         if total_main <= px(0.0) {
             return;
@@ -210,7 +210,12 @@ fn main_axis_pt(dir: LayoutDirection, p: Point<Pixels>) -> Pixels {
 // Render
 // =====================================================================
 
-const DIVIDER_THICKNESS: f32 = 4.0;
+/// Espesor visible de la franja del divisor (la barrita coloreada).
+const DIVIDER_VISUAL: f32 = 4.0;
+/// Espesor total de la zona interactiva: cursor + handlers de mouse. Más
+/// generosa que el visual para no pelearse con el usuario al apuntar a
+/// una banda de 4px. El visual queda centrado dentro del hit zone.
+const DIVIDER_HIT_ZONE: f32 = 12.0;
 
 impl Render for SplitContainer {
     fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
@@ -260,13 +265,21 @@ impl Render for SplitContainer {
             item.style().flex_grow = Some(weight);
             item.style().flex_shrink = Some(1.0);
 
-            // CRUCIAL: el default de flexbox es `min-width: auto` (= min
-            // content size). Si no lo aplastamos a 0, taffy clamp-ea al
-            // tamaño mínimo del contenido (un TreeView con label largo, un
-            // uniform_list, etc.) y el divisor no puede pasar de ese punto
-            // — el cursor avanza pero el divisor se queda. Forzando min=0
-            // y overflow:hidden en el wrapper, el child puede shrink-arse a
-            // donde sea y el contenido se recorta.
+            // CRUCIAL: flex-basis = 0 (no `auto`). El default `auto` toma
+            // el min-content de cada hijo como punto de partida; cuando un
+            // hijo tiene contenido grande (canvas con WHEEL_SIZE fijo, un
+            // panel con muchos controles en flex_wrap, etc.) la suma de
+            // bases excede el contenedor y flexbox abandona el reparto
+            // por flex-grow para usar shrink proporcional a la basis —
+            // resultado: el ratio 1:4 que pide el host se ignora y el
+            // hijo más liviano (p. ej. el tree) se aplasta a 0px. Con
+            // basis=0 todo el espacio es "free space" y el ratio se
+            // respeta sin importar el contenido.
+            item.style().flex_basis = Some(Length::Definite(px(0.0).into()));
+
+            // Floor de shrink: con basis=0 esto rara vez importa, pero lo
+            // dejamos por defensa contra contenidos que fuercen min-size
+            // intrínseco (uniform_list mide su primera row, etc.).
             item.style().min_size.width = Some(Length::Definite(px(0.0).into()));
             item.style().min_size.height = Some(Length::Definite(px(0.0).into()));
 
@@ -285,27 +298,46 @@ impl Render for SplitContainer {
                 let divider_idx = i;
                 let entity_for_canvas = entity.clone();
 
-                let mut divider = div();
-                let divider_bg = if self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx)
-                {
+                let is_active = self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx);
+                let visual_bg = if is_active {
                     theme.accent_strong
                 } else {
                     theme.border_strong
                 };
-                divider = divider.bg(divider_bg).hover(|s| s.bg(theme.accent));
 
+                // Visual: la franja fina coloreada que el usuario ve.
+                let visual = match direction {
+                    LayoutDirection::Horizontal => div()
+                        .w(px(DIVIDER_VISUAL))
+                        .h_full()
+                        .bg(visual_bg),
+                    _ => div()
+                        .w_full()
+                        .h(px(DIVIDER_VISUAL))
+                        .bg(visual_bg),
+                };
+
+                // Hit zone: wrapper transparente más ancho que captura
+                // cursor y handlers de mouse. Centra el visual con flex.
+                // `relative` para que el canvas hijo (absolute) se ancle
+                // al wrapper y reporte sus bounds correctos.
+                let mut divider = div().relative().flex().items_center().justify_center();
                 divider = match direction {
                     LayoutDirection::Horizontal => divider
-                        .w(px(DIVIDER_THICKNESS))
+                        .w(px(DIVIDER_HIT_ZONE))
                         .h_full()
                         .cursor_ew_resize(),
                     _ => divider
                         .w_full()
-                        .h(px(DIVIDER_THICKNESS))
+                        .h(px(DIVIDER_HIT_ZONE))
                         .cursor_ns_resize(),
                 };
+                divider = divider.child(visual);
 
-                // Canvas con handlers de drag a nivel de window.
+                // Canvas con handlers de drag a nivel de window — su
+                // bounds = bounds del wrapper (hit zone completo), así
+                // que el `canvas_bounds.contains` acepta clicks en todo
+                // el ancho del hit zone, no solo sobre el visual.
                 let divider = divider.child(
                     canvas(
                         |_, _, _| (),
@@ -350,6 +382,7 @@ impl Render for SplitContainer {
                             });
                         },
                     )
+                    .absolute()
                     .size_full(),
                 );