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 · 2026-05-16T01:06:03Z (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
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()
+ }
+}