gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definitions

feat(gioser): shake on click, mouseleave rebound, element particles, taskbar

Renderer (gioser-canvas-web):
- Spring shake (SpringDamper1, 7.5 Hz / ζ=0.13) aplicado como rotación Z
  en el MVP. impulse_click() inyecta velocidad alternada → vibración fuerte
  con ~5 ciclos decayendo en ~0.8s.
- release_tilt() pone target del tilt en (0,0) → la chacana cae al frente
  con el rebote natural del spring sub-crítico.
- world_scale_for_aspect(): en portrait (aspect<1) escala baja proporcional
  para que el aro exterior no se corte por los lados. Base 1.05, piso 0.45.
- click_radius_css_px() expone radio del aro en CSS-pixels desde el centro
  del canvas; la app lo usa para hit-test del impulso.
- set_client_size() separa CSS-pixels de device-pixels (DPR).
- tilt_degrees() ahora retorna (pitch, yaw, roll) — el brand replica los 3.
- 4 nuevos uniforms u_aire/fuego/tierra/agua_color para el shader de
  partículas.

Shader (gioser-shaders/FS_CHACANA):
- Función element_particles(tip, outward, color, kind) → 4 partículas por
  cardinal con personalidad: AIRE drift+sway, FUEGO rise+flicker (siempre
  hacia +Y), TIERRA cae, AGUA ondula descendiendo. Gauss + envelope
  sinusoidal en la vida. ~16 partículas total, costo modesto.

App (gioser-web):
- pointerdown en canvas → si distancia al centro < click_radius_css_px →
  impulse_click(). Touch y mouse vienen unificados por PointerEvent.
- mouseleave en canvas → release_tilt(). Sin set_target, el spring se
  quedaría en la última posición — ahora vuelve al frente con rebote.
- position_tips ahora clampea raw_x/raw_y a [margin, viewport - taskbar -
  margin] en CSS pixels. Los botones NUNCA salen del canvas ni cubren la
  taskbar incluso en aspect extremos o tilt máximo.
- AppState + TaskbarState (RefCell): trackea drawers abiertos + activo.
  open_tab/switch_tab/close_tab/home aplican mutación + sync().
- sync() rebuild de taskbar-list innerHTML por cada cambio de estado,
  más swap de body classes + drawer .open classes.
- Click delegation en taskbar-list — un listener para todas las cajitas.
- Botón home con data-home en la barra (svg de casa) cierra todo y
  limpia el taskbar.
- Escape también cierra el drawer activo.
- update_tilt_css ahora setea --tilt-z también — brand title roll
  visible en el shake.

CSS:
- .drawer bottom: 52px (reserva taskbar).
- .taskbar full ancho fixed bottom, glass + gold border, scrollable horiz
  para muchas cajitas.
- .taskbar-item con --task-color por elemento (aire/fuego/tierra/agua),
  .active glow del color + inset border bottom.
- .taskbar-home con svg de casa dorado, hover glow.
- Responsive: taskbar 46px en mobile + ajustes.
- .brand transform agrega rotateZ(--tilt-z) para que el título vibre con
  la chacana en click impulses.

Workspace verde + 18 tests.

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

5e0fcae4b438491c340d858d9a907103d366593e · by sergio · (5 months ago) · parent fce630c8d · 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-shaders/src/lib.rs

6 files · +664 −137

diff --git a/crates/apps/gioser-web/Cargo.toml b/crates/apps/gioser-web/Cargo.toml
index e666b52..9235d8e 100644
--- a/crates/apps/gioser-web/Cargo.toml
+++ b/crates/apps/gioser-web/Cargo.toml
@@ -22,6 +22,7 @@ features = [
     "Window",
     "Document",
     "Element",
+    "Node",
     "HtmlElement",
     "HtmlCanvasElement",
     "CssStyleDeclaration",
@@ -30,6 +31,7 @@ features = [
     "Event",
     "EventTarget",
     "MouseEvent",
+    "PointerEvent",
     "KeyboardEvent",
     "NodeList",
     "Performance",
diff --git a/crates/apps/gioser-web/index.html b/crates/apps/gioser-web/index.html
index 7d5173a..884361d 100644
--- a/crates/apps/gioser-web/index.html
+++ b/crates/apps/gioser-web/index.html
@@ -105,6 +105,20 @@
     <section class="drawer-content" id="drawer-agua-content"></section>
   </aside>
 
+  <!-- TASKBAR estilo Windows: home a la izquierda + cajitas dinámicas
+       de las vistas MD abiertas. Sincronizada por WASM. -->
+  <nav class="taskbar" aria-label="Vistas abiertas">
+    <button class="taskbar-home" data-home aria-label="Volver al home" type="button">
+      <svg viewBox="0 0 24 24" class="taskbar-home-glyph" aria-hidden="true">
+        <path d="M3 12 L12 3 L21 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
+        <path d="M5 11 V20 H10 V14 H14 V20 H19 V11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
+        <circle cx="12" cy="17" r="0.8" fill="currentColor"/>
+      </svg>
+    </button>
+    <span class="taskbar-divider" aria-hidden="true"></span>
+    <ul class="taskbar-list" id="taskbar-list" role="presentation"></ul>
+  </nav>
+
   <script type="module">
     import init from "./pkg/gioser_web.js";
     init().catch(err => {
diff --git a/crates/apps/gioser-web/src/lib.rs b/crates/apps/gioser-web/src/lib.rs
index c66dcca..a9a516b 100644
--- a/crates/apps/gioser-web/src/lib.rs
+++ b/crates/apps/gioser-web/src/lib.rs
@@ -1,14 +1,16 @@
 //! Entrypoint WASM de la landing GioSer.
 //!
 //! 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.
+//! - Montar canvas WebGL2 + listeners de mouse/pointer/resize/keyboard/RAF.
+//! - Reposicionar los 4 tips DOM cada frame siguiendo el aro de la chacana,
+//!   con clamp para que nunca salgan del viewport ni cubran la taskbar.
+//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt+roll.
+//! - Manejar **click/tap dentro del aro** → vibración (impulso al shake spring).
+//! - Manejar **mouseleave del canvas** → tilt vuelve al frente con rebote.
+//! - Drawers MD por elemento que crecen desde la posición del botón
+//!   clickeado hasta fullscreen (excepto la taskbar).
+//! - Taskbar estilo Windows: home a la izquierda + cajitas dinámicas por
+//!   cada vista MD abierta, click cambia el activo.
 
 use std::cell::RefCell;
 use std::rc::Rc;
@@ -18,12 +20,163 @@ use pluma_reader_web::Reader;
 use wasm_bindgen::prelude::*;
 use wasm_bindgen::JsCast;
 use web_sys::{
-    Document, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent, Window,
+    Document, Element, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent,
+    PointerEvent, Window,
 };
 
-/// Factor radial sobre `arm_extent` donde se anclan los botones DOM.
-/// Queda entre la punta de la chacana y el aro grueso.
+/// Botones se anclan entre la punta de la chacana y el aro grueso.
 const BUTTON_RADIUS_FACTOR: f32 = 1.32;
+/// Altura reservada para la taskbar abajo. Sincronizar con CSS `.taskbar { height }`.
+const TASKBAR_HEIGHT_PX: f32 = 52.0;
+/// Padding de seguridad alrededor de los botones para que nunca toquen los bordes.
+const BUTTON_HALF_W_PX: f32 = 90.0;
+const BUTTON_HALF_H_PX: f32 = 64.0;
+const VIEWPORT_MARGIN_PX: f32 = 14.0;
+const ELEMENTS: [&str; 4] = ["aire", "fuego", "tierra", "agua"];
+
+#[derive(Default)]
+struct TaskbarState {
+    /// Elementos abiertos (en orden de apertura), aparecen como cajitas.
+    open: Vec<String>,
+    /// Cuál está visible. `None` = home (sin drawer activo).
+    active: Option<String>,
+}
+
+struct AppState {
+    document: Document,
+    state: RefCell<TaskbarState>,
+}
+
+impl AppState {
+    fn open_tab(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) {
+        self.set_drawer_origin(element, origin_x, origin_y);
+        {
+            let mut s = self.state.borrow_mut();
+            if !s.open.iter().any(|e| e == element) {
+                s.open.push(element.to_string());
+            }
+            s.active = Some(element.to_string());
+        }
+        self.sync();
+        self.load_md_if_empty(element, md_url);
+    }
+
+    fn switch_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
+        self.set_drawer_origin(element, origin_x, origin_y);
+        let mut s = self.state.borrow_mut();
+        if s.open.iter().any(|e| e == element) {
+            s.active = Some(element.to_string());
+        }
+        drop(s);
+        self.sync();
+    }
+
+    fn close_tab(&self, element: &str) {
+        let mut s = self.state.borrow_mut();
+        s.open.retain(|e| e != element);
+        if s.active.as_deref() == Some(element) {
+            s.active = s.open.last().cloned();
+        }
+        drop(s);
+        self.sync();
+    }
+
+    fn home(&self) {
+        let mut s = self.state.borrow_mut();
+        s.open.clear();
+        s.active = None;
+        drop(s);
+        self.sync();
+    }
+
+    fn active(&self) -> Option<String> {
+        self.state.borrow().active.clone()
+    }
+
+    fn set_drawer_origin(&self, element: &str, x: f64, y: f64) {
+        let id = format!("drawer-{}", element);
+        if let Some(el) = self.document.get_element_by_id(&id) {
+            if let Ok(el) = el.dyn_into::<HtmlElement>() {
+                let _ = el.style().set_property("--origin-x", &format!("{:.1}px", x));
+                let _ = el.style().set_property("--origin-y", &format!("{:.1}px", y));
+            }
+        }
+    }
+
+    fn sync(&self) {
+        let s = self.state.borrow();
+        let body = match self.document.body() {
+            Some(b) => b,
+            None => return,
+        };
+        if s.active.is_some() {
+            let _ = body.class_list().add_1("drawer-active");
+        } else {
+            let _ = body.class_list().remove_1("drawer-active");
+        }
+        for &e in &ELEMENTS {
+            let cls = format!("drawer-active-{}", e);
+            if s.active.as_deref() == Some(e) {
+                let _ = body.class_list().add_1(&cls);
+            } else {
+                let _ = body.class_list().remove_1(&cls);
+            }
+        }
+        for &e in &ELEMENTS {
+            let id = format!("drawer-{}", e);
+            if let Some(el) = self.document.get_element_by_id(&id) {
+                if let Ok(el) = el.dyn_into::<HtmlElement>() {
+                    if s.active.as_deref() == Some(e) {
+                        let _ = el.class_list().add_1("open");
+                        el.set_attribute("aria-hidden", "false").ok();
+                    } else {
+                        let _ = el.class_list().remove_1("open");
+                        el.set_attribute("aria-hidden", "true").ok();
+                    }
+                }
+            }
+        }
+        if let Some(list) = self.document.get_element_by_id("taskbar-list") {
+            let mut html = String::new();
+            for e in &s.open {
+                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);
+        }
+    }
+
+    fn load_md_if_empty(&self, element: &str, md_url: &str) {
+        let content_id = format!("drawer-{}-content", element);
+        let Some(content_el) = self.document.get_element_by_id(&content_id) else {
+            return;
+        };
+        let Ok(content): Result<HtmlElement, _> = content_el.dyn_into() else {
+            return;
+        };
+        let inner = content.inner_html();
+        // Si ya tiene contenido renderizado (pluma-doc) y no es loader/error, no re-fetch.
+        if inner.contains("pluma-doc") {
+            return;
+        }
+        let reader = Reader::new(content);
+        let element_owned = element.to_string();
+        let url_owned = md_url.to_string();
+        wasm_bindgen_futures::spawn_local(async move {
+            if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
+                web_sys::console::warn_1(&e);
+            }
+        });
+    }
+}
 
 #[wasm_bindgen(start)]
 pub fn boot() -> Result<(), JsValue> {
@@ -40,13 +193,28 @@ pub fn boot() -> Result<(), JsValue> {
 
     fit_canvas(&canvas, &window);
     let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
-    renderer
-        .borrow_mut()
-        .resize(canvas.width(), canvas.height());
+    {
+        let mut r = renderer.borrow_mut();
+        r.resize(canvas.width(), canvas.height());
+        r.set_client_size(
+            canvas.client_width() as f32,
+            canvas.client_height() as f32,
+        );
+    }
+
+    let app = Rc::new(AppState {
+        document: document.clone(),
+        state: RefCell::default(),
+    });
 
     install_resize(&window, &canvas, &renderer)?;
     install_mouse(&document, &canvas, &renderer)?;
-    install_drawer_handlers(&document)?;
+    install_canvas_pointer(&canvas, &renderer)?;
+    install_canvas_leave(&canvas, &renderer)?;
+    install_tip_clicks(&document, &app)?;
+    install_drawer_close_buttons(&document, &app)?;
+    install_taskbar(&document, &app)?;
+    install_keyboard(&document, &app)?;
     install_raf(&window, &document, &canvas, &renderer);
 
     Ok(())
@@ -62,7 +230,12 @@ fn install_resize(
     let r = renderer.clone();
     let cb = Closure::<dyn FnMut()>::new(move || {
         fit_canvas(&canvas, &win2);
-        r.borrow_mut().resize(canvas.width(), canvas.height());
+        let mut rr = r.borrow_mut();
+        rr.resize(canvas.width(), canvas.height());
+        rr.set_client_size(
+            canvas.client_width() as f32,
+            canvas.client_height() as f32,
+        );
     });
     window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
     cb.forget();
@@ -88,114 +261,151 @@ fn install_mouse(
     Ok(())
 }
 
-fn install_drawer_handlers(document: &Document) -> Result<(), JsValue> {
-    let doc = document.clone();
+/// Pointer down dentro del aro → impulso de vibración (click/tap shake).
+fn install_canvas_pointer(
+    canvas: &HtmlCanvasElement,
+    renderer: &Rc<RefCell<Renderer>>,
+) -> Result<(), JsValue> {
+    let canvas2 = canvas.clone();
+    let r = renderer.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let rect = canvas2.get_bounding_client_rect();
+        let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
+        let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
+        let dist2 = dx * dx + dy * dy;
+        let ring = {
+            let rb = r.borrow();
+            rb.click_radius_css_px() as f64
+        };
+        if dist2 <= ring * ring {
+            r.borrow_mut().impulse_click();
+        }
+    });
+    canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+/// Mouse sale del canvas → tilt vuelve al frente con rebote del spring.
+fn install_canvas_leave(
+    canvas: &HtmlCanvasElement,
+    renderer: &Rc<RefCell<Renderer>>,
+) -> Result<(), JsValue> {
+    let r = renderer.clone();
+    let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
+        r.borrow_mut().release_tilt();
+    });
+    canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
 
-    // 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 };
+fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+    let tips_nodes = document.query_selector_all(".tip[data-md]")?;
+    for i in 0..tips_nodes.length() {
+        let Some(node) = tips_nodes.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 app2 = app.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);
+            let rect = el_for_rect.get_bounding_client_rect();
+            let cx = rect.left() + rect.width() / 2.0;
+            let cy = rect.top() + rect.height() / 2.0;
+            app2.open_tab(&element, cx, cy, &md_url);
         });
         el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
         cb.forget();
     }
+    Ok(())
+}
 
-    // Cualquier elemento marcado con `data-close-drawer` cierra.
+fn install_drawer_close_buttons(
+    document: &Document,
+    app: &Rc<AppState>,
+) -> Result<(), JsValue> {
     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 Ok(el) = node.dyn_into::<HtmlElement>() else {
+            continue;
+        };
+        let el_ref: &Element = el.as_ref();
+        let element_attr = el_ref
+            .closest(".drawer")
+            .ok()
+            .flatten()
+            .and_then(|d| d.get_attribute("data-element"))
+            .unwrap_or_default();
+        let app2 = app.clone();
         let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
             e.stop_propagation();
-            close_drawers(&d);
+            app2.close_tab(&element_attr);
         });
         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));
+fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+    // Botón home
+    let homes = document.query_selector_all("[data-home]")?;
+    for i in 0..homes.length() {
+        let Some(node) = homes.item(i) else { continue };
+        let Ok(el) = node.dyn_into::<HtmlElement>() else {
+            continue;
+        };
+        let app2 = app.clone();
+        let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
+            app2.home();
+        });
+        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+        cb.forget();
     }
-
-    // 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);
+    // Delegación: 1 listener en la lista, dispatch por data-task del closest .taskbar-item.
+    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;
+                app2.switch_tab(&task, cx, cy);
             }
         });
+        list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+        cb.forget();
     }
+    Ok(())
 }
 
-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_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+    let app2 = app.clone();
+    let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
+        if e.key() == "Escape" {
+            if let Some(active) = app2.active() {
+                app2.close_tab(&active);
+            }
         }
-    }
+    });
+    document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
 }
 
 fn install_raf(
@@ -211,9 +421,6 @@ 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);
         let r = renderer.borrow();
         position_tips(&document, &canvas, &r);
@@ -250,10 +457,17 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
     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;
+    // Bounds en CSS pixels donde los botones pueden moverse libremente.
+    let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
+    let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
+    let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
+    let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
     for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
         let (nx, ny) = clips[i];
-        let px = (nx + 1.0) * 0.5 * cw;
-        let py = (1.0 - (ny + 1.0) * 0.5) * ch;
+        let raw_x = (nx + 1.0) * 0.5 * cw;
+        let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
+        let px = raw_x.clamp(min_x, max_x);
+        let py = raw_y.clamp(min_y, max_y);
         let sel = format!("tip-{}", id);
         if let Some(el) = document.get_element_by_id(&sel) {
             if let Ok(el) = el.dyn_into::<HtmlElement>() {
@@ -267,16 +481,18 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
 }
 
 fn update_tilt_css(document: &Document, renderer: &Renderer) {
-    let (pitch, yaw) = renderer.tilt_degrees();
+    let (pitch, yaw, roll) = 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));
+            let _ = brand
+                .style()
+                .set_property("--tilt-z", &format!("{:.2}deg", roll));
         }
     }
 }
diff --git a/crates/apps/gioser-web/styles.css b/crates/apps/gioser-web/styles.css
index fd71309..84b4ca3 100644
--- a/crates/apps/gioser-web/styles.css
+++ b/crates/apps/gioser-web/styles.css
@@ -47,12 +47,14 @@ html, body {
   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 */
+  /* perspective hace que la rotación del título dé sensación 3D igual que la chacana.
+     rotateZ refleja el shake de click — el título tiembla con la cruz. */
   transform:
     translate(-50%, -50%)
     perspective(900px)
     rotateX(var(--tilt-x, 0deg))
-    rotateY(var(--tilt-y, 0deg));
+    rotateY(var(--tilt-y, 0deg))
+    rotateZ(var(--tilt-z, 0deg));
   transition: transform 30ms linear;
 }
 .brand-title {
@@ -188,7 +190,10 @@ body.drawer-active .brand {
 /* === Drawers (visor MD full-screen) === */
 .drawer {
   position: fixed;
-  inset: 0;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 52px; /* reservamos la altura de la taskbar */
   z-index: 100;
   pointer-events: none;
   opacity: 0;
@@ -487,6 +492,114 @@ body.drawer-active .brand {
 }
 
 /* === Responsive === */
+/* === Taskbar estilo Windows === */
+.taskbar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 52px;
+  z-index: 200;
+  display: flex;
+  align-items: center;
+  gap: 0.45rem;
+  padding: 0 0.7rem;
+  background:
+    linear-gradient(to top, rgba(6, 5, 13, 0.92), rgba(8, 6, 22, 0.78));
+  backdrop-filter: blur(24px) saturate(140%);
+  -webkit-backdrop-filter: blur(24px) saturate(140%);
+  border-top: 1px solid rgba(216, 168, 93, 0.22);
+  box-shadow: 0 -10px 36px rgba(0, 0, 0, 0.45);
+}
+
+.taskbar-home {
+  width: 40px;
+  height: 40px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: transparent;
+  border: 1px solid rgba(216, 168, 93, 0.32);
+  color: var(--gold);
+  border-radius: 9px;
+  cursor: pointer;
+  padding: 0;
+  transition: all 220ms var(--ease-emerge);
+}
+.taskbar-home:hover {
+  border-color: var(--gold);
+  background: rgba(216, 168, 93, 0.12);
+  box-shadow: 0 0 16px rgba(216, 168, 93, 0.35);
+  transform: translateY(-1px);
+}
+.taskbar-home-glyph {
+  width: 22px;
+  height: 22px;
+  filter: drop-shadow(0 0 6px currentColor);
+}
+
+.taskbar-divider {
+  display: inline-block;
+  width: 1px;
+  height: 26px;
+  background: rgba(255, 255, 255, 0.12);
+  margin: 0 0.2rem;
+}
+
+.taskbar-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  gap: 0.4rem;
+  overflow-x: auto;
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+}
+.taskbar-list::-webkit-scrollbar { display: none; }
+
+.taskbar-item {
+  height: 38px;
+  padding: 0 1.0rem;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.55rem;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.10);
+  border-radius: 9px;
+  color: var(--task-color, var(--fg));
+  font-family: 'Cinzel', serif;
+  font-size: 0.72rem;
+  letter-spacing: 0.36em;
+  text-indent: 0.36em;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 260ms var(--ease-emerge);
+}
+.taskbar-item:hover {
+  background: rgba(255, 255, 255, 0.09);
+  border-color: var(--task-color, currentColor);
+  transform: translateY(-1px);
+}
+.taskbar-item.active {
+  background: rgba(255, 255, 255, 0.11);
+  border-color: var(--task-color);
+  box-shadow:
+    0 0 18px var(--task-color),
+    inset 0 -2px 0 0 var(--task-color);
+}
+.taskbar-item-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--task-color, var(--gold));
+  box-shadow: 0 0 8px currentColor;
+}
+.taskbar-item[data-task="aire"]   { --task-color: var(--aire); }
+.taskbar-item[data-task="fuego"]  { --task-color: var(--fuego); }
+.taskbar-item[data-task="tierra"] { --task-color: var(--tierra); }
+.taskbar-item[data-task="agua"]   { --task-color: var(--agua); }
+
 @media (max-width: 720px) {
   .tip { min-width: 110px; padding: 0.7rem 0.9rem; }
   .tip-glyph { width: 36px; height: 36px; }
@@ -495,4 +608,8 @@ body.drawer-active .brand {
   .brand-title { font-size: clamp(2rem, 10vw, 3rem); }
   .drawer-head { padding: 5vh 5vw 1vh; }
   .drawer-content { padding: 0 5vw 5vh; }
+  .taskbar { height: 46px; padding: 0 0.4rem; gap: 0.3rem; }
+  .taskbar-home { width: 36px; height: 36px; }
+  .taskbar-item { height: 34px; padding: 0 0.7rem; font-size: 0.65rem; }
+  .drawer { bottom: 46px; }
 }
diff --git a/crates/modules/gioser/gioser-canvas-web/src/lib.rs b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
index 53e77e3..6da50b0 100644
--- a/crates/modules/gioser/gioser-canvas-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
@@ -1,18 +1,14 @@
 //! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
 //!
-//! 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);
-//! r.set_mouse_px(dx, dy);
-//! r.render(time_ms);
-//! ```
+//! El loop externo (típicamente `requestAnimationFrame`) llama `render(time_ms)`.
+//! Los eventos input se propagan vía métodos: `set_mouse_px`, `release_tilt`,
+//! `impulse_click`. El cliente puede consultar dimensiones derivadas
+//! (`click_radius_css_px`, `tilt_degrees`, `cardinal_positions_ndc`) para
+//! sincronizar DOM (botones, título, taskbar).
 
 use gioser_geom::ChacanaSpec;
 use gioser_palette::{cosmos, Rgb};
-use gioser_physics::SpringDamper2;
+use gioser_physics::{SpringDamper1, SpringDamper2};
 use gioser_shaders::{
     chacana_quad, FS_CHACANA, FS_COSMOS, FULLSCREEN_QUAD, VS_CHACANA, VS_FULLSCREEN,
 };
@@ -26,22 +22,23 @@ use web_sys::{
 
 const RAD: f32 = core::f32::consts::PI / 180.0;
 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".
+/// Inclinación máxima en cada eje.
 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;
+/// `cot(45°/2)` — factor de proyección. Lo necesitamos también para calcular
+/// el radio del círculo en pixels (hit-test del click).
+const COT_HALF_FOV: f32 = 2.414_213_5;
+/// Distancia del aro principal respecto al centro de la chacana — sincronizar
+/// con `FS_CHACANA::ringR_main` del shader.
+const RING_FACTOR: f32 = 1.45;
 
-/// Identidad de cada cardinal (id, color de acento, label visible).
-/// Orden `[N, E, S, W]` coincide con `ChacanaSpec::tips()`.
+/// Identidad de cada cardinal (id, color de acento, label). Orden `[N, E, S, W]`.
 pub mod tips {
     use gioser_palette::{elements, Rgb};
     pub const ORDER: [(&str, Rgb, &str); 4] = [
-        ("aire", elements::AIRE, "AIRE"),       // N
-        ("fuego", elements::FUEGO, "FUEGO"),    // E
-        ("tierra", elements::TIERRA, "TIERRA"), // S
-        ("agua", elements::AGUA, "AGUA"),       // W
+        ("aire", elements::AIRE, "AIRE"),
+        ("fuego", elements::FUEGO, "FUEGO"),
+        ("tierra", elements::TIERRA, "TIERRA"),
+        ("agua", elements::AGUA, "AGUA"),
     ];
 }
 
@@ -53,10 +50,19 @@ pub struct Renderer {
     chacana_vao: WebGlVertexArrayObject,
     chacana_quad_count: i32,
     chacana: ChacanaSpec,
+    /// Spring del tilt 3D que sigue al mouse. Sub-crítico orgánico.
     tilt: SpringDamper2,
+    /// Spring de "vibración" tras click: rotación Z bien underdamped que
+    /// decae naturalmente. Independiente del tilt.
+    shake: SpringDamper1,
+    /// Contador para alternar sentido del shake en clicks sucesivos.
+    click_count: u32,
     sun_pulse: f32,
     last_time_ms: f64,
+    /// Dimensiones device-pixel del canvas (lo que GL viewport usa).
     viewport: (u32, u32),
+    /// Dimensiones CSS-pixel del canvas (lo que ven los eventos DOM).
+    client_size: (f32, f32),
     /// Mouse en clip-space, x ∈ [-aspect, aspect], y ∈ [-1, 1].
     mouse: (f32, f32),
 }
@@ -137,6 +143,21 @@ fn upload_quad(
     Ok((vao, (verts.len() / 2) as i32))
 }
 
+/// Devuelve el factor de escala mundo→viewport en función del aspect.
+/// Para portrait (aspect < 1), achicamos proporcionalmente para que la
+/// circunferencia exterior no se corte por los lados.
+fn world_scale_for_aspect(aspect: f32) -> f32 {
+    let base = 1.05;
+    if aspect >= 1.0 {
+        base
+    } else {
+        // En portrait, el extent visible horizontal se reduce con `aspect`.
+        // Bajamos la escala para mantener el aro entero dentro del viewport,
+        // con piso 0.45 para que no quede ridículamente pequeña.
+        (base * aspect.max(0.45)).min(base)
+    }
+}
+
 impl Renderer {
     pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
         let gl = canvas
@@ -176,6 +197,10 @@ impl Renderer {
                 "u_rim_color",
                 "u_sun_color",
                 "u_dark_color",
+                "u_aire_color",
+                "u_fuego_color",
+                "u_tierra_color",
+                "u_agua_color",
                 "u_sun_pulse",
             ],
         )
@@ -187,6 +212,9 @@ impl Renderer {
             upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
 
         let tilt = SpringDamper2::new(1.7, 0.65);
+        // Shake: alta frecuencia, muy underdamped → vibración fuerte que
+        // muere en ~0.8 s con varios ciclos visibles.
+        let shake = SpringDamper1::new(7.5, 0.13);
 
         Ok(Self {
             gl,
@@ -197,9 +225,15 @@ impl Renderer {
             chacana_quad_count,
             chacana,
             tilt,
+            shake,
+            click_count: 0,
             sun_pulse: 0.0,
             last_time_ms: 0.0,
             viewport: (canvas.width().max(1), canvas.height().max(1)),
+            client_size: (
+                canvas.client_width().max(1) as f32,
+                canvas.client_height().max(1) as f32,
+            ),
             mouse: (0.0, 0.0),
         })
     }
@@ -210,6 +244,12 @@ impl Renderer {
             .viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
     }
 
+    /// Tamaño en CSS pixels (independiente del DPR). Lo usa el hit-test del
+    /// click para que coincida con coordenadas DOM.
+    pub fn set_client_size(&mut self, w: f32, h: f32) {
+        self.client_size = (w.max(1.0), h.max(1.0));
+    }
+
     pub fn set_mouse_px(&mut self, x: f32, y: f32) {
         let (w, h) = self.viewport;
         if h == 0 {
@@ -225,14 +265,41 @@ impl Renderer {
         self.tilt.set_target(target);
     }
 
+    /// Mouse fuera del canvas — la chacana vuelve al frente con rebote
+    /// natural del spring sub-crítico.
+    pub fn release_tilt(&mut self) {
+        self.tilt.set_target([0.0, 0.0]);
+        // mouse parallax (fondo) también vuelve al centro
+        self.mouse = (0.0, 0.0);
+    }
+
+    /// Inyecta un impulso al spring shake — la chacana vibra fuerte y decae.
+    /// Llamar en respuesta a un click/tap dentro del aro.
+    pub fn impulse_click(&mut self) {
+        self.click_count = self.click_count.wrapping_add(1);
+        let dir = if self.click_count % 2 == 0 { 1.0 } else { -1.0 };
+        // Magnitud del impulso en rad/s. Con ω≈47, esto produce un pico
+        // de ~5-7° en la rotación Z, decayendo en ~0.8 s.
+        self.shake.velocity[0] += 6.5 * dir;
+    }
+
+    /// Radio del aro exterior, en CSS pixels desde el centro del canvas.
+    /// El cliente lo usa para decidir si un click cae dentro del círculo.
+    pub fn click_radius_css_px(&self) -> f32 {
+        let (w, _h) = self.viewport;
+        let aspect = w as f32 / self.viewport.1.max(1) as f32;
+        let scale = world_scale_for_aspect(aspect);
+        let ring_ndc = self.chacana.arm_extent() * RING_FACTOR * scale * COT_HALF_FOV / 2.6;
+        ring_ndc * self.client_size.1 / 2.0
+    }
+
     /// 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.
+    /// Posiciones NDC para anclar botones en los 4 cardinales a un radio
+    /// específico (factor sobre `arm_extent`).
     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)])
@@ -257,22 +324,26 @@ impl Renderer {
         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)
+    /// `(pitch_deg, yaw_deg, roll_deg)` actuales. Roll viene del shake spring.
+    pub fn tilt_degrees(&self) -> (f32, f32, f32) {
+        (
+            self.tilt.position[0] * DEG,
+            self.tilt.position[1] * DEG,
+            self.shake.position[0] * DEG,
+        )
     }
 
     fn build_mvp(&self) -> Mat4 {
         let (w, h) = self.viewport;
         let aspect = w as f32 / h as f32;
+        let scale_val = world_scale_for_aspect(aspect);
         let proj = Mat4::perspective_rh(45.0_f32.to_radians(), aspect, 0.1, 20.0);
         let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
         let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
         let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
-        let scale = Mat4::from_scale(Vec3::splat(WORLD_SCALE));
-        proj * view * yaw * pitch * scale
+        let roll = Mat4::from_rotation_z(self.shake.position[0]);
+        let scale = Mat4::from_scale(Vec3::splat(scale_val));
+        proj * view * yaw * pitch * roll * scale
     }
 
     pub fn render(&mut self, time_ms: f64) {
@@ -282,11 +353,17 @@ 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;
+
+        // Subdividir físico — el shake corre a alta frecuencia y necesita
+        // dt < 1/freq para mantenerse estable (1/7.5 ≈ 133 ms; 8 sub-pasos a
+        // 60fps dejan 2 ms por sub-paso).
+        let sub = 8;
         let sub_dt = dt / sub as f32;
         for _ in 0..sub {
             self.tilt.step(sub_dt);
+            self.shake.step(sub_dt);
         }
+
         let t = time_ms as f32 * 0.001;
         self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin();
 
@@ -316,7 +393,7 @@ impl Renderer {
         gl.bind_vertex_array(Some(&self.cosmos_vao));
         gl.draw_arrays(GL::TRIANGLES, 0, 6);
 
-        // Chacana (blend aditivo para que dorado y sol sumen luz al cosmos)
+        // Chacana (blend aditivo)
         gl.blend_func(GL::SRC_ALPHA, GL::ONE);
         gl.use_program(Some(&self.chacana_prog.program));
         let mvp = self.build_mvp();
@@ -339,6 +416,26 @@ impl Renderer {
         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);
+        upload_rgb(
+            gl,
+            self.chacana_prog.u("u_aire_color"),
+            gioser_palette::elements::AIRE,
+        );
+        upload_rgb(
+            gl,
+            self.chacana_prog.u("u_fuego_color"),
+            gioser_palette::elements::FUEGO,
+        );
+        upload_rgb(
+            gl,
+            self.chacana_prog.u("u_tierra_color"),
+            gioser_palette::elements::TIERRA,
+        );
+        upload_rgb(
+            gl,
+            self.chacana_prog.u("u_agua_color"),
+            gioser_palette::elements::AGUA,
+        );
         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 6c684a6..0ccb486 100644
--- a/crates/modules/gioser/gioser-shaders/src/lib.rs
+++ b/crates/modules/gioser/gioser-shaders/src/lib.rs
@@ -186,10 +186,21 @@ uniform vec3  u_line_color;
 uniform vec3  u_rim_color;
 uniform vec3  u_sun_color;
 uniform vec3  u_dark_color;
+uniform vec3  u_aire_color;
+uniform vec3  u_fuego_color;
+uniform vec3  u_tierra_color;
+uniform vec3  u_agua_color;
 uniform float u_sun_pulse;
 
 const float PI = 3.14159265;
 
+float hash21c(vec2 p) {
+    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+}
+float hash11c(float n) {
+    return fract(sin(n * 78.233) * 43758.5453);
+}
+
 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);
@@ -214,6 +225,65 @@ float sdChacana(vec2 p, float s, float c) {
     return d;
 }
 
+// Emisor de partículas por tip cardinal. Cada elemento tiene su propio
+// patrón de velocidad para sentirse vivo:
+//   AIRE  → drift hacia afuera con sway lateral (viento)
+//   FUEGO → asciende erráticamente con flicker amplio
+//   TIERRA→ cae con gravedad y rebote sutil
+//   AGUA  → ondula descendiendo (gotas que se deslizan)
+//
+// `element_kind`: 0=AIRE, 1=FUEGO, 2=TIERRA, 3=AGUA.
+// `outward`: dirección unitaria desde el centro hacia el tip.
+vec3 element_particles(vec2 p, vec2 tip, vec2 outward, vec3 color, int kind, float seed_base) {
+    vec3 accum = vec3(0.0);
+    vec2 perp = vec2(-outward.y, outward.x);
+    // 4 partículas por tip — suficiente densidad sin saturar el costo del frag.
+    for (int k = 0; k < 4; k++) {
+        float seed = seed_base + float(k) * 1.31;
+        float life = 1.5 + hash11c(seed * 11.0) * 0.7;
+        float t_seeded = u_time + seed * 9.3;
+        float phase = mod(t_seeded, life);
+        float ph = phase / life; // 0..1
+
+        // Random offsets por época (cuando el ciclo reinicia).
+        float epoch = floor(t_seeded / life);
+        vec2 jitter = vec2(
+            hash21c(vec2(seed, epoch)) - 0.5,
+            hash21c(vec2(epoch, seed)) - 0.5
+        );
+
+        // Velocidad por elemento — distinto carácter visual.
+        vec2 vel;
+        float sway = sin(u_time * 4.0 + seed * 7.3);
+        if (kind == 0) {
+            // AIRE: drift hacia afuera + sway perpendicular notable.
+            vel = outward * 0.14 + perp * sway * 0.10;
+        } else if (kind == 1) {
+            // FUEGO: rise erratic — siempre con componente +Y (hacia arriba en el mundo),
+            // independiente del tip → flamas suben.
+            float erratic = sin(u_time * 6.0 + seed * 11.0) * 0.06;
+            vel = outward * 0.10 + vec2(erratic, 0.18 + 0.04 * sway);
+        } else if (kind == 2) {
+            // TIERRA: cae — outward más componente -Y.
+            vel = outward * 0.05 + vec2(0.03 * sway, -0.16);
+        } else {
+            // AGUA: drift outward con descenso y ondulación.
+            float wave = sin(u_time * 3.2 + seed * 8.7) * 0.07;
+            vel = outward * 0.12 + vec2(wave, -0.08);
+        }
+
+        vec2 pos = tip + vel * phase + jitter * 0.04;
+
+        // Brillo gauss + envelope sinusoidal en la vida.
+        float bright = sin(ph * PI);
+        float dist = length(p - pos);
+        float size = 0.014 + 0.006 * (kind == 1 ? sway : 0.0); // fuego pulsa
+        float glow = exp(-(dist * dist) / (2.0 * size * size));
+        accum += color * glow * bright;
+    }
+    return accum;
+}
+
 // 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);
@@ -282,6 +352,15 @@ void main() {
     // 4 grupos de 3 puntos cardinales sobre el aro principal.
     float dots = cardinal_dots(p, ringR_main, 0.008) * 1.10;
 
+    // === PARTÍCULAS POR ELEMENTO ===
+    // Cada tip emite partículas con la personalidad del elemento.
+    float L = u_arm_extent;
+    vec3 particles = vec3(0.0);
+    particles += element_particles(p, vec2(0.0,  L), vec2(0.0,  1.0), u_aire_color,   0, 0.31);
+    particles += element_particles(p, vec2( L, 0.0), vec2( 1.0, 0.0), u_fuego_color,  1, 1.73);
+    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);
+
     // === COMPOSICIÓN ===
     vec3 col = vec3(0.0);
     // Sol detrás (clip a interior).
@@ -295,9 +374,11 @@ void main() {
     col += u_line_color * ring_main * 1.45;
     col += u_rim_color  * ring_inner * 1.05;
     col += u_line_color * dots * 1.85;
+    col += particles * 1.25;
 
     float alpha = clamp(
-        halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12,
+        halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12
+            + (particles.r + particles.g + particles.b) * 0.5,
         0.0, 1.0);
     fragColor = vec4(col, alpha);
 }