gioser

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Definitions

feat(gioser): sol detrás, título central, drawers MD + pluma agnóstico

Visual de la chacana retrabajado contra chakana.png de referencia:
- Sol detrás (gauss + corona, masked al interior de la chacana — sólo
  asoma por la superficie de la cruz, no se cuela afuera).
- Doble outline dorado (línea principal + paralela offset 0.020), color
  CHACANA_LINE pasa de cyan helado a dorado-ámbar del logo.
- Interior con niebla violeta-noche (u_dark_color) y rayos radiales
  sutiles desde el centro, modulados por sin(t * 0.3).
- Aro doble exterior: ring fino interior + ring grueso con 4 grupos de
  3 puntos cardinales (calculados angularmente, no rayos largos).
- WORLD_SCALE 1.45→1.05, MAX_TILT 35°→28° (más sólido, menos caricaturesco).

Título "GioSer" centrado dentro de la superficie de la chacana, sin
subtítulo. Se inclina junto con la chacana vía CSS perspective +
rotateX/rotateY desde u-tilt-x/y inyectadas cada frame por WASM.

Botones (4 tips):
- Reposicionados a `arm_extent * 1.32` (entre punta y aro grueso).
- Bigger: min-width 168px, glyph 54px, label Cinzel 0.95rem.
- Doble anillo en hover (::before con border + glow).
- Cuando un drawer se abre, fade-out de tips + canvas + brand.

Drawers MD (uno por elemento):
- `<aside class="drawer drawer-{element}">` con transform-origin desde
  CSS vars (--origin-x/y) seteadas por WASM al click — crece desde la
  posición exacta del botón hasta fullscreen en 700ms con cubic-bezier.
- Ambience por elemento: AIRE (radial drift), FUEGO (flicker keyframe),
  AGUA (tide vertical), TIERRA (warm earth gradient).
- Cerrado con botón X, Escape o data-close-drawer.
- Carga MD desde ./md/{element}.md via spawn_local + Reader::open_url.

Pluma (visor MD agnóstico, dos crates nuevos):
- `crates/modules/pluma/pluma-md` — wrapper sobre pulldown-cmark 0.12.
  API: to_html(), to_themed_html(md, theme) con sanitización del theme,
  events() para AST stream. GFM completo. No deps web. 5 tests.
- `crates/modules/pluma/pluma-reader-web` — toma HtmlElement, expone
  open_url async (fetch via wasm-bindgen-futures), render_md sync,
  show_loading/show_error. NO inyecta CSS — el host estiliza
  `.pluma-doc[data-pluma-theme="..."]` con sus colores.

CSS pluma-doc completo: h1/h2/h3, code/pre con border-left accent,
blockquote, tables, lists, hr gradient. Loader spinner + error state.

Placeholders en md/{aire,fuego,tierra,agua}.md con texto seed.

Workspace verde + 18 tests (6 geom + 4 palette + 3 physics + 5 pluma-md).

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

fce630c8d0a50843b6d447531efb106a627fd1c7 · by sergio · (5 months ago) · parent 3dbdfb357 · browse the tree at this commit

Semantic delta · 43

crates/apps/gioser-web/src/lib.rs

crates/modules/gioser/gioser-canvas-web/src/lib.rs

crates/modules/gioser/gioser-palette/src/lib.rs

crates/modules/gioser/gioser-shaders/src/lib.rs

crates/modules/pluma/pluma-md/src/lib.rs

crates/modules/pluma/pluma-reader-web/src/lib.rs

17 files · +1132 −243

diff --git a/Cargo.lock b/Cargo.lock
index 2b6d924..9383dcb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3950,7 +3950,9 @@ version = "0.1.0"
 dependencies = [
  "gioser-canvas-web",
  "js-sys",
+ "pluma-reader-web",
  "wasm-bindgen",
+ "wasm-bindgen-futures",
  "web-sys",
 ]
 
@@ -7995,6 +7997,24 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
 
+[[package]]
+name = "pluma-md"
+version = "0.1.0"
+dependencies = [
+ "pulldown-cmark",
+]
+
+[[package]]
+name = "pluma-reader-web"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "pluma-md",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
 [[package]]
 name = "png"
 version = "0.17.16"
@@ -8291,6 +8311,24 @@ dependencies = [
  "syn 1.0.109",
 ]
 
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags 2.11.1",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
 [[package]]
 name = "pxfm"
 version = "0.1.29"
diff --git a/Cargo.toml b/Cargo.toml
index b5165d8..eda49e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -117,6 +117,12 @@ members = [
     "crates/modules/gioser/gioser-shaders",
     "crates/modules/gioser/gioser-canvas-web",
 
+    # ============================================================
+    # modules/pluma/ — markdown agnóstico + visor web elegante
+    # ============================================================
+    "crates/modules/pluma/pluma-md",
+    "crates/modules/pluma/pluma-reader-web",
+
     # ============================================================
     # apps/ — apps que consumen el protocolo (yahweh modules+shell)
     # ============================================================
@@ -231,10 +237,14 @@ directories = "5"
 
 # === WASM web (gioser) ===
 wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
 js-sys = "0.3"
 web-sys = "0.3"
 glam = "0.30"
 
+# === Markdown (pluma) ===
+pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
+
 # ============================================================
 # Intra-workspace deps de yahweh (referenciadas por workspace = true)
 # ============================================================
diff --git a/crates/apps/gioser-web/Cargo.toml b/crates/apps/gioser-web/Cargo.toml
index 0b2f3d9..e666b52 100644
--- a/crates/apps/gioser-web/Cargo.toml
+++ b/crates/apps/gioser-web/Cargo.toml
@@ -11,7 +11,9 @@ crate-type = ["cdylib", "rlib"]
 
 [dependencies]
 gioser-canvas-web = { path = "../../modules/gioser/gioser-canvas-web" }
+pluma-reader-web = { path = "../../modules/pluma/pluma-reader-web" }
 wasm-bindgen.workspace = true
+wasm-bindgen-futures.workspace = true
 js-sys.workspace = true
 
 [dependencies.web-sys]
@@ -23,9 +25,13 @@ features = [
     "HtmlElement",
     "HtmlCanvasElement",
     "CssStyleDeclaration",
-    "MouseEvent",
+    "DomTokenList",
+    "DomRect",
+    "Event",
     "EventTarget",
+    "MouseEvent",
+    "KeyboardEvent",
+    "NodeList",
     "Performance",
     "console",
 ]
-
diff --git a/crates/apps/gioser-web/index.html b/crates/apps/gioser-web/index.html
index abb54b9..7d5173a 100644
--- a/crates/apps/gioser-web/index.html
+++ b/crates/apps/gioser-web/index.html
@@ -7,67 +7,110 @@
   <link rel="stylesheet" href="./styles.css">
   <link rel="preconnect" href="https://fonts.googleapis.com">
   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@300;500&display=swap">
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@300;500;600&family=JetBrains+Mono:wght@400;600&display=swap">
 </head>
 <body>
   <canvas id="gioser-canvas" aria-hidden="true"></canvas>
 
-  <header class="brand">
+  <header id="brand" class="brand">
     <h1 class="brand-title">Gio<span class="brand-dot">·</span>Ser</h1>
-    <p class="brand-sub">EN EL CENTRO · EL SER</p>
   </header>
 
   <main id="tips" aria-label="Cuatro elementos">
-    <a id="tip-aire" class="tip tip-aire" href="#aire" aria-label="Aire">
-      <svg viewBox="0 0 40 40" class="tip-glyph">
-        <path d="M6 14 q 7 -10 14 0 t 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
-        <path d="M6 22 q 7 -10 14 0 t 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.75"/>
-        <circle cx="32" cy="9" r="2.2" fill="none" stroke="currentColor" stroke-width="1.2"/>
+    <a id="tip-aire" class="tip tip-aire" href="#aire" data-md="./md/aire.md" aria-label="Aire — Software e IA">
+      <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+        <path d="M6 18 q 9 -12 18 0 t 18 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+        <path d="M6 28 q 9 -12 18 0 t 18 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.75"/>
+        <path d="M6 38 q 9 -12 18 0 t 18 0" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" opacity="0.45"/>
       </svg>
       <span class="tip-label">AIRE</span>
       <span class="tip-sub">Software · IA</span>
     </a>
 
-    <a id="tip-fuego" class="tip tip-fuego" href="#fuego" aria-label="Fuego">
-      <svg viewBox="0 0 40 40" class="tip-glyph">
-        <path d="M20 4 q -10 10 -5 20 q 3 -5 5 -5 q 1 8 5 10 q 8 -8 0 -18 q -4 5 -5 -7 z"
-              fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
-        <circle cx="20" cy="22" r="2.5" fill="currentColor" opacity="0.55"/>
+    <a id="tip-fuego" class="tip tip-fuego" href="#fuego" data-md="./md/fuego.md" aria-label="Fuego — Inspiración">
+      <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+        <path d="M24 4 q -12 12 -6 24 q 3 -6 6 -6 q 1 10 6 12 q 10 -10 0 -22 q -4 6 -6 -8 z"
+              fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
+        <circle cx="24" cy="28" r="3" fill="currentColor" opacity="0.5"/>
       </svg>
       <span class="tip-label">FUEGO</span>
       <span class="tip-sub">Inspiración</span>
     </a>
 
-    <a id="tip-tierra" class="tip tip-tierra" href="#tierra" aria-label="Tierra">
-      <svg viewBox="0 0 40 40" class="tip-glyph">
-        <path d="M4 30 l 10 -14 l 6 7 l 5 -10 l 11 17 z"
-              fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
-        <path d="M20 30 v 6 M16 32 l -3 4 M24 32 l 3 4 M20 36 v 1"
-              fill="none" stroke="currentColor" stroke-width="1.1" opacity="0.7"/>
+    <a id="tip-tierra" class="tip tip-tierra" href="#tierra" data-md="./md/tierra.md" aria-label="Tierra — Cuerpo">
+      <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+        <path d="M4 36 l 12 -16 l 8 9 l 6 -12 l 14 19 z"
+              fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
+        <path d="M24 36 v 8 M19 38 l -4 5 M29 38 l 4 5 M24 44 v 2"
+              fill="none" stroke="currentColor" stroke-width="1.3" opacity="0.7"/>
       </svg>
       <span class="tip-label">TIERRA</span>
       <span class="tip-sub">Cuerpo</span>
     </a>
 
-    <a id="tip-agua" class="tip tip-agua" href="#agua" aria-label="Agua">
-      <svg viewBox="0 0 40 40" class="tip-glyph">
-        <path d="M5 20 q 5 -8 10 0 t 10 0 t 10 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
-        <path d="M5 27 q 5 -8 10 0 t 10 0 t 10 0" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" opacity="0.75"/>
-        <path d="M5 34 q 5 -8 10 0 t 10 0 t 10 0" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.5"/>
+    <a id="tip-agua" class="tip tip-agua" href="#agua" data-md="./md/agua.md" aria-label="Agua — Espiritualidad aplicada">
+      <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+        <path d="M6 22 q 6 -10 12 0 t 12 0 t 12 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+        <path d="M6 30 q 6 -10 12 0 t 12 0 t 12 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.75"/>
+        <path d="M6 38 q 6 -10 12 0 t 12 0 t 12 0" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" opacity="0.5"/>
       </svg>
       <span class="tip-label">AGUA</span>
       <span class="tip-sub">Espiritualidad</span>
     </a>
   </main>
 
-  <footer class="quote">aire · agua · fuego · tierra</footer>
+  <!-- DRAWERS: uno por elemento. Cada uno crece desde la posición del tip
+       clickeado (origin set via CSS vars desde WASM) hasta fullscreen. -->
+  <aside id="drawer-aire" class="drawer drawer-aire" data-element="aire" aria-hidden="true">
+    <button class="drawer-close" data-close-drawer aria-label="Cerrar Aire">×</button>
+    <div class="drawer-ambience" aria-hidden="true"></div>
+    <header class="drawer-head">
+      <span class="drawer-mark">aire</span>
+      <h2 class="drawer-title">Aire</h2>
+      <span class="drawer-tag">Software · IA · Aspiración</span>
+    </header>
+    <section class="drawer-content" id="drawer-aire-content"></section>
+  </aside>
+
+  <aside id="drawer-fuego" class="drawer drawer-fuego" data-element="fuego" aria-hidden="true">
+    <button class="drawer-close" data-close-drawer aria-label="Cerrar Fuego">×</button>
+    <div class="drawer-ambience" aria-hidden="true"></div>
+    <header class="drawer-head">
+      <span class="drawer-mark">fuego</span>
+      <h2 class="drawer-title">Fuego</h2>
+      <span class="drawer-tag">Inspiración</span>
+    </header>
+    <section class="drawer-content" id="drawer-fuego-content"></section>
+  </aside>
+
+  <aside id="drawer-tierra" class="drawer drawer-tierra" data-element="tierra" aria-hidden="true">
+    <button class="drawer-close" data-close-drawer aria-label="Cerrar Tierra">×</button>
+    <div class="drawer-ambience" aria-hidden="true"></div>
+    <header class="drawer-head">
+      <span class="drawer-mark">tierra</span>
+      <h2 class="drawer-title">Tierra</h2>
+      <span class="drawer-tag">Cuerpo</span>
+    </header>
+    <section class="drawer-content" id="drawer-tierra-content"></section>
+  </aside>
+
+  <aside id="drawer-agua" class="drawer drawer-agua" data-element="agua" aria-hidden="true">
+    <button class="drawer-close" data-close-drawer aria-label="Cerrar Agua">×</button>
+    <div class="drawer-ambience" aria-hidden="true"></div>
+    <header class="drawer-head">
+      <span class="drawer-mark">agua</span>
+      <h2 class="drawer-title">Agua</h2>
+      <span class="drawer-tag">Espiritualidad aplicada</span>
+    </header>
+    <section class="drawer-content" id="drawer-agua-content"></section>
+  </aside>
 
   <script type="module">
     import init from "./pkg/gioser_web.js";
     init().catch(err => {
       console.error(err);
       document.body.insertAdjacentHTML("beforeend",
-        '<pre style="color:#f59056;position:fixed;left:1rem;bottom:1rem;max-width:90vw;white-space:pre-wrap;font-family:monospace">' +
+        '<pre style="color:#f59056;position:fixed;left:1rem;bottom:1rem;max-width:90vw;white-space:pre-wrap;font-family:monospace;z-index:9999">' +
         String(err) + '</pre>');
     });
   </script>
diff --git a/crates/apps/gioser-web/md/agua.md b/crates/apps/gioser-web/md/agua.md
new file mode 100644
index 0000000..43d52bb
--- /dev/null
+++ b/crates/apps/gioser-web/md/agua.md
@@ -0,0 +1,25 @@
+# Agua
+
+> *Lo que fluye. Lo que une dentro y afuera.*
+
+El **Agua** es el dominio de la **espiritualidad aplicada**: las
+prácticas, lecturas y tradiciones que sostienen la atención y dan
+sentido al hacer. No es decoración mística: es la práctica concreta
+de mantenerse permeable, vivo, conectado.
+
+## Espiritualidad aplicada
+
+Aplicada significa que no se queda en libros: pasa por la práctica
+diaria — la lectura, la meditación, la ceremonia, la conversación
+honda. El agua moja todos los otros ejes.
+
+## Lo que vive acá
+
+- Notas de lectura sobre filosofía, mística, sabiduría andina.
+- Diario de prácticas (meditación, ceremonias, retiros).
+- Conversaciones con maestros y comunidades.
+
+## Próximamente
+
+*Acá se va a ir armando una bitácora de lecturas y prácticas. Por
+ahora el placeholder verifica el render bajo el tema **agua**.*
diff --git a/crates/apps/gioser-web/md/aire.md b/crates/apps/gioser-web/md/aire.md
new file mode 100644
index 0000000..8147ce8
--- /dev/null
+++ b/crates/apps/gioser-web/md/aire.md
@@ -0,0 +1,26 @@
+# Aire
+
+> *Lo que respira el sistema. Lo que sube.*
+
+El **Aire** es el dominio del **software público y la IA**. Es la capa
+intangible que transporta pensamiento — los bits que vuelan entre
+máquinas, las inferencias que destilan sentido del ruido, las APIs
+que conversan sin verse.
+
+## Aspiración
+
+El Aire **aspira**: empuja hacia arriba. Es el movimiento de subir el
+nivel de abstracción, de hacer que una cosa difícil parezca obvia, de
+regalarle al usuario una herramienta que no le pesa.
+
+## Lo que vive acá
+
+- Herramientas open source que **GioSer** publica y mantiene.
+- Modelos de IA que asisten al ciclo de creación.
+- Documentación, ensayos, manifiestos.
+
+## Próximamente
+
+*Esta sección se va a llenar con los proyectos concretos del eje aire.*
+Por ahora, este placeholder vive en `md/aire.md` y se renderiza vía
+`pluma-md` con tema *aire*.
diff --git a/crates/apps/gioser-web/md/fuego.md b/crates/apps/gioser-web/md/fuego.md
new file mode 100644
index 0000000..f78e4fb
--- /dev/null
+++ b/crates/apps/gioser-web/md/fuego.md
@@ -0,0 +1,25 @@
+# Fuego
+
+> *Lo que enciende. Lo que transforma.*
+
+El **Fuego** es el dominio de la **inspiración**. Es la chispa que
+convierte una idea en gesto, una frase en ritual, un problema en
+prototipo. Sin fuego, los otros tres elementos se enfrían y se quedan
+contemplándose.
+
+## Inspiración
+
+El fuego no se planea, se **atiende**. Llega — y la respuesta es no
+dejarlo pasar. Acá viven los ensayos, los videos, los manifiestos y
+los experimentos que nacieron porque algo prendió.
+
+## Lo que vive acá
+
+- Charlas, ensayos cortos, posts crudos.
+- Bocetos visuales, exploraciones tipográficas.
+- Documentos de manifiesto sobre cómo trabajar y para qué.
+
+## Próximamente
+
+*Voy a ir enlazando archivos `.md` específicos acá. Por ahora este
+texto sirve para verificar el render bajo el tema **fuego**.*
diff --git a/crates/apps/gioser-web/md/tierra.md b/crates/apps/gioser-web/md/tierra.md
new file mode 100644
index 0000000..49cb2a4
--- /dev/null
+++ b/crates/apps/gioser-web/md/tierra.md
@@ -0,0 +1,26 @@
+# Tierra
+
+> *El cuerpo. La materia. Lo que sostiene.*
+
+La **Tierra** es el dominio del **cuerpo**. Es lo que se toca, lo que
+huele, lo que se siembra. El eje terrestre de GioSer recuerda que
+todo proyecto —por muy abstracto que parezca— pasa por un cuerpo que
+respira, come, descansa y se conmueve.
+
+## Cuerpo
+
+El cuerpo no es una metáfora: es donde aterriza el aire, donde el
+agua se vuelve vida, donde el fuego deja huella. Cuidarlo es parte
+del trabajo.
+
+## Lo que vive acá
+
+- Prácticas, rutinas, recetas.
+- Materialidad: objetos, lugares, oficios.
+- Salud y reposo como infraestructura.
+
+## Próximamente
+
+*Esta sección va a recibir notas, fotos y enlaces a oficios y
+prácticas concretas. Por ahora el placeholder verifica el tema
+**tierra**.*
diff --git a/crates/apps/gioser-web/src/lib.rs b/crates/apps/gioser-web/src/lib.rs
index 034f554..c66dcca 100644
--- a/crates/apps/gioser-web/src/lib.rs
+++ b/crates/apps/gioser-web/src/lib.rs
@@ -1,17 +1,29 @@
 //! Entrypoint WASM de la landing GioSer.
 //!
-//! Monta el canvas, instala listeners de mouse y resize, corre el loop
-//! con `requestAnimationFrame`, y reposiciona los 4 botones DOM
-//! `#tip-{aire|fuego|tierra|agua}` sobre las puntas proyectadas
-//! de la chacana cada frame.
+//! Responsabilidades:
+//! - Montar canvas WebGL2 + listeners de mouse/resize/RAF loop.
+//! - Reposicionar los 4 tips DOM (botones cardinales) sobre las posiciones
+//!   proyectadas del aro de la chacana cada frame.
+//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt.
+//! - Manejar click sobre cada tip → animar drawer expandiéndose desde la
+//!   posición del botón hasta fullscreen, cargar el .md asociado vía
+//!   `pluma-reader-web` y renderearlo themed por elemento.
+//! - Cerrar drawer con close button, Escape o backdrop click.
 
 use std::cell::RefCell;
 use std::rc::Rc;
 
 use gioser_canvas_web::{tips, Renderer};
+use pluma_reader_web::Reader;
 use wasm_bindgen::prelude::*;
 use wasm_bindgen::JsCast;
-use web_sys::{Document, HtmlCanvasElement, HtmlElement, MouseEvent, Window};
+use web_sys::{
+    Document, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent, Window,
+};
+
+/// Factor radial sobre `arm_extent` donde se anclan los botones DOM.
+/// Queda entre la punta de la chacana y el aro grueso.
+const BUTTON_RADIUS_FACTOR: f32 = 1.32;
 
 #[wasm_bindgen(start)]
 pub fn boot() -> Result<(), JsValue> {
@@ -34,6 +46,7 @@ pub fn boot() -> Result<(), JsValue> {
 
     install_resize(&window, &canvas, &renderer)?;
     install_mouse(&document, &canvas, &renderer)?;
+    install_drawer_handlers(&document)?;
     install_raf(&window, &document, &canvas, &renderer);
 
     Ok(())
@@ -66,7 +79,6 @@ fn install_mouse(
     let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
         let w = canvas.client_width().max(1) as f32;
         let h = canvas.client_height().max(1) as f32;
-        // Origen en el centro del canvas, +y arriba.
         let x = e.client_x() as f32 - w * 0.5;
         let y = h * 0.5 - e.client_y() as f32;
         r.borrow_mut().set_mouse_px(x, y);
@@ -76,6 +88,116 @@ fn install_mouse(
     Ok(())
 }
 
+fn install_drawer_handlers(document: &Document) -> Result<(), JsValue> {
+    let doc = document.clone();
+
+    // Clicks en los 4 tips → open drawer.
+    let tips = document.query_selector_all(".tip[data-md]")?;
+    for i in 0..tips.length() {
+        let Some(node) = tips.item(i) else { continue };
+        let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
+        let id = el.id();
+        let element = id.strip_prefix("tip-").unwrap_or("").to_string();
+        let md_url = el.get_attribute("data-md").unwrap_or_default();
+        let d = doc.clone();
+        let el_for_rect = el.clone();
+        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
+            e.prevent_default();
+            open_drawer(&d, &element, &el_for_rect, &md_url);
+        });
+        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+        cb.forget();
+    }
+
+    // Cualquier elemento marcado con `data-close-drawer` cierra.
+    let closes = document.query_selector_all("[data-close-drawer]")?;
+    for i in 0..closes.length() {
+        let Some(node) = closes.item(i) else { continue };
+        let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
+        let d = doc.clone();
+        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
+            e.stop_propagation();
+            close_drawers(&d);
+        });
+        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+        cb.forget();
+    }
+
+    // Escape cierra.
+    let d = doc.clone();
+    let kcb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
+        if e.key() == "Escape" {
+            close_drawers(&d);
+        }
+    });
+    document.add_event_listener_with_callback("keydown", kcb.as_ref().unchecked_ref())?;
+    kcb.forget();
+
+    Ok(())
+}
+
+fn open_drawer(doc: &Document, element: &str, button: &HtmlElement, md_url: &str) {
+    let rect = button.get_bounding_client_rect();
+    let cx = rect.left() + rect.width() / 2.0;
+    let cy = rect.top() + rect.height() / 2.0;
+    let drawer_id = format!("drawer-{}", element);
+    let Some(drawer_el) = doc.get_element_by_id(&drawer_id) else {
+        return;
+    };
+    let drawer: HtmlElement = drawer_el.unchecked_into();
+    let _ = drawer
+        .style()
+        .set_property("--origin-x", &format!("{:.1}px", cx));
+    let _ = drawer
+        .style()
+        .set_property("--origin-y", &format!("{:.1}px", cy));
+    let _ = drawer.class_list().add_1("open");
+    drawer.set_attribute("aria-hidden", "false").ok();
+
+    if let Some(body) = doc.body() {
+        let _ = body.class_list().add_1("drawer-active");
+        let _ = body
+            .class_list()
+            .add_1(&format!("drawer-active-{}", element));
+    }
+
+    // Carga del .md en background.
+    let content_id = format!("drawer-{}-content", element);
+    if let Some(content_el) = doc.get_element_by_id(&content_id) {
+        let content: HtmlElement = content_el.unchecked_into();
+        let reader = Reader::new(content);
+        let element_owned = element.to_string();
+        let md_url_owned = md_url.to_string();
+        wasm_bindgen_futures::spawn_local(async move {
+            if let Err(e) = reader.open_url(&md_url_owned, &element_owned).await {
+                web_sys::console::warn_1(&e);
+            }
+        });
+    }
+}
+
+fn close_drawers(doc: &Document) {
+    let Ok(drawers) = doc.query_selector_all(".drawer.open") else {
+        return;
+    };
+    for i in 0..drawers.length() {
+        let Some(node) = drawers.item(i) else { continue };
+        let Ok(el) = node.dyn_into::<HtmlElement>() else {
+            continue;
+        };
+        let _ = el.class_list().remove_1("open");
+        let _ = el.set_attribute("aria-hidden", "true");
+    }
+    if let Some(body) = doc.body() {
+        let _ = body.class_list().remove_1("drawer-active");
+        for e in ["aire", "fuego", "tierra", "agua"] {
+            let _ = body
+                .class_list()
+                .remove_1(&format!("drawer-active-{}", e));
+        }
+    }
+}
+
 fn install_raf(
     window: &Window,
     document: &Document,
@@ -89,8 +211,14 @@ fn install_raf(
     let document = document.clone();
     let window2 = window.clone();
     *g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
+        let r = renderer.borrow_mut();
+        // r es Mut, ojo: el render necesita mut, lo hacemos antes de paint.
+        drop(r);
         renderer.borrow_mut().render(time_ms);
-        position_tips(&document, &canvas, &renderer.borrow());
+        let r = renderer.borrow();
+        position_tips(&document, &canvas, &r);
+        update_tilt_css(&document, &r);
+        drop(r);
         if let Some(cb) = f.borrow().as_ref() {
             let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
         }
@@ -119,7 +247,7 @@ fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
 }
 
 fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
-    let clips = renderer.tips_ndc();
+    let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
     let cw = canvas.client_width().max(1) as f32;
     let ch = canvas.client_height().max(1) as f32;
     for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
@@ -138,6 +266,21 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
     }
 }
 
+fn update_tilt_css(document: &Document, renderer: &Renderer) {
+    let (pitch, yaw) = renderer.tilt_degrees();
+    if let Some(brand) = document.get_element_by_id("brand") {
+        if let Ok(brand) = brand.dyn_into::<HtmlElement>() {
+            // CSS rotateX usa el mismo signo que nuestra pitch (mouse up tilts top toward viewer).
+            let _ = brand
+                .style()
+                .set_property("--tilt-x", &format!("{:.2}deg", pitch));
+            let _ = brand
+                .style()
+                .set_property("--tilt-y", &format!("{:.2}deg", yaw));
+        }
+    }
+}
+
 fn install_panic_hook() {
     static SET: std::sync::Once = std::sync::Once::new();
     SET.call_once(|| {
diff --git a/crates/apps/gioser-web/styles.css b/crates/apps/gioser-web/styles.css
index aaa9b5c..fd71309 100644
--- a/crates/apps/gioser-web/styles.css
+++ b/crates/apps/gioser-web/styles.css
@@ -1,11 +1,16 @@
+/* === Tokens === */
 :root {
   --bg: #06050d;
   --fg: #e8eaf5;
+  --gold: #d8a85d;
+  --gold-deep: #b77e34;
   --aire:   #d0dbff;
   --agua:   #6cd0f3;
   --fuego:  #f59056;
   --tierra: #d49873;
-  --rim:    #d8a85d;
+
+  --ease-emerge: cubic-bezier(0.22, 0.61, 0.20, 1);
+  --ease-magma: cubic-bezier(0.32, 0, 0.05, 1);
 }
 
 * { box-sizing: border-box; }
@@ -20,9 +25,9 @@ html, body {
   font-family: 'Inter', system-ui, -apple-system, sans-serif;
   font-weight: 300;
   overflow: hidden;
-  cursor: default;
 }
 
+/* === Canvas WebGL === */
 #gioser-canvas {
   position: fixed;
   inset: 0;
@@ -30,47 +35,47 @@ html, body {
   height: 100vh;
   display: block;
   z-index: 0;
+  transition: opacity 600ms var(--ease-emerge), filter 600ms var(--ease-emerge);
 }
 
+/* === Marca central: GioSer sobre la superficie de la chacana === */
 .brand {
   position: fixed;
-  top: 4vh;
+  top: 50%;
   left: 50%;
-  transform: translateX(-50%);
-  text-align: center;
-  z-index: 10;
+  z-index: 6;
   pointer-events: none;
   user-select: none;
+  transform-style: preserve-3d;
+  /* perspective hace que la rotación del título dé sensación 3D igual que la chacana */
+  transform:
+    translate(-50%, -50%)
+    perspective(900px)
+    rotateX(var(--tilt-x, 0deg))
+    rotateY(var(--tilt-y, 0deg));
+  transition: transform 30ms linear;
 }
-
 .brand-title {
   font-family: 'Cinzel', serif;
   font-weight: 700;
-  font-size: clamp(2.4rem, 5vw, 4.4rem);
+  font-size: clamp(2.6rem, 6vw, 5.4rem);
   margin: 0;
   letter-spacing: 0.08em;
-  color: var(--fg);
+  color: #f4eedf;
   text-shadow:
-    0 0 18px rgba(108, 208, 243, 0.35),
-    0 0 40px rgba(216, 168, 93, 0.18);
+    0 0 22px rgba(216, 168, 93, 0.55),
+    0 0 50px rgba(183, 126, 52, 0.30),
+    0 0 80px rgba(216, 168, 93, 0.15);
 }
-
 .brand-dot {
-  color: var(--rim);
+  color: var(--gold);
   margin: 0 0.05em;
-  text-shadow: 0 0 14px var(--rim), 0 0 28px rgba(245, 144, 86, 0.5);
-}
-
-.brand-sub {
-  font-family: 'Inter', sans-serif;
-  font-weight: 300;
-  letter-spacing: 0.5em;
-  font-size: clamp(0.65rem, 0.9vw, 0.8rem);
-  margin: 0.55rem 0 0;
-  color: rgba(232, 234, 245, 0.6);
-  text-indent: 0.5em;
+  text-shadow:
+    0 0 14px var(--gold),
+    0 0 32px rgba(245, 144, 86, 0.55);
 }
 
+/* === Tips (botones cardinales) === */
 #tips {
   position: fixed;
   inset: 0;
@@ -85,107 +90,409 @@ html, body {
   text-decoration: none;
   color: var(--fg);
   user-select: none;
+  cursor: pointer;
 
   display: flex;
   flex-direction: column;
   align-items: center;
-  gap: 0.35rem;
-  padding: 0.7rem 1.0rem 0.55rem;
-  min-width: 120px;
+  gap: 0.55rem;
+  padding: 1.1rem 1.6rem 0.95rem;
+  min-width: 168px;
+
+  background:
+    radial-gradient(ellipse at top, rgba(255, 255, 255, 0.04), transparent 65%),
+    rgba(8, 6, 22, 0.55);
+  backdrop-filter: blur(12px) saturate(140%);
+  -webkit-backdrop-filter: blur(12px) saturate(140%);
+  border-radius: 20px;
+  border: 1px solid rgba(216, 168, 93, 0.25);
 
-  background: rgba(8, 6, 22, 0.42);
-  backdrop-filter: blur(8px) saturate(120%);
-  -webkit-backdrop-filter: blur(8px) saturate(120%);
-  border-radius: 14px;
-  border: 1px solid rgba(255, 255, 255, 0.07);
-  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
+  box-shadow:
+    0 12px 36px rgba(0, 0, 0, 0.40),
+    inset 0 0 0 1px rgba(255, 255, 255, 0.04);
 
   transition:
-    box-shadow 260ms ease,
-    border-color 260ms ease,
-    background 260ms ease,
-    letter-spacing 260ms ease;
+    box-shadow 350ms var(--ease-emerge),
+    border-color 350ms var(--ease-emerge),
+    background  350ms var(--ease-emerge),
+    opacity 300ms ease;
 }
-
 .tip::before {
   content: "";
   position: absolute;
-  inset: -1px;
-  border-radius: inherit;
-  padding: 1px;
-  background: linear-gradient(135deg,
-    rgba(255,255,255,0.18),
-    rgba(255,255,255,0.0) 40%,
-    rgba(216, 168, 93, 0.18));
-  -webkit-mask:
-    linear-gradient(#000 0 0) content-box,
-    linear-gradient(#000 0 0);
-  -webkit-mask-composite: xor;
-          mask-composite: exclude;
+  inset: -10px;
+  border-radius: 26px;
+  border: 1px solid currentColor;
+  opacity: 0;
+  transition: opacity 400ms ease, inset 400ms var(--ease-emerge);
   pointer-events: none;
-  opacity: 0.7;
 }
-
 .tip:hover {
-  border-color: rgba(255, 255, 255, 0.22);
-  background: rgba(20, 14, 40, 0.6);
+  border-color: currentColor;
+  background:
+    radial-gradient(ellipse at top, rgba(255, 255, 255, 0.07), transparent 65%),
+    rgba(20, 14, 40, 0.72);
+}
+.tip:hover::before {
+  opacity: 0.45;
+  inset: -16px;
 }
-
 .tip-glyph {
-  width: 42px;
-  height: 42px;
+  width: 54px;
+  height: 54px;
   color: currentColor;
-  filter: drop-shadow(0 0 6px currentColor);
-  transition: filter 240ms ease;
+  filter: drop-shadow(0 0 6px currentColor) drop-shadow(0 0 16px currentColor);
+  transition: filter 320ms ease, transform 350ms var(--ease-emerge);
 }
 .tip:hover .tip-glyph {
-  filter: drop-shadow(0 0 12px currentColor) drop-shadow(0 0 20px currentColor);
+  filter: drop-shadow(0 0 14px currentColor) drop-shadow(0 0 28px currentColor);
+  transform: translateY(-3px);
 }
-
 .tip-label {
   font-family: 'Cinzel', serif;
-  font-size: 0.74rem;
-  letter-spacing: 0.36em;
+  font-size: 0.95rem;
+  letter-spacing: 0.42em;
   font-weight: 600;
-  text-indent: 0.36em;
+  text-indent: 0.42em;
+  margin-top: 0.15rem;
 }
-
 .tip-sub {
   font-family: 'Inter', sans-serif;
-  font-size: 0.62rem;
-  letter-spacing: 0.18em;
+  font-size: 0.7rem;
+  letter-spacing: 0.22em;
   font-weight: 300;
-  color: rgba(232, 234, 245, 0.55);
+  color: rgba(232, 234, 245, 0.62);
   text-transform: uppercase;
+  text-indent: 0.22em;
 }
-
 .tip-aire   { color: var(--aire); }
-.tip-aire:hover   { box-shadow: 0 0 32px rgba(208, 219, 255, 0.45); }
 .tip-fuego  { color: var(--fuego); }
-.tip-fuego:hover  { box-shadow: 0 0 32px rgba(245, 144, 86, 0.45); }
 .tip-agua   { color: var(--agua); }
-.tip-agua:hover   { box-shadow: 0 0 32px rgba(108, 208, 243, 0.45); }
 .tip-tierra { color: var(--tierra); }
-.tip-tierra:hover { box-shadow: 0 0 32px rgba(212, 152, 115, 0.45); }
 
-.quote {
+/* Cuando un drawer está abierto, los tips se ocultan suavemente. */
+body.drawer-active .tip {
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 250ms ease;
+}
+body.drawer-active #gioser-canvas {
+  opacity: 0.35;
+  filter: blur(4px) saturate(80%);
+}
+body.drawer-active .brand {
+  opacity: 0;
+  transition: opacity 250ms ease;
+}
+
+/* === Drawers (visor MD full-screen) === */
+.drawer {
   position: fixed;
-  bottom: 3vh;
-  left: 50%;
-  transform: translateX(-50%);
-  font-family: 'Cinzel', serif;
+  inset: 0;
+  z-index: 100;
+  pointer-events: none;
+  opacity: 0;
+  visibility: hidden;
+  transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
+  transform: scale(0.0);
+  background:
+    radial-gradient(ellipse at center, var(--drawer-glow, rgba(216, 168, 93, 0.15)), transparent 65%),
+    rgba(6, 5, 13, 0.96);
+  backdrop-filter: blur(28px) saturate(140%);
+  -webkit-backdrop-filter: blur(28px) saturate(140%);
+  transition:
+    transform 700ms var(--ease-magma),
+    opacity 450ms ease,
+    visibility 0s 700ms;
+  overflow: hidden;
+}
+.drawer.open {
+  pointer-events: auto;
+  opacity: 1;
+  visibility: visible;
+  transform: scale(1);
+  transition:
+    transform 700ms var(--ease-magma),
+    opacity 450ms ease,
+    visibility 0s;
+}
+
+.drawer-aire   { --drawer-glow: rgba(208, 219, 255, 0.22); --drawer-accent: var(--aire); }
+.drawer-fuego  { --drawer-glow: rgba(245, 144, 86, 0.28);  --drawer-accent: var(--fuego); }
+.drawer-agua   { --drawer-glow: rgba(108, 208, 243, 0.22); --drawer-accent: var(--agua); }
+.drawer-tierra { --drawer-glow: rgba(212, 152, 115, 0.24); --drawer-accent: var(--tierra); }
+
+/* Ambience: capa decorativa con efectos según elemento. */
+.drawer-ambience {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 0;
+}
+.drawer-aire .drawer-ambience {
+  background:
+    radial-gradient(circle at 18% 22%, rgba(208, 219, 255, 0.20), transparent 38%),
+    radial-gradient(circle at 78% 68%, rgba(208, 219, 255, 0.14), transparent 40%),
+    radial-gradient(circle at 45% 90%, rgba(180, 200, 255, 0.10), transparent 45%);
+  animation: aire-drift 28s ease-in-out infinite alternate;
+}
+.drawer-fuego .drawer-ambience {
+  background:
+    radial-gradient(circle at 50% 100%, rgba(245, 144, 86, 0.35), transparent 55%),
+    radial-gradient(circle at 25% 80%, rgba(255, 90, 40, 0.18), transparent 35%),
+    radial-gradient(circle at 80% 85%, rgba(255, 140, 60, 0.18), transparent 35%);
+  animation: fuego-flicker 5s ease-in-out infinite;
+}
+.drawer-agua .drawer-ambience {
+  background:
+    radial-gradient(ellipse at 50% 95%, rgba(60, 160, 230, 0.30), transparent 60%),
+    radial-gradient(ellipse at 20% 70%, rgba(108, 208, 243, 0.15), transparent 50%),
+    radial-gradient(ellipse at 80% 75%, rgba(108, 208, 243, 0.12), transparent 50%);
+  animation: agua-tide 14s ease-in-out infinite alternate;
+}
+.drawer-tierra .drawer-ambience {
+  background:
+    radial-gradient(ellipse at 50% 100%, rgba(120, 80, 40, 0.40), transparent 60%),
+    radial-gradient(ellipse at 22% 88%, rgba(180, 130, 80, 0.20), transparent 45%),
+    radial-gradient(ellipse at 78% 88%, rgba(150, 100, 60, 0.22), transparent 45%);
+}
+
+@keyframes aire-drift {
+  from { transform: translate(-4%, -1%); }
+  to   { transform: translate(4%, 2%); }
+}
+@keyframes fuego-flicker {
+  0%, 100% { opacity: 0.85; transform: scaleY(1.00); }
+  35%      { opacity: 1.00; transform: scaleY(1.04); }
+  60%      { opacity: 0.92; transform: scaleY(0.98); }
+}
+@keyframes agua-tide {
+  from { transform: translateY(0); }
+  to   { transform: translateY(-3%); }
+}
+
+/* === Drawer head + close === */
+.drawer-head {
+  position: relative;
+  z-index: 2;
+  text-align: center;
+  padding: 7vh 8vw 2vh;
+  color: var(--drawer-accent);
+}
+.drawer-mark {
+  display: inline-block;
+  font-family: 'Inter', sans-serif;
   font-size: 0.7rem;
   letter-spacing: 0.55em;
-  color: rgba(232, 234, 245, 0.42);
-  pointer-events: none;
-  user-select: none;
-  z-index: 10;
+  text-transform: uppercase;
+  color: rgba(232, 234, 245, 0.6);
+  margin-bottom: 0.4rem;
   text-indent: 0.55em;
 }
+.drawer-title {
+  font-family: 'Cinzel', serif;
+  font-weight: 700;
+  font-size: clamp(2.4rem, 6vw, 4.6rem);
+  margin: 0;
+  letter-spacing: 0.08em;
+  color: var(--drawer-accent);
+  text-shadow: 0 0 28px currentColor, 0 0 56px rgba(255, 255, 255, 0.10);
+}
+.drawer-tag {
+  display: block;
+  font-family: 'Inter', sans-serif;
+  font-size: 0.78rem;
+  letter-spacing: 0.32em;
+  text-transform: uppercase;
+  color: rgba(232, 234, 245, 0.55);
+  margin-top: 0.7rem;
+  text-indent: 0.32em;
+}
+
+.drawer-close {
+  position: absolute;
+  top: 2.4vh;
+  right: 2.4vw;
+  z-index: 5;
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  color: var(--drawer-accent);
+  font-size: 1.6rem;
+  line-height: 1;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 200ms ease, transform 250ms var(--ease-emerge), border-color 200ms ease;
+  font-family: 'Inter', sans-serif;
+}
+.drawer-close:hover {
+  background: rgba(255, 255, 255, 0.10);
+  border-color: currentColor;
+  transform: rotate(90deg);
+}
+
+/* === Drawer content (MD render via pluma-reader-web) === */
+.drawer-content {
+  position: relative;
+  z-index: 2;
+  max-height: calc(100vh - 22vh);
+  overflow-y: auto;
+  padding: 1vh 10vw 8vh;
+  opacity: 0;
+  transition: opacity 500ms ease 250ms;
+}
+.drawer.open .drawer-content {
+  opacity: 1;
+}
+.drawer-content::-webkit-scrollbar { width: 6px; }
+.drawer-content::-webkit-scrollbar-thumb {
+  background: var(--drawer-accent);
+  border-radius: 3px;
+  opacity: 0.4;
+}
+
+/* === pluma-doc: estilos del visor MD === */
+.pluma-doc {
+  max-width: 760px;
+  margin: 0 auto;
+  font-family: 'Inter', sans-serif;
+  font-weight: 300;
+  font-size: 1.05rem;
+  line-height: 1.78;
+  color: rgba(232, 234, 245, 0.92);
+}
+.pluma-doc > * + * { margin-top: 1.0em; }
+.pluma-doc h1 {
+  font-family: 'Cinzel', serif;
+  font-weight: 700;
+  font-size: clamp(1.7rem, 3vw, 2.4rem);
+  color: var(--drawer-accent);
+  text-shadow: 0 0 18px currentColor;
+  letter-spacing: 0.04em;
+  margin-top: 1.4em;
+}
+.pluma-doc h2 {
+  font-family: 'Cinzel', serif;
+  font-weight: 500;
+  font-size: 1.5rem;
+  color: var(--drawer-accent);
+  letter-spacing: 0.04em;
+  margin-top: 1.6em;
+  padding-bottom: 0.3em;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.10);
+}
+.pluma-doc h3 {
+  font-family: 'Inter', sans-serif;
+  font-weight: 600;
+  font-size: 1.18rem;
+  color: rgba(255, 255, 255, 0.92);
+  letter-spacing: 0.03em;
+  margin-top: 1.6em;
+}
+.pluma-doc p { margin: 0; }
+.pluma-doc a {
+  color: var(--drawer-accent);
+  text-decoration: none;
+  border-bottom: 1px solid currentColor;
+  transition: opacity 200ms ease;
+}
+.pluma-doc a:hover { opacity: 0.7; }
+.pluma-doc strong { color: rgba(255, 255, 255, 0.98); font-weight: 600; }
+.pluma-doc em { color: rgba(255, 255, 255, 0.92); }
+.pluma-doc code {
+  font-family: 'JetBrains Mono', ui-monospace, monospace;
+  background: rgba(255, 255, 255, 0.06);
+  padding: 0.12em 0.45em;
+  border-radius: 4px;
+  font-size: 0.92em;
+  color: var(--drawer-accent);
+}
+.pluma-doc pre {
+  background: rgba(0, 0, 0, 0.45);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-left: 3px solid var(--drawer-accent);
+  border-radius: 8px;
+  padding: 1rem 1.2rem;
+  overflow-x: auto;
+  font-size: 0.92rem;
+}
+.pluma-doc pre code {
+  background: transparent;
+  color: rgba(232, 234, 245, 0.92);
+  padding: 0;
+}
+.pluma-doc blockquote {
+  border-left: 3px solid var(--drawer-accent);
+  padding: 0.4em 1.2em;
+  color: rgba(232, 234, 245, 0.75);
+  font-style: italic;
+  background: rgba(255, 255, 255, 0.03);
+  border-radius: 0 6px 6px 0;
+}
+.pluma-doc ul, .pluma-doc ol { padding-left: 1.6em; }
+.pluma-doc li { margin: 0.4em 0; }
+.pluma-doc li::marker { color: var(--drawer-accent); }
+.pluma-doc hr {
+  border: none;
+  height: 1px;
+  background: linear-gradient(to right, transparent, var(--drawer-accent), transparent);
+  margin: 2em 0;
+}
+.pluma-doc table {
+  border-collapse: collapse;
+  width: 100%;
+  font-size: 0.95rem;
+}
+.pluma-doc th, .pluma-doc td {
+  padding: 0.55em 0.9em;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+  text-align: left;
+}
+.pluma-doc th {
+  color: var(--drawer-accent);
+  font-weight: 600;
+  letter-spacing: 0.04em;
+}
+
+.pluma-loading, .pluma-error {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 30vh;
+  color: rgba(232, 234, 245, 0.55);
+  font-family: 'Cinzel', serif;
+  letter-spacing: 0.4em;
+  font-size: 0.9rem;
+}
+.pluma-loading::before {
+  content: "";
+  width: 28px;
+  height: 28px;
+  margin-right: 1rem;
+  border: 1px solid var(--drawer-accent);
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: pluma-spin 1s linear infinite;
+}
+.pluma-error {
+  color: var(--fuego);
+  font-style: italic;
+}
+@keyframes pluma-spin {
+  to { transform: rotate(360deg); }
+}
 
+/* === Responsive === */
 @media (max-width: 720px) {
-  .tip { min-width: 90px; padding: 0.5rem 0.7rem; }
-  .tip-glyph { width: 32px; height: 32px; }
-  .tip-label { font-size: 0.6rem; }
+  .tip { min-width: 110px; padding: 0.7rem 0.9rem; }
+  .tip-glyph { width: 36px; height: 36px; }
+  .tip-label { font-size: 0.72rem; }
   .tip-sub { display: none; }
+  .brand-title { font-size: clamp(2rem, 10vw, 3rem); }
+  .drawer-head { padding: 5vh 5vw 1vh; }
+  .drawer-content { padding: 0 5vw 5vh; }
 }
diff --git a/crates/modules/gioser/gioser-canvas-web/src/lib.rs b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
index 3b6656d..53e77e3 100644
--- a/crates/modules/gioser/gioser-canvas-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
@@ -1,20 +1,14 @@
 //! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
 //!
-//! Es agnóstico del DOM: el caller monta el `<canvas>`, pasa eventos de mouse,
-//! y llama `render(time_ms)` desde un `requestAnimationFrame`.
+//! Es agnóstico del DOM: el caller monta el `<canvas>`, le pasa eventos
+//! de mouse y llama `render(time_ms)` desde un `requestAnimationFrame`.
 //!
 //! ```ignore
 //! let mut r = Renderer::new(&canvas)?;
 //! r.resize(w, h);
-//! // por cada mousemove:
 //! r.set_mouse_px(dx, dy);
-//! // por cada frame:
 //! r.render(time_ms);
 //! ```
-//!
-//! El layout sigue el patrón de `yahweh_launcher::launch_app(title, size, factory)`:
-//! una sola línea para arrancar, escena auto-contenida. Cuando exista `yahweh-web`,
-//! este renderer es el "factory" que recibirá.
 
 use gioser_geom::ChacanaSpec;
 use gioser_palette::{cosmos, Rgb};
@@ -31,15 +25,16 @@ use web_sys::{
 };
 
 const RAD: f32 = core::f32::consts::PI / 180.0;
-/// Inclinación máxima en cada eje. 35° hace que las puntas se desplacen
-/// visiblemente en pantalla — los botones DOM cabalgan ese movimiento.
-const MAX_TILT_DEG: f32 = 35.0;
-/// Escala mundo→viewport: la chacana clásica con arm_extent ≈ 0.65 ocupa
-/// ~94% del eje vertical del viewport, dejando aire para aros y glow.
-const WORLD_SCALE: f32 = 1.45;
+const DEG: f32 = 180.0 / core::f32::consts::PI;
+/// Inclinación máxima en cada eje. 28° = movimiento bien legible pero
+/// no caricaturesco; la chacana se siente "pesada y noble".
+const MAX_TILT_DEG: f32 = 28.0;
+/// Escala mundo→viewport: con arm_extent=0.65 + aro a 1.45×, la chacana
+/// + aro entran cómodos con margen para botones DOM más allá del aro.
+const WORLD_SCALE: f32 = 1.05;
 
-/// Re-export para apps: identidad (id, color, label) de cada punta cardinal,
-/// en el orden `[N, E, S, W]` de `ChacanaSpec::tips()`.
+/// Identidad de cada cardinal (id, color de acento, label visible).
+/// Orden `[N, E, S, W]` coincide con `ChacanaSpec::tips()`.
 pub mod tips {
     use gioser_palette::{elements, Rgb};
     pub const ORDER: [(&str, Rgb, &str); 4] = [
@@ -180,6 +175,7 @@ impl Renderer {
                 "u_line_color",
                 "u_rim_color",
                 "u_sun_color",
+                "u_dark_color",
                 "u_sun_pulse",
             ],
         )
@@ -190,9 +186,7 @@ impl Renderer {
         let (chacana_vao, chacana_quad_count) =
             upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
 
-        // Spring sub-crítico, frecuencia baja: sensación de cuerpo pesado
-        // que se inclina hacia el cursor con overshoot orgánico (~10%).
-        let tilt = SpringDamper2::new(1.8, 0.62);
+        let tilt = SpringDamper2::new(1.7, 0.65);
 
         Ok(Self {
             gl,
@@ -216,7 +210,6 @@ impl Renderer {
             .viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
     }
 
-    /// Mouse en pixeles desde el centro del canvas (+x derecha, +y arriba).
     pub fn set_mouse_px(&mut self, x: f32, y: f32) {
         let (w, h) = self.viewport;
         if h == 0 {
@@ -227,20 +220,28 @@ impl Renderer {
         let mx = (x / half_h).clamp(-aspect, aspect);
         let my = (y / half_h).clamp(-1.0, 1.0);
         self.mouse = (mx, my);
-
         let max_tilt = MAX_TILT_DEG * RAD;
-        // Pitch (+rotX) cuando mouse arriba: el tope se acerca al viewer.
-        // Yaw  (-rotY) cuando mouse derecha: el lado derecho se acerca.
         let target = [my * max_tilt, -mx * max_tilt / aspect];
         self.tilt.set_target(target);
     }
 
-    /// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en orden N/E/S/W.
-    /// El caller la usa para anclar el DOM.
+    /// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`.
     pub fn tips_ndc(&self) -> [(f32, f32); 4] {
+        self.points_ndc(&self.chacana.tips())
+    }
+
+    /// Posición NDC de un punto en cualquier radio cardinal (factor sobre
+    /// `arm_extent`). Útil para anclar los botones DOM más allá de la chacana
+    /// pero dentro del aro.
+    pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] {
+        let r = self.chacana.arm_extent() * radius_factor;
+        self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)])
+    }
+
+    fn points_ndc(&self, pts: &[(f32, f32); 4]) -> [(f32, f32); 4] {
         let mvp = self.build_mvp();
         let mut out = [(0.0_f32, 0.0_f32); 4];
-        for (i, t) in self.chacana.tips().iter().enumerate() {
+        for (i, t) in pts.iter().enumerate() {
             let p = mvp * Vec4::new(t.0, t.1, 0.0, 1.0);
             let w = if p.w == 0.0 { 1.0 } else { p.w };
             out[i] = (p.x / w, p.y / w);
@@ -252,12 +253,17 @@ impl Renderer {
         &self.chacana
     }
 
-    /// Posición normalizada del mouse en clip-space; útil para que el caller
-    /// añada parallax sutil a elementos DOM independientes de la chacana.
     pub fn mouse_clip(&self) -> (f32, f32) {
         self.mouse
     }
 
+    /// Devuelve `(pitch_deg, yaw_deg)` actuales del spring de tilt.
+    /// El caller los inyecta como CSS vars en el contenedor del título para
+    /// que el HTML se tumbe junto con la chacana renderizada en GL.
+    pub fn tilt_degrees(&self) -> (f32, f32) {
+        (self.tilt.position[0] * DEG, self.tilt.position[1] * DEG)
+    }
+
     fn build_mvp(&self) -> Mat4 {
         let (w, h) = self.viewport;
         let aspect = w as f32 / h as f32;
@@ -276,7 +282,6 @@ impl Renderer {
             ((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
         };
         self.last_time_ms = time_ms;
-
         let sub = 4;
         let sub_dt = dt / sub as f32;
         for _ in 0..sub {
@@ -293,7 +298,7 @@ impl Renderer {
         gl.clear_color(0.02, 0.015, 0.04, 1.0);
         gl.clear(GL::COLOR_BUFFER_BIT);
 
-        // ---- Cosmos ----
+        // Cosmos
         gl.use_program(Some(&self.cosmos_prog.program));
         if let Some(u) = self.cosmos_prog.u("u_resolution") {
             gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
@@ -311,7 +316,7 @@ impl Renderer {
         gl.bind_vertex_array(Some(&self.cosmos_vao));
         gl.draw_arrays(GL::TRIANGLES, 0, 6);
 
-        // ---- Chacana (blend aditivo para que el glow sume luz) ----
+        // Chacana (blend aditivo para que dorado y sol sumen luz al cosmos)
         gl.blend_func(GL::SRC_ALPHA, GL::ONE);
         gl.use_program(Some(&self.chacana_prog.program));
         let mvp = self.build_mvp();
@@ -333,6 +338,7 @@ impl Renderer {
         upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
         upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
         upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
+        upload_rgb(gl, self.chacana_prog.u("u_dark_color"), cosmos::CHACANA_DARK);
         if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
             gl.uniform1f(Some(u), self.sun_pulse);
         }
diff --git a/crates/modules/gioser/gioser-palette/src/lib.rs b/crates/modules/gioser/gioser-palette/src/lib.rs
index 565077e..48d6174 100644
--- a/crates/modules/gioser/gioser-palette/src/lib.rs
+++ b/crates/modules/gioser/gioser-palette/src/lib.rs
@@ -87,12 +87,14 @@ pub mod cosmos {
     pub const NEBULA_A: Rgb = Rgb(0.220, 0.130, 0.380);
     /// Nebulosa exterior — azul profundo.
     pub const NEBULA_B: Rgb = Rgb(0.080, 0.180, 0.320);
-    /// Núcleo solar central.
-    pub const SUN_CORE: Rgb = Rgb(1.000, 0.860, 0.520);
-    /// Líneas de la chacana — cyan helado.
-    pub const CHACANA_LINE: Rgb = Rgb(0.55, 0.92, 1.00);
-    /// Aro de fuego del logo — dorado-ámbar.
-    pub const CHACANA_RIM: Rgb = Rgb(0.95, 0.65, 0.32);
+    /// Núcleo solar central — amarillo cálido, base del halo dorado.
+    pub const SUN_CORE: Rgb = Rgb(1.000, 0.870, 0.540);
+    /// Línea principal de la chacana — dorado/ámbar luminoso (color del logo).
+    pub const CHACANA_LINE: Rgb = Rgb(0.96, 0.74, 0.40);
+    /// Aro/rim cálido más profundo — ámbar tostado.
+    pub const CHACANA_RIM: Rgb = Rgb(0.88, 0.58, 0.28);
+    /// Niebla oscura del interior de la chacana — violeta-negro translúcido.
+    pub const CHACANA_DARK: Rgb = Rgb(0.04, 0.03, 0.10);
     /// Polvo de estrellas.
     pub const STARDUST: Rgb = Rgb(0.85, 0.88, 1.00);
 }
diff --git a/crates/modules/gioser/gioser-shaders/src/lib.rs b/crates/modules/gioser/gioser-shaders/src/lib.rs
index acf0994..6c684a6 100644
--- a/crates/modules/gioser/gioser-shaders/src/lib.rs
+++ b/crates/modules/gioser/gioser-shaders/src/lib.rs
@@ -1,9 +1,8 @@
 //! Fuentes GLSL ES 3.00 para GioSer.
 //!
-//! Cada `const &str` es un shader completo, listo para pasar a
-//! `gl.shaderSource()`. No dependemos de ningún backend; el cliente
-//! decide cómo compilarlos. Convención: precision `highp float`,
-//! atributo `a_pos`, varying `v_*`, uniforms `u_*`.
+//! Cada `const &str` es un shader completo listo para `gl.shaderSource()`.
+//! Convención: precision `highp float`, atributo `a_pos`, varying `v_*`,
+//! uniforms `u_*`.
 
 #![no_std]
 
@@ -20,9 +19,9 @@ void main() {
 }
 ";
 
-/// Fragment del fondo cósmico: nubes FBM en 3 capas, 3 estratos de
-/// estrellas con titilación independiente, viñeta, y 4 meteoros
-/// procedurales que cruzan el cielo periódicamente.
+/// Fragment del fondo cósmico: nubes FBM en 3 capas con drift visible,
+/// 3 estratos de estrellas con titilación independiente, viñeta radial,
+/// 4 meteoros procedurales con vida cíclica.
 pub const FS_COSMOS: &str = "#version 300 es
 precision highp float;
 in vec2 v_clip;
@@ -63,7 +62,6 @@ float fbm(vec2 p) {
     return v;
 }
 
-// Meteoro procedural: trazo brillante con cola, vida 1.6s, respawnea solo.
 float meteor(vec2 uv, float seed) {
     float period = 6.5 + 4.0 * hash11(seed * 17.0);
     float t_seeded = u_time + seed * 19.0;
@@ -81,20 +79,16 @@ float meteor(vec2 uv, float seed) {
         hash21(vec2(seed + 1.0, epoch)) * 1.6 - 0.8,
         -0.7 - hash21(vec2(seed + 2.0, epoch)) * 0.6
     ));
-
     vec2 head = origin + dir * t * 2.1;
     vec2 tail = head - dir * 0.24;
-
     vec2 pa = uv - tail;
     vec2 ba = head - tail;
     float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
     float dist = length(pa - ba * h);
-
     float perpGlow = exp(-dist * 420.0);
     float trailFalloff = smoothstep(0.0, 1.0, h);
     float headPulse = exp(-dist * 900.0);
     float lifeFade = sin(t * 3.14159);
-
     return (perpGlow * trailFalloff + headPulse * 1.4) * lifeFade;
 }
 
@@ -103,11 +97,9 @@ void main() {
     vec2 uv = v_clip;
     uv.x *= aspect;
 
-    // === NUBES (drift visible, 5× más rápido que la versión anterior) ===
     vec2 d1 = vec2( u_time * 0.055,  u_time * 0.022) + u_parallax * 0.10;
     vec2 d2 = vec2(-u_time * 0.085,  u_time * 0.058) + u_parallax * 0.22;
     vec2 d3 = vec2( u_time * 0.130, -u_time * 0.095) + u_parallax * 0.40;
-
     float n1 = fbm(uv * 0.85 + d1);
     float n2 = fbm(uv * 2.05 + d2);
     float n3 = fbm(uv * 4.40 + d3);
@@ -117,12 +109,9 @@ void main() {
     color = mix(color, u_nebula_b, pow(n2, 1.85) * 0.62);
     color += u_nebula_a * pow(n3, 3.0) * 0.28;
 
-    // Viñeta radial.
     float r = length(v_clip);
     color *= 1.0 - smoothstep(0.55, 1.40, r) * 0.85;
 
-    // === ESTRELLAS — 3 estratos con titilación distinta ===
-    // Brillantes, pocas, titilan rápido.
     {
         vec2 sgrid = uv * 75.0;
         vec2 sid = floor(sgrid);
@@ -131,7 +120,6 @@ void main() {
         float mask = smoothstep(0.9935, 0.999, sh);
         color += u_stardust * mask * tw * 1.15;
     }
-    // Medianas, densas, titilan lento.
     {
         vec2 sgrid = uv * 135.0 + vec2(7.0, 11.0);
         vec2 sid = floor(sgrid);
@@ -140,7 +128,6 @@ void main() {
         float mask = smoothstep(0.987, 0.994, sh);
         color += u_stardust * mask * tw * 0.75;
     }
-    // Polvo de fondo, muchas, casi sin twinkle.
     {
         vec2 sgrid = uv * 260.0 + vec2(13.0, 3.0);
         vec2 sid = floor(sgrid);
@@ -150,7 +137,6 @@ void main() {
         color += u_stardust * mask * tw * 0.40;
     }
 
-    // === METEOROS (4 procedurales, respawn independiente) ===
     float meteors = 0.0;
     meteors += meteor(uv, 0.31);
     meteors += meteor(uv, 1.73);
@@ -174,10 +160,20 @@ void main() {
 }
 ";
 
-/// Fragment de la chacana mística: SDF de 2 escalones por brazo,
-/// líneas glow + aro + rayos zodiacales + sol central pulsante.
-/// Uniforms: `u_time`, `u_thickness` (s), `u_center_half` (c), `u_arm_extent`,
-/// `u_line_color`, `u_rim_color`, `u_sun_color`, `u_sun_pulse`.
+/// Fragment de la chacana mística (estética dorada del logo GioSer):
+/// 1. **Sol detrás**: halo gauss + corona, visible SÓLO dentro de la superficie
+///    de la chacana (clip por SDF), apenas asomando por las junturas de los pasos.
+/// 2. **Doble outline**: dos líneas paralelas en dorado/ámbar — la chacana se
+///    siente "grabada" sobre el cielo.
+/// 3. **Interior**: niebla oscura translúcida con sutiles rayos radiales
+///    desde el centro (el sol los proyecta a través de la superficie).
+/// 4. **Aro doble exterior**: ring fino + ring grueso (este último con marcas
+///    cardinales de 3 puntos cada una, como en el logo).
+///
+/// Uniforms:
+///   `u_time`, `u_thickness` (s), `u_center_half` (c), `u_arm_extent` (L),
+///   `u_line_color` (gold rim), `u_rim_color` (gold rim oscuro),
+///   `u_sun_color`, `u_sun_pulse`, `u_dark_color` (interior fill).
 pub const FS_CHACANA: &str = "#version 300 es
 precision highp float;
 in vec2 v_world;
@@ -189,26 +185,26 @@ uniform float u_arm_extent;
 uniform vec3  u_line_color;
 uniform vec3  u_rim_color;
 uniform vec3  u_sun_color;
+uniform vec3  u_dark_color;
 uniform float u_sun_pulse;
 
+const float PI = 3.14159265;
+
 float sdBox(vec2 p, vec2 b) {
     vec2 d = abs(p) - b;
     return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
 }
 
-// Chacana de 2 escalones (mística clásica): centro 2c×2c + 4 brazos
-// con 2 niveles. Inner level half-width = 2s, outer (tip) = s.
+// Chacana de 2 escalones (mística clásica de Tiwanaku).
 float sdChacana(vec2 p, float s, float c) {
     float d = sdBox(p, vec2(c, c));
     float hd = 0.5 * s;
-    // Nivel interno (más ancho, pegado al centro).
     float mid1 = c + 0.5 * s;
     float hw1 = 2.0 * s;
-    d = min(d, sdBox(p - vec2(0.0,  mid1), vec2(hw1, hd))); // N
-    d = min(d, sdBox(p - vec2(0.0, -mid1), vec2(hw1, hd))); // S
-    d = min(d, sdBox(p - vec2( mid1, 0.0), vec2(hd, hw1))); // E
-    d = min(d, sdBox(p - vec2(-mid1, 0.0), vec2(hd, hw1))); // W
-    // Punta (más angosta, externa).
+    d = min(d, sdBox(p - vec2(0.0,  mid1), vec2(hw1, hd)));
+    d = min(d, sdBox(p - vec2(0.0, -mid1), vec2(hw1, hd)));
+    d = min(d, sdBox(p - vec2( mid1, 0.0), vec2(hd, hw1)));
+    d = min(d, sdBox(p - vec2(-mid1, 0.0), vec2(hd, hw1)));
     float mid2 = c + 1.5 * s;
     float hw2 = 1.0 * s;
     d = min(d, sdBox(p - vec2(0.0,  mid2), vec2(hw2, hd)));
@@ -218,66 +214,90 @@ float sdChacana(vec2 p, float s, float c) {
     return d;
 }
 
+// 3 puntos pequeños en cada uno de los 4 cardinales sobre el aro grueso.
+float cardinal_dots(vec2 p, float ringR, float dotSize) {
+    float r = length(p);
+    float ang = atan(p.y, p.x);
+    // Acercamiento al aro (gauss tight en r=ringR).
+    float on_ring = exp(-((r - ringR) * (r - ringR)) / (2.0 * dotSize * dotSize));
+    float dots = 0.0;
+    // 4 cardinales en ángulos 0, π/2, π, -π/2.
+    for (int i = 0; i < 4; i++) {
+        float base = float(i) * (PI * 0.5);
+        // 3 puntos por cardinal, offset angular pequeño.
+        for (int j = -1; j <= 1; j++) {
+            float a = base + float(j) * 0.075;
+            float da = ang - a;
+            da = da - 2.0 * PI * floor((da + PI) / (2.0 * PI));
+            dots += exp(-(da * da) / (2.0 * 0.012 * 0.012));
+        }
+    }
+    return on_ring * dots;
+}
+
 void main() {
     vec2 p = v_world;
     float d = sdChacana(p, u_thickness, u_center_half);
     float r = length(p);
 
-    // Línea principal: gaussiana sobre el borde de la chacana.
-    float lineW = 0.011;
-    float line = exp(-(d * d) / (2.0 * lineW * lineW));
-
-    // Glow exterior cae suave hacia el infinito.
-    float glow = exp(-max(d, 0.0) * 8.0) * 0.55;
-
-    // Fill interior, una niebla cyan muy tenue.
-    float fill = smoothstep(0.0, -0.025, d);
-
-    // Aro circular que envuelve la chacana (rasgo del logo).
-    float ringR_outer = u_arm_extent * 1.32;
-    float ringD_outer = abs(r - ringR_outer);
-    float ring_outer = exp(-(ringD_outer * ringD_outer) / (2.0 * 0.008 * 0.008)) * 0.80;
-
-    // Aro interior fino (segundo orbital).
-    float ringR_inner = u_arm_extent * 1.18;
-    float ringD_inner = abs(r - ringR_inner);
-    float ring_inner = exp(-(ringD_inner * ringD_inner) / (2.0 * 0.0035 * 0.0035)) * 0.42;
-
-    // Ventana radial entre arm_extent y el aro exterior — para rayos y muescas.
+    // === SOL DETRÁS ===
+    // Halo grande, sólo visible dentro de la superficie de la chacana.
+    float inside = 1.0 - smoothstep(-0.004, 0.004, d);
+    float sunR = u_thickness * 0.42;
+    float sun = exp(-(r * r) / (2.0 * sunR * sunR));
+    float corR = u_center_half * 0.75;
+    float corona = exp(-(r * r) / (2.0 * corR * corR));
+    float halo = sun * (1.15 + 0.20 * u_sun_pulse) + corona * (0.55 + 0.15 * u_sun_pulse);
+
+    // Rayos radiales sutiles desde el centro, sólo visibles donde la superficie
+    // de la chacana los recibe.
     float ang = atan(p.y, p.x);
-    float band = smoothstep(u_arm_extent * 1.00, u_arm_extent * 1.10, r)
-               * (1.0 - smoothstep(ringR_outer * 0.92, ringR_outer * 1.00, r));
-
-    // Rayos: 12 divisiones (meses andinos / horas), modulados en el tiempo.
-    float rays = pow(abs(cos(ang * 6.0)), 24.0) * band
-               * (0.55 + 0.45 * sin(u_time * 0.7));
-
-    // Marcas cardinales (4 muescas finas) — exponente alto = picos angostos.
-    float card = pow(abs(cos(ang * 2.0)), 120.0) * band * 1.10;
-
-    // Sol central: gauss tight + corona suave + pulso.
-    float sunR = u_thickness * 0.50;
-    float sunDist = r;
-    float sun = exp(-(sunDist * sunDist) / (2.0 * sunR * sunR));
-    float corR = sunR * 5.0;
-    float corona = exp(-(sunDist * sunDist) / (2.0 * corR * corR)) * 0.50;
-    float sunMix = sun * (1.0 + 0.2 * u_sun_pulse) + corona * (0.7 + 0.3 * u_sun_pulse);
+    float radial = pow(abs(cos(ang * 4.0 + sin(u_time * 0.3) * 0.2)), 8.0)
+                 * smoothstep(0.0, u_center_half * 0.8, r)
+                 * (1.0 - smoothstep(u_center_half * 0.85, u_center_half * 1.2, r))
+                 * 0.30;
+
+    // === DOBLE OUTLINE ===
+    // Línea interior (sobre la SDF=0).
+    float lineW1 = 0.0085;
+    float line_in = exp(-(d * d) / (2.0 * lineW1 * lineW1));
+    // Línea exterior paralela, offset 0.018 hacia afuera.
+    float dOff = d - 0.020;
+    float lineW2 = 0.005;
+    float line_out = exp(-(dOff * dOff) / (2.0 * lineW2 * lineW2));
+    float line = line_in * 1.0 + line_out * 0.65;
+
+    // Glow exterior leve.
+    float glow = exp(-max(d, 0.0) * 14.0) * 0.30;
+
+    // === AROS EXTERIORES ===
+    float ringR_main = u_arm_extent * 1.45;
+    float ringD_main = abs(r - ringR_main);
+    float ring_main = exp(-(ringD_main * ringD_main) / (2.0 * 0.005 * 0.005));
+
+    float ringR_inner = u_arm_extent * 1.30;
+    float ringD_inner = abs(r - ringR_inner);
+    float ring_inner = exp(-(ringD_inner * ringD_inner) / (2.0 * 0.003 * 0.003)) * 0.40;
 
-    // Halo del centro: cuadrado oscuro detrás de la chacana para profundidad.
-    float coreShadow = smoothstep(u_center_half * 0.95, u_center_half * 0.3, max(abs(p.x), abs(p.y))) * 0.20;
+    // 4 grupos de 3 puntos cardinales sobre el aro principal.
+    float dots = cardinal_dots(p, ringR_main, 0.008) * 1.10;
 
+    // === COMPOSICIÓN ===
     vec3 col = vec3(0.0);
-    col += u_line_color * line * 1.55;
-    col += u_rim_color  * glow * 1.05;
-    col += u_line_color * ring_outer * 1.00;
-    col += u_rim_color  * ring_inner * 1.15;
-    col += u_rim_color  * rays * 1.20;
-    col += u_line_color * card * 1.30;
-    col += u_sun_color  * sunMix * 1.45;
-    col += vec3(0.05, 0.08, 0.14) * (fill + coreShadow) * 0.6;
+    // Sol detrás (clip a interior).
+    col += u_sun_color * halo * inside * 1.55;
+    col += u_line_color * radial * inside * 0.6;
+    // Niebla oscura translúcida en el interior para profundidad.
+    col += u_dark_color * inside * 0.20;
+    // Líneas y aros.
+    col += u_line_color * line * 1.70;
+    col += u_line_color * glow * 0.95;
+    col += u_line_color * ring_main * 1.45;
+    col += u_rim_color  * ring_inner * 1.05;
+    col += u_line_color * dots * 1.85;
 
     float alpha = clamp(
-        line * 1.2 + glow + ring_outer + ring_inner + rays + card + sunMix + fill * 0.5,
+        halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12,
         0.0, 1.0);
     fragColor = vec4(col, alpha);
 }
@@ -288,10 +308,9 @@ pub const FULLSCREEN_QUAD: [f32; 12] = [
     -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0,
 ];
 
-/// Quad ligeramente mayor que la chacana para no recortar aros ni glow.
-/// `arm_extent` es la distancia centro→punta; multiplicamos por un factor
-/// que cubre el aro exterior (1.32×) más halo.
+/// Quad ligeramente mayor que la chacana + aros + glow.
 pub fn chacana_quad(arm_extent: f32) -> [f32; 12] {
-    let e = arm_extent * 1.65;
+    // Aro principal vive a 1.45 * arm_extent; sumamos margen para el glow.
+    let e = arm_extent * 1.70;
     [-e, -e, e, -e, e, e, -e, -e, e, e, -e, e]
 }
diff --git a/crates/modules/pluma/pluma-md/Cargo.toml b/crates/modules/pluma/pluma-md/Cargo.toml
new file mode 100644
index 0000000..bde0357
--- /dev/null
+++ b/crates/modules/pluma/pluma-md/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "pluma-md"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+pulldown-cmark = { workspace = true }
diff --git a/crates/modules/pluma/pluma-md/src/lib.rs b/crates/modules/pluma/pluma-md/src/lib.rs
new file mode 100644
index 0000000..ed3d2dd
--- /dev/null
+++ b/crates/modules/pluma/pluma-md/src/lib.rs
@@ -0,0 +1,90 @@
+//! Pluma — parser markdown agnóstico, listo para envolver en cualquier viewer.
+//!
+//! Es deliberadamente delgado: wrappea `pulldown-cmark` (todas las
+//! extensiones GFM habilitadas) y emite HTML envuelto en `<div class="pluma-doc">`
+//! con un `data-pluma-theme="…"` para que el CSS del viewer aplique colores
+//! por tema sin necesidad de re-renderear.
+//!
+//! No tiene deps de web/DOM/wasm: corre igual en server, terminal, WASM o
+//! tests. Si necesitás emitir Markdown-AST en lugar de HTML, usá la API
+//! `events()` y construí tu propio renderer.
+
+use pulldown_cmark::{html, Event, Options, Parser};
+
+/// Opciones por default — GFM completo: tables, footnotes, tasklists, strikethrough,
+/// smart punctuation, heading anchors.
+pub fn default_options() -> Options {
+    Options::ENABLE_TABLES
+        | Options::ENABLE_FOOTNOTES
+        | Options::ENABLE_STRIKETHROUGH
+        | Options::ENABLE_TASKLISTS
+        | Options::ENABLE_SMART_PUNCTUATION
+        | Options::ENABLE_HEADING_ATTRIBUTES
+}
+
+/// Markdown → HTML "crudo" (sin wrapper de tema).
+pub fn to_html(md: &str) -> String {
+    let mut out = String::with_capacity(md.len() * 2);
+    let parser = Parser::new_ext(md, default_options());
+    html::push_html(&mut out, parser);
+    out
+}
+
+/// Markdown → HTML envuelto en `<div class="pluma-doc" data-pluma-theme="…">`.
+/// El `theme` es un string opaco (ej. "aire", "fuego") que el CSS del viewer
+/// matchea via `[data-pluma-theme="aire"]`.
+pub fn to_themed_html(md: &str, theme: &str) -> String {
+    let body = to_html(md);
+    let safe_theme: String = theme
+        .chars()
+        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
+        .collect();
+    format!(
+        r#"<article class="pluma-doc" data-pluma-theme="{theme}">{body}</article>"#,
+        theme = safe_theme,
+        body = body
+    )
+}
+
+/// Devuelve un iterador de eventos pulldown-cmark (AST stream).
+/// Útil si querés renderear a algo distinto que HTML.
+pub fn events(md: &str) -> impl Iterator<Item = Event<'_>> {
+    Parser::new_ext(md, default_options())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn renders_h1() {
+        let html = to_html("# Hola");
+        assert!(html.contains("<h1>Hola</h1>"), "got {}", html);
+    }
+
+    #[test]
+    fn renders_list() {
+        let html = to_html("- a\n- b\n");
+        assert!(html.contains("<li>a</li>"));
+        assert!(html.contains("<li>b</li>"));
+    }
+
+    #[test]
+    fn themed_wrapper_sanitizes_theme_name() {
+        let html = to_themed_html("# x", "aire<script>");
+        assert!(html.contains(r#"data-pluma-theme="airescript""#));
+    }
+
+    #[test]
+    fn renders_code_fence() {
+        let html = to_html("```rust\nfn main(){}\n```");
+        assert!(html.contains("<pre><code") && html.contains("fn main"));
+    }
+
+    #[test]
+    fn renders_table_gfm() {
+        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
+        let html = to_html(md);
+        assert!(html.contains("<table>"), "got {}", html);
+    }
+}
diff --git a/crates/modules/pluma/pluma-reader-web/Cargo.toml b/crates/modules/pluma/pluma-reader-web/Cargo.toml
new file mode 100644
index 0000000..47faec3
--- /dev/null
+++ b/crates/modules/pluma/pluma-reader-web/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pluma-reader-web"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+pluma-md = { path = "../pluma-md" }
+wasm-bindgen.workspace = true
+wasm-bindgen-futures.workspace = true
+js-sys.workspace = true
+
+[dependencies.web-sys]
+workspace = true
+features = [
+    "Window",
+    "Document",
+    "Element",
+    "HtmlElement",
+    "CssStyleDeclaration",
+    "Response",
+    "console",
+]
diff --git a/crates/modules/pluma/pluma-reader-web/src/lib.rs b/crates/modules/pluma/pluma-reader-web/src/lib.rs
new file mode 100644
index 0000000..4b242ef
--- /dev/null
+++ b/crates/modules/pluma/pluma-reader-web/src/lib.rs
@@ -0,0 +1,88 @@
+//! Pluma reader — visor de markdown elegante para WASM/web.
+//!
+//! Toma un `<div>` que actúa como contenedor y le inyecta el HTML
+//! producido por `pluma-md`. El styling (fonts, colores, animaciones)
+//! lo provee el CSS del host: este crate no inyecta estilos, sólo
+//! marcado y `data-pluma-theme="…"` para que el CSS reaccione.
+//!
+//! Patrón de uso:
+//!
+//! ```ignore
+//! let container = document.get_element_by_id("drawer-aire-content")?
+//!     .dyn_into::<HtmlElement>()?;
+//! let reader = Reader::new(container);
+//! reader.show_loading();
+//! wasm_bindgen_futures::spawn_local(async move {
+//!     let _ = reader.open_url("./md/aire.md", "aire").await;
+//! });
+//! ```
+
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use wasm_bindgen_futures::JsFuture;
+use web_sys::{HtmlElement, Response};
+
+pub struct Reader {
+    container: HtmlElement,
+}
+
+impl Reader {
+    pub fn new(container: HtmlElement) -> Self {
+        Self { container }
+    }
+
+    pub fn container(&self) -> &HtmlElement {
+        &self.container
+    }
+
+    /// Inyecta un mensaje de carga mientras se resuelve `open_url`.
+    pub fn show_loading(&self) {
+        self.container.set_inner_html(
+            r#"<div class="pluma-loading" aria-live="polite">…</div>"#,
+        );
+    }
+
+    /// Inyecta un mensaje de error visible.
+    pub fn show_error(&self, msg: &str) {
+        let safe: String = msg.replace('<', "&lt;").replace('>', "&gt;");
+        self.container.set_inner_html(&format!(
+            r#"<div class="pluma-error">{}</div>"#,
+            safe
+        ));
+    }
+
+    /// Renderea un string markdown directamente, sin fetch.
+    pub fn render_md(&self, md: &str, theme: &str) {
+        let html = pluma_md::to_themed_html(md, theme);
+        self.container.set_inner_html(&html);
+    }
+
+    /// Inyecta HTML pre-renderizado (sin parsear). Útil si el caller ya
+    /// hizo el parse en otro lado.
+    pub fn render_html(&self, html: &str) {
+        self.container.set_inner_html(html);
+    }
+
+    /// Limpia el contenedor.
+    pub fn clear(&self) {
+        self.container.set_inner_html("");
+    }
+
+    /// Fetcha la URL, parsea el markdown y lo renderea con el tema dado.
+    /// El loader muestra un placeholder mientras la promesa está pendiente.
+    pub async fn open_url(&self, url: &str, theme: &str) -> Result<(), JsValue> {
+        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
+        self.show_loading();
+        let resp_value = JsFuture::from(window.fetch_with_str(url)).await?;
+        let resp: Response = resp_value.dyn_into()?;
+        if !resp.ok() {
+            let err = format!("HTTP {} para {}", resp.status(), url);
+            self.show_error(&err);
+            return Err(JsValue::from_str(&err));
+        }
+        let text_value = JsFuture::from(resp.text()?).await?;
+        let md = text_value.as_string().unwrap_or_default();
+        self.render_md(&md, theme);
+        Ok(())
+    }
+}