From 7728013012444c20c7fe7c4248c0bc7c296f43f7 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 14 May 2026 02:42:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(gioser/web):=20fix=20mobile=20swipe,=20tas?= =?UTF-8?q?kbar=20agn=C3=B3stica,=20trazos=20zodiacales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

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 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 --- Cargo.lock | 10 + Cargo.toml | 5 + crates/apps/gioser-web/Cargo.toml | 1 + crates/apps/gioser-web/src/lib.rs | 85 ++++---- crates/apps/gioser-web/styles.css | 17 +- crates/modules/barra/barra-web/Cargo.toml | 24 +++ crates/modules/barra/barra-web/src/lib.rs | 189 ++++++++++++++++++ .../gioser/gioser-canvas-web/src/lib.rs | 36 ++++ .../modules/gioser/gioser-shaders/src/lib.rs | 43 +++- crates/modules/vista/vista-web/Cargo.toml | 1 + crates/modules/vista/vista-web/src/lib.rs | 12 +- 11 files changed, 370 insertions(+), 53 deletions(-) create mode 100644 crates/modules/barra/barra-web/Cargo.toml create mode 100644 crates/modules/barra/barra-web/src/lib.rs 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, } @@ -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!( - "

  • " - )); - } - list.set_inner_html(&html); - } + let tasks: Vec = 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) -> 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) -> 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::::new(move |e: MouseEvent| { - let Some(target) = e.target() else { return }; - let Ok(target_el): Result = 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 , 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 `
      ` 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 +//! +//! ``` +//! +//! 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=""` — 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, label: impl Into) -> 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>>>, +} + +impl TaskList { + /// Monta el módulo sobre el elemento `
        ` 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 { + let on_click: Rc>>> = + Rc::new(RefCell::new(None)); + let on_click2 = on_click.clone(); + let cb = Closure::::new(move |e: MouseEvent| { + let Some(target) = e.target() else { return }; + let Ok(target_el): Result = 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!( + "
      • " + )); + } + 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(&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 `
          ` 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("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + 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"), "aire"); + assert_eq!(sanitize_attr("a\"b"), "ab"); + } + + #[test] + fn escape_text_escapes_html() { + assert_eq!(escape_text("AIRE"), "AIRE"); + assert_eq!(escape_text("