tawasuyu

tawasuyu/brahman

Rust 321TOML 122JSON 14Markdown 114332 Definiciones

feat(cosmobiologia): crate WASM + fallback inteligente + DEPLOY.md (fase 3b)

Cierra el requerimiento del módulo web. El cliente puede correr en
modo WASM (render local, scrubbing instantáneo, sin round-trip) o
caer al SSR (server compone el SVG) si el bundle WASM no está
desplegado. Switch automático sin configuración.

cosmobiologia-web (crate nuevo, cdylib + rlib):
- `lib.rs` con un único export wasm-bindgen
  `render_model_to_svg(json, size, rot_offset_deg) -> String` que
  deserializa un `RenderModel`, llama `compose_wheel` +
  `draw_commands_to_svg` de cosmobiologia-render, y devuelve el
  SVG inline listo para `wheel.innerHTML = svg`.
- Cargo.toml con `wasm-bindgen` + `getrandom` con feature
  `wasm_js` solo bajo `target_arch = "wasm32"` (en nativo no se
  arrastran).
- `.cargo/config.toml` con `--cfg getrandom_backend="wasm_js"`
  para que la transitividad
  `uuid → cosmobiologia-model → cosmobiologia-render` compile a
  wasm32-unknown-unknown.
- `cargo check -p cosmobiologia-web` pasa en nativo (valida la
  signature). Build WASM real lo dispara el usuario con
  `wasm-pack build --target web --out-dir ../../../apps/
  cosmobiologia-server/static/wasm` — comando documentado en
  DEPLOY.md y en doc del crate.

cosmobiologia-server — soporte cliente WASM:
- Nuevo flag `--static-wasm <dir>` (default = static/wasm relativo
  al cwd). Si el directorio existe, los archivos WASM se sirven
  en `/static/wasm/*`. Si no existe, devuelve 404 y el cliente
  cae al SSR.
- ServeDir de `tower-http` para fileserver simple.

index.html:
- Nueva función `tryLoadWasm()` que hace `import dinámico` del
  módulo WASM al boot. Si carga OK, `wasm` global queda set; si
  falla (archivo no existe o error de WASM), se loguea info y
  sigue.
- `refreshSelected()` ahora hace fetch del RenderModel JSON
  (`/api/sky` o `/api/charts/:id/render`); si hay WASM, llama
  `wasm.render_model_to_svg(json)` localmente; si no hay WASM o
  el render WASM falla, hace fetch del SVG SSR como fallback.
- Info row muestra "WASM" o "SSR" según el modo activo —
  visualmente claro qué pipeline está corriendo.

cosmobiologia-server/DEPLOY.md (nuevo):
- Build del binario + build del WASM (con wasm-pack).
- systemd service template (sandboxing básico: ProtectSystem
  strict, ProtectHome, PrivateTmp, NoNewPrivileges).
- Caddyfile y nginx para reverse proxy con TLS.
- DNS: A records para cosmobiologia.gioser.net + api.*.
- CORS: warnings sobre permissive vs producción multi-usuario.
- Separación demo público (DB vacía en VPS) vs desktop personal
  (DB compartida en `~/.local/share/cosmobiologia/`).
- Backup con SQLite `.backup`.
- Smoke test post-deploy con curl.
- Tabla de referencia de TODOS los endpoints.

Tests: 10 verdes (cosmobiologia-render::math). El cliente WASM
no agrega tests propios — la lógica testeable vive en render.

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

4619ba3a2bd7323c62bd620a7a8c036b1a3b2966 · por sergio · (hace 5 meses) · padre eac8c5897 · ver el árbol en este commit

Delta semántico · 6

crates/apps/cosmobiologia-server/src/main.rs

  • cambió de firma struct Cli
  • cambió por dentro fn main

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

8 archivos · +467 −14

diff --git a/Cargo.lock b/Cargo.lock
index 8cda503..4993f3f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2355,6 +2355,17 @@ dependencies = [
  "yahweh-widget-tree",
 ]
 
+[[package]]
+name = "cosmobiologia-web"
+version = "0.1.0"
+dependencies = [
+ "cosmobiologia-render",
+ "getrandom 0.3.4",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.17"
diff --git a/Cargo.toml b/Cargo.toml
index 8f97c75..84d5f0a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -146,6 +146,7 @@ members = [
     "crates/modules/cosmobiologia/cosmobiologia-canvas",
     "crates/modules/cosmobiologia/cosmobiologia-tree",
     "crates/modules/cosmobiologia/cosmobiologia-panel",
+    "crates/modules/cosmobiologia/cosmobiologia-web",
 
     # ============================================================
     # apps/ — apps que consumen el protocolo (yahweh modules+shell)
diff --git a/crates/apps/cosmobiologia-server/DEPLOY.md b/crates/apps/cosmobiologia-server/DEPLOY.md
new file mode 100644
index 0000000..6f35a31
--- /dev/null
+++ b/crates/apps/cosmobiologia-server/DEPLOY.md
@@ -0,0 +1,298 @@
+# Cosmobiología — guía de deploy
+
+Server HTTP single-user, escrito en Rust + axum. Sirve cartas
+astrológicas computadas con `cosmobiologia-engine` (VSOP2013 en Rust
+puro) y la página web HTML/JS del cliente. Diseñado para correr
+**local** o detrás de un reverse proxy con TLS.
+
+---
+
+## 1. Build
+
+### Binario del server
+
+```bash
+cargo build --release -p cosmobiologia-server
+# ./target/release/cosmobiologia-server
+```
+
+### Cliente WASM (opcional pero recomendado)
+
+Sin esto, el cliente cae al **SSR**: cada interacción pide al server
+el SVG recompuesto (~12 KB por click). Con WASM, el cliente compone
+localmente — primera carga ~150 KB, después scrubbing instantáneo
+sin round-trip.
+
+```bash
+# Una sola vez:
+cargo install wasm-pack
+
+# Cada vez que cambie cosmobiologia-render o cosmobiologia-web:
+cd crates/modules/cosmobiologia/cosmobiologia-web
+wasm-pack build --release --target web \
+    --out-dir ../../../../apps/cosmobiologia-server/static/wasm
+```
+
+`wasm-pack` produce `cosmobiologia_web.js` +
+`cosmobiologia_web_bg.wasm` en
+`crates/apps/cosmobiologia-server/static/wasm/`. El server los sirve
+en `/static/wasm/*` y el `index.html` los importa con
+`import init, { render_model_to_svg } from
+'/static/wasm/cosmobiologia_web.js'`.
+
+Si el directorio NO existe (build incompleto), el server devuelve
+404 y el cliente cae al SSR automáticamente — sin error visible.
+
+---
+
+## 2. Levantar el server
+
+### Local (single-user, sin reverse proxy)
+
+```bash
+./target/release/cosmobiologia-server \
+    --port 8787 \
+    --bind 127.0.0.1 \
+    --db ~/.local/share/cosmobiologia/charts.db
+```
+
+Abrí `http://127.0.0.1:8787/`. La DB es la misma que usa la app
+desktop — cualquier carta creada en la app aparece en el browser
+y viceversa.
+
+### systemd (server público vía VPS)
+
+```ini
+# /etc/systemd/system/cosmobiologia.service
+[Unit]
+Description=Cosmobiología (server astrológico)
+After=network.target
+
+[Service]
+Type=simple
+User=cosmobio
+Group=cosmobio
+WorkingDirectory=/opt/cosmobiologia
+ExecStart=/opt/cosmobiologia/cosmobiologia-server \
+    --port 8787 \
+    --bind 127.0.0.1 \
+    --db /var/lib/cosmobiologia/charts.db \
+    --static-wasm /opt/cosmobiologia/static/wasm
+Environment=RUST_LOG=cosmobiologia_server=info,tower_http=warn
+Restart=on-failure
+RestartSec=3
+# Sandboxing básico
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/cosmobiologia
+NoNewPrivileges=true
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo useradd -r -s /usr/sbin/nologin cosmobio
+sudo mkdir -p /opt/cosmobiologia/static/wasm /var/lib/cosmobiologia
+sudo cp target/release/cosmobiologia-server /opt/cosmobiologia/
+sudo cp -r crates/apps/cosmobiologia-server/static/wasm/* \
+    /opt/cosmobiologia/static/wasm/
+sudo chown -R cosmobio:cosmobio /opt/cosmobiologia /var/lib/cosmobiologia
+sudo systemctl daemon-reload
+sudo systemctl enable --now cosmobiologia
+sudo systemctl status cosmobiologia
+```
+
+---
+
+## 3. Reverse proxy (HTTPS + DNS bonito)
+
+Con dos subdominios apuntando al host:
+
+| DNS | Función |
+|-----|---------|
+| `cosmobiologia.gioser.net` | página web (HTML + WASM) |
+| `api.cosmobiologia.gioser.net` | endpoints `/api/*` (JSON / SVG) |
+
+Hoy el server sirve los dos roles en el mismo puerto — el split por
+subdominio lo hace el proxy, **sin cambiar nada del Rust**.
+
+### Caddyfile (recomendado — TLS automático con Let's Encrypt)
+
+```Caddyfile
+cosmobiologia.gioser.net {
+    encode gzip zstd
+    # Página web + estáticos + WASM
+    @api path /api/*
+    handle @api {
+        # Si el cliente pega un /api/ directo al subdominio principal,
+        # lo dejamos pasar (más amigable que 404).
+        reverse_proxy 127.0.0.1:8787
+    }
+    handle {
+        reverse_proxy 127.0.0.1:8787
+    }
+}
+
+api.cosmobiologia.gioser.net {
+    encode gzip zstd
+    # Solo los endpoints /api/*; rechaza el resto.
+    @api path /api/*
+    handle @api {
+        reverse_proxy 127.0.0.1:8787
+    }
+    handle {
+        respond "Use cosmobiologia.gioser.net para la página" 404
+    }
+}
+```
+
+### nginx (alternativa)
+
+```nginx
+# /etc/nginx/sites-available/cosmobiologia
+server {
+    server_name cosmobiologia.gioser.net;
+    listen 443 ssl http2;
+    # ssl_certificate / ssl_certificate_key — vía certbot
+    location / {
+        proxy_pass http://127.0.0.1:8787;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+    gzip on;
+    gzip_types application/javascript application/wasm image/svg+xml application/json text/css text/html;
+}
+
+server {
+    server_name api.cosmobiologia.gioser.net;
+    listen 443 ssl http2;
+    location /api/ {
+        proxy_pass http://127.0.0.1:8787;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+    location / { return 404; }
+    gzip on;
+    gzip_types application/json image/svg+xml;
+}
+```
+
+### DNS
+
+A records (o AAAA si IPv6) hacia tu VPS:
+
+```
+cosmobiologia.gioser.net.       A  <ip-del-VPS>
+api.cosmobiologia.gioser.net.   A  <ip-del-VPS>
+```
+
+---
+
+## 4. CORS y separación cliente↔API
+
+Hoy el server tiene `CorsLayer::permissive()` — cualquier origen
+puede hacer fetch contra `/api/*`. Eso es OK para:
+
+- **Single-user local**: nadie más alcanza al server.
+- **Demo público single-tenant**: misma DB para todos los visitantes,
+  sin datos sensibles. Los visitantes pueden leer y crear cartas
+  públicamente (es la naturaleza del demo).
+
+**No use CorsLayer::permissive en producción multi-usuario**. Para
+eso hay que:
+1. Agregar auth (sesiones / JWT / API key).
+2. Reemplazar con `CorsLayer::new().allow_origin(["https://cosmobiologia.gioser.net".parse().unwrap()])`.
+3. Volverte el `AllowCredentials::yes()` si vas a usar cookies.
+
+---
+
+## 5. Separación demo público ↔ desktop personal
+
+El path por default de la DB (`~/.local/share/cosmobiologia/charts.db`)
+es **compartido entre el server y la app desktop**. Eso es lo que
+querés en tu máquina local — abrís el browser y ves las mismas
+cartas que tenés en la app gpui.
+
+**Pero NO querés que el server público en
+`cosmobiologia.gioser.net` exponga TUS cartas privadas**. Para el
+demo público:
+
+```bash
+# En tu VPS:
+mkdir -p /var/lib/cosmobiologia
+# Empezás con DB vacía (la app crea las tablas al primer arranque).
+cosmobiologia-server --db /var/lib/cosmobiologia/charts.db
+```
+
+Si querés precargar cartas demo (Einstein, una carta natal pública),
+podés copiarlas desde tu DB local con la app, exportarlas como JSON
+via `/api/charts/:id`, y postearlas al server público con POST
+`/api/charts`. O simplemente abrir el browser, ir a "Nuevo
+contacto" → "Nueva carta…" y cargarlas a mano.
+
+---
+
+## 6. Backup
+
+La DB SQLite es **un solo archivo**. Backup = `cp` (mientras el
+server está parado, o usá `sqlite3 charts.db ".backup
+charts.bak"` con el server corriendo).
+
+```bash
+# Snapshot diario sin parar el server
+sqlite3 /var/lib/cosmobiologia/charts.db ".backup /var/backups/cosmobiologia-$(date +%F).db"
+```
+
+---
+
+## 7. Smoke test post-deploy
+
+```bash
+# Desde tu máquina:
+curl https://cosmobiologia.gioser.net/api/health
+# → {"status":"ok","service":"cosmobiologia-server"}
+
+curl https://cosmobiologia.gioser.net/api/sky | jq .title
+# → "Cielo 2026-05-19 00:55 UTC"
+
+# Abrí la página:
+open https://cosmobiologia.gioser.net/
+# (deberías ver la rueda del cielo + sidebar con "Cielo ahora")
+```
+
+Si el cliente WASM cargó, en la barra inferior verás "WASM".
+Si cayó al SSR, verás "SSR". Ambos modos son funcionales.
+
+---
+
+## 8. Endpoints públicos (referencia)
+
+| Método | Path | Función |
+|--------|------|---------|
+| GET | `/api/health` | healthcheck |
+| GET | `/api/tree` | árbol completo (groups/contacts/charts) |
+| GET | `/api/sky` | RenderModel "Cielo ahora" |
+| GET | `/api/sky.svg` | SVG agnóstico del cielo (server-side) |
+| GET | `/api/charts/:id` | Chart JSON |
+| GET | `/api/charts/:id/render?...` | RenderModel con overlays |
+| GET | `/api/charts/:id/svg?...` | SVG vía engine (svg_export) |
+| GET | `/api/charts/:id/wheel.svg?...` | SVG vía render agnóstico |
+| POST | `/api/charts` | crear carta |
+| PATCH | `/api/charts/:id` | editar label/birth/config |
+| DELETE | `/api/charts/:id` | borrar |
+| POST | `/api/groups` | crear grupo |
+| PATCH | `/api/groups/:id` | renombrar |
+| DELETE | `/api/groups/:id` | borrar |
+| POST | `/api/contacts` | crear contacto |
+| PATCH | `/api/contacts/:id` | renombrar |
+| DELETE | `/api/contacts/:id` | borrar |
+
+Query params del render (`?...`):
+
+- `offset_min=<i64>` — time scrubbing (minutos desde el natal).
+- `transit=1` — activa overlay de tránsito al `now` del server.
+- `prog_age=<f64>` — progresión secundaria a edad N.
+- `sa_age=<f64>` — solar arc a edad N.
+- `pd_age=<f64>` — primary directions GR (Naibod).
diff --git a/crates/apps/cosmobiologia-server/src/main.rs b/crates/apps/cosmobiologia-server/src/main.rs
index 844b3cf..e59ea13 100644
--- a/crates/apps/cosmobiologia-server/src/main.rs
+++ b/crates/apps/cosmobiologia-server/src/main.rs
@@ -53,6 +53,7 @@ use cosmobiologia_model::{
 use cosmobiologia_store::Store;
 use serde::{Deserialize, Serialize};
 use tower_http::cors::CorsLayer;
+use tower_http::services::ServeDir;
 use tower_http::trace::TraceLayer;
 use tracing::info;
 
@@ -73,6 +74,12 @@ struct Cli {
     /// (`$XDG_DATA_HOME/cosmobiologia/charts.db`).
     #[arg(long)]
     db: Option<PathBuf>,
+    /// Directorio con los assets estáticos del cliente WASM
+    /// (output de `wasm-pack build --out-dir <este path>`). Si el
+    /// directorio no existe, el endpoint `/static/wasm/*` devuelve
+    /// 404 y el cliente cae al SSR.
+    #[arg(long, default_value = "crates/apps/cosmobiologia-server/static/wasm")]
+    static_wasm: PathBuf,
 }
 
 #[derive(Clone)]
@@ -102,7 +109,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let store = Arc::new(Store::open(&db_path)?);
 
     let state = AppState { store };
-    let app = router().with_state(state);
+    let app = router()
+        .nest_service("/static/wasm", ServeDir::new(&cli.static_wasm))
+        .with_state(state);
 
     let addr: SocketAddr = format!("{}:{}", cli.bind, cli.port).parse()?;
     info!("listening on http://{}", addr);
diff --git a/crates/apps/cosmobiologia-server/static/index.html b/crates/apps/cosmobiologia-server/static/index.html
index 5e355d5..4d7f481 100644
--- a/crates/apps/cosmobiologia-server/static/index.html
+++ b/crates/apps/cosmobiologia-server/static/index.html
@@ -171,30 +171,58 @@
       await refreshSelected();
     }
 
+    // Cliente WASM opcional. Si `/static/wasm/cosmobiologia_web.js`
+    // existe (= el usuario corrió `wasm-pack build` después de
+    // `cargo build`), el rendering se hace localmente sin pedirle
+    // el SVG al server por cada interacción. Si NO existe (deploy
+    // mínimo), caemos al SSR de `/api/*.svg`. Toggle automático,
+    // sin configuración.
+    let wasm = null;
+    async function tryLoadWasm() {
+      try {
+        const mod = await import('/static/wasm/cosmobiologia_web.js');
+        await mod.default(); // wasm-pack: inicializa
+        wasm = mod;
+        document.getElementById('info').textContent = 'WASM cargado — render local';
+      } catch (e) {
+        console.info('WASM no disponible, usando SSR:', e.message);
+      }
+    }
+
     async function refreshSelected() {
       const offset = document.getElementById('offset').value || 0;
       const transit = document.getElementById('transit').checked ? '1' : '0';
       const params = new URLSearchParams({ offset_min: offset, transit });
-      let svgUrl, infoUrl;
-      if (mode === 'sky') {
-        svgUrl = `/api/sky.svg`;
-        infoUrl = `/api/sky`;
+      const jsonUrl = mode === 'sky'
+        ? `/api/sky`
+        : `/api/charts/${selectedChartId}/render?${params}`;
+      const ssrUrl = mode === 'sky'
+        ? `/api/sky.svg`
+        : `/api/charts/${selectedChartId}/wheel.svg?${params}`;
+
+      const render = await fetch(jsonUrl).then(r => r.json()).catch(() => null);
+      let svg;
+      if (wasm && render) {
+        // Render local — WASM compose_wheel + draw_commands_to_svg.
+        try {
+          svg = wasm.render_model_to_svg(JSON.stringify(render), 600, 0);
+        } catch (e) {
+          console.warn('WASM render falló, fallback SSR:', e);
+          svg = await fetch(ssrUrl).then(r => r.text());
+        }
       } else {
-        svgUrl = `/api/charts/${selectedChartId}/wheel.svg?${params}`;
-        infoUrl = `/api/charts/${selectedChartId}/render?${params}`;
+        // Fallback: SSR — el server devuelve el SVG ya compuesto.
+        svg = await fetch(ssrUrl).then(r => r.text());
       }
-      const [svg, render] = await Promise.all([
-        fetch(svgUrl).then(r => r.text()),
-        fetch(infoUrl).then(r => r.json()).catch(() => null),
-      ]);
       document.getElementById('wheel-container').innerHTML = svg;
       const info = document.getElementById('info');
       if (render) {
+        const mode_label = wasm ? 'WASM' : 'SSR';
         info.innerHTML =
           `<b>${escapeHtml(render.title)}</b> · ` +
           `Asc ${render.ascendant_deg.toFixed(2)}° · ` +
           `MC ${render.midheaven_deg.toFixed(2)}° · ` +
-          `${render.compute_ms} ms`;
+          `${render.compute_ms} ms · ${mode_label}`;
       } else {
         info.textContent = '';
       }
@@ -238,8 +266,11 @@
       }[c]));
     }
 
-    loadTree();
-    loadSky();
+    (async () => {
+      await tryLoadWasm();
+      await loadTree();
+      await loadSky();
+    })();
   </script>
 </body>
 </html>
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml b/crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml
new file mode 100644
index 0000000..97520ab
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml
@@ -0,0 +1,7 @@
+# Forzamos el backend `wasm_js` de getrandom para el target
+# wasm32-unknown-unknown — sin esta flag, el getrandom transitivo
+# vía `uuid → cosmobiologia-model → cosmobiologia-render` falla a
+# compilar a WASM con "wasm32-unknown-unknown targets are not
+# supported by default".
+[target.wasm32-unknown-unknown]
+rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml b/crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml
new file mode 100644
index 0000000..cb30d70
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "cosmobiologia-web"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Cosmobiología — cliente WASM. Reusa cosmobiologia-render para componer la rueda en SVG localmente, sin round-trip al server por cada interacción."
+
+[dependencies]
+cosmobiologia-render = { path = "../cosmobiologia-render" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+# wasm-bindgen solo se compila para target wasm32; en nativo el
+# crate igual compila (rlib), pero esta API no se expone.
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = { workspace = true }
+# Backend de getrandom para WASM: pide al embedder (browser) que
+# provea la randomness vía Web Crypto. Se activa con el cfg
+# `getrandom_backend = "wasm_js"` desde .cargo/config.toml.
+getrandom = { version = "0.3", features = ["wasm_js"] }
+
+[lib]
+crate-type = ["cdylib", "rlib"]
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
new file mode 100644
index 0000000..15acdf5
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
@@ -0,0 +1,73 @@
+//! `cosmobiologia-web` — cdylib WASM que renderiza la rueda
+//! astrológica desde el browser, sin round-trip al server por cada
+//! interacción.
+//!
+//! ## Flujo
+//!
+//! 1. El cliente JS hace `await fetch('/api/sky')` o
+//!    `/api/charts/:id/render?...` y recibe un `RenderModel` JSON.
+//! 2. JS llama `render_model_to_svg(json)` (exportado desde WASM) que
+//!    deserializa + corre `cosmobiologia_render::compose_wheel` +
+//!    serializa SVG.
+//! 3. JS hace `wheelContainer.innerHTML = svg`.
+//!
+//! ## Build
+//!
+//! ```bash
+//! cargo install wasm-pack          # una vez
+//! cd crates/modules/cosmobiologia/cosmobiologia-web
+//! wasm-pack build --target web --out-dir ../../../../apps/cosmobiologia-server/static/wasm
+//! ```
+//!
+//! Esto produce un módulo ES6 (`cosmobiologia_web.js` +
+//! `cosmobiologia_web_bg.wasm`) que el `index.html` del server
+//! importa con `import init, { render_model_to_svg } from
+//! '/static/wasm/cosmobiologia_web.js';`.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+// La API pública SOLO se expone con `wasm-bindgen` en target
+// wasm32. En nativo (rlib) el crate compila para validar la
+// signature pero no exporta nada — los tests del render ya viven
+// en `cosmobiologia-render::math`.
+#[cfg(target_arch = "wasm32")]
+mod wasm {
+    use cosmobiologia_render::{
+        compose_wheel, draw_commands_to_svg, CompositionOpts, RenderModel,
+    };
+    use wasm_bindgen::prelude::*;
+
+    /// Renderea un `RenderModel` (JSON string) como SVG. El JSON sale
+    /// de `/api/sky` o `/api/charts/:id/render` del server.
+    ///
+    /// `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> {
+        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,
+        };
+        let cmds = compose_wheel(&model, &opts);
+        Ok(draw_commands_to_svg(&cmds, opts.size))
+    }
+
+    /// Hook de inicialización opcional — wasm_pack lo invoca al
+    /// cargar el módulo. Útil para instalar un panic hook hacia
+    /// `console.error`. Por ahora no-op.
+    #[wasm_bindgen(start)]
+    pub fn main_js() {}
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub fn _native_marker() {
+    // Sin target wasm32, el crate solo expone el render como
+    // transitivo. Esta función vive para que `cargo check -p
+    // cosmobiologia-web` valide la compilación nativa sin
+    // wasm-bindgen — útil en CI y en desarrollo desktop.
+    let _ = std::any::type_name::<cosmobiologia_render::RenderModel>();
+}