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>
This commit is contained in:
sergio
2026-05-14 02:42:50 +00:00
parent 62058ab193
commit 7728013012
11 changed files with 370 additions and 53 deletions
Generated
+10
View File
@@ -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",
+5
View File
@@ -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)
# ============================================================
+1
View File
@@ -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
+37 -46
View File
@@ -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(())
}
+13 -4
View File
@@ -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); }
+24
View File
@@ -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",
]
+189
View File
@@ -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");
}
}
@@ -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);
}
@@ -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);
}
@@ -23,4 +23,5 @@ features = [
"EventTarget",
"PointerEvent",
"MouseEvent",
"AddEventListenerOptions",
]
+11 -1
View File
@@ -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(())
}