gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Sutichaykuna

feat(cosmobiologia-render): compose_wheel rico con palette + dial 3D + spread + coord labels

El render agnóstico ya no es un esqueleto — porta al WASM la mayoría
de los detalles visuales que tenía solo el canvas gpui nativo:

- palette.rs: Palette dark/light replicando AstroPalette del theme
  nativo, pero en Rgba (no Hsla de gpui). Métodos planet/aspect/sign
  para resolver color por id simbólico, + house_ring con hue-shift.
- CompositionOpts extendido: palette, dial_3d, draw_ascensional_cross,
  show_coord_labels, show_minor_aspects. Defaults razonables.
- compose_wheel ahora dibuja: background panel, dial 3D bevel (4
  strokes concéntricos con alpha decreciente), subdivisiones cada 10°
  con sign boundaries reforzados, signos con color elemental, casas
  topocéntricas + geocéntricas en sus rings canónicos, cuerpos con
  spread anti-solapamiento + clusters + disco coloreado por planeta,
  coord labels "DD°MM'♈" en natal, aspectos con width inversa al
  orbe + filtrado opcional de minors, cruz ascensional dashed +
  pills ASC/MC/DESC/IC.
- cosmobiologia-web: nuevo render_model_to_svg_themed(dark: bool)
  para que el cliente JS elija palette según preferencia del UA.

Tests del módulo math siguen verdes (10/10). Smoke test del server:
/api/sky.svg ahora emite 22 circles, 77 lines, 52 texts con paleta
real (vs ~6 circles, 24 lines, 36 texts del esqueleto previo).

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

86fb6ae20bbb52403b0e2f1e598d53f8efafeabb · ruwaq sergio · (5 killa ñawpaq) · tayta 4619ba3a2 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 18

crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs

crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs

4 qillqakuna · +530 −85

diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
index 88ab27c..5218d74 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
@@ -94,9 +94,8 @@ fn default_anchor() -> TextAnchor {
     TextAnchor::Middle
 }
 
-/// Opciones para `compose_wheel` — el caller decide tamaño total del
-/// wheel y rotación visual. Los colores son simples por ahora;
-/// extender después con una palette completa.
+/// Opciones para `compose_wheel` — el caller decide tamaño total,
+/// rotación visual, palette (dark/light) y qué overlays acompañar.
 #[derive(Debug, Clone)]
 pub struct CompositionOpts {
     /// Tamaño total del wheel en px (lado del cuadrado contenedor).
@@ -106,6 +105,19 @@ pub struct CompositionOpts {
     /// Si `false`, la lista no incluye los glyphs de cuerpos (útil
     /// para previews compactos).
     pub include_bodies: bool,
+    /// Paleta — controla todos los colores del lienzo. Default `dark()`.
+    pub palette: crate::Palette,
+    /// Si `true`, dibuja la cruz ascensional (líneas ASC↔DESC e
+    /// IC↔MC a través del centro) + pills con etiquetas.
+    pub draw_ascensional_cross: bool,
+    /// Si `true`, dibuja coord labels ("DD°MM'♈") al lado de cada
+    /// cuerpo natal.
+    pub show_coord_labels: bool,
+    /// Mostrar líneas de aspectos menores (semisextile, quincunx, etc.).
+    pub show_minor_aspects: bool,
+    /// Activa relieve 3D del dial (varios strokes concéntricos con
+    /// alpha decreciente para emular bevel).
+    pub dial_3d: bool,
 }
 
 impl Default for CompositionOpts {
@@ -114,19 +126,31 @@ impl Default for CompositionOpts {
             size: 600.0,
             rot_offset_deg: 0.0,
             include_bodies: true,
+            palette: crate::Palette::dark(),
+            draw_ascensional_cross: true,
+            show_coord_labels: true,
+            show_minor_aspects: false,
+            dial_3d: true,
         }
     }
 }
 
 /// Compone una lista de `DrawCommand`s a partir de un `RenderModel`.
-/// Versión inicial: anillo de signos + cusps cada 30° + house numbers
-/// + cuerpos natales. Sin clusters/spread/aspectos (extiende en
-/// commits siguientes).
+/// Incluye:
+/// - Background panel + dial 3D (bevel via strokes concéntricos)
+/// - Anillos A/B/C/D/E con sus cusps
+/// - Casas topocéntricas (ring B→C) + geocéntricas (ring C→D) cuando
+///   están en el modelo
+/// - Glyphs zodiacales con color de su elemento
+/// - Cuerpos natales con disco coloreado + spread anti-solapamiento
+///   + coord labels en pills
+/// - Aspectos: width inversa al orbe, filtrado opcional de minors
+/// - Cruz ascensional ASC↔DESC + IC↔MC + pills con etiquetas
 pub fn compose_wheel(
     model: &crate::RenderModel,
     opts: &CompositionOpts,
 ) -> Vec<DrawCommand> {
-    use crate::math::{polar_to_screen, Radii};
+    use crate::math::{find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii};
     let mut out = Vec::new();
 
     let cx = opts.size / 2.0;
@@ -137,35 +161,66 @@ pub fn compose_wheel(
 
     let asc = model.ascendant_deg;
     let rot = opts.rot_offset_deg;
+    let pal = &opts.palette;
 
-    // Colores neutros (en fase próxima los reemplazo por palette real)
-    let ink_strong = Rgba::opaque(0.15, 0.15, 0.20);
-    let ink_mid = Rgba::opaque(0.45, 0.45, 0.50).with_alpha(0.85);
-    let ink_soft = Rgba::opaque(0.55, 0.55, 0.60).with_alpha(0.55);
-    let house_color = Rgba::opaque(0.30, 0.55, 0.50).with_alpha(0.85);
-    let angle_color = Rgba::opaque(0.85, 0.55, 0.20);
+    // === Background panel ===
+    out.push(DrawCommand::Circle {
+        cx,
+        cy,
+        r: radii.sign_outer + opts.size * 0.02,
+        stroke: None,
+        fill: Some(pal.bg_panel),
+        stroke_w: 0.0,
+    });
+
+    // === Dial 3D — relieve via strokes concéntricos cerca de aro A ===
+    if opts.dial_3d {
+        let bevel_steps: [(f32, f32, f32); 4] = [
+            (1.012, 0.18, 0.6), // halo externo
+            (1.006, 0.32, 0.9),
+            (0.994, 0.40, 1.0),
+            (0.988, 0.20, 0.7), // halo interno
+        ];
+        for (factor, alpha, w) in bevel_steps {
+            out.push(DrawCommand::Circle {
+                cx,
+                cy,
+                r: radii.sign_outer * factor,
+                stroke: Some(pal.dial_ring.with_alpha(alpha)),
+                fill: None,
+                stroke_w: w,
+            });
+        }
+    }
 
     // === Aro A (externo zodiaco) + B (interno) ===
     out.push(DrawCommand::Circle {
         cx,
         cy,
         r: radii.sign_outer,
-        stroke: Some(ink_strong),
+        stroke: Some(pal.dial_ring),
         fill: None,
-        stroke_w: 1.5,
+        stroke_w: 1.6,
     });
     out.push(DrawCommand::Circle {
         cx,
         cy,
         r: radii.sign_inner,
-        stroke: Some(ink_mid),
+        stroke: Some(pal.dial_ring.with_alpha(0.7)),
         fill: None,
         stroke_w: 1.0,
     });
 
-    // === Cusps zodiacales (12 radios entre sign_inner y sign_outer) ===
-    for i in 0..12 {
-        let lon = (i as f32) * 30.0;
+    // === Cusps zodiacales cada 30°, sub-divisiones cada 10° (más sutiles) ===
+    for i in 0..36 {
+        let lon = (i as f32) * 10.0;
+        let is_sign_boundary = i % 3 == 0;
+        let color = if is_sign_boundary {
+            pal.dial_ring
+        } else {
+            pal.dial_ring.with_alpha(0.30)
+        };
+        let width = if is_sign_boundary { 1.0 } else { 0.4 };
         let (xi, yi) = polar_to_screen(lon, asc, rot, radii.sign_inner);
         let (xo, yo) = polar_to_screen(lon, asc, rot, radii.sign_outer);
         out.push(DrawCommand::Line {
@@ -173,20 +228,58 @@ pub fn compose_wheel(
             y1: cy + yi,
             x2: cx + xo,
             y2: cy + yo,
-            color: ink_mid,
-            width: 1.0,
+            color,
+            width,
             dash: None,
         });
     }
 
-    // === Casas: aros + cusps + glyph número ===
+    // === Glyphs zodiacales con color elemental ===
+    let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
+    for layer in &model.layers {
+        if !matches!(layer.kind, crate::LayerKind::SignDial) {
+            continue;
+        }
+        for g in &layer.glyphs {
+            let (gx, gy) = polar_to_screen(g.deg, asc, rot, sign_ring_mid);
+            out.push(DrawCommand::Text {
+                x: cx + gx,
+                y: cy + gy,
+                content: sign_unicode(&g.symbol).into(),
+                color: pal.sign(&g.symbol),
+                size: opts.size * 0.032,
+                anchor: TextAnchor::Middle,
+            });
+        }
+    }
+
+    // === Casas topocéntricas (ring B→C) — si están en el modelo ===
+    let topo_outer = radii.topo_houses_outer;
+    let topo_inner = radii.topo_houses_inner;
+    let topo_ring_color = pal.house_ring();
+    let has_topo = model
+        .layers
+        .iter()
+        .any(|l| matches!(l.kind, crate::LayerKind::Houses) && l.module_id == "topocentric");
+    if has_topo {
+        out.push(DrawCommand::Circle {
+            cx,
+            cy,
+            r: topo_inner,
+            stroke: Some(topo_ring_color.with_alpha(0.55)),
+            fill: None,
+            stroke_w: 0.8,
+        });
+    }
+
+    // === Casas geocéntricas (ring C→D) ===
     let house_outer_r = radii.houses_outer;
     let house_inner_r = radii.houses_inner;
     out.push(DrawCommand::Circle {
         cx,
         cy,
         r: house_outer_r,
-        stroke: Some(house_color),
+        stroke: Some(pal.house_cusp),
         fill: None,
         stroke_w: 1.0,
     });
@@ -194,24 +287,31 @@ pub fn compose_wheel(
         cx,
         cy,
         r: house_inner_r,
-        stroke: Some(house_color),
+        stroke: Some(pal.house_cusp),
         fill: None,
         stroke_w: 1.0,
     });
+
+    // Draws cusps + numbers for both house systems (topo + geo) in their respective rings.
     for layer in &model.layers {
         if !matches!(layer.kind, crate::LayerKind::Houses) {
             continue;
         }
-        if layer.module_id != "natal" {
-            continue;
-        }
+        let (ring_outer, ring_inner, base_color, label_color) = match layer.module_id.as_str() {
+            "topocentric" => (topo_outer, topo_inner, topo_ring_color, topo_ring_color),
+            _ => (house_outer_r, house_inner_r, pal.house_cusp, pal.fg_muted),
+        };
         if let crate::Geometry::Ring { cusps_deg } = &layer.geometry {
             for (i, c) in cusps_deg.iter().enumerate() {
                 let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
-                let color = if is_angle { angle_color } else { house_color };
-                let width = if is_angle { 2.0 } else { 0.8 };
-                let (xi, yi) = polar_to_screen(*c, asc, rot, house_inner_r);
-                let (xo, yo) = polar_to_screen(*c, asc, rot, house_outer_r);
+                let color = if is_angle {
+                    pal.angle_highlight
+                } else {
+                    base_color
+                };
+                let width = if is_angle { 1.8 } else { 0.8 };
+                let (xi, yi) = polar_to_screen(*c, asc, rot, ring_inner);
+                let (xo, yo) = polar_to_screen(*c, asc, rot, ring_outer);
                 out.push(DrawCommand::Line {
                     x1: cx + xi,
                     y1: cy + yi,
@@ -223,8 +323,8 @@ pub fn compose_wheel(
                 });
             }
         }
-        // House numbers
-        let label_r = (house_outer_r + house_inner_r) / 2.0;
+        // House numbers en el centro del ring
+        let label_r = (ring_outer + ring_inner) / 2.0;
         for g in &layer.glyphs {
             if let Some(h) = g.house {
                 let (gx, gy) = polar_to_screen(g.deg, asc, rot, label_r);
@@ -232,7 +332,7 @@ pub fn compose_wheel(
                     x: cx + gx,
                     y: cy + gy,
                     content: format!("{}", h),
-                    color: ink_mid,
+                    color: label_color,
                     size: opts.size * 0.018,
                     anchor: TextAnchor::Middle,
                 });
@@ -240,55 +340,89 @@ pub fn compose_wheel(
         }
     }
 
-    // === Glyphs zodiacales ===
-    let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
-    for layer in &model.layers {
-        if !matches!(layer.kind, crate::LayerKind::SignDial) {
-            continue;
-        }
-        for g in &layer.glyphs {
-            let (gx, gy) = polar_to_screen(g.deg, asc, rot, sign_ring_mid);
-            out.push(DrawCommand::Text {
-                x: cx + gx,
-                y: cy + gy,
-                content: sign_unicode(&g.symbol).into(),
-                color: ink_strong,
-                size: opts.size * 0.03,
-                anchor: TextAnchor::Middle,
-            });
-        }
-    }
-
-    // === Cuerpos natales (sin spread/cluster — minimal) ===
+    // === Cuerpos por módulo (natal, topocentric, transit, progresión…) ===
+    // Spread anti-solapamiento + clusters compartidos + coord labels.
+    // Cada body-layer se renderea en su ring canónico.
+    let mut natal_display_by_body: std::collections::HashMap<String, f32> =
+        std::collections::HashMap::new();
     if opts.include_bodies {
         for layer in &model.layers {
             if !matches!(layer.kind, crate::LayerKind::Bodies) {
                 continue;
             }
-            if layer.module_id != "natal" {
-                continue;
+            let ring = radii.body_ring(&layer.module_id);
+            let is_natal = layer.module_id == "natal";
+            // Spread: separación mínima ~10°, shift máximo ~12°.
+            let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
+            let (display_degs, residual) = spread_angles(&raw_degs, 10.0, 12.0);
+            // Clusters para encoger discos
+            let clusters = find_clusters(&raw_degs, 9.0);
+            let mut cluster_size: Vec<usize> = vec![1; layer.glyphs.len()];
+            for c in &clusters {
+                for &i in c {
+                    cluster_size[i] = c.len();
+                }
             }
-            let ring = radii.bodies;
-            for g in &layer.glyphs {
-                let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
-                // Disco halo
+            // Disco base y escala por cluster size
+            let base_disk = opts.size * 0.022;
+            let base_font = opts.size * 0.028;
+            for (i, g) in layer.glyphs.iter().enumerate() {
+                let disp_deg = display_degs[i];
+                if is_natal {
+                    natal_display_by_body.insert(g.symbol.clone(), disp_deg);
+                }
+                // Encoge un poco si el cluster es denso o quedó presión residual
+                let dense_factor = if cluster_size[i] >= 3 {
+                    0.78
+                } else if cluster_size[i] == 2 {
+                    0.88
+                } else {
+                    1.0
+                };
+                let stress_factor = (1.0 - residual * 0.5).max(0.6);
+                let disk = base_disk * dense_factor * stress_factor;
+                let font = (base_font * dense_factor * stress_factor).max(opts.size * 0.018);
+
+                let (gx, gy) = polar_to_screen(disp_deg, asc, rot, ring);
+
+                // Halo del disco — color del planeta, fill oscuro/claro según tema
+                let body_color = pal.planet(&g.symbol);
+                let halo_fill = if pal.is_dark {
+                    pal.bg_panel.with_alpha(0.92)
+                } else {
+                    Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92)
+                };
                 out.push(DrawCommand::Circle {
                     cx: cx + gx,
                     cy: cy + gy,
-                    r: opts.size * 0.022,
-                    stroke: Some(ink_strong),
-                    fill: Some(Rgba::opaque(0.97, 0.97, 0.97).with_alpha(0.92)),
-                    stroke_w: 1.0,
+                    r: disk,
+                    stroke: Some(body_color),
+                    fill: Some(halo_fill),
+                    stroke_w: 1.2,
                 });
-                // Glyph del cuerpo
+                // Glyph
                 out.push(DrawCommand::Text {
                     x: cx + gx,
                     y: cy + gy,
-                    content: planet_unicode(&g.symbol).into(),
-                    color: ink_strong,
-                    size: opts.size * 0.028,
+                    content: planet_unicode_with_retro(&g.symbol, g.retrograde),
+                    color: body_color,
+                    size: font,
                     anchor: TextAnchor::Middle,
                 });
+
+                // Coord label en pill — solo natal por ahora (sino se ensucia)
+                if opts.show_coord_labels && is_natal {
+                    let label_ring = ring - disk * 1.6;
+                    let (lx, ly) = polar_to_screen(disp_deg, asc, rot, label_ring);
+                    out.push(DrawCommand::Text {
+                        x: cx + lx,
+                        y: cy + ly,
+                        content: format_coord_compact(g.deg),
+                        color: pal.fg_muted,
+                        size: opts.size * 0.0155,
+                        anchor: TextAnchor::Middle,
+                    });
+                }
             }
         }
     }
@@ -298,32 +432,95 @@ pub fn compose_wheel(
         cx,
         cy,
         r: radii.aspects,
-        stroke: Some(ink_soft),
+        stroke: Some(pal.fg_muted.with_alpha(0.35)),
         fill: None,
-        stroke_w: 0.7,
+        stroke_w: 0.6,
     });
     for layer in &model.layers {
         if !matches!(layer.kind, crate::LayerKind::Aspects) {
             continue;
         }
+        let (ring_a, ring_b) = radii.aspect_endpoints(&layer.module_id);
         if let crate::Geometry::Lines(segs) = &layer.geometry {
             for seg in segs {
-                let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, radii.aspects);
-                let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, radii.aspects);
+                // Filtrar menores si opt off
+                let is_minor = !matches!(
+                    seg.kind.as_str(),
+                    "conjunction" | "sextile" | "square" | "trine" | "opposition"
+                );
+                if is_minor && !opts.show_minor_aspects {
+                    continue;
+                }
+                // Endpoints: si tenemos display_deg natal, usarlo para que la
+                // línea apunte al cuerpo "spread", no al "raw". Cae al raw
+                // si no hay match (overlays sin natal de un lado).
+                let from_deg = natal_display_by_body
+                    .get(&seg.from_body)
+                    .copied()
+                    .unwrap_or(seg.from_deg);
+                let to_deg = natal_display_by_body
+                    .get(&seg.to_body)
+                    .copied()
+                    .unwrap_or(seg.to_deg);
+                let (ax, ay) = polar_to_screen(from_deg, asc, rot, ring_a);
+                let (bx, by) = polar_to_screen(to_deg, asc, rot, ring_b);
                 let alpha = (seg.opacity).clamp(0.0, 1.0);
+                // Width inversa al orbe — orbe 0 → 1.6, orbe 10° → 0.5
+                let width = (1.6 - seg.orb_deg.abs() * 0.10).clamp(0.45, 1.8);
                 out.push(DrawCommand::Line {
                     x1: cx + ax,
                     y1: cy + ay,
                     x2: cx + bx,
                     y2: cy + by,
-                    color: aspect_color(&seg.kind).with_alpha(alpha),
-                    width: 0.9,
+                    color: pal.aspect(&seg.kind).with_alpha(alpha),
+                    width,
                     dash: None,
                 });
             }
         }
     }
 
+    // === Cruz ascensional + pills ASC/MC/DESC/IC ===
+    if opts.draw_ascensional_cross {
+        let cross_r = radii.aspects * 0.96;
+        let angles: [(f32, &str); 4] = [
+            (model.ascendant_deg, "Asc"),
+            (model.descendant_deg, "Desc"),
+            (model.midheaven_deg, "MC"),
+            (model.imum_coeli_deg, "IC"),
+        ];
+        // Asc↔Desc + IC↔MC — dos líneas finas a través del centro
+        for (a, b) in [
+            (model.ascendant_deg, model.descendant_deg),
+            (model.imum_coeli_deg, model.midheaven_deg),
+        ] {
+            let (ax, ay) = polar_to_screen(a, asc, rot, cross_r);
+            let (bx, by) = polar_to_screen(b, asc, rot, cross_r);
+            out.push(DrawCommand::Line {
+                x1: cx + ax,
+                y1: cy + ay,
+                x2: cx + bx,
+                y2: cy + by,
+                color: pal.angle_highlight.with_alpha(0.35),
+                width: 0.8,
+                dash: Some((4.0, 4.0)),
+            });
+        }
+        // Pills — label justo afuera del sign_outer
+        let pill_r = radii.sign_outer + opts.size * 0.025;
+        for (deg, label) in angles {
+            let (gx, gy) = polar_to_screen(deg, asc, rot, pill_r);
+            out.push(DrawCommand::Text {
+                x: cx + gx,
+                y: cy + gy,
+                content: label.into(),
+                color: pal.angle_highlight,
+                size: opts.size * 0.022,
+                anchor: TextAnchor::Middle,
+            });
+        }
+    }
+
     out
 }
 
@@ -423,13 +620,12 @@ fn planet_unicode(name: &str) -> &'static str {
     }
 }
 
-fn aspect_color(kind: &str) -> Rgba {
-    match kind {
-        "conjunction" => Rgba::opaque(0.85, 0.65, 0.20),
-        "sextile" => Rgba::opaque(0.20, 0.55, 0.80),
-        "square" => Rgba::opaque(0.90, 0.30, 0.30),
-        "trine" => Rgba::opaque(0.30, 0.70, 0.40),
-        "opposition" => Rgba::opaque(0.55, 0.30, 0.75),
-        _ => Rgba::opaque(0.55, 0.55, 0.60).with_alpha(0.55),
+/// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación
+/// directa en el text para no agregar más comandos por planeta.
+fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String {
+    if retrograde {
+        format!("{}℞", planet_unicode(name))
+    } else {
+        planet_unicode(name).to_string()
     }
 }
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
index 819abb5..360f169 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
@@ -32,6 +32,7 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
 
 pub mod draw;
 pub mod math;
+pub mod palette;
 
 pub use draw::{
     compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
@@ -39,6 +40,7 @@ pub use draw::{
 pub use math::{
     find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
 };
+pub use palette::Palette;
 
 // =====================================================================
 // RenderModel — lo que el client renderea
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs
new file mode 100644
index 0000000..7f2f6ef
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs
@@ -0,0 +1,221 @@
+//! Paleta astrológica agnóstica (`Rgba`, no `Hsla` de gpui). Replica
+//! los slots de `cosmobiologia-theme::AstroPalette` con `dark()` y
+//! `light()`, sin arrastrar dependencia de gpui. El canvas nativo
+//! traduce desde su `AstroPalette` Hsla; el cliente WASM usa esta
+//! palette directamente.
+
+use crate::draw::Rgba;
+
+/// Color en HSL `[0..1]^4`, helper local para construir la palette
+/// con la misma convención que el theme nativo (que era Hsla de gpui).
+fn hsla(h_deg: f32, s: f32, l: f32, a: f32) -> Rgba {
+    // Conversión HSL → RGB (algoritmo estándar). H en grados.
+    let h = h_deg / 360.0;
+    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
+    let h6 = (h * 6.0).rem_euclid(6.0);
+    let x = c * (1.0 - (h6 % 2.0 - 1.0).abs());
+    let (r1, g1, b1) = match h6 as i32 {
+        0 => (c, x, 0.0),
+        1 => (x, c, 0.0),
+        2 => (0.0, c, x),
+        3 => (0.0, x, c),
+        4 => (x, 0.0, c),
+        _ => (c, 0.0, x),
+    };
+    let m = l - c / 2.0;
+    Rgba {
+        r: (r1 + m).clamp(0.0, 1.0),
+        g: (g1 + m).clamp(0.0, 1.0),
+        b: (b1 + m).clamp(0.0, 1.0),
+        a,
+    }
+}
+
+/// Paleta astrológica completa. Mismos slots que el theme nativo —
+/// permite que el cliente WASM y el canvas gpui generen las mismas
+/// decisiones de color en su superficie.
+#[derive(Debug, Clone, Copy)]
+pub struct Palette {
+    pub is_dark: bool,
+    // Elementos
+    pub fire: Rgba,
+    pub earth: Rgba,
+    pub air: Rgba,
+    pub water: Rgba,
+    // Planetas
+    pub sun: Rgba,
+    pub moon: Rgba,
+    pub mercury: Rgba,
+    pub venus: Rgba,
+    pub mars: Rgba,
+    pub jupiter: Rgba,
+    pub saturn: Rgba,
+    pub uranus: Rgba,
+    pub neptune: Rgba,
+    pub pluto: Rgba,
+    pub chiron: Rgba,
+    pub north_node: Rgba,
+    pub south_node: Rgba,
+    pub lilith: Rgba,
+    // Aspectos
+    pub conjunction: Rgba,
+    pub sextile: Rgba,
+    pub square: Rgba,
+    pub trine: Rgba,
+    pub opposition: Rgba,
+    pub minor_aspect: Rgba,
+    // Estructura
+    pub dial_ring: Rgba,
+    pub house_cusp: Rgba,
+    pub angle_highlight: Rgba,
+    // Estructura del lienzo (background panel / texto)
+    pub bg_panel: Rgba,
+    pub fg_text: Rgba,
+    pub fg_muted: Rgba,
+}
+
+impl Palette {
+    /// Paleta dark — equivalente a `AstroPalette::dark()` del theme
+    /// nativo.
+    pub fn dark() -> Self {
+        Self {
+            is_dark: true,
+            fire: hsla(11.0, 0.78, 0.58, 1.0),
+            earth: hsla(95.0, 0.40, 0.48, 1.0),
+            air: hsla(48.0, 0.72, 0.66, 1.0),
+            water: hsla(210.0, 0.68, 0.58, 1.0),
+            sun: hsla(45.0, 0.92, 0.62, 1.0),
+            moon: hsla(220.0, 0.25, 0.85, 1.0),
+            mercury: hsla(140.0, 0.40, 0.62, 1.0),
+            venus: hsla(330.0, 0.55, 0.70, 1.0),
+            mars: hsla(8.0, 0.78, 0.55, 1.0),
+            jupiter: hsla(38.0, 0.72, 0.62, 1.0),
+            saturn: hsla(28.0, 0.20, 0.50, 1.0),
+            uranus: hsla(195.0, 0.65, 0.62, 1.0),
+            neptune: hsla(225.0, 0.55, 0.66, 1.0),
+            pluto: hsla(280.0, 0.40, 0.45, 1.0),
+            chiron: hsla(75.0, 0.30, 0.55, 1.0),
+            north_node: hsla(35.0, 0.35, 0.70, 1.0),
+            south_node: hsla(35.0, 0.20, 0.45, 1.0),
+            lilith: hsla(310.0, 0.45, 0.40, 1.0),
+            conjunction: hsla(50.0, 0.65, 0.70, 0.85),
+            sextile: hsla(195.0, 0.60, 0.62, 0.75),
+            square: hsla(8.0, 0.75, 0.58, 0.85),
+            trine: hsla(140.0, 0.55, 0.55, 0.80),
+            opposition: hsla(280.0, 0.55, 0.62, 0.85),
+            minor_aspect: hsla(220.0, 0.20, 0.55, 0.55),
+            dial_ring: hsla(40.0, 0.18, 0.78, 0.85),
+            house_cusp: hsla(40.0, 0.12, 0.55, 0.60),
+            angle_highlight: hsla(50.0, 0.95, 0.65, 1.0),
+            bg_panel: hsla(245.0, 0.28, 0.10, 1.0),
+            fg_text: hsla(210.0, 0.35, 0.88, 1.0),
+            fg_muted: hsla(215.0, 0.22, 0.58, 1.0),
+        }
+    }
+
+    /// Paleta light — análoga a `AstroPalette::light()`.
+    pub fn light() -> Self {
+        Self {
+            is_dark: false,
+            fire: hsla(11.0, 0.65, 0.42, 1.0),
+            earth: hsla(95.0, 0.45, 0.30, 1.0),
+            air: hsla(48.0, 0.55, 0.42, 1.0),
+            water: hsla(210.0, 0.60, 0.38, 1.0),
+            sun: hsla(38.0, 0.85, 0.45, 1.0),
+            moon: hsla(220.0, 0.22, 0.45, 1.0),
+            mercury: hsla(140.0, 0.45, 0.36, 1.0),
+            venus: hsla(330.0, 0.55, 0.45, 1.0),
+            mars: hsla(8.0, 0.75, 0.40, 1.0),
+            jupiter: hsla(38.0, 0.72, 0.42, 1.0),
+            saturn: hsla(28.0, 0.25, 0.30, 1.0),
+            uranus: hsla(195.0, 0.65, 0.40, 1.0),
+            neptune: hsla(225.0, 0.55, 0.42, 1.0),
+            pluto: hsla(280.0, 0.45, 0.30, 1.0),
+            chiron: hsla(75.0, 0.32, 0.35, 1.0),
+            north_node: hsla(35.0, 0.45, 0.45, 1.0),
+            south_node: hsla(35.0, 0.20, 0.30, 1.0),
+            lilith: hsla(310.0, 0.50, 0.30, 1.0),
+            conjunction: hsla(45.0, 0.70, 0.38, 0.95),
+            sextile: hsla(195.0, 0.65, 0.36, 0.90),
+            square: hsla(8.0, 0.80, 0.38, 0.95),
+            trine: hsla(140.0, 0.60, 0.32, 0.92),
+            opposition: hsla(280.0, 0.60, 0.40, 0.95),
+            minor_aspect: hsla(220.0, 0.30, 0.38, 0.75),
+            dial_ring: hsla(40.0, 0.20, 0.28, 0.95),
+            house_cusp: hsla(40.0, 0.15, 0.32, 0.80),
+            angle_highlight: hsla(38.0, 0.90, 0.38, 1.0),
+            bg_panel: hsla(40.0, 0.25, 0.97, 1.0),
+            fg_text: hsla(30.0, 0.15, 0.18, 1.0),
+            fg_muted: hsla(30.0, 0.12, 0.40, 1.0),
+        }
+    }
+
+    /// Color del planeta por su id simbólico (`"sun"`, `"moon"`, …).
+    pub fn planet(&self, sym: &str) -> Rgba {
+        match sym {
+            "sun" => self.sun,
+            "moon" => self.moon,
+            "mercury" => self.mercury,
+            "venus" => self.venus,
+            "mars" => self.mars,
+            "jupiter" => self.jupiter,
+            "saturn" => self.saturn,
+            "uranus" => self.uranus,
+            "neptune" => self.neptune,
+            "pluto" => self.pluto,
+            "chiron" => self.chiron,
+            "north_node" => self.north_node,
+            "south_node" => self.south_node,
+            "lilith" => self.lilith,
+            _ => self.fg_muted,
+        }
+    }
+
+    /// Color del aspecto por su kind.
+    pub fn aspect(&self, kind: &str) -> Rgba {
+        match kind {
+            "conjunction" => self.conjunction,
+            "sextile" => self.sextile,
+            "square" => self.square,
+            "trine" => self.trine,
+            "opposition" => self.opposition,
+            _ => self.minor_aspect,
+        }
+    }
+
+    /// Color del signo zodiacal por su elemento (fire/earth/air/water).
+    pub fn sign(&self, sym: &str) -> Rgba {
+        match sym {
+            "aries" | "leo" | "sagittarius" => self.fire,
+            "taurus" | "virgo" | "capricorn" => self.earth,
+            "gemini" | "libra" | "aquarius" => self.air,
+            "cancer" | "scorpio" | "pisces" => self.water,
+            _ => self.fg_muted,
+        }
+    }
+
+    /// Color del anillo de casas (sistema ascensional Polich-Page).
+    /// Hue-shift de 140° respecto a `house_cusp` para diferenciar
+    /// del dial zodiacal — replica el shift que hace el canvas
+    /// nativo via `house_ring_color`.
+    pub fn house_ring(&self) -> Rgba {
+        // Aproximación rápida en HSL: rotamos el hue por 140°
+        // manteniendo aspecto similar.
+        let base = self.house_cusp;
+        // Conversión RGB → HSL → shift → RGB. Para no agregar
+        // dependencias, lo aproximamos con un mix simple hacia el
+        // verde/teal de la palette base.
+        let target = if self.is_dark {
+            hsla(170.0, 0.30, 0.55, base.a)
+        } else {
+            hsla(170.0, 0.40, 0.32, base.a)
+        };
+        target
+    }
+}
+
+impl Default for Palette {
+    fn default() -> Self {
+        Self::dark()
+    }
+}
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
index 15acdf5..36ac18e 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
@@ -34,7 +34,7 @@
 #[cfg(target_arch = "wasm32")]
 mod wasm {
     use cosmobiologia_render::{
-        compose_wheel, draw_commands_to_svg, CompositionOpts, RenderModel,
+        compose_wheel, draw_commands_to_svg, CompositionOpts, Palette, RenderModel,
     };
     use wasm_bindgen::prelude::*;
 
@@ -44,13 +44,39 @@ mod wasm {
     /// `size` es el lado del cuadrado contenedor en px (default 600).
     /// `rot_offset_deg` permite rotar la vista (jog-dial / preview).
     #[wasm_bindgen]
-    pub fn render_model_to_svg(json: &str, size: f32, rot_offset_deg: f32) -> Result<String, JsValue> {
+    pub fn render_model_to_svg(
+        json: &str,
+        size: f32,
+        rot_offset_deg: f32,
+    ) -> Result<String, JsValue> {
+        render_with_opts(json, size, rot_offset_deg, true)
+    }
+
+    /// Variante con palette explícita (dark = `true` por default, light
+    /// = `false`). El JS pasa el modo según preferencia/tema del UA.
+    #[wasm_bindgen]
+    pub fn render_model_to_svg_themed(
+        json: &str,
+        size: f32,
+        rot_offset_deg: f32,
+        dark: bool,
+    ) -> Result<String, JsValue> {
+        render_with_opts(json, size, rot_offset_deg, dark)
+    }
+
+    fn render_with_opts(
+        json: &str,
+        size: f32,
+        rot_offset_deg: f32,
+        dark: bool,
+    ) -> Result<String, JsValue> {
         let model: RenderModel = serde_json::from_str(json)
             .map_err(|e| JsValue::from_str(&format!("parse RenderModel: {}", e)))?;
         let opts = CompositionOpts {
             size: if size > 0.0 { size } else { 600.0 },
             rot_offset_deg,
-            include_bodies: true,
+            palette: if dark { Palette::dark() } else { Palette::light() },
+            ..Default::default()
         };
         let cmds = compose_wheel(&model, &opts);
         Ok(draw_commands_to_svg(&cmds, opts.size))