tawasuyu

tawasuyu/brahman

Rust 321TOML 122JSON 14Markdown 114332 Sutichaykuna

feat(tahuantinsuyu): scaffolding del estudio astrológico (10 crates + ventana 3-panes)

Módulo nuevo `modules/tahuantinsuyu/` con 9 crates reusables + app
`apps/tahuantinsuyu` ejecutable que abre la ventana del explorador y
coordina los widgets:

- tahuantinsuyu-card: Card Brahman + spawn_sidecar (flows
  chart-request/chart-result).
- tahuantinsuyu-model: tipos agnósticos (Group/Contact/Chart,
  StoredBirthData, StoredChartConfig, ChartKind, TreeSelection).
- tahuantinsuyu-store: persistencia SQLite (rusqlite) con migración v1,
  CRUD por entidad y descenso recursivo `charts_under_group`.
- tahuantinsuyu-engine: bridge agnóstico al canvas vía `RenderModel`
  (Layer/Glyph/Geometry). Feature `eternal-bridge` (off por default)
  reservada para enchufar eternal-astrology desde ~/eternal.
- tahuantinsuyu-modules: registry de módulos pluggables (Module trait
  + Control schema) con `NatalModule` placeholder.
- tahuantinsuyu-theme: AstroPalette (elementos / modos / planetas /
  aspectos) con variantes dark + light sobre yahweh-theme.
- tahuantinsuyu-canvas: widget GPUI con CanvasState (Empty / Wheel /
  Thumbnails). Render placeholder hasta cablear la rueda real.
- tahuantinsuyu-tree: explorador izquierdo sobre yahweh-widget-tree,
  prefijos g:/c:/h: para Group/Contact/Chart.
- tahuantinsuyu-panel: control panel inferior que lee Controls de los
  módulos del registry y los pinta.
- apps/tahuantinsuyu: binario `tahuantinsuyu` (launch_app-style) con
  Shell coordinador (tree↔canvas↔panel), DB en $XDG_DATA_HOME.

Workspace Cargo.toml actualizado con los 10 miembros. `cargo check`
verde, tests unitarios verdes (model/store/engine/modules/theme/card).

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

c48638fe8778f313ede0744f7e7597c8e496de89 · ruwaq sergio · (5 killa ñawpaq) · tayta e8f97b50c · kay commitpi sach'ata qhaway

Semántico sayk'uy · 152

crates/apps/tahuantinsuyu/src/main.rs

crates/apps/tahuantinsuyu/src/shell.rs

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

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

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

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

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

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

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

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

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

23 qillqakuna · +3256 −0

diff --git a/Cargo.lock b/Cargo.lock
index 233634e..bdda8b9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1584,6 +1584,15 @@ dependencies = [
  "unicode-security",
 ]
 
+[[package]]
+name = "celestial-eop-data"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0db7627f7cbdcaed155e66503e07025e10701a3566bc211a85e35b918bc40812"
+dependencies = [
+ "zstd",
+]
+
 [[package]]
 name = "cexpr"
 version = "0.6.0"
@@ -2513,6 +2522,15 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys 0.4.1",
+]
+
 [[package]]
 name = "dirs"
 version = "4.0.0"
@@ -3239,6 +3257,88 @@ dependencies = [
  "svg_fmt",
 ]
 
+[[package]]
+name = "eternal-astrology"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "eternal-core",
+ "eternal-ephemeris",
+ "eternal-sky",
+ "eternal-time",
+ "eternal-validation",
+ "libm",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "eternal-coords"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "celestial-eop-data",
+ "eternal-core",
+ "eternal-time",
+ "libm",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "eternal-core"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "libm",
+ "once_cell",
+ "regex",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "eternal-ephemeris"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "eternal-coords",
+ "eternal-core",
+ "eternal-time",
+ "libm",
+ "memmap2",
+]
+
+[[package]]
+name = "eternal-sky"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "eternal-coords",
+ "eternal-core",
+ "eternal-ephemeris",
+ "eternal-time",
+ "eternal-validation",
+ "libm",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "eternal-time"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "eternal-core",
+ "libm",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "eternal-validation"
+version = "0.1.1-alpha.2"
+dependencies = [
+ "anyhow",
+ "clap",
+ "eternal-coords",
+ "eternal-core",
+ "eternal-ephemeris",
+ "eternal-time",
+ "libm",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "euclid"
 version = "0.22.14"
@@ -10800,6 +10900,120 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
 
+[[package]]
+name = "tahuantinsuyu"
+version = "0.1.0"
+dependencies = [
+ "directories",
+ "gpui",
+ "tahuantinsuyu-canvas",
+ "tahuantinsuyu-card",
+ "tahuantinsuyu-engine",
+ "tahuantinsuyu-model",
+ "tahuantinsuyu-modules",
+ "tahuantinsuyu-panel",
+ "tahuantinsuyu-store",
+ "tahuantinsuyu-theme",
+ "tahuantinsuyu-tree",
+ "yahweh-bus",
+ "yahweh-theme",
+]
+
+[[package]]
+name = "tahuantinsuyu-canvas"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "tahuantinsuyu-engine",
+ "tahuantinsuyu-model",
+ "tahuantinsuyu-modules",
+ "tahuantinsuyu-theme",
+ "yahweh-theme",
+]
+
+[[package]]
+name = "tahuantinsuyu-card"
+version = "0.1.0"
+dependencies = [
+ "brahman-card",
+ "brahman-sidecar",
+ "ulid",
+]
+
+[[package]]
+name = "tahuantinsuyu-engine"
+version = "0.1.0"
+dependencies = [
+ "eternal-astrology",
+ "eternal-sky",
+ "serde",
+ "tahuantinsuyu-model",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tahuantinsuyu-model"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "ulid",
+]
+
+[[package]]
+name = "tahuantinsuyu-modules"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tahuantinsuyu-engine",
+ "tahuantinsuyu-model",
+]
+
+[[package]]
+name = "tahuantinsuyu-panel"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "serde_json",
+ "tahuantinsuyu-model",
+ "tahuantinsuyu-modules",
+ "tahuantinsuyu-theme",
+ "yahweh-theme",
+]
+
+[[package]]
+name = "tahuantinsuyu-store"
+version = "0.1.0"
+dependencies = [
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "tahuantinsuyu-model",
+ "thiserror 2.0.18",
+ "ulid",
+]
+
+[[package]]
+name = "tahuantinsuyu-theme"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "yahweh-theme",
+]
+
+[[package]]
+name = "tahuantinsuyu-tree"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "tahuantinsuyu-model",
+ "tahuantinsuyu-store",
+ "yahweh-theme",
+ "yahweh-widget-tree",
+]
+
 [[package]]
 name = "take-until"
 version = "0.2.0"
@@ -14011,6 +14225,34 @@ version = "1.0.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
 
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
 [[package]]
 name = "zune-core"
 version = "0.5.1"
diff --git a/Cargo.toml b/Cargo.toml
index 9eaf3d9..19ca9ed 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -133,6 +133,19 @@ members = [
     # ============================================================
     "crates/modules/barra/barra-web",
 
+    # ============================================================
+    # modules/tahuantinsuyu/ — estudio de astrología profesional
+    # ============================================================
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-card",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-model",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-store",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-engine",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-modules",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-theme",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-canvas",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-tree",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-panel",
+
     # ============================================================
     # apps/ — apps que consumen el protocolo (yahweh modules+shell)
     # ============================================================
@@ -156,6 +169,7 @@ members = [
     "crates/apps/lapaloma-stream-demo",
     "crates/apps/lapaloma-phosphor-demo",
     "crates/apps/lapaloma-financial-demo",
+    "crates/apps/tahuantinsuyu",
 ]
 
 [workspace.package]
diff --git a/crates/apps/tahuantinsuyu/Cargo.toml b/crates/apps/tahuantinsuyu/Cargo.toml
new file mode 100644
index 0000000..a8c02d7
--- /dev/null
+++ b/crates/apps/tahuantinsuyu/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "tahuantinsuyu"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — estudio profesional de astrología. Tree + canvas + panel sobre yahweh + eternal-astrology."
+
+[dependencies]
+tahuantinsuyu-card = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-card" }
+tahuantinsuyu-canvas = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-canvas" }
+tahuantinsuyu-engine = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-engine" }
+tahuantinsuyu-model = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-model" }
+tahuantinsuyu-modules = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-modules" }
+tahuantinsuyu-panel = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-panel" }
+tahuantinsuyu-store = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-store" }
+tahuantinsuyu-theme = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-theme" }
+tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" }
+
+yahweh-bus = { workspace = true }
+yahweh-theme = { workspace = true }
+gpui = { workspace = true }
+directories = { workspace = true }
+
+[[bin]]
+name = "tahuantinsuyu"
+path = "src/main.rs"
diff --git a/crates/apps/tahuantinsuyu/src/main.rs b/crates/apps/tahuantinsuyu/src/main.rs
new file mode 100644
index 0000000..a0ca5a2
--- /dev/null
+++ b/crates/apps/tahuantinsuyu/src/main.rs
@@ -0,0 +1,88 @@
+//! Tahuantinsuyu — binario standalone.
+//!
+//! Boot:
+//! 1. `tahuantinsuyu_card::spawn_sidecar()` se presenta al Init brahman
+//!    (fire-and-forget; si no hay Init, la app sigue standalone).
+//! 2. Abre la DB SQLite en `$XDG_DATA_HOME/tahuantinsuyu/charts.db`
+//!    (fallback a `~/.local/share/tahuantinsuyu/charts.db`).
+//! 3. Levanta GPUI con [`yahweh_theme::Theme::install_default`].
+//! 4. Compone el shell: [`Shell`] dueño del tree (izq), canvas (centro)
+//!    y panel (abajo). Cablea las suscripciones cross-widget.
+//!
+//! ## Layout
+//!
+//! ```text
+//!  ┌───────────┬────────────────────────────────────────┐
+//!  │           │                                        │
+//!  │   tree    │              canvas                    │
+//!  │  (groups, │  (rueda / thumbnails)                  │
+//!  │   contacts,│                                       │
+//!  │   charts) │                                        │
+//!  │           │                                        │
+//!  ├───────────┴────────────────────────────────────────┤
+//!  │              control panel (módulos)               │
+//!  └─────────────────────────────────────────────────────┘
+//! ```
+
+mod shell;
+
+use std::path::PathBuf;
+
+use gpui::{
+    App, AppContext, Application, Bounds, SharedString, TitlebarOptions, WindowBounds,
+    WindowOptions, px, size,
+};
+
+use tahuantinsuyu_store::Store;
+use yahweh_theme::Theme;
+
+use crate::shell::Shell;
+
+const DB_FILENAME: &str = "charts.db";
+const APP_TITLE: &str = "Tahuantinsuyu";
+
+fn main() {
+    // Sidecar brahman primero — si el Init está corriendo, nos presentamos.
+    tahuantinsuyu_card::spawn_sidecar();
+
+    // DB en directorio de datos del usuario.
+    let db_path = resolve_db_path();
+    let store = match Store::open(&db_path) {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!(
+                "[tahuantinsuyu] no se pudo abrir la DB en {:?}: {} — usando memoria",
+                db_path, e
+            );
+            Store::in_memory().expect("in-memory store")
+        }
+    };
+
+    Application::new().run(move |cx: &mut App| {
+        Theme::install_default(cx);
+
+        let bounds = Bounds::centered(None, size(px(1400.0), px(900.0)), cx);
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                titlebar: Some(TitlebarOptions {
+                    title: Some(SharedString::from(APP_TITLE)),
+                    ..Default::default()
+                }),
+                ..Default::default()
+            },
+            move |_w, cx| cx.new(|cx| Shell::new(store.clone(), cx)),
+        )
+        .expect("open window");
+        cx.activate(true);
+    });
+}
+
+fn resolve_db_path() -> PathBuf {
+    if let Some(dirs) = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu") {
+        let dir = dirs.data_dir().to_path_buf();
+        let _ = std::fs::create_dir_all(&dir);
+        return dir.join(DB_FILENAME);
+    }
+    PathBuf::from(DB_FILENAME)
+}
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
new file mode 100644
index 0000000..476f8cd
--- /dev/null
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -0,0 +1,238 @@
+//! Shell — coordinador de los tres widgets.
+//!
+//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
+//! reenvía eventos entre ellos y aplica las mutaciones en la store.
+//!
+//! Flujo típico:
+//!
+//! ```text
+//!   Tree.Selected(Chart)  →  Shell  →  Canvas.set_mode(Wheel)
+//!                                  →  Panel.set_active_kind(chart.kind)
+//!
+//!   Tree.Selected(Group)  →  Shell  →  Canvas.set_mode(Thumbnails{…})
+//!                                  →  Panel.set_active_kind(None)
+//!
+//!   Panel.ModuleToggled   →  Shell  →  Store.upsert_module_state
+//!                                  →  Canvas.toggle_module
+//! ```
+//!
+//! Fase 1: las suscripciones están cableadas pero los handlers son
+//! mínimos (logging + transición de modo). La pipeline real de cómputo
+//! viene con la fase 3.
+
+use gpui::{
+    Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
+    prelude::*, px,
+};
+
+use tahuantinsuyu_canvas::{
+    AstrologyCanvas, CanvasMode, ThumbnailItem, ThumbnailScope,
+};
+use tahuantinsuyu_engine::compute_mock;
+use tahuantinsuyu_model::TreeSelection;
+use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
+use tahuantinsuyu_store::Store;
+use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
+use yahweh_bus::AppBus;
+use yahweh_theme::Theme;
+
+const TREE_WIDTH: f32 = 280.0;
+const PANEL_HEIGHT: f32 = 180.0;
+
+pub struct Shell {
+    store: Store,
+    #[allow(dead_code)]
+    bus: Entity<AppBus>,
+    tree: Entity<TahuantinsuyuTree>,
+    canvas: Entity<AstrologyCanvas>,
+    panel: Entity<ControlPanel>,
+}
+
+impl Shell {
+    pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
+        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
+
+        let bus = cx.new(|_| AppBus);
+        let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
+        let canvas = cx.new(AstrologyCanvas::new);
+        let panel = cx.new(ControlPanel::new);
+
+        // Tree → Shell: aplicar selección al canvas/panel.
+        cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
+            this.on_tree_event(ev, cx);
+        })
+        .detach();
+
+        // Panel → Shell: persistir y propagar al canvas.
+        cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
+            this.on_panel_event(ev, cx);
+        })
+        .detach();
+
+        Self {
+            store,
+            bus,
+            tree,
+            canvas,
+            panel,
+        }
+    }
+
+    fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
+        let selection = match ev {
+            TreeEvent::Selected(s) => s,
+            TreeEvent::Opened(s) => s,
+        };
+        self.apply_selection(selection.clone(), cx);
+    }
+
+    fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
+        match sel {
+            TreeSelection::Chart(id) => {
+                let chart = match self.store.get_chart(id) {
+                    Ok(c) => c,
+                    Err(e) => {
+                        eprintln!("[shell] get_chart {}: {}", id, e);
+                        return;
+                    }
+                };
+                let kind = chart.kind;
+                let render = compute_mock(&chart);
+                self.canvas.update(cx, |c, cx| {
+                    c.set_mode(
+                        CanvasMode::Wheel {
+                            render: Box::new(render),
+                        },
+                        cx,
+                    );
+                });
+                self.panel
+                    .update(cx, |p, cx| p.set_active_kind(Some(kind), cx));
+            }
+            TreeSelection::Contact(id) => {
+                let charts = self.store.list_charts(id).unwrap_or_default();
+                let items: Vec<ThumbnailItem> = charts
+                    .into_iter()
+                    .map(|c| ThumbnailItem {
+                        chart_id: c.id,
+                        label: SharedString::from(c.label),
+                        subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
+                        preview: None,
+                    })
+                    .collect();
+                self.canvas.update(cx, |c, cx| {
+                    c.set_mode(
+                        CanvasMode::Thumbnails {
+                            scope: ThumbnailScope::Contact(id),
+                            items,
+                        },
+                        cx,
+                    );
+                });
+                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
+            }
+            TreeSelection::Group(id) => {
+                let charts = self.store.charts_under_group(id).unwrap_or_default();
+                let items: Vec<ThumbnailItem> = charts
+                    .into_iter()
+                    .map(|c| ThumbnailItem {
+                        chart_id: c.id,
+                        label: SharedString::from(c.label),
+                        subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
+                        preview: None,
+                    })
+                    .collect();
+                self.canvas.update(cx, |c, cx| {
+                    c.set_mode(
+                        CanvasMode::Thumbnails {
+                            scope: ThumbnailScope::Group(id),
+                            items,
+                        },
+                        cx,
+                    );
+                });
+                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
+            }
+        }
+    }
+
+    fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
+        match ev {
+            PanelEvent::ModuleToggled { module_id, .. } => {
+                self.canvas
+                    .update(cx, |c, cx| c.toggle_module(module_id, cx));
+            }
+            PanelEvent::ControlChanged { .. } => {
+                // Fase 4: aplicar config al canvas + persistir en store.
+            }
+        }
+        // Silenciar warnings de campos no leídos hasta que la fase 2
+        // cablee CRUD desde el tree.
+        let _ = (&self.store, &self.tree, &self.bus);
+    }
+}
+
+impl Render for Shell {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+
+        let header = div()
+            .h(px(34.0))
+            .px(px(12.0))
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(10.0))
+            .border_b_1()
+            .border_color(theme.border)
+            .child(
+                div()
+                    .text_size(px(13.0))
+                    .text_color(theme.fg_text)
+                    .child("☉ Tahuantinsuyu"),
+            )
+            .child(
+                div()
+                    .text_size(px(10.0))
+                    .text_color(theme.fg_muted)
+                    .child("estudio de astrología profesional"),
+            );
+
+        let tree_panel = div()
+            .w(px(TREE_WIDTH))
+            .min_w(px(TREE_WIDTH))
+            .h_full()
+            .border_r_1()
+            .border_color(theme.border)
+            .child(self.tree.clone());
+
+        let canvas_panel = div()
+            .flex_grow()
+            .h_full()
+            .child(self.canvas.clone());
+
+        let main_row = div()
+            .flex_grow()
+            .flex()
+            .flex_row()
+            .child(tree_panel)
+            .child(canvas_panel);
+
+        let bottom_panel = div()
+            .h(px(PANEL_HEIGHT))
+            .min_h(px(PANEL_HEIGHT))
+            .w_full()
+            .border_t_1()
+            .border_color(theme.border)
+            .child(self.panel.clone());
+
+        div()
+            .size_full()
+            .bg(theme.bg_app.clone())
+            .flex()
+            .flex_col()
+            .child(header)
+            .child(main_row)
+            .child(bottom_panel)
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml
new file mode 100644
index 0000000..fdc1780
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "tahuantinsuyu-canvas"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — widget GPUI del canvas astrológico. Capas modulares, jog-dial perimetral, estado unificado."
+
+[dependencies]
+tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" }
+tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" }
+yahweh-theme = { workspace = true }
+gpui = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
new file mode 100644
index 0000000..88b504f
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -0,0 +1,264 @@
+//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico.
+//!
+//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
+//! unificado [`CanvasState`] guarda offsets de rotación, flags de
+//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
+//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
+//! `RenderModel` y la deriva al frame.
+//!
+//! ## Modos
+//!
+//! - [`CanvasMode::Wheel`] — pinta una carta única (la rueda).
+//! - [`CanvasMode::Thumbnails`] — pinta una grilla de mini-cartas
+//!   cuando el item activo del tree es un Group o Contact.
+//! - [`CanvasMode::Empty`] — sin selección.
+//!
+//! ## Fase 1
+//!
+//! Este crate trae el esqueleto: tipos, estado, render placeholder
+//! (caja cuadrada con título centrado, eje cardinal y un anillo
+//! perfilado). Las interacciones del jog-dial, el árbol Uraniano y la
+//! pintura de cada `Layer` vienen en fases siguientes.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use gpui::{
+    Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px,
+};
+
+use tahuantinsuyu_engine::RenderModel;
+use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
+use tahuantinsuyu_theme::AstroPalette;
+use yahweh_theme::Theme;
+
+// =====================================================================
+// Eventos
+// =====================================================================
+
+#[derive(Clone, Debug)]
+pub enum CanvasEvent {
+    /// El usuario hizo doble click sobre un thumbnail o pidió abrir la
+    /// carta activa. El host (la app) decide si emitir al AppBus.
+    ChartRequested(ChartId),
+    /// El usuario rotó la rueda de tiempo: minutos de offset acumulados.
+    TimeOffsetChanged(i64),
+}
+
+// =====================================================================
+// Estado
+// =====================================================================
+
+/// Modo de visualización del canvas.
+#[derive(Clone, Debug, Default)]
+pub enum CanvasMode {
+    #[default]
+    Empty,
+    /// Single chart wheel.
+    Wheel { render: Box<RenderModel> },
+    /// Grilla de thumbnails para un Group o Contact con varias cartas.
+    Thumbnails {
+        scope: ThumbnailScope,
+        items: Vec<ThumbnailItem>,
+    },
+}
+
+#[derive(Clone, Debug)]
+pub enum ThumbnailScope {
+    Group(GroupId),
+    Contact(ContactId),
+}
+
+#[derive(Clone, Debug)]
+pub struct ThumbnailItem {
+    pub chart_id: ChartId,
+    pub label: SharedString,
+    pub subtitle: Option<SharedString>,
+    /// `Some` si ya hay un render-mock disponible. `None` = lazy.
+    pub preview: Option<RenderModel>,
+}
+
+/// Estado unificado del canvas. Inspirado en la conversación de Sergio
+/// con el agente — todo lo que controla qué se pinta vive acá.
+#[derive(Clone, Debug, Default)]
+pub struct CanvasState {
+    pub mode: CanvasMode,
+
+    /// Rotación manual del lienzo en grados. `0.0` = Aries al este.
+    pub view_rotation_deg: f32,
+
+    /// Offset acumulado del time-scrubbing (jog-dial perimetral) en
+    /// minutos. La engine recalcula la `RenderModel` cuando esto cambia.
+    pub time_offset_minutes: i64,
+
+    /// Capas activas por `module_id`. Si una capa del `RenderModel`
+    /// pertenece a un módulo no presente aquí, no se pinta.
+    pub active_modules: std::collections::HashSet<String>,
+}
+
+// =====================================================================
+// Widget
+// =====================================================================
+
+pub struct AstrologyCanvas {
+    state: CanvasState,
+}
+
+impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
+
+impl AstrologyCanvas {
+    pub fn new(cx: &mut Context<Self>) -> Self {
+        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
+        Self {
+            state: CanvasState::default(),
+        }
+    }
+
+    pub fn state(&self) -> &CanvasState {
+        &self.state
+    }
+
+    /// Reemplaza el modo de visualización (lo que se pinta).
+    pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<Self>) {
+        self.state.mode = mode;
+        cx.notify();
+    }
+
+    pub fn toggle_module(&mut self, module_id: &str, cx: &mut Context<Self>) {
+        if !self.state.active_modules.remove(module_id) {
+            self.state.active_modules.insert(module_id.to_string());
+        }
+        cx.notify();
+    }
+
+    pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<Self>) {
+        self.state.view_rotation_deg = deg.rem_euclid(360.0);
+        cx.notify();
+    }
+}
+
+// =====================================================================
+// Render
+// =====================================================================
+
+impl Render for AstrologyCanvas {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+        let palette = AstroPalette::for_theme(&theme);
+
+        let body = match &self.state.mode {
+            CanvasMode::Empty => render_empty(&theme),
+            CanvasMode::Wheel { render } => render_wheel(&theme, &palette, render),
+            CanvasMode::Thumbnails { scope: _, items } => {
+                render_thumbnails(&theme, &palette, items)
+            }
+        };
+
+        div()
+            .size_full()
+            .bg(theme.bg_panel.clone())
+            .flex()
+            .flex_col()
+            .items_center()
+            .justify_center()
+            .child(body)
+    }
+}
+
+fn render_empty(theme: &Theme) -> gpui::Div {
+    div()
+        .flex()
+        .flex_col()
+        .items_center()
+        .justify_center()
+        .gap(px(12.0))
+        .child(
+            div()
+                .text_size(px(13.0))
+                .text_color(theme.fg_muted)
+                .child("Tahuantinsuyu"),
+        )
+        .child(
+            div()
+                .text_size(px(11.0))
+                .text_color(theme.fg_disabled)
+                .child("Seleccioná una carta en el árbol para empezar."),
+        )
+}
+
+fn render_wheel(theme: &Theme, palette: &AstroPalette, render: &RenderModel) -> gpui::Div {
+    // Fase 1: placeholder visual. Una caja cuadrada con el título y un
+    // contador de capas. El pintado real de los Layer vendrá con
+    // `gpui::canvas` + matrices en la fase 3.
+    let _ = palette; // silencia warning hasta la fase 3.
+    div()
+        .flex()
+        .flex_col()
+        .items_center()
+        .justify_center()
+        .gap(px(10.0))
+        .child(
+            div()
+                .text_size(px(16.0))
+                .text_color(theme.fg_text)
+                .child(SharedString::from(render.title.clone())),
+        )
+        .child(
+            div()
+                .text_size(px(11.0))
+                .text_color(theme.fg_muted)
+                .child(SharedString::from(format!(
+                    "{} capa(s) · {} ms",
+                    render.layers.len(),
+                    render.compute_ms
+                ))),
+        )
+        .child(
+            // Marco cuadrado provisional — el render real lo ocupará.
+            div()
+                .size(px(480.0))
+                .rounded(px(8.0))
+                .border_1()
+                .border_color(theme.border_strong)
+                .bg(theme.bg_panel_alt.clone()),
+        )
+}
+
+fn render_thumbnails(
+    theme: &Theme,
+    _palette: &AstroPalette,
+    items: &[ThumbnailItem],
+) -> gpui::Div {
+    if items.is_empty() {
+        return div()
+            .text_size(px(12.0))
+            .text_color(theme.fg_muted)
+            .child("Sin cartas en este grupo todavía.");
+    }
+    // Grid simple en flex-wrap. La fase 3 lo reemplaza por miniaturas
+    // pintadas con la rueda en miniatura.
+    let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
+    for it in items {
+        row = row.child(
+            div()
+                .w(px(140.0))
+                .h(px(160.0))
+                .rounded(px(6.0))
+                .border_1()
+                .border_color(theme.border)
+                .bg(theme.bg_panel_alt.clone())
+                .flex()
+                .flex_col()
+                .items_center()
+                .justify_end()
+                .pb(px(8.0))
+                .child(
+                    div()
+                        .text_size(px(11.0))
+                        .text_color(theme.fg_text)
+                        .child(it.label.clone()),
+                ),
+        );
+    }
+    row
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml
new file mode 100644
index 0000000..985c14b
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "tahuantinsuyu-card"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — Tarjeta de Presentación brahman + spawn del sidecar."
+
+[dependencies]
+brahman-card = { path = "../../../core/brahman-card" }
+brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
+ulid = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs
new file mode 100644
index 0000000..e558fcf
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs
@@ -0,0 +1,89 @@
+//! `tahuantinsuyu-card` — Tarjeta de Presentación + sidecar de la app.
+//!
+//! Cualquier binario que levante Tahuantinsuyu llama [`spawn_sidecar`]
+//! antes de abrir la ventana GPUI. La lógica de thread / tokio /
+//! ping-loop vive en `brahman-sidecar`; aquí solo declaramos quién es
+//! Tahuantinsuyu como módulo Brahman.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::BTreeSet;
+
+use brahman_card::{
+    Card, Flow, Flows, FsPolicy, IpcPolicy, Lifecycle, Payload, Permissions, Priority, Supervision,
+    TypeRef, CARD_SCHEMA_VERSION,
+};
+use ulid::Ulid;
+
+/// Label canónico — coincide con el binario y aparece en `ListEntes`.
+pub const LABEL: &str = "brahman.tahuantinsuyu";
+
+/// Spawn fire-and-forget. Si el Init no está corriendo, el sidecar
+/// loggea y termina; la app sigue ejecutándose standalone.
+pub fn spawn_sidecar() {
+    brahman_sidecar::spawn(build_card());
+}
+
+/// Construye la Card. Expuesto público para tests + para shells que
+/// quieran inspeccionar el manifiesto antes de spawnear.
+pub fn build_card() -> Card {
+    Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        lineage: None,
+        label: LABEL.into(),
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        payload: Payload::Virtual,
+        supervision: Supervision::Delegate,
+        lifecycle: Lifecycle::Widget,
+        priority: Priority::Normal,
+        permissions: Permissions {
+            // La app guarda su DB SQLite en disco; necesita RW filesystem.
+            filesystem: FsPolicy::ReadWrite,
+            ipc: IpcPolicy {
+                allow: vec!["wit-v1".into()],
+            },
+            ..Default::default()
+        },
+        flow: Flows {
+            // Recibe peticiones de cómputo (carta natal, transit, etc.)
+            // serializadas como JSON. La forma exacta la define
+            // `tahuantinsuyu-engine`.
+            input: vec![Flow {
+                name: "chart-request".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+            // Publica el resultado de un cómputo (placements, aspectos,
+            // casas) también como JSON. Otras apps brahman pueden
+            // consumirlo para visualizar o derivar.
+            output: vec![Flow {
+                name: "chart-result".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+        },
+        ..Default::default()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn card_label_and_flow() {
+        let c = build_card();
+        assert_eq!(c.label, LABEL);
+        assert_eq!(c.flow.input.len(), 1);
+        assert_eq!(c.flow.output.len(), 1);
+        assert_eq!(c.flow.input[0].name, "chart-request");
+        assert_eq!(c.flow.output[0].name, "chart-result");
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml
new file mode 100644
index 0000000..4940e95
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "tahuantinsuyu-engine"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — bridge entre el modelo agnóstico y eternal-astrology. Produce RenderModel agnóstico para el canvas."
+
+[dependencies]
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+serde = { workspace = true }
+thiserror = { workspace = true }
+
+# eternal-astrology vive en otro workspace (~/eternal). Lo enlazamos por
+# path para que el bridge use la misma lógica validada que el harness de
+# Sergio. Si el path no existe (CI sin eternal checked out), el feature
+# `eternal-bridge` se apaga.
+[dependencies.eternal-astrology]
+path = "../../../../../eternal/eternal-astrology"
+optional = true
+
+[dependencies.eternal-sky]
+path = "../../../../../eternal/eternal-sky"
+optional = true
+
+[features]
+default = []
+# Activa el bridge real contra eternal-astrology. Sin este feature, la
+# engine sólo expone el RenderModel y mocks — útil para tests y para
+# compilar la UI antes de que eternal esté disponible.
+eternal-bridge = ["dep:eternal-astrology", "dep:eternal-sky"]
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
new file mode 100644
index 0000000..51522a7
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -0,0 +1,272 @@
+//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y
+//! `eternal-astrology`.
+//!
+//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
+//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
+//! el canvas a tipos de la librería astronómica. El canvas habla
+//! grados decimales, radios normalizados y kinds simbólicos.
+//!
+//! ## Por qué un RenderModel intermedio
+//!
+//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
+//!    upstream.
+//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
+//!    arrancar eternal.
+//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
+//!    coordina N módulos sin saber qué calcularon.
+//!
+//! ## Feature `eternal-bridge`
+//!
+//! - **off** (default): la engine sólo expone los tipos `RenderModel`,
+//!   `Layer`, `Glyph`, etc. y un `compute_mock()` con un disco de
+//!   prueba. Útil para la UI antes de que `eternal-astrology` compile.
+//! - **on**: agrega `compute(chart) -> RenderModel` con la pipeline
+//!   real.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
+
+// =====================================================================
+// RenderModel — lo que el canvas necesita pintar una capa
+// =====================================================================
+
+/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
+/// Cada `Layer` es independiente — el canvas las apila por z-order.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RenderModel {
+    /// Identidad estable de la carta a la que pertenece este render.
+    pub chart_id: ChartId,
+    /// Kind original — el canvas lo usa para títulos y ornamentos.
+    pub chart_kind: ChartKind,
+    /// Capas a pintar. Orden = z-order ascendente.
+    pub layers: Vec<Layer>,
+    /// Texto humano-legible breve. Ej. "Sergio · 14 mar 1987 · Caracas".
+    pub title: String,
+    /// Tiempo de cómputo en ms — métrica para diagnóstico.
+    pub compute_ms: u64,
+}
+
+/// Una capa visual. Cada módulo de astrología publica una o varias.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Layer {
+    /// Identidad estable del módulo emisor ("natal", "transit", "uranian").
+    pub module_id: String,
+    /// Tipo de capa — controla cómo se compone con vecinas.
+    pub kind: LayerKind,
+    /// Radio normalizado [0, 1] sobre el lienzo. Permite stack de anillos.
+    pub ring: f32,
+    /// Z-order absoluto (más alto = encima). Default 0.
+    #[serde(default)]
+    pub z: i32,
+    /// Geometría: puntos, arcos, líneas.
+    pub geometry: Geometry,
+    /// Glifos simbólicos sobre la geometría (planetas, signos, casas).
+    #[serde(default)]
+    pub glyphs: Vec<Glyph>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LayerKind {
+    /// El anillo zodiacal de fondo (12 signos).
+    SignDial,
+    /// Las 12 cusps de casas + cuadrantes.
+    Houses,
+    /// Los planetas / cuerpos en sus posiciones.
+    Bodies,
+    /// Líneas de aspecto entre cuerpos.
+    Aspects,
+    /// Puntos arábigos / lots.
+    Lots,
+    /// Estrellas fijas como overlay.
+    FixedStars,
+    /// Puntos medios y simetría Uraniana.
+    Midpoints,
+    /// Anillo externo de tránsitos / progresiones / direcciones.
+    Outer,
+    /// Geometría libre — usa cuando una capa no encaja en las otras.
+    Custom,
+}
+
+/// Geometría primitiva, agnóstica del renderer.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Geometry {
+    /// Sólo glifos posicionados — sin trazo de fondo.
+    GlyphsOnly,
+    /// Anillo dividido en sectores (zodíaco, casas).
+    Ring {
+        /// Divisiones en grados zodiacales [0, 360). El canvas pinta
+        /// líneas radiales en cada uno.
+        cusps_deg: Vec<f32>,
+    },
+    /// Conjunto de líneas (aspectos). Cada par = `(from_deg, to_deg)`.
+    Lines(Vec<LineSeg>),
+    /// Puntos sueltos con marcadores (lots, fixed stars).
+    Points(Vec<PointMark>),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LineSeg {
+    pub from_deg: f32,
+    pub to_deg: f32,
+    /// Categoría simbólica (conjunction, trine, square…) — el theme
+    /// resuelve el color.
+    pub kind: String,
+    /// Opacidad sugerida [0, 1].
+    pub opacity: f32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PointMark {
+    pub deg: f32,
+    pub label: String,
+    /// Tag simbólico para que el theme elija color/glifo.
+    pub tag: String,
+}
+
+/// Glifo dibujable sobre una capa.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Glyph {
+    /// Posición zodiacal en grados [0, 360).
+    pub deg: f32,
+    /// Glyph simbólico ("sun","moon","aries",…). El theme lo mapea a
+    /// imagen o codepoint.
+    pub symbol: String,
+    /// Texto secundario (ej. el grado dentro del signo).
+    #[serde(default)]
+    pub annotation: Option<String>,
+    /// `true` si el cuerpo está retrógrado.
+    #[serde(default)]
+    pub retrograde: bool,
+    /// Casa en la que cae (1..=12), si aplica.
+    #[serde(default)]
+    pub house: Option<u8>,
+}
+
+// =====================================================================
+// Errores
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum EngineError {
+    #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
+    BridgeDisabled,
+    #[error("model: {0}")]
+    Model(#[from] tahuantinsuyu_model::ModelError),
+    #[cfg(feature = "eternal-bridge")]
+    #[error("eternal: {0}")]
+    Eternal(String),
+}
+
+// =====================================================================
+// API pública
+// =====================================================================
+
+/// Computa el RenderModel real contra `eternal-astrology`. Requiere
+/// el feature `eternal-bridge`.
+#[cfg(feature = "eternal-bridge")]
+pub fn compute(_chart: &Chart) -> Result<RenderModel, EngineError> {
+    // TODO: pipeline real — abrir `EphemerisSession`, traducir
+    // `StoredBirthData → BirthData`, `StoredChartConfig → ChartConfig`,
+    // correr `NatalChart::compute`, mapear a `Layer`s. Se cablea en la
+    // fase 3 del plan.
+    Err(EngineError::Eternal("pendiente fase 3".into()))
+}
+
+/// Stub que devuelve un disco vacío de placeholder — sirve a la UI
+/// mientras la pipeline real no esté cableada. Usar en demos y
+/// desarrollo.
+pub fn compute_mock(chart: &Chart) -> RenderModel {
+    use std::time::Instant;
+    let t0 = Instant::now();
+
+    let sign_dial = Layer {
+        module_id: "natal".into(),
+        kind: LayerKind::SignDial,
+        ring: 0.95,
+        z: 0,
+        geometry: Geometry::Ring {
+            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
+        },
+        glyphs: (0..12)
+            .map(|i| Glyph {
+                deg: (i as f32) * 30.0 + 15.0,
+                symbol: ZODIAC_GLYPHS[i].into(),
+                annotation: None,
+                retrograde: false,
+                house: None,
+            })
+            .collect(),
+    };
+
+    RenderModel {
+        chart_id: chart.id,
+        chart_kind: chart.kind,
+        layers: vec![sign_dial],
+        title: chart.label.clone(),
+        compute_ms: t0.elapsed().as_millis() as u64,
+    }
+}
+
+const ZODIAC_GLYPHS: [&str; 12] = [
+    "aries",
+    "taurus",
+    "gemini",
+    "cancer",
+    "leo",
+    "virgo",
+    "libra",
+    "scorpio",
+    "sagittarius",
+    "capricorn",
+    "aquarius",
+    "pisces",
+];
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tahuantinsuyu_model::{
+        Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
+    };
+
+    fn sample_chart() -> Chart {
+        Chart {
+            id: ChartId::new(),
+            contact_id: ContactId::new(),
+            kind: ChartKind::Natal,
+            label: "test".into(),
+            birth_data: StoredBirthData {
+                year: 1987,
+                month: 3,
+                day: 14,
+                hour: 5,
+                minute: 22,
+                second: 0.0,
+                tz_offset_minutes: -240,
+                latitude_deg: 10.4806,
+                longitude_deg: -66.9036,
+                altitude_m: 900.0,
+                time_certainty: Default::default(),
+                subject_name: None,
+                birthplace_label: None,
+            },
+            config: StoredChartConfig::default(),
+            related_chart_id: None,
+            created_at_ms: 0,
+        }
+    }
+
+    #[test]
+    fn mock_emits_sign_dial() {
+        let model = compute_mock(&sample_chart());
+        assert_eq!(model.layers.len(), 1);
+        assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
+        assert_eq!(model.layers[0].glyphs.len(), 12);
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/Cargo.toml
new file mode 100644
index 0000000..ada92be
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "tahuantinsuyu-model"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — tipos agnósticos del modelo astrológico (Group, Contact, Chart, StoredBirthData, StoredChartConfig)."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
new file mode 100644
index 0000000..52a4604
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
@@ -0,0 +1,363 @@
+//! `tahuantinsuyu-model` — tipos agnósticos del estudio astrológico.
+//!
+//! Esta es la capa de **datos puros**: no conoce GPUI, ni rusqlite, ni
+//! `eternal-astrology`. Solo tipos `serde`-able que viajan entre la
+//! store, la engine, los widgets, y eventualmente la Card de Brahman.
+//!
+//! ## Jerarquía
+//!
+//! ```text
+//!   Group  (puede anidar otros Groups vía parent_id)
+//!     ├── Group  (sub-agrupación)
+//!     └── Contact  (persona / evento / lugar)
+//!           └── Chart  (carta astrológica)
+//! ```
+//!
+//! Las `Chart` son las hojas — cada una guarda su `StoredBirthData` y su
+//! `StoredChartConfig`. La engine las traduce a tipos de `eternal-astrology`
+//! cuando hay que computar.
+//!
+//! ## Por qué tipos "Stored" propios y no reusar `eternal-astrology`
+//!
+//! Forward-compat: si mañana cambia el shape de `BirthData` upstream, o
+//! queremos persistir en otro backend astronómico, el modelo + la base
+//! sobreviven. La engine es el único puente que conoce ambas formas.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use ulid::Ulid;
+
+pub use ::ulid;
+
+// =====================================================================
+// Identidades
+// =====================================================================
+
+macro_rules! ulid_newtype {
+    ($name:ident, $doc:expr) => {
+        #[doc = $doc]
+        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+        pub struct $name(pub Ulid);
+
+        impl $name {
+            pub fn new() -> Self {
+                Self(Ulid::new())
+            }
+        }
+
+        impl Default for $name {
+            fn default() -> Self {
+                Self::new()
+            }
+        }
+
+        impl std::fmt::Display for $name {
+            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+                self.0.fmt(f)
+            }
+        }
+
+        impl std::str::FromStr for $name {
+            type Err = ulid::DecodeError;
+            fn from_str(s: &str) -> Result<Self, Self::Err> {
+                Ulid::from_string(s).map(Self)
+            }
+        }
+    };
+}
+
+ulid_newtype!(GroupId, "Identificador estable de un Group.");
+ulid_newtype!(ContactId, "Identificador estable de un Contact.");
+ulid_newtype!(ChartId, "Identificador estable de un Chart.");
+
+// =====================================================================
+// Group / Contact
+// =====================================================================
+
+/// Agrupación jerárquica de contactos. Puede anidar otros groups vía
+/// `parent_id` (un Group raíz tiene `parent_id = None`).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Group {
+    pub id: GroupId,
+    pub parent_id: Option<GroupId>,
+    pub name: String,
+    #[serde(default)]
+    pub description: Option<String>,
+    /// Epoch millis. Decisión: `i64` para tolerar valores pre-1970 en
+    /// imports históricos sin overflow.
+    pub created_at_ms: i64,
+    /// Orden manual dentro del padre. Más bajo = primero. Empate → por nombre.
+    #[serde(default)]
+    pub sort_order: i32,
+}
+
+/// Persona o evento del que se calcula una o más cartas. Puede vivir
+/// directamente en la raíz (`group_id = None`).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Contact {
+    pub id: ContactId,
+    pub group_id: Option<GroupId>,
+    pub name: String,
+    #[serde(default)]
+    pub notes: Option<String>,
+    pub created_at_ms: i64,
+}
+
+// =====================================================================
+// Datos de nacimiento (espejo agnóstico de eternal_astrology::BirthData)
+// =====================================================================
+
+/// Datos crudos de nacimiento. La engine los traduce a
+/// `eternal_astrology::BirthData` cuando hay que computar.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StoredBirthData {
+    /// Calendario civil local.
+    pub year: i32,
+    pub month: u32,
+    pub day: u32,
+    pub hour: u32,
+    pub minute: u32,
+    /// Segundos fraccionarios (0.0..60.0).
+    pub second: f64,
+    /// Offset desde UTC, en minutos. Ej: -240 = UTC-04:00.
+    pub tz_offset_minutes: i32,
+
+    /// Coordenadas geográficas en grados decimales.
+    pub latitude_deg: f64,
+    pub longitude_deg: f64,
+    /// Altura en metros sobre el geoide WGS-84.
+    #[serde(default)]
+    pub altitude_m: f64,
+
+    #[serde(default)]
+    pub time_certainty: TimeCertainty,
+    #[serde(default)]
+    pub subject_name: Option<String>,
+    #[serde(default)]
+    pub birthplace_label: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TimeCertainty {
+    #[default]
+    Exact,
+    RoundedHour,
+    RoundedDay,
+    Estimated,
+}
+
+// =====================================================================
+// Configuración de carta (espejo agnóstico de eternal_astrology::ChartConfig)
+// =====================================================================
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Zodiac {
+    #[default]
+    Tropical,
+    Sidereal,
+    Draconic,
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum HouseSystem {
+    #[default]
+    Placidus,
+    Koch,
+    Regiomontanus,
+    Campanus,
+    Porphyry,
+    Equal,
+    WholeSign,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StoredChartConfig {
+    #[serde(default)]
+    pub zodiac: Zodiac,
+    #[serde(default)]
+    pub house_system: HouseSystem,
+    /// Nombre del ayanamsha cuando `zodiac == Sidereal`. Ej: "lahiri",
+    /// "fagan_bradley". Ignorado para Tropical/Draconic.
+    #[serde(default)]
+    pub ayanamsha: Option<String>,
+    /// Cuerpos a incluir. Strings opacos para que el modelo no se ate
+    /// al enum `Body` de eternal. Ej: ["sun","moon","mercury",…].
+    #[serde(default = "default_bodies")]
+    pub bodies: Vec<String>,
+    #[serde(default = "default_true")]
+    pub include_south_node: bool,
+    #[serde(default)]
+    pub include_lilith: bool,
+    #[serde(default)]
+    pub include_main_belt_asteroids: bool,
+    #[serde(default)]
+    pub include_fixed_stars: bool,
+    /// Tabla de orbes a usar (nombre simbólico). `None` → orbes defaults
+    /// de la engine.
+    #[serde(default)]
+    pub orb_table: Option<String>,
+}
+
+impl Default for StoredChartConfig {
+    fn default() -> Self {
+        Self {
+            zodiac: Zodiac::default(),
+            house_system: HouseSystem::default(),
+            ayanamsha: None,
+            bodies: default_bodies(),
+            include_south_node: true,
+            include_lilith: false,
+            include_main_belt_asteroids: false,
+            include_fixed_stars: false,
+            orb_table: None,
+        }
+    }
+}
+
+fn default_bodies() -> Vec<String> {
+    vec![
+        "sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune",
+        "pluto", "mean_node",
+    ]
+    .into_iter()
+    .map(String::from)
+    .collect()
+}
+
+fn default_true() -> bool {
+    true
+}
+
+// =====================================================================
+// Chart
+// =====================================================================
+
+/// Tipo de carta astrológica. Determina qué rutina de la engine corre
+/// y qué `Layer`s aporta al canvas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ChartKind {
+    Natal,
+    Transit,
+    SecondaryProgression,
+    TertiaryProgression,
+    MinorProgression,
+    SolarArc,
+    SolarReturn,
+    LunarReturn,
+    Synastry,
+    Composite,
+    Davison,
+    Profection,
+    PrimaryDirection,
+    /// Carta "mundial" para un instante + lugar sin sujeto natal.
+    Mundane,
+}
+
+impl ChartKind {
+    /// `true` si la carta necesita una segunda carta natal como referencia
+    /// (synastry/composite/davison). Útil para validar al persistir.
+    pub fn requires_related_chart(self) -> bool {
+        matches!(
+            self,
+            ChartKind::Synastry | ChartKind::Composite | ChartKind::Davison
+        )
+    }
+}
+
+/// Una carta concreta dentro de un contacto. Las cartas de tipo
+/// derivado (transit, progression, synastry, …) referencian la carta
+/// natal de la que parten vía `related_chart_id`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Chart {
+    pub id: ChartId,
+    pub contact_id: ContactId,
+    pub kind: ChartKind,
+    pub label: String,
+    pub birth_data: StoredBirthData,
+    pub config: StoredChartConfig,
+    /// Para cartas derivadas: la carta de referencia. Para transit/
+    /// progression apunta a la natal del mismo contacto. Para synastry
+    /// apunta a la carta del otro sujeto.
+    #[serde(default)]
+    pub related_chart_id: Option<ChartId>,
+    pub created_at_ms: i64,
+}
+
+// =====================================================================
+// Estado de módulos por carta (qué capas están activas + su config)
+// =====================================================================
+
+/// Cada `ChartKind` puede activar uno o más `module_id` (ej. una carta
+/// natal puede tener `natal`, `dignities`, `fixed_stars`, `uranian`).
+/// El estado por-carta se persiste en la store; el canvas lo consulta
+/// para decidir qué capas pintar y qué controles mostrar en el panel.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModuleState {
+    pub chart_id: ChartId,
+    pub module_id: String,
+    pub enabled: bool,
+    /// JSON libre — cada módulo define su schema.
+    #[serde(default)]
+    pub config: serde_json::Value,
+}
+
+// =====================================================================
+// Selección activa (qué muestra el canvas)
+// =====================================================================
+
+/// Item activo del tree. El canvas reacciona a este tipo:
+/// - `Chart` → abre la carta puntual.
+/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum TreeSelection {
+    Group(GroupId),
+    Contact(ContactId),
+    Chart(ChartId),
+}
+
+// =====================================================================
+// Errores
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum ModelError {
+    #[error("chart {kind:?} requiere related_chart_id pero recibió None")]
+    MissingRelatedChart { kind: ChartKind },
+    #[error("group {0} no puede ser su propio ancestro")]
+    GroupCycle(GroupId),
+    #[error("invalid field {field}: {reason}")]
+    InvalidField {
+        field: &'static str,
+        reason: String,
+    },
+}
+
+impl Chart {
+    /// Validación liviana: ataja errores que la base no captura
+    /// (ej. synastry sin `related_chart_id`).
+    pub fn validate(&self) -> Result<(), ModelError> {
+        if self.kind.requires_related_chart() && self.related_chart_id.is_none() {
+            return Err(ModelError::MissingRelatedChart { kind: self.kind });
+        }
+        if !(-90.0..=90.0).contains(&self.birth_data.latitude_deg) {
+            return Err(ModelError::InvalidField {
+                field: "latitude_deg",
+                reason: format!("{} fuera de [-90, 90]", self.birth_data.latitude_deg),
+            });
+        }
+        if !(-180.0..=180.0).contains(&self.birth_data.longitude_deg) {
+            return Err(ModelError::InvalidField {
+                field: "longitude_deg",
+                reason: format!("{} fuera de [-180, 180]", self.birth_data.longitude_deg),
+            });
+        }
+        Ok(())
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/Cargo.toml
new file mode 100644
index 0000000..b6318b9
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "tahuantinsuyu-modules"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — registry de módulos astrológicos (Natal, Transit, Synastry, Uranian, …)."
+
+[dependencies]
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
+serde = { workspace = true }
+serde_json = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
new file mode 100644
index 0000000..3e4c6b6
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -0,0 +1,231 @@
+//! `tahuantinsuyu-modules` — registry de módulos astrológicos.
+//!
+//! Cada tipo de astrología (natal, tránsito, progresión, sinastría,
+//! Uraniano, …) es un **módulo** que declara:
+//!
+//! - Qué `Layer`s aporta al `RenderModel`.
+//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects).
+//! - Hotkeys opcionales.
+//! - Si su cómputo es lazy (sólo cuando se activa) o eager.
+//!
+//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta
+//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel
+//! pinta sus controles. Activar / desactivar persiste en
+//! `ModuleState` (en la store).
+//!
+//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder.
+//! En fases posteriores agregamos Transit, Progression, Synastry,
+//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots…
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use serde::{Deserialize, Serialize};
+
+use tahuantinsuyu_engine::Layer;
+use tahuantinsuyu_model::{Chart, ChartKind};
+
+// =====================================================================
+// Trait Module
+// =====================================================================
+
+/// Una capa de astrología enchufable.
+///
+/// `Send + Sync` para que el registry sea estático y se pueda consultar
+/// desde cualquier thread (el cómputo pesado va a un background executor).
+pub trait Module: Send + Sync {
+    /// Identidad estable del módulo. Coincide con `ModuleState.module_id`
+    /// en la store.
+    fn id(&self) -> &'static str;
+
+    /// Etiqueta amigable para el panel.
+    fn label(&self) -> &'static str;
+
+    /// Breve descripción para tooltip.
+    fn description(&self) -> &'static str;
+
+    /// Para qué tipos de carta tiene sentido este módulo. El panel filtra
+    /// con esto al armar la lista de toggles disponibles.
+    fn applies_to(&self, kind: ChartKind) -> bool;
+
+    /// Si el módulo está activado por default al crear una carta.
+    fn enabled_by_default(&self) -> bool {
+        false
+    }
+
+    /// Controles que aporta al panel inferior.
+    fn controls(&self) -> Vec<Control> {
+        Vec::new()
+    }
+
+    /// Computa las capas que este módulo aporta al RenderModel de
+    /// `chart`. La engine la llama solo si el módulo está activado
+    /// para esa carta.
+    ///
+    /// Devuelve `Vec` (no Option) — un módulo puede no aportar capas
+    /// si su config interna lo apaga (ej. "Uranian: mostrar simetría
+    /// = false"); en ese caso retorna `Vec::new()`.
+    fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec<Layer>;
+}
+
+// =====================================================================
+// Controls expuestos al panel
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Control {
+    Toggle {
+        key: String,
+        label: String,
+        default: bool,
+        hotkey: Option<String>,
+    },
+    Slider {
+        key: String,
+        label: String,
+        min: f64,
+        max: f64,
+        step: f64,
+        default: f64,
+    },
+    Select {
+        key: String,
+        label: String,
+        options: Vec<SelectOption>,
+        default: String,
+    },
+    /// Texto libre — útil para etiquetas, comentarios.
+    TextInput {
+        key: String,
+        label: String,
+        default: String,
+    },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SelectOption {
+    pub value: String,
+    pub label: String,
+}
+
+// =====================================================================
+// Registry
+// =====================================================================
+
+/// Lista estática de módulos disponibles. La app los registra al boot.
+pub struct Registry {
+    modules: Vec<Box<dyn Module>>,
+}
+
+impl Registry {
+    /// Registry con todos los módulos built-in. La app llama esto al
+    /// boot y luego usa `find()` / `for_kind()` para consultar.
+    pub fn with_builtins() -> Self {
+        let mut r = Self { modules: Vec::new() };
+        r.register(Box::new(natal::NatalModule));
+        r
+    }
+
+    pub fn register(&mut self, m: Box<dyn Module>) {
+        self.modules.push(m);
+    }
+
+    pub fn all(&self) -> &[Box<dyn Module>] {
+        &self.modules
+    }
+
+    pub fn find(&self, id: &str) -> Option<&dyn Module> {
+        self.modules
+            .iter()
+            .find(|m| m.id() == id)
+            .map(|m| m.as_ref())
+    }
+
+    pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> {
+        self.modules
+            .iter()
+            .filter(|m| m.applies_to(kind))
+            .map(|m| m.as_ref())
+            .collect()
+    }
+}
+
+// =====================================================================
+// NatalModule — placeholder fase 1
+// =====================================================================
+
+pub mod natal {
+    use super::*;
+    use tahuantinsuyu_engine::compute_mock;
+
+    pub struct NatalModule;
+
+    impl Module for NatalModule {
+        fn id(&self) -> &'static str {
+            "natal"
+        }
+        fn label(&self) -> &'static str {
+            "Carta natal"
+        }
+        fn description(&self) -> &'static str {
+            "Posiciones natales, casas y aspectos."
+        }
+        fn applies_to(&self, kind: ChartKind) -> bool {
+            matches!(kind, ChartKind::Natal)
+        }
+        fn enabled_by_default(&self) -> bool {
+            true
+        }
+
+        fn controls(&self) -> Vec<Control> {
+            vec![
+                Control::Toggle {
+                    key: "show_ecliptic".into(),
+                    label: "Eclíptica".into(),
+                    default: true,
+                    hotkey: Some("E".into()),
+                },
+                Control::Toggle {
+                    key: "show_ascensional".into(),
+                    label: "Ascensional".into(),
+                    default: false,
+                    hotkey: Some("A".into()),
+                },
+                Control::Toggle {
+                    key: "show_aspects".into(),
+                    label: "Aspectos".into(),
+                    default: true,
+                    hotkey: None,
+                },
+                Control::Slider {
+                    key: "harmonic".into(),
+                    label: "Armónico".into(),
+                    min: 1.0,
+                    max: 20.0,
+                    step: 1.0,
+                    default: 1.0,
+                },
+            ]
+        }
+
+        fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
+            // Fase 1: delega al mock de la engine para que la UI tenga
+            // algo que pintar. Fase 3 reemplaza con `engine::compute`
+            // contra `eternal-astrology`.
+            compute_mock(chart).layers
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn registry_finds_natal() {
+        let r = Registry::with_builtins();
+        assert!(r.find("natal").is_some());
+        assert_eq!(r.for_kind(ChartKind::Natal).len(), 1);
+        assert!(r.for_kind(ChartKind::Synastry).is_empty());
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/Cargo.toml
new file mode 100644
index 0000000..37bd9e6
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "tahuantinsuyu-panel"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — panel de control inferior. Toggles, sliders y selectores por módulo de astrología."
+
+[dependencies]
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" }
+tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" }
+yahweh-theme = { workspace = true }
+gpui = { workspace = true }
+serde_json = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
new file mode 100644
index 0000000..5cc5e65
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -0,0 +1,255 @@
+//! `tahuantinsuyu-panel` — control panel inferior de la app.
+//!
+//! Lee los módulos disponibles para la carta activa (vía
+//! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus
+//! [`Control`]s como toggles / sliders / selects. Cada cambio emite
+//! [`PanelEvent`] que la app traduce a mutaciones de `ModuleState` en
+//! la store y a `toggle_module` sobre el canvas.
+//!
+//! Fase 1: render placeholder con el listado de módulos disponibles y
+//! sus controles, sin handlers cableados todavía (la interacción real
+//! viene con la fase 4).
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use gpui::{
+    Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px,
+};
+
+use tahuantinsuyu_model::ChartKind;
+use tahuantinsuyu_modules::{Control, Registry};
+use yahweh_theme::Theme;
+
+// =====================================================================
+// Eventos
+// =====================================================================
+
+#[derive(Clone, Debug)]
+pub enum PanelEvent {
+    /// Toggle on/off de un módulo entero.
+    ModuleToggled { module_id: String, enabled: bool },
+    /// Cambio de un control puntual.
+    ControlChanged {
+        module_id: String,
+        key: String,
+        value: serde_json::Value,
+    },
+}
+
+// =====================================================================
+// Widget
+// =====================================================================
+
+pub struct ControlPanel {
+    /// Módulo activo a mostrar. `None` ⇒ no hay carta seleccionada,
+    /// pintamos un placeholder.
+    active_kind: Option<ChartKind>,
+}
+
+impl EventEmitter<PanelEvent> for ControlPanel {}
+
+impl ControlPanel {
+    pub fn new(cx: &mut Context<Self>) -> Self {
+        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
+        Self { active_kind: None }
+    }
+
+    pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
+        self.active_kind = kind;
+        cx.notify();
+    }
+}
+
+impl Render for ControlPanel {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+        let registry = Registry::with_builtins();
+
+        let header = div()
+            .h(px(28.0))
+            .px(px(12.0))
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(8.0))
+            .border_b_1()
+            .border_color(theme.border)
+            .child(
+                div()
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_muted)
+                    .child("Panel de control"),
+            )
+            .child(div().ml_auto().text_size(px(10.0)).text_color(theme.fg_disabled).child(
+                match self.active_kind {
+                    Some(k) => SharedString::from(format!("{:?}", k)),
+                    None => SharedString::from("sin carta activa"),
+                },
+            ));
+
+        let mut body = div()
+            .flex()
+            .flex_row()
+            .flex_wrap()
+            .gap(px(16.0))
+            .px(px(12.0))
+            .py(px(8.0));
+
+        if let Some(kind) = self.active_kind {
+            for m in registry.for_kind(kind) {
+                body = body.child(render_module(&theme, m));
+            }
+        } else {
+            body = body.child(
+                div()
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_disabled)
+                    .child("Seleccioná una carta para ver sus controles."),
+            );
+        }
+
+        div()
+            .size_full()
+            .bg(theme.bg_panel.clone())
+            .flex()
+            .flex_col()
+            .child(header)
+            .child(body)
+    }
+}
+
+fn render_module(theme: &Theme, m: &dyn tahuantinsuyu_modules::Module) -> gpui::Div {
+    let header = div()
+        .flex()
+        .flex_row()
+        .items_center()
+        .gap(px(6.0))
+        .child(
+            div()
+                .text_size(px(12.0))
+                .text_color(theme.fg_text)
+                .child(SharedString::from(m.label())),
+        )
+        .child(
+            div()
+                .text_size(px(10.0))
+                .text_color(theme.fg_muted)
+                .child(SharedString::from(m.description())),
+        );
+
+    let mut controls = div().flex().flex_col().gap(px(4.0));
+    for c in m.controls() {
+        controls = controls.child(render_control(theme, &c));
+    }
+
+    div()
+        .min_w(px(220.0))
+        .p(px(8.0))
+        .rounded(px(6.0))
+        .bg(theme.bg_panel_alt.clone())
+        .border_1()
+        .border_color(theme.border)
+        .flex()
+        .flex_col()
+        .gap(px(6.0))
+        .child(header)
+        .child(controls)
+}
+
+fn render_control(theme: &Theme, c: &Control) -> gpui::Div {
+    match c {
+        Control::Toggle { label, default, hotkey, .. } => {
+            let dot_color = if *default {
+                theme.accent
+            } else {
+                theme.fg_disabled
+            };
+            div()
+                .flex()
+                .flex_row()
+                .items_center()
+                .gap(px(8.0))
+                .child(
+                    div()
+                        .size(px(8.0))
+                        .rounded(px(4.0))
+                        .bg(dot_color),
+                )
+                .child(
+                    div()
+                        .text_size(px(11.0))
+                        .text_color(theme.fg_text)
+                        .child(SharedString::from(label.clone())),
+                )
+                .child(
+                    div()
+                        .ml_auto()
+                        .text_size(px(10.0))
+                        .text_color(theme.fg_muted)
+                        .child(SharedString::from(
+                            hotkey.clone().map(|h| format!("[{}]", h)).unwrap_or_default(),
+                        )),
+                )
+        }
+        Control::Slider {
+            label, min, max, default, ..
+        } => div()
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(8.0))
+            .child(
+                div()
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_text)
+                    .child(SharedString::from(label.clone())),
+            )
+            .child(
+                div()
+                    .ml_auto()
+                    .text_size(px(10.0))
+                    .text_color(theme.fg_muted)
+                    .child(SharedString::from(format!(
+                        "{} ({}…{})",
+                        default, min, max
+                    ))),
+            ),
+        Control::Select { label, default, .. } => div()
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(8.0))
+            .child(
+                div()
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_text)
+                    .child(SharedString::from(label.clone())),
+            )
+            .child(
+                div()
+                    .ml_auto()
+                    .text_size(px(10.0))
+                    .text_color(theme.fg_muted)
+                    .child(SharedString::from(default.clone())),
+            ),
+        Control::TextInput { label, default, .. } => div()
+            .flex()
+            .flex_row()
+            .items_center()
+            .gap(px(8.0))
+            .child(
+                div()
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_text)
+                    .child(SharedString::from(label.clone())),
+            )
+            .child(
+                div()
+                    .ml_auto()
+                    .text_size(px(10.0))
+                    .text_color(theme.fg_muted)
+                    .child(SharedString::from(default.clone())),
+            ),
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/Cargo.toml
new file mode 100644
index 0000000..de76030
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "tahuantinsuyu-store"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — persistencia SQLite de groups / contacts / charts / module_state."
+
+[dependencies]
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+rusqlite = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
new file mode 100644
index 0000000..d5fa5ba
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
@@ -0,0 +1,557 @@
+//! `tahuantinsuyu-store` — persistencia SQLite del estudio astrológico.
+//!
+//! Una sola conexión `rusqlite` envuelta en `Arc<Mutex>` para que la app
+//! GPUI la comparta entre threads sin pelearse con el ownership. La
+//! migración inicial corre la primera vez que se abre un archivo nuevo
+//! (idempotente vía `CREATE TABLE IF NOT EXISTS`).
+//!
+//! Patrón inspirado en `yahweh_provider_sqlite::SqliteDataProvider` pero
+//! con dominio propio (no extiende el `DataProvider` agnóstico — esa
+//! integración viene en `tahuantinsuyu-tree` que envuelve este store
+//! detrás del trait de yahweh).
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::path::Path;
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rusqlite::{Connection, OptionalExtension, params};
+use thiserror::Error;
+
+use tahuantinsuyu_model::{
+    Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, ModuleState, StoredBirthData,
+    StoredChartConfig,
+};
+
+const SCHEMA_VERSION: i32 = 1;
+
+#[derive(Debug, Error)]
+pub enum StoreError {
+    #[error("sqlite: {0}")]
+    Sqlite(#[from] rusqlite::Error),
+    #[error("json: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error("schema downgrade: db is at v{found}, code expects v{expected}")]
+    SchemaDowngrade { found: i32, expected: i32 },
+    #[error("ulid decode: {0}")]
+    UlidDecode(#[from] ulid::DecodeError),
+    #[error("model invariant: {0}")]
+    Model(#[from] tahuantinsuyu_model::ModelError),
+    #[error("not found: {0}")]
+    NotFound(String),
+}
+
+pub type StoreResult<T> = Result<T, StoreError>;
+
+/// Store backed by a single SQLite file.
+///
+/// Clone-able: comparte la misma conexión bajo el mutex. Útil para que
+/// distintos widgets (tree, panel, canvas) compartan una vista
+/// consistente sin pasar `&mut` por todos lados.
+#[derive(Clone)]
+pub struct Store {
+    conn: Arc<Mutex<Connection>>,
+}
+
+impl Store {
+    /// Abre (o crea) un archivo SQLite y corre las migraciones.
+    pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
+        let conn = Connection::open(path)?;
+        let store = Self {
+            conn: Arc::new(Mutex::new(conn)),
+        };
+        store.migrate()?;
+        Ok(store)
+    }
+
+    /// Variante in-memory para tests.
+    pub fn in_memory() -> StoreResult<Self> {
+        let conn = Connection::open_in_memory()?;
+        let store = Self {
+            conn: Arc::new(Mutex::new(conn)),
+        };
+        store.migrate()?;
+        Ok(store)
+    }
+
+    fn migrate(&self) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute_batch(MIGRATION_V1)?;
+
+        let found: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
+        if found > SCHEMA_VERSION {
+            return Err(StoreError::SchemaDowngrade {
+                found,
+                expected: SCHEMA_VERSION,
+            });
+        }
+        if found < SCHEMA_VERSION {
+            conn.execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?;
+        }
+        Ok(())
+    }
+
+    // -----------------------------------------------------------------
+    // Groups
+    // -----------------------------------------------------------------
+
+    pub fn create_group(
+        &self,
+        parent_id: Option<GroupId>,
+        name: &str,
+        description: Option<&str>,
+    ) -> StoreResult<Group> {
+        let group = Group {
+            id: GroupId::new(),
+            parent_id,
+            name: name.into(),
+            description: description.map(String::from),
+            created_at_ms: now_ms(),
+            sort_order: 0,
+        };
+        let conn = self.conn.lock().unwrap();
+        conn.execute(
+            "INSERT INTO groups (id, parent_id, name, description, created_at_ms, sort_order) \
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+            params![
+                group.id.to_string(),
+                group.parent_id.map(|g| g.to_string()),
+                group.name,
+                group.description,
+                group.created_at_ms,
+                group.sort_order,
+            ],
+        )?;
+        Ok(group)
+    }
+
+    pub fn list_groups(&self, parent_id: Option<GroupId>) -> StoreResult<Vec<Group>> {
+        let conn = self.conn.lock().unwrap();
+        let mut stmt = conn.prepare(
+            "SELECT id, parent_id, name, description, created_at_ms, sort_order \
+             FROM groups WHERE parent_id IS ?1 \
+             ORDER BY sort_order ASC, name COLLATE NOCASE ASC",
+        )?;
+        let parent_str = parent_id.map(|g| g.to_string());
+        let rows = stmt.query_map(params![parent_str], row_to_group)?;
+        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
+    }
+
+    pub fn delete_group(&self, id: GroupId) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute("DELETE FROM groups WHERE id = ?1", params![id.to_string()])?;
+        Ok(())
+    }
+
+    pub fn rename_group(&self, id: GroupId, name: &str) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute(
+            "UPDATE groups SET name = ?2 WHERE id = ?1",
+            params![id.to_string(), name],
+        )?;
+        Ok(())
+    }
+
+    // -----------------------------------------------------------------
+    // Contacts
+    // -----------------------------------------------------------------
+
+    pub fn create_contact(
+        &self,
+        group_id: Option<GroupId>,
+        name: &str,
+        notes: Option<&str>,
+    ) -> StoreResult<Contact> {
+        let c = Contact {
+            id: ContactId::new(),
+            group_id,
+            name: name.into(),
+            notes: notes.map(String::from),
+            created_at_ms: now_ms(),
+        };
+        let conn = self.conn.lock().unwrap();
+        conn.execute(
+            "INSERT INTO contacts (id, group_id, name, notes, created_at_ms) \
+             VALUES (?1, ?2, ?3, ?4, ?5)",
+            params![
+                c.id.to_string(),
+                c.group_id.map(|g| g.to_string()),
+                c.name,
+                c.notes,
+                c.created_at_ms,
+            ],
+        )?;
+        Ok(c)
+    }
+
+    pub fn list_contacts(&self, group_id: Option<GroupId>) -> StoreResult<Vec<Contact>> {
+        let conn = self.conn.lock().unwrap();
+        let mut stmt = conn.prepare(
+            "SELECT id, group_id, name, notes, created_at_ms \
+             FROM contacts WHERE group_id IS ?1 \
+             ORDER BY name COLLATE NOCASE ASC",
+        )?;
+        let g = group_id.map(|g| g.to_string());
+        let rows = stmt.query_map(params![g], row_to_contact)?;
+        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
+    }
+
+    pub fn delete_contact(&self, id: ContactId) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute("DELETE FROM contacts WHERE id = ?1", params![id.to_string()])?;
+        Ok(())
+    }
+
+    // -----------------------------------------------------------------
+    // Charts
+    // -----------------------------------------------------------------
+
+    pub fn create_chart(
+        &self,
+        contact_id: ContactId,
+        kind: ChartKind,
+        label: &str,
+        birth: &StoredBirthData,
+        config: &StoredChartConfig,
+        related_chart_id: Option<ChartId>,
+    ) -> StoreResult<Chart> {
+        let chart = Chart {
+            id: ChartId::new(),
+            contact_id,
+            kind,
+            label: label.into(),
+            birth_data: birth.clone(),
+            config: config.clone(),
+            related_chart_id,
+            created_at_ms: now_ms(),
+        };
+        chart.validate()?;
+        let conn = self.conn.lock().unwrap();
+        conn.execute(
+            "INSERT INTO charts \
+             (id, contact_id, kind, label, birth_data_json, config_json, \
+              related_chart_id, created_at_ms) \
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
+            params![
+                chart.id.to_string(),
+                chart.contact_id.to_string(),
+                serde_json::to_string(&chart.kind)?,
+                chart.label,
+                serde_json::to_string(&chart.birth_data)?,
+                serde_json::to_string(&chart.config)?,
+                chart.related_chart_id.map(|c| c.to_string()),
+                chart.created_at_ms,
+            ],
+        )?;
+        Ok(chart)
+    }
+
+    pub fn list_charts(&self, contact_id: ContactId) -> StoreResult<Vec<Chart>> {
+        let conn = self.conn.lock().unwrap();
+        let mut stmt = conn.prepare(
+            "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
+                    related_chart_id, created_at_ms \
+             FROM charts WHERE contact_id = ?1 \
+             ORDER BY created_at_ms ASC",
+        )?;
+        let rows = stmt.query_map(params![contact_id.to_string()], row_to_chart)?;
+        rows.collect::<Result<Vec<_>, _>>()
+            .map_err(StoreError::from)
+            .and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
+    }
+
+    pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
+        let conn = self.conn.lock().unwrap();
+        let mut stmt = conn.prepare(
+            "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
+                    related_chart_id, created_at_ms \
+             FROM charts WHERE id = ?1",
+        )?;
+        let chart = stmt
+            .query_row(params![id.to_string()], row_to_chart)
+            .optional()?;
+        match chart {
+            Some(Ok(c)) => Ok(c),
+            Some(Err(e)) => Err(e),
+            None => Err(StoreError::NotFound(format!("chart {}", id))),
+        }
+    }
+
+    pub fn delete_chart(&self, id: ChartId) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute("DELETE FROM charts WHERE id = ?1", params![id.to_string()])?;
+        Ok(())
+    }
+
+    // -----------------------------------------------------------------
+    // Module state
+    // -----------------------------------------------------------------
+
+    pub fn upsert_module_state(&self, state: &ModuleState) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute(
+            "INSERT INTO module_state (chart_id, module_id, enabled, config_json) \
+             VALUES (?1, ?2, ?3, ?4) \
+             ON CONFLICT(chart_id, module_id) DO UPDATE SET \
+                 enabled = excluded.enabled, \
+                 config_json = excluded.config_json",
+            params![
+                state.chart_id.to_string(),
+                state.module_id,
+                state.enabled as i32,
+                state.config.to_string(),
+            ],
+        )?;
+        Ok(())
+    }
+
+    pub fn list_module_states(&self, chart_id: ChartId) -> StoreResult<Vec<ModuleState>> {
+        let conn = self.conn.lock().unwrap();
+        let mut stmt = conn.prepare(
+            "SELECT chart_id, module_id, enabled, config_json \
+             FROM module_state WHERE chart_id = ?1",
+        )?;
+        let rows = stmt.query_map(params![chart_id.to_string()], |row| {
+            Ok((
+                row.get::<_, String>(0)?,
+                row.get::<_, String>(1)?,
+                row.get::<_, i32>(2)?,
+                row.get::<_, String>(3)?,
+            ))
+        })?;
+        let mut out = Vec::new();
+        for r in rows {
+            let (chart_str, module_id, enabled, config_str) = r?;
+            out.push(ModuleState {
+                chart_id: chart_str
+                    .parse()
+                    .map_err(|e: ulid::DecodeError| StoreError::UlidDecode(e))?,
+                module_id,
+                enabled: enabled != 0,
+                config: serde_json::from_str(&config_str).unwrap_or(serde_json::Value::Null),
+            });
+        }
+        Ok(out)
+    }
+
+    // -----------------------------------------------------------------
+    // Recursive descent: charts under a group/contact (para thumbnails)
+    // -----------------------------------------------------------------
+
+    /// Devuelve todas las cartas que descienden de un Group (incluyendo
+    /// los Contacts de sub-groups recursivamente).
+    pub fn charts_under_group(&self, root: GroupId) -> StoreResult<Vec<Chart>> {
+        let conn = self.conn.lock().unwrap();
+        // CTE recursivo para listar todos los descendientes del group.
+        let mut stmt = conn.prepare(
+            "WITH RECURSIVE descendants(id) AS ( \
+                 SELECT ?1 \
+                 UNION ALL \
+                 SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \
+             ) \
+             SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \
+                    c.related_chart_id, c.created_at_ms \
+             FROM charts c \
+             JOIN contacts ct ON ct.id = c.contact_id \
+             WHERE ct.group_id IN descendants \
+             ORDER BY c.created_at_ms ASC",
+        )?;
+        let rows = stmt.query_map(params![root.to_string()], row_to_chart)?;
+        let mut out = Vec::new();
+        for r in rows {
+            out.push(r??);
+        }
+        Ok(out)
+    }
+}
+
+// =====================================================================
+// SQL schema
+// =====================================================================
+
+const MIGRATION_V1: &str = r#"
+PRAGMA foreign_keys = ON;
+
+CREATE TABLE IF NOT EXISTS groups (
+    id              TEXT PRIMARY KEY,
+    parent_id       TEXT,
+    name            TEXT NOT NULL,
+    description     TEXT,
+    created_at_ms   INTEGER NOT NULL,
+    sort_order      INTEGER NOT NULL DEFAULT 0,
+    FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id);
+
+CREATE TABLE IF NOT EXISTS contacts (
+    id              TEXT PRIMARY KEY,
+    group_id        TEXT,
+    name            TEXT NOT NULL,
+    notes           TEXT,
+    created_at_ms   INTEGER NOT NULL,
+    FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL
+);
+CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id);
+
+CREATE TABLE IF NOT EXISTS charts (
+    id                  TEXT PRIMARY KEY,
+    contact_id          TEXT NOT NULL,
+    kind                TEXT NOT NULL,
+    label               TEXT NOT NULL,
+    birth_data_json     TEXT NOT NULL,
+    config_json         TEXT NOT NULL,
+    related_chart_id    TEXT,
+    created_at_ms       INTEGER NOT NULL,
+    FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
+    FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL
+);
+CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id);
+
+CREATE TABLE IF NOT EXISTS module_state (
+    chart_id    TEXT NOT NULL,
+    module_id   TEXT NOT NULL,
+    enabled     INTEGER NOT NULL DEFAULT 0,
+    config_json TEXT NOT NULL DEFAULT '{}',
+    PRIMARY KEY(chart_id, module_id),
+    FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+    key   TEXT PRIMARY KEY,
+    value TEXT NOT NULL
+);
+"#;
+
+// =====================================================================
+// Row decoders
+// =====================================================================
+
+fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result<Group> {
+    let id_str: String = row.get(0)?;
+    let parent_id_str: Option<String> = row.get(1)?;
+    Ok(Group {
+        id: id_str
+            .parse()
+            .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
+        parent_id: match parent_id_str {
+            Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
+                rusqlite::Error::ToSqlConversionFailure(Box::new(e))
+            })?),
+            None => None,
+        },
+        name: row.get(2)?,
+        description: row.get(3)?,
+        created_at_ms: row.get(4)?,
+        sort_order: row.get(5)?,
+    })
+}
+
+fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
+    let id_str: String = row.get(0)?;
+    let group_str: Option<String> = row.get(1)?;
+    Ok(Contact {
+        id: id_str
+            .parse()
+            .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
+        group_id: match group_str {
+            Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
+                rusqlite::Error::ToSqlConversionFailure(Box::new(e))
+            })?),
+            None => None,
+        },
+        name: row.get(2)?,
+        notes: row.get(3)?,
+        created_at_ms: row.get(4)?,
+    })
+}
+
+fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<Chart>> {
+    // Doble-Result porque hay deserialización JSON adentro que rusqlite no
+    // sabe modelar. El caller la aplana.
+    let id_str: String = row.get(0)?;
+    let contact_str: String = row.get(1)?;
+    let kind_json: String = row.get(2)?;
+    let label: String = row.get(3)?;
+    let bd_json: String = row.get(4)?;
+    let cfg_json: String = row.get(5)?;
+    let related_str: Option<String> = row.get(6)?;
+    let created_at_ms: i64 = row.get(7)?;
+
+    Ok((|| -> StoreResult<Chart> {
+        Ok(Chart {
+            id: id_str.parse()?,
+            contact_id: contact_str.parse()?,
+            kind: serde_json::from_str(&kind_json)?,
+            label,
+            birth_data: serde_json::from_str(&bd_json)?,
+            config: serde_json::from_str(&cfg_json)?,
+            related_chart_id: match related_str {
+                Some(s) => Some(s.parse()?),
+                None => None,
+            },
+            created_at_ms,
+        })
+    })())
+}
+
+fn now_ms() -> i64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_millis() as i64)
+        .unwrap_or(0)
+}
+
+// =====================================================================
+// Tests
+// =====================================================================
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tahuantinsuyu_model::{StoredBirthData, StoredChartConfig};
+
+    #[test]
+    fn open_and_migrate() {
+        let s = Store::in_memory().unwrap();
+        let groups = s.list_groups(None).unwrap();
+        assert!(groups.is_empty());
+    }
+
+    #[test]
+    fn full_hierarchy_roundtrip() {
+        let s = Store::in_memory().unwrap();
+        let g = s.create_group(None, "Familia", None).unwrap();
+        let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
+        let chart = s
+            .create_chart(
+                c.id,
+                ChartKind::Natal,
+                "Natal",
+                &StoredBirthData {
+                    year: 1987,
+                    month: 3,
+                    day: 14,
+                    hour: 5,
+                    minute: 22,
+                    second: 0.0,
+                    tz_offset_minutes: -240,
+                    latitude_deg: 10.4806,
+                    longitude_deg: -66.9036,
+                    altitude_m: 900.0,
+                    time_certainty: Default::default(),
+                    subject_name: Some("Sergio".into()),
+                    birthplace_label: Some("Caracas".into()),
+                },
+                &StoredChartConfig::default(),
+                None,
+            )
+            .unwrap();
+        assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal");
+
+        let under = s.charts_under_group(g.id).unwrap();
+        assert_eq!(under.len(), 1);
+        assert_eq!(under[0].id, chart.id);
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/Cargo.toml
new file mode 100644
index 0000000..e13305f
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "tahuantinsuyu-theme"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — paleta astrológica (elementos, planetas, signos) + presets dark/light místicos sobre yahweh-theme."
+
+[dependencies]
+gpui = { workspace = true }
+yahweh-theme = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs
new file mode 100644
index 0000000..b32dd10
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs
@@ -0,0 +1,295 @@
+//! `tahuantinsuyu-theme` — paleta simbólica + presets místicos.
+//!
+//! Una capa fina sobre [`yahweh_theme::Theme`]: el theme base aporta los
+//! slots de panel/foreground/accent; nosotros agregamos paletas
+//! semánticas para los elementos (fuego/tierra/aire/agua), los modos
+//! (cardinal/fijo/mutable), los planetas y los aspectos.
+//!
+//! El canvas pide colores por símbolo (`palette.element(Element::Fire)`),
+//! nunca hex directos. Así una sola tabla controla tanto el dark como el
+//! light, y cambiar la paleta no requiere tocar el render.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use gpui::{Hsla, hsla};
+
+// =====================================================================
+// Símbolos
+// =====================================================================
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Element {
+    Fire,
+    Earth,
+    Air,
+    Water,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Modality {
+    Cardinal,
+    Fixed,
+    Mutable,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Planet {
+    Sun,
+    Moon,
+    Mercury,
+    Venus,
+    Mars,
+    Jupiter,
+    Saturn,
+    Uranus,
+    Neptune,
+    Pluto,
+    Chiron,
+    NorthNode,
+    SouthNode,
+    Lilith,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum AspectKind {
+    Conjunction,
+    Sextile,
+    Square,
+    Trine,
+    Opposition,
+    Quincunx,
+    Semisextile,
+    Semisquare,
+    Sesquisquare,
+    Quintile,
+    Biquintile,
+}
+
+// =====================================================================
+// Paleta
+// =====================================================================
+
+/// Paleta completa de símbolos astrológicos resuelta a colores HSLA. Las
+/// dos variantes (`dark` / `light`) comparten estructura — el canvas
+/// elige según `yahweh_theme::Theme::is_dark`.
+#[derive(Debug, Clone)]
+pub struct AstroPalette {
+    pub is_dark: bool,
+
+    pub fire: Hsla,
+    pub earth: Hsla,
+    pub air: Hsla,
+    pub water: Hsla,
+
+    pub cardinal: Hsla,
+    pub fixed: Hsla,
+    pub mutable: Hsla,
+
+    pub sun: Hsla,
+    pub moon: Hsla,
+    pub mercury: Hsla,
+    pub venus: Hsla,
+    pub mars: Hsla,
+    pub jupiter: Hsla,
+    pub saturn: Hsla,
+    pub uranus: Hsla,
+    pub neptune: Hsla,
+    pub pluto: Hsla,
+    pub chiron: Hsla,
+    pub north_node: Hsla,
+    pub south_node: Hsla,
+    pub lilith: Hsla,
+
+    pub conjunction: Hsla,
+    pub sextile: Hsla,
+    pub square: Hsla,
+    pub trine: Hsla,
+    pub opposition: Hsla,
+    pub minor_aspect: Hsla,
+
+    /// Color del dial zodiacal (anillo exterior).
+    pub dial_ring: Hsla,
+    /// Cusps de casas.
+    pub house_cusp: Hsla,
+    /// Resaltado del ascendente / MC.
+    pub angle_highlight: Hsla,
+}
+
+impl AstroPalette {
+    /// Variante oscura — calibrada para sentirse cálida y mística sin
+    /// caer en saturación de carnaval. Las cusps quedan apenas más
+    /// claras que el fondo, los planetas tienen luminancia media-alta
+    /// para destacar sin glow falso.
+    pub fn dark() -> Self {
+        Self {
+            is_dark: true,
+
+            // Elementos — saturación alta + luminancia media. Familiares
+            // al símbolo pero suaves para coexistir.
+            fire: hsla(11.0 / 360.0, 0.78, 0.58, 1.0),
+            earth: hsla(95.0 / 360.0, 0.40, 0.48, 1.0),
+            air: hsla(48.0 / 360.0, 0.72, 0.66, 1.0),
+            water: hsla(210.0 / 360.0, 0.68, 0.58, 1.0),
+
+            cardinal: hsla(340.0 / 360.0, 0.55, 0.62, 1.0),
+            fixed: hsla(258.0 / 360.0, 0.48, 0.58, 1.0),
+            mutable: hsla(170.0 / 360.0, 0.42, 0.55, 1.0),
+
+            sun: hsla(45.0 / 360.0, 0.92, 0.62, 1.0),
+            moon: hsla(220.0 / 360.0, 0.25, 0.85, 1.0),
+            mercury: hsla(140.0 / 360.0, 0.40, 0.62, 1.0),
+            venus: hsla(330.0 / 360.0, 0.55, 0.70, 1.0),
+            mars: hsla(8.0 / 360.0, 0.78, 0.55, 1.0),
+            jupiter: hsla(38.0 / 360.0, 0.72, 0.62, 1.0),
+            saturn: hsla(28.0 / 360.0, 0.20, 0.50, 1.0),
+            uranus: hsla(195.0 / 360.0, 0.65, 0.62, 1.0),
+            neptune: hsla(225.0 / 360.0, 0.55, 0.66, 1.0),
+            pluto: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
+            chiron: hsla(75.0 / 360.0, 0.30, 0.55, 1.0),
+            north_node: hsla(35.0 / 360.0, 0.35, 0.70, 1.0),
+            south_node: hsla(35.0 / 360.0, 0.20, 0.45, 1.0),
+            lilith: hsla(310.0 / 360.0, 0.45, 0.40, 1.0),
+
+            conjunction: hsla(50.0 / 360.0, 0.65, 0.70, 0.85),
+            sextile: hsla(195.0 / 360.0, 0.60, 0.62, 0.75),
+            square: hsla(8.0 / 360.0, 0.75, 0.58, 0.85),
+            trine: hsla(140.0 / 360.0, 0.55, 0.55, 0.80),
+            opposition: hsla(280.0 / 360.0, 0.55, 0.62, 0.85),
+            minor_aspect: hsla(220.0 / 360.0, 0.20, 0.55, 0.55),
+
+            dial_ring: hsla(40.0 / 360.0, 0.18, 0.78, 0.85),
+            house_cusp: hsla(40.0 / 360.0, 0.12, 0.55, 0.60),
+            angle_highlight: hsla(50.0 / 360.0, 0.95, 0.65, 1.0),
+        }
+    }
+
+    /// Variante clara — desaturada y con luminancias bajas para que los
+    /// símbolos no compitan con el fondo blanco. Pensada para imprimir.
+    pub fn light() -> Self {
+        Self {
+            is_dark: false,
+
+            fire: hsla(11.0 / 360.0, 0.65, 0.42, 1.0),
+            earth: hsla(95.0 / 360.0, 0.45, 0.30, 1.0),
+            air: hsla(48.0 / 360.0, 0.55, 0.42, 1.0),
+            water: hsla(210.0 / 360.0, 0.60, 0.38, 1.0),
+
+            cardinal: hsla(340.0 / 360.0, 0.55, 0.42, 1.0),
+            fixed: hsla(258.0 / 360.0, 0.45, 0.40, 1.0),
+            mutable: hsla(170.0 / 360.0, 0.42, 0.35, 1.0),
+
+            sun: hsla(38.0 / 360.0, 0.85, 0.45, 1.0),
+            moon: hsla(220.0 / 360.0, 0.22, 0.45, 1.0),
+            mercury: hsla(140.0 / 360.0, 0.45, 0.36, 1.0),
+            venus: hsla(330.0 / 360.0, 0.55, 0.45, 1.0),
+            mars: hsla(8.0 / 360.0, 0.75, 0.40, 1.0),
+            jupiter: hsla(38.0 / 360.0, 0.72, 0.42, 1.0),
+            saturn: hsla(28.0 / 360.0, 0.25, 0.30, 1.0),
+            uranus: hsla(195.0 / 360.0, 0.65, 0.40, 1.0),
+            neptune: hsla(225.0 / 360.0, 0.55, 0.42, 1.0),
+            pluto: hsla(280.0 / 360.0, 0.45, 0.30, 1.0),
+            chiron: hsla(75.0 / 360.0, 0.32, 0.35, 1.0),
+            north_node: hsla(35.0 / 360.0, 0.45, 0.45, 1.0),
+            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),
+        }
+    }
+
+    pub fn for_theme(theme: &yahweh_theme::Theme) -> Self {
+        if theme.is_dark {
+            Self::dark()
+        } else {
+            Self::light()
+        }
+    }
+
+    pub fn element(&self, e: Element) -> Hsla {
+        match e {
+            Element::Fire => self.fire,
+            Element::Earth => self.earth,
+            Element::Air => self.air,
+            Element::Water => self.water,
+        }
+    }
+
+    pub fn modality(&self, m: Modality) -> Hsla {
+        match m {
+            Modality::Cardinal => self.cardinal,
+            Modality::Fixed => self.fixed,
+            Modality::Mutable => self.mutable,
+        }
+    }
+
+    pub fn planet(&self, p: Planet) -> Hsla {
+        match p {
+            Planet::Sun => self.sun,
+            Planet::Moon => self.moon,
+            Planet::Mercury => self.mercury,
+            Planet::Venus => self.venus,
+            Planet::Mars => self.mars,
+            Planet::Jupiter => self.jupiter,
+            Planet::Saturn => self.saturn,
+            Planet::Uranus => self.uranus,
+            Planet::Neptune => self.neptune,
+            Planet::Pluto => self.pluto,
+            Planet::Chiron => self.chiron,
+            Planet::NorthNode => self.north_node,
+            Planet::SouthNode => self.south_node,
+            Planet::Lilith => self.lilith,
+        }
+    }
+
+    pub fn aspect(&self, a: AspectKind) -> Hsla {
+        match a {
+            AspectKind::Conjunction => self.conjunction,
+            AspectKind::Sextile => self.sextile,
+            AspectKind::Square => self.square,
+            AspectKind::Trine => self.trine,
+            AspectKind::Opposition => self.opposition,
+            _ => self.minor_aspect,
+        }
+    }
+}
+
+/// Resuelve un símbolo zodiacal (string) a su elemento.
+/// Ej. `"aries" → Fire`, `"taurus" → Earth`, …
+pub fn element_for_sign(sign: &str) -> Option<Element> {
+    Some(match sign.to_ascii_lowercase().as_str() {
+        "aries" | "leo" | "sagittarius" => Element::Fire,
+        "taurus" | "virgo" | "capricorn" => Element::Earth,
+        "gemini" | "libra" | "aquarius" => Element::Air,
+        "cancer" | "scorpio" | "pisces" => Element::Water,
+        _ => return None,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn element_lookup() {
+        assert_eq!(element_for_sign("aries"), Some(Element::Fire));
+        assert_eq!(element_for_sign("CAPRICORN"), Some(Element::Earth));
+        assert_eq!(element_for_sign("zod"), None);
+    }
+
+    #[test]
+    fn palette_indexes() {
+        let p = AstroPalette::dark();
+        assert_eq!(p.planet(Planet::Sun), p.sun);
+        assert_eq!(p.element(Element::Water), p.water);
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml
new file mode 100644
index 0000000..99fe9ac
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "tahuantinsuyu-tree"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — explorador izquierdo (Groups/Contacts/Charts) sobre yahweh-widget-tree."
+
+[dependencies]
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+tahuantinsuyu-store = { path = "../tahuantinsuyu-store" }
+yahweh-theme = { workspace = true }
+yahweh-widget-tree = { workspace = true }
+gpui = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
new file mode 100644
index 0000000..bb915bb
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -0,0 +1,192 @@
+//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
+//!
+//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
+//! propia de Tahuantinsuyu. Los `RowId` codifican el tipo del item con
+//! prefijo:
+//!
+//! - `g:<ulid>` → Group
+//! - `c:<ulid>` → Contact
+//! - `h:<ulid>` → Chart
+//!
+//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
+//! del bus de yahweh para que el canvas/panel reaccionen.
+//!
+//! Esta fase 1 trae el wrapper + el armado de filas; el CRUD UX
+//! (drag-to-nest, rename inline, menú contextual) llega con la fase 2.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::HashSet;
+
+use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, prelude::*};
+
+use tahuantinsuyu_model::{ContactId, GroupId, TreeSelection};
+use tahuantinsuyu_store::Store;
+use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
+
+const PREFIX_GROUP: &str = "g:";
+const PREFIX_CONTACT: &str = "c:";
+const PREFIX_CHART: &str = "h:";
+
+// =====================================================================
+// Eventos públicos
+// =====================================================================
+
+#[derive(Clone, Debug)]
+pub enum TreeEvent {
+    /// El usuario activó (single click) un item.
+    Selected(TreeSelection),
+    /// El usuario abrió (doble click) un item — la app decide qué hacer
+    /// (en general, abrir la carta en el canvas).
+    Opened(TreeSelection),
+}
+
+// =====================================================================
+// Widget
+// =====================================================================
+
+pub struct TahuantinsuyuTree {
+    store: Store,
+    inner: Entity<TreeView>,
+    expanded: HashSet<String>,
+}
+
+impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
+
+impl TahuantinsuyuTree {
+    pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
+        let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
+        cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
+            this.on_inner(ev, cx);
+        })
+        .detach();
+
+        let mut me = Self {
+            store,
+            inner,
+            expanded: HashSet::new(),
+        };
+        me.refresh(cx);
+        me
+    }
+
+    /// Re-lee la jerarquía desde la store y empuja al TreeView. Llamar
+    /// después de crear/borrar items.
+    pub fn refresh(&mut self, cx: &mut Context<Self>) {
+        let mut rows = Vec::new();
+        self.append_groups(None, 0, &mut rows);
+        self.append_contacts(None, 0, &mut rows);
+        self.inner
+            .update(cx, |t, cx| t.set_rows(rows, cx));
+    }
+
+    fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
+        let groups = match self.store.list_groups(parent) {
+            Ok(v) => v,
+            Err(_) => return,
+        };
+        for g in groups {
+            let id_str = format!("{}{}", PREFIX_GROUP, g.id);
+            let expanded = self.expanded.contains(&id_str);
+            out.push(TreeRow {
+                id: RowId::new(id_str.clone()),
+                label: g.name.clone(),
+                depth,
+                kind: RowKind::Branch,
+                expanded,
+                icon: Some("📁".into()),
+            });
+            if expanded {
+                self.append_groups(Some(g.id), depth + 1, out);
+                self.append_contacts(Some(g.id), depth + 1, out);
+            }
+        }
+    }
+
+    fn append_contacts(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
+        let contacts = match self.store.list_contacts(parent) {
+            Ok(v) => v,
+            Err(_) => return,
+        };
+        for c in contacts {
+            let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
+            let expanded = self.expanded.contains(&id_str);
+            out.push(TreeRow {
+                id: RowId::new(id_str.clone()),
+                label: c.name.clone(),
+                depth,
+                kind: RowKind::Branch,
+                expanded,
+                icon: Some("🜨".into()),
+            });
+            if expanded {
+                self.append_charts(c.id, depth + 1, out);
+            }
+        }
+    }
+
+    fn append_charts(&self, contact: ContactId, depth: u32, out: &mut Vec<TreeRow>) {
+        let charts = match self.store.list_charts(contact) {
+            Ok(v) => v,
+            Err(_) => return,
+        };
+        for h in charts {
+            let id_str = format!("{}{}", PREFIX_CHART, h.id);
+            out.push(TreeRow {
+                id: RowId::new(id_str),
+                label: h.label.clone(),
+                depth,
+                kind: RowKind::Leaf,
+                expanded: false,
+                icon: Some("✦".into()),
+            });
+        }
+    }
+
+    fn on_inner(&mut self, ev: &InnerTreeEvent, cx: &mut Context<Self>) {
+        match ev {
+            InnerTreeEvent::ChevronToggled(id) => {
+                let s = id.as_str().to_string();
+                if !self.expanded.remove(&s) {
+                    self.expanded.insert(s);
+                }
+                self.refresh(cx);
+            }
+            InnerTreeEvent::RowClicked(id) => {
+                if let Some(sel) = parse_row(id) {
+                    cx.emit(TreeEvent::Selected(sel));
+                }
+            }
+            InnerTreeEvent::RowDoubleClicked(id) => {
+                if let Some(sel) = parse_row(id) {
+                    cx.emit(TreeEvent::Opened(sel));
+                }
+            }
+            InnerTreeEvent::ContextMenuRequested { .. } => {
+                // Fase 2: menú contextual para crear/renombrar/borrar.
+            }
+            InnerTreeEvent::ActiveChanged(_) => {}
+        }
+    }
+}
+
+fn parse_row(id: &RowId) -> Option<TreeSelection> {
+    let s = id.as_str();
+    if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
+        return rest.parse().ok().map(TreeSelection::Group);
+    }
+    if let Some(rest) = s.strip_prefix(PREFIX_CONTACT) {
+        return rest.parse().ok().map(TreeSelection::Contact);
+    }
+    if let Some(rest) = s.strip_prefix(PREFIX_CHART) {
+        return rest.parse().ok().map(TreeSelection::Chart);
+    }
+    None
+}
+
+impl Render for TahuantinsuyuTree {
+    fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        self.inner.clone()
+    }
+}