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>
This commit is contained in:
sergio
2026-05-14 01:46:05 +00:00
parent fce630c8d0
commit 5e0fcae4b4
6 changed files with 683 additions and 156 deletions
+335 -119
View File
@@ -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();
// 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);
/// 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();
}
});
document.add_event_listener_with_callback("keydown", kcb.as_ref().unchecked_ref())?;
kcb.forget();
canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
cb.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);
}
});
}
/// 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(())
}
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 };
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 _ = el.class_list().remove_1("open");
let _ = el.set_attribute("aria-hidden", "true");
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 app2 = app.clone();
let el_for_rect = el.clone();
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
e.prevent_default();
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();
}
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));
Ok(())
}
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 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();
app2.close_tab(&element_attr);
});
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
cb.forget();
}
Ok(())
}
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();
}
// 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 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));
}
}
}