tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Definitions

feat(gioser/web): fix mobile swipe, taskbar agnóstica, trazos zodiacales

Mobile drag fix (vista-web):
- pointermove listener ahora con `AddEventListenerOptions { passive: false }`.
  Sin esto, en navegadores móviles `preventDefault()` es no-op y el browser
  se traga el gesto horizontal como pan/scroll antes de que JS pueda
  detectar la dirección y capturar el pointer.
- CSS: `.deck-strip` y `.deck-strip *` y `.deck-page` con
  `touch-action: pan-y`. El touch-action del target inmediato es lo que
  el browser consulta; sin esto, sobre un <p> dentro del strip el browser
  asume `auto` y reclama horizontal.

Taskbar agnóstica (barra-web):
- Nuevo crate `crates/modules/barra/barra-web` que maneja sólo el LIST
  dinámico de tareas; el resto del layout (home, brand, credits) es del
  host. Misma filosofía que vista-web: separar lo reusable.
- API: Task::new(id, label).active() builder; TaskList::mount(ul) +
  set_tasks/on_click/task_center. Click delegado, callback recibe
  (id, cx, cy) en CSS pixels para origin de animaciones.
- Sanitiza IDs a [a-zA-Z0-9_-] y HTML-escapa labels.
- 3 tests unitarios.
- gioser-web refactoreado para consumir TaskList: sync_taskbar arma
  Vec<Task> y delega; on_click del taskbar dispara minimize/restore_from_tab
  según estado. install_taskbar reducido a sólo home buttons.

Trazos zodiacales (gioser-shaders + canvas-web):
- 12 líneas radiales muy sutiles entre la chacana y el aro principal, una
  por signo, con colores significativos:
    Aries→fuego rojo, Tauro→tierra verde, Géminis→aire amarillo,
    Cáncer→agua plata, Leo→fuego dorado, Virgo→tierra marrón,
    Libra→aire rosa, Escorpio→agua rojo profundo, Sagitario→fuego púrpura,
    Capricornio→tierra verde oscuro, Acuario→aire celeste, Piscis→agua
    verde mar.
- Aries empieza en el norte, giran en sentido horario (rueda zodiacal
  clásica). Banda radial r∈[1.05*L, 0.96*ringR_main], gauss angular
  con σ=0.0042 rad (~0.24° de ancho), multiplier 0.55 → apenas visible.
- Uniform `vec3 u_zodiac[12]` subido como array plano de 36 floats vía
  uniform3fv. Constante ZODIAC_COLORS expuesta en canvas-web por si otros
  callers la quieren.

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

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

7728013012444c20c7fe7c4248c0bc7c296f43f7 · by sergio · (5 months ago) · parent 62058ab19 · browse the tree at this commit

Semantic delta · 25

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

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

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

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

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

11 files · +370 −53

diff --git a/Cargo.lock b/Cargo.lock
index 2979fa3..233634e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -885,6 +885,15 @@ dependencies = [
  "windows-link 0.2.1",
 ]
 
+[[package]]
+name = "barra-web"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "base-x"
 version = "0.2.11"
@@ -3948,6 +3957,7 @@ version = "0.1.0"
 name = "gioser-web"
 version = "0.1.0"
 dependencies = [
+ "barra-web",
  "gioser-canvas-web",
  "js-sys",
  "pluma-reader-web",
diff --git a/Cargo.toml b/Cargo.toml
index f6610fc..9eaf3d9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -128,6 +128,11 @@ members = [
     # ============================================================
     "crates/modules/vista/vista-web",
 
+    # ============================================================
+    # modules/barra/ — taskbar agnóstica estilo Windows
+    # ============================================================
+    "crates/modules/barra/barra-web",
+
     # ============================================================
     # apps/ — apps que consumen el protocolo (yahweh modules+shell)
     # ============================================================
diff --git a/crates/apps/gioser-web/Cargo.toml b/crates/apps/gioser-web/Cargo.toml
index 5ae02ee..3fcd66f 100644
--- a/crates/apps/gioser-web/Cargo.toml
+++ b/crates/apps/gioser-web/Cargo.toml
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
 gioser-canvas-web = { path = "../../modules/gioser/gioser-canvas-web" }
 pluma-reader-web = { path = "../../modules/pluma/pluma-reader-web" }
 vista-web = { path = "../../modules/vista/vista-web" }
+barra-web = { path = "../../modules/barra/barra-web" }
 wasm-bindgen.workspace = true
 wasm-bindgen-futures.workspace = true
 js-sys.workspace = true
diff --git a/crates/apps/gioser-web/src/lib.rs b/crates/apps/gioser-web/src/lib.rs
index ffdcac4..86f9b3e 100644
--- a/crates/apps/gioser-web/src/lib.rs
+++ b/crates/apps/gioser-web/src/lib.rs
@@ -19,6 +19,7 @@
 use std::cell::RefCell;
 use std::rc::Rc;
 
+use barra_web::{Task, TaskList};
 use gioser_canvas_web::{tips, Renderer};
 use pluma_reader_web::Reader;
 use vista_web::Deck;
@@ -48,6 +49,7 @@ struct DeckState {
 struct AppState {
     document: Document,
     deck: Deck,
+    taskbar: TaskList,
     state: RefCell<DeckState>,
 }
 
@@ -195,22 +197,18 @@ impl AppState {
 
     fn sync_taskbar(&self) {
         let s = self.state.borrow();
-        if let Some(list) = self.document.get_element_by_id("taskbar-list") {
-            let mut html = String::new();
-            for e in &s.pages {
-                let label = e.to_uppercase();
-                let active = if s.active.as_deref() == Some(e.as_str()) {
-                    " active"
-                } else {
-                    ""
-                };
-                html.push_str(&format!(
-                    "<li><button class=\"taskbar-item{active}\" data-task=\"{e}\" type=\"button\">\
-                     <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label}</button></li>"
-                ));
-            }
-            list.set_inner_html(&html);
-        }
+        let tasks: Vec<Task> = s
+            .pages
+            .iter()
+            .map(|e| {
+                let mut t = Task::new(e.clone(), e.to_uppercase());
+                if s.active.as_deref() == Some(e.as_str()) {
+                    t = t.active();
+                }
+                t
+            })
+            .collect();
+        self.taskbar.set_tasks(&tasks);
     }
 
     fn ensure_page_dom(&self, element: &str) {
@@ -294,8 +292,7 @@ impl AppState {
     }
 
     fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
-        let sel = format!(".taskbar-item[data-task=\"{}\"]", element);
-        self.element_center(&sel)
+        self.taskbar.task_center(element)
     }
 
     fn viewport_height(&self) -> f64 {
@@ -337,9 +334,17 @@ pub fn boot() -> Result<(), JsValue> {
         .dyn_into()?;
     let deck = Deck::mount(strip_el)?;
 
+    // Mount barra-web taskbar (manages the dynamic task list).
+    let list_el: HtmlElement = document
+        .get_element_by_id("taskbar-list")
+        .ok_or_else(|| JsValue::from_str("no #taskbar-list"))?
+        .dyn_into()?;
+    let taskbar = TaskList::mount(list_el)?;
+
     let app = Rc::new(AppState {
         document: document.clone(),
         deck: deck.clone(),
+        taskbar: taskbar.clone(),
         state: RefCell::default(),
     });
 
@@ -351,6 +356,19 @@ pub fn boot() -> Result<(), JsValue> {
         });
     }
 
+    // barra on_click → restore / toggle minimize del app
+    {
+        let app2 = app.clone();
+        taskbar.on_click(move |id, cx, cy| {
+            let is_active = app2.state.borrow().active.as_deref() == Some(id);
+            if is_active {
+                app2.minimize(cx, cy);
+            } else {
+                app2.restore_from_tab(id, cx, cy);
+            }
+        });
+    }
+
     install_resize(&window, &canvas, &renderer)?;
     install_mouse(&document, &canvas, &renderer)?;
     install_canvas_pointer(&canvas, &renderer)?;
@@ -517,8 +535,9 @@ fn center_of_element(el: &Element) -> (f64, f64) {
     )
 }
 
+/// Home button + brand link (data-home) — toda la lógica de tabs vive en
+/// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home].
 fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
-    // Home button & brand link comparten data-home — minimizan todo.
     let homes = document.query_selector_all("[data-home]")?;
     for i in 0..homes.length() {
         let Some(node) = homes.item(i) else { continue };
@@ -533,34 +552,6 @@ fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValu
         el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
         cb.forget();
     }
-
-    // Delegación en la lista de tabs.
-    if let Some(list) = document.get_element_by_id("taskbar-list") {
-        let app2 = app.clone();
-        let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
-            let Some(target) = e.target() else { return };
-            let Ok(target_el): Result<Element, _> = target.dyn_into() else {
-                return;
-            };
-            let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
-                return;
-            };
-            if let Some(task) = item.get_attribute("data-task") {
-                let rect = item.get_bounding_client_rect();
-                let cx = rect.left() + rect.width() / 2.0;
-                let cy = rect.top() + rect.height() / 2.0;
-                // Si la pestaña ya está activa, minimiza (toggle estilo Windows).
-                let is_active = app2.state.borrow().active.as_deref() == Some(&task);
-                if is_active {
-                    app2.minimize(cx, cy);
-                } else {
-                    app2.restore_from_tab(&task, cx, cy);
-                }
-            }
-        });
-        list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
-        cb.forget();
-    }
     Ok(())
 }
 
diff --git a/crates/apps/gioser-web/styles.css b/crates/apps/gioser-web/styles.css
index 4b8a005..c53ea2b 100644
--- a/crates/apps/gioser-web/styles.css
+++ b/crates/apps/gioser-web/styles.css
@@ -185,7 +185,11 @@ body.deck-active-fuego  .deck { --deck-glow: rgba(245, 144, 86, 0.28); }
 body.deck-active-agua   .deck { --deck-glow: rgba(108, 208, 243, 0.22); }
 body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
 
-/* Strip horizontal con páginas — vista-web traslada esto. */
+/* Strip horizontal con páginas — vista-web traslada esto.
+   touch-action: pan-y declara al browser "yo manejo horizontal, el
+   vertical (scroll interno de cada página) lo dejas pasar". Sin esto
+   el navegador móvil se traga el gesto horizontal antes de que JS
+   pueda capturarlo. */
 .deck-strip {
   display: flex;
   flex-direction: row;
@@ -194,6 +198,13 @@ body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
   transform: translate3d(var(--vista-offset, 0px), 0, 0);
   transition: transform 360ms var(--ease-page);
   will-change: transform;
+  touch-action: pan-y;
+}
+/* Asegurar que TODOS los descendientes del strip hereden el contrato
+   touch-action — si el toque llega a un párrafo o <a>, el browser
+   chequea el touch-action del target, no del padre. */
+.deck-strip * {
+  touch-action: pan-y;
 }
 .deck-strip.vista-dragging,
 .deck-strip.vista-instant {
@@ -207,9 +218,7 @@ body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
   position: relative;
   overflow-y: auto;
   overflow-x: hidden;
-  /* contenido alineado al pop-snap mismo si la lista de páginas viviera
-     dentro de scroll nativo; con vista-web esto es informativo nomás. */
-  scroll-snap-align: start;
+  touch-action: pan-y;
 }
 .deck-page[data-element="aire"]   { --page-accent: var(--aire); }
 .deck-page[data-element="fuego"]  { --page-accent: var(--fuego); }
diff --git a/crates/modules/barra/barra-web/Cargo.toml b/crates/modules/barra/barra-web/Cargo.toml
new file mode 100644
index 0000000..696e508
--- /dev/null
+++ b/crates/modules/barra/barra-web/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "barra-web"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+wasm-bindgen.workspace = true
+js-sys.workspace = true
+
+[dependencies.web-sys]
+workspace = true
+features = [
+    "Window",
+    "Document",
+    "Element",
+    "HtmlElement",
+    "DomRect",
+    "Event",
+    "EventTarget",
+    "MouseEvent",
+]
diff --git a/crates/modules/barra/barra-web/src/lib.rs b/crates/modules/barra/barra-web/src/lib.rs
new file mode 100644
index 0000000..297bbde
--- /dev/null
+++ b/crates/modules/barra/barra-web/src/lib.rs
@@ -0,0 +1,189 @@
+//! Barra-web — taskbar estilo Windows, agnóstica del dominio.
+//!
+//! Maneja la lista dinámica de "tareas" (cajitas, una por ventana abierta)
+//! dentro de un elemento `<ul>` provisto por el host. El layout del resto
+//! de la barra (home button, brand, créditos, dividers, etc.) es
+//! responsabilidad del host — el módulo sólo se encarga del LIST + CLICK.
+//!
+//! Contrato HTML mínimo:
+//! ```html
+//! <ul id="my-tasks" class="taskbar-list" role="presentation"></ul>
+//! ```
+//!
+//! Convenciones de clase generadas:
+//! - `.taskbar-item` — cada cajita
+//! - `.taskbar-item.active` — la cajita visible/foreground
+//! - `.taskbar-item-dot` — punto decorativo dentro de la cajita
+//! - `data-task="<id>"` — identificador único usable por CSS para theming
+//!   (`.taskbar-item[data-task="aire"] { --task-color: ... }`)
+//!
+//! El módulo NO inyecta CSS — el host estiliza estas clases.
+//!
+//! ```rust,ignore
+//! let list: HtmlElement = doc.get_element_by_id("my-tasks")?.dyn_into()?;
+//! let bar = barra_web::TaskList::mount(list)?;
+//! bar.set_tasks(&[
+//!     Task::new("aire", "AIRE"),
+//!     Task::new("fuego", "FUEGO").active(),
+//! ]);
+//! bar.on_click(|id, cx, cy| {
+//!     // El click cayó en la cajita `id`. (cx, cy) es el centro de la
+//!     // cajita en CSS pixels — útil como origin de animaciones.
+//! });
+//! ```
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{Element, HtmlElement, MouseEvent};
+
+/// Una tarea (cajita) en la barra.
+#[derive(Clone, Debug)]
+pub struct Task {
+    pub id: String,
+    pub label: String,
+    pub active: bool,
+}
+
+impl Task {
+    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
+        Self {
+            id: id.into(),
+            label: label.into(),
+            active: false,
+        }
+    }
+
+    pub fn active(mut self) -> Self {
+        self.active = true;
+        self
+    }
+}
+
+#[derive(Clone)]
+pub struct TaskList {
+    list: HtmlElement,
+    on_click: Rc<RefCell<Option<Box<dyn FnMut(&str, f64, f64)>>>>,
+}
+
+impl TaskList {
+    /// Monta el módulo sobre el elemento `<ul>` provisto. Instala un único
+    /// listener de click delegado: cualquier click dentro del list que caiga
+    /// sobre un `.taskbar-item` dispara `on_click(id, cx, cy)`.
+    pub fn mount(list: HtmlElement) -> Result<Self, JsValue> {
+        let on_click: Rc<RefCell<Option<Box<dyn FnMut(&str, f64, f64)>>>> =
+            Rc::new(RefCell::new(None));
+        let on_click2 = on_click.clone();
+        let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
+            let Some(target) = e.target() else { return };
+            let Ok(target_el): Result<Element, _> = target.dyn_into() else {
+                return;
+            };
+            let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
+                return;
+            };
+            let Some(id) = item.get_attribute("data-task") else {
+                return;
+            };
+            let rect = item.get_bounding_client_rect();
+            let cx = rect.left() + rect.width() / 2.0;
+            let cy = rect.top() + rect.height() / 2.0;
+            if let Some(cb) = on_click2.borrow_mut().as_mut() {
+                cb(&id, cx, cy);
+            }
+        });
+        list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+        cb.forget();
+        Ok(Self { list, on_click })
+    }
+
+    /// Reemplaza el contenido de la lista con las tareas dadas.
+    /// Los IDs se filtran a `[a-zA-Z0-9_-]` para uso seguro en atributos.
+    /// Los labels se HTML-escapan.
+    pub fn set_tasks(&self, tasks: &[Task]) {
+        let mut html = String::new();
+        for t in tasks {
+            let id_safe = sanitize_attr(&t.id);
+            let label_safe = escape_text(&t.label);
+            let active_cls = if t.active { " active" } else { "" };
+            html.push_str(&format!(
+                "<li><button class=\"taskbar-item{active_cls}\" data-task=\"{id_safe}\" type=\"button\">\
+                 <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label_safe}</button></li>"
+            ));
+        }
+        self.list.set_inner_html(&html);
+    }
+
+    /// Registra (o reemplaza) el callback al click sobre una cajita.
+    /// El callback recibe `(id, center_x, center_y)` en CSS pixels.
+    pub fn on_click<F: FnMut(&str, f64, f64) + 'static>(&self, cb: F) {
+        *self.on_click.borrow_mut() = Some(Box::new(cb));
+    }
+
+    /// Centro en CSS pixels de la cajita con `id` dado, o `None` si no existe.
+    pub fn task_center(&self, id: &str) -> Option<(f64, f64)> {
+        let sel = format!(".taskbar-item[data-task=\"{}\"]", sanitize_attr(id));
+        let el = self.list.query_selector(&sel).ok().flatten()?;
+        let rect = el.get_bounding_client_rect();
+        Some((
+            rect.left() + rect.width() / 2.0,
+            rect.top() + rect.height() / 2.0,
+        ))
+    }
+
+    /// Acceso al elemento `<ul>` host por si el caller quiere modificar
+    /// styling o ARIA atributos directamente.
+    pub fn list_el(&self) -> &HtmlElement {
+        &self.list
+    }
+}
+
+fn sanitize_attr(s: &str) -> String {
+    s.chars()
+        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
+        .collect()
+}
+
+fn escape_text(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            c => out.push(c),
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn task_builder_defaults_inactive() {
+        let t = Task::new("aire", "AIRE");
+        assert!(!t.active);
+        let t2 = Task::new("fuego", "FUEGO").active();
+        assert!(t2.active);
+    }
+
+    #[test]
+    fn sanitize_attr_removes_unsafe_chars() {
+        assert_eq!(sanitize_attr("aire"), "aire");
+        assert_eq!(sanitize_attr("a-b_c"), "a-b_c");
+        assert_eq!(sanitize_attr("ai<re>"), "aire");
+        assert_eq!(sanitize_attr("a\"b"), "ab");
+    }
+
+    #[test]
+    fn escape_text_escapes_html() {
+        assert_eq!(escape_text("AIRE"), "AIRE");
+        assert_eq!(escape_text("<script>"), "&lt;script&gt;");
+        assert_eq!(escape_text("a & b"), "a &amp; b");
+    }
+}
diff --git a/crates/modules/gioser/gioser-canvas-web/src/lib.rs b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
index 6da50b0..bb34011 100644
--- a/crates/modules/gioser/gioser-canvas-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
@@ -42,6 +42,30 @@ pub mod tips {
     ];
 }
 
+/// Colores zodiacales en orden Aries→Piscis. Sigue la asignación tradicional
+/// por triplicidad elemental:
+///   fuego: aries, leo, sagitario     (rojo, dorado, púrpura)
+///   tierra: tauro, virgo, capricornio (verde, marrón, verde oscuro)
+///   aire: géminis, libra, acuario     (amarillo, rosa, celeste)
+///   agua: cáncer, escorpio, piscis    (plata, rojo profundo, verde mar)
+///
+/// El shader los recibe como `uniform vec3 u_zodiac[12]` y los dibuja como
+/// trazos radiales muy sutiles entre la chacana y el aro exterior.
+pub const ZODIAC_COLORS: [[f32; 3]; 12] = [
+    [0.95, 0.30, 0.20], // 0 Aries — fuego rojo
+    [0.35, 0.65, 0.30], // 1 Tauro — tierra verde
+    [0.95, 0.85, 0.30], // 2 Géminis — aire amarillo
+    [0.80, 0.88, 0.95], // 3 Cáncer — agua plata
+    [0.98, 0.65, 0.20], // 4 Leo — fuego dorado
+    [0.62, 0.50, 0.32], // 5 Virgo — tierra marrón
+    [0.95, 0.65, 0.82], // 6 Libra — aire rosa
+    [0.55, 0.15, 0.22], // 7 Escorpio — agua rojo profundo
+    [0.60, 0.30, 0.85], // 8 Sagitario — fuego púrpura
+    [0.22, 0.45, 0.28], // 9 Capricornio — tierra verde oscuro
+    [0.48, 0.78, 0.95], // 10 Acuario — aire celeste
+    [0.22, 0.72, 0.62], // 11 Piscis — agua verde mar
+];
+
 pub struct Renderer {
     gl: GL,
     cosmos_prog: Program,
@@ -201,6 +225,7 @@ impl Renderer {
                 "u_fuego_color",
                 "u_tierra_color",
                 "u_agua_color",
+                "u_zodiac[0]",
                 "u_sun_pulse",
             ],
         )
@@ -436,6 +461,17 @@ impl Renderer {
             self.chacana_prog.u("u_agua_color"),
             gioser_palette::elements::AGUA,
         );
+        // Subir las 12 colores zodiacales como vec3[12]. Aplanamos a un único
+        // slice de 36 floats; uniform3fv interpreta cada terna como vec3.
+        if let Some(u) = self.chacana_prog.u("u_zodiac[0]") {
+            let mut flat = [0.0f32; 36];
+            for (i, c) in ZODIAC_COLORS.iter().enumerate() {
+                flat[i * 3] = c[0];
+                flat[i * 3 + 1] = c[1];
+                flat[i * 3 + 2] = c[2];
+            }
+            gl.uniform3fv_with_f32_array(Some(u), &flat);
+        }
         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-shaders/src/lib.rs b/crates/modules/gioser/gioser-shaders/src/lib.rs
index 0ccb486..773dbc5 100644
--- a/crates/modules/gioser/gioser-shaders/src/lib.rs
+++ b/crates/modules/gioser/gioser-shaders/src/lib.rs
@@ -190,6 +190,7 @@ uniform vec3  u_aire_color;
 uniform vec3  u_fuego_color;
 uniform vec3  u_tierra_color;
 uniform vec3  u_agua_color;
+uniform vec3  u_zodiac[12];
 uniform float u_sun_pulse;
 
 const float PI = 3.14159265;
@@ -361,6 +362,43 @@ void main() {
     particles += element_particles(p, vec2(0.0, -L), vec2(0.0, -1.0), u_tierra_color, 2, 3.11);
     particles += element_particles(p, vec2(-L, 0.0), vec2(-1.0, 0.0), u_agua_color,   3, 5.97);
 
+    // === TRAZOS ZODIACALES ===
+    // 12 líneas radiales muy sutiles entre la chacana y el aro principal,
+    // una por signo, con sus colores significativos (Aries=fuego rojo,
+    // Tauro=tierra verde, Géminis=aire amarillo, Cáncer=agua plata, ...).
+    // Aries arranca en el norte y giran en sentido horario (rueda zodiacal
+    // clásica).
+    vec3 zodiac = vec3(0.0);
+    {
+        float seg = 2.0 * PI / 12.0;
+        // delta = ángulo medido desde el norte, en sentido horario, en [0, 2π).
+        float delta = (PI * 0.5) - ang;
+        delta = mod(delta + 8.0 * PI, 2.0 * PI);
+        // Índice del signo más cercano.
+        float k_round = mod(floor(delta / seg + 0.5), 12.0);
+        int k = int(k_round);
+        // Distancia angular al centro del segmento de ese signo.
+        float center_delta = k_round * seg;
+        float ang_diff = delta - center_delta;
+        // Wrap a (-π, π].
+        if (ang_diff >  PI) ang_diff -= 2.0 * PI;
+        if (ang_diff < -PI) ang_diff += 2.0 * PI;
+        float ang_dist = abs(ang_diff);
+
+        // Línea fina, gaussiana.
+        float lineW = 0.0042;
+        float line = exp(-(ang_dist * ang_dist) / (2.0 * lineW * lineW));
+
+        // Banda radial: arranca un poco fuera de la punta de la chacana y
+        // termina antes del aro principal.
+        float r_inner = u_arm_extent * 1.05;
+        float r_outer = ringR_main * 0.96;
+        float band = smoothstep(r_inner, r_inner + 0.035, r)
+                   * (1.0 - smoothstep(r_outer - 0.035, r_outer, r));
+
+        zodiac = u_zodiac[k] * line * band;
+    }
+
     // === COMPOSICIÓN ===
     vec3 col = vec3(0.0);
     // Sol detrás (clip a interior).
@@ -375,10 +413,13 @@ void main() {
     col += u_rim_color  * ring_inner * 1.05;
     col += u_line_color * dots * 1.85;
     col += particles * 1.25;
+    col += zodiac * 0.55; // muy sutil — apenas visible.
 
+    float zodiac_lum = zodiac.r + zodiac.g + zodiac.b;
     float alpha = clamp(
         halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12
-            + (particles.r + particles.g + particles.b) * 0.5,
+            + (particles.r + particles.g + particles.b) * 0.5
+            + zodiac_lum * 0.3,
         0.0, 1.0);
     fragColor = vec4(col, alpha);
 }
diff --git a/crates/modules/vista/vista-web/Cargo.toml b/crates/modules/vista/vista-web/Cargo.toml
index 0357ccc..c25d012 100644
--- a/crates/modules/vista/vista-web/Cargo.toml
+++ b/crates/modules/vista/vista-web/Cargo.toml
@@ -23,4 +23,5 @@ features = [
     "EventTarget",
     "PointerEvent",
     "MouseEvent",
+    "AddEventListenerOptions",
 ]
diff --git a/crates/modules/vista/vista-web/src/lib.rs b/crates/modules/vista/vista-web/src/lib.rs
index 91eb60a..02cfbd7 100644
--- a/crates/modules/vista/vista-web/src/lib.rs
+++ b/crates/modules/vista/vista-web/src/lib.rs
@@ -189,10 +189,20 @@ fn install_pointermove(
             let _ = strip2
                 .style()
                 .set_property("--vista-offset", &format!("{}px", offset));
+            // CRÍTICO en móvil: con listener passive el preventDefault sería
+            // un no-op y el browser se llevaría el gesto como pan/scroll.
             e.prevent_default();
         }
     });
-    strip.add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
+    // `passive: false` es la diferencia entre que el swipe funcione o no en
+    // navegadores móviles. Default = passive, donde preventDefault no aplica.
+    let opts = web_sys::AddEventListenerOptions::new();
+    opts.set_passive(false);
+    strip.add_event_listener_with_callback_and_add_event_listener_options(
+        "pointermove",
+        cb.as_ref().unchecked_ref(),
+        &opts,
+    )?;
     cb.forget();
     Ok(())
 }