tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

feat(gioser): deck swipeable + minimize, brand+copyleft en taskbar (vista-web)

Nuevo módulo agnóstico:
- `crates/modules/vista/vista-web` — Deck::mount(strip) instala swipe
  horizontal con snap a la página más cercana, estilo Flutter PageView.
  goto(idx, smooth) navega programáticamente. on_change(cb) fires tras
  snap. Drag decision: horizontal si dx > 8px y > 1.3*dy; sino cede al
  pan-y nativo (scroll vertical del contenido). resize listener ajusta
  --vista-offset sin animar usando .vista-instant un frame.

App rediseñada:
- Brand "GioSer" sacado del centro de la chacana → ahora en la taskbar
  junto al botón home (data-home). brand-dot dorado entre Gio·Ser. El
  centro de la chacana queda con sol limpio.
- Copyleft + sergio@gioser.net a la derecha de la taskbar, abre
  https://sergio.gioser.net en nueva pestaña (target=_blank rel=noopener).
- 4 drawers separados → reemplazados por un único `.deck` con `.deck-strip`
  vista-web manejado. Las páginas se crean dinámicamente al abrir un
  elemento por primera vez (`ensure_page_dom`).
- Cada página tiene controles minimizar (─) y cerrar (×) arriba a la
  derecha, con ambience animada por elemento.
- Click minimize → active=None, deck scale(0) hacia la cajita del
  taskbar (origin = bounding rect del taskbar-item). Página queda en
  memoria, tab sigue en la barra.
- Click cajita del taskbar:
  - Si está activa → minimize (toggle).
  - Si está minimizada → restore con scale-up desde la cajita.
- Click home / brand → minimize all (estilo Show Desktop, no destruye).
- Swipe horizontal o click cajita → deck.goto(idx, smooth=true) con snap
  animado por vista-web. on_swipe sync de taskbar active state.
- Cerrar página → remueve del strip + del Vec pages; si era activa,
  reemplaza por neighbor o hide deck si era la última.

CSS:
- Eliminado `.brand` fixed center y `.drawer` × 4 individuales.
- `.deck` único + `.deck-strip` con `transform: translate3d(--vista-offset)`
  y transition transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1).
- `.deck-strip.vista-dragging` / `.vista-instant` → transition: none.
- `.deck-page[data-element]` cada una con su page-ambience animada
  (aire-drift, fuego-flicker, agua-tide, tierra static).
- `.taskbar-brand` Cinzel 1.3rem dorado + .brand-dot.
- `.taskbar-credit` con `.copyleft-mark` (© con scaleX(-1) = copyleft
  visual).
- `.taskbar-spacer { flex:1 }` empuja credit a la derecha.
- `.taskbar-item.active` glow del color del elemento + border-bottom.
- `body.deck-visible` baja opacity del canvas + esconde tips y brand.

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

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

62058ab19302c95a7e1ad2b379769ed0bc152533 · por sergio · (hace 5 meses) · padre 5e0fcae4b · ver el árbol en este commit

Delta semántico · 52

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

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

8 archivos · +828 −344

diff --git a/Cargo.lock b/Cargo.lock
index 9383dcb..2979fa3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3951,6 +3951,7 @@ dependencies = [
  "gioser-canvas-web",
  "js-sys",
  "pluma-reader-web",
+ "vista-web",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
@@ -11880,6 +11881,15 @@ version = "0.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
 
+[[package]]
+name = "vista-web"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "vswhom"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index eda49e7..f6610fc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -123,6 +123,11 @@ members = [
     "crates/modules/pluma/pluma-md",
     "crates/modules/pluma/pluma-reader-web",
 
+    # ============================================================
+    # modules/vista/ — deck horizontal swipe estilo Flutter PageView
+    # ============================================================
+    "crates/modules/vista/vista-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 9235d8e..5ae02ee 100644
--- a/crates/apps/gioser-web/Cargo.toml
+++ b/crates/apps/gioser-web/Cargo.toml
@@ -12,6 +12,7 @@ crate-type = ["cdylib", "rlib"]
 [dependencies]
 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" }
 wasm-bindgen.workspace = true
 wasm-bindgen-futures.workspace = true
 js-sys.workspace = true
diff --git a/crates/apps/gioser-web/index.html b/crates/apps/gioser-web/index.html
index 884361d..99fe19a 100644
--- a/crates/apps/gioser-web/index.html
+++ b/crates/apps/gioser-web/index.html
@@ -12,10 +12,6 @@
 <body>
   <canvas id="gioser-canvas" aria-hidden="true"></canvas>
 
-  <header id="brand" class="brand">
-    <h1 class="brand-title">Gio<span class="brand-dot">·</span>Ser</h1>
-  </header>
-
   <main id="tips" aria-label="Cuatro elementos">
     <a id="tip-aire" class="tip tip-aire" href="#aire" data-md="./md/aire.md" aria-label="Aire — Software e IA">
       <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
@@ -59,55 +55,14 @@
     </a>
   </main>
 
-  <!-- DRAWERS: uno por elemento. Cada uno crece desde la posición del tip
-       clickeado (origin set via CSS vars desde WASM) hasta fullscreen. -->
-  <aside id="drawer-aire" class="drawer drawer-aire" data-element="aire" aria-hidden="true">
-    <button class="drawer-close" data-close-drawer aria-label="Cerrar Aire">×</button>
-    <div class="drawer-ambience" aria-hidden="true"></div>
-    <header class="drawer-head">
-      <span class="drawer-mark">aire</span>
-      <h2 class="drawer-title">Aire</h2>
-      <span class="drawer-tag">Software · IA · Aspiración</span>
-    </header>
-    <section class="drawer-content" id="drawer-aire-content"></section>
-  </aside>
-
-  <aside id="drawer-fuego" class="drawer drawer-fuego" data-element="fuego" aria-hidden="true">
-    <button class="drawer-close" data-close-drawer aria-label="Cerrar Fuego">×</button>
-    <div class="drawer-ambience" aria-hidden="true"></div>
-    <header class="drawer-head">
-      <span class="drawer-mark">fuego</span>
-      <h2 class="drawer-title">Fuego</h2>
-      <span class="drawer-tag">Inspiración</span>
-    </header>
-    <section class="drawer-content" id="drawer-fuego-content"></section>
-  </aside>
-
-  <aside id="drawer-tierra" class="drawer drawer-tierra" data-element="tierra" aria-hidden="true">
-    <button class="drawer-close" data-close-drawer aria-label="Cerrar Tierra">×</button>
-    <div class="drawer-ambience" aria-hidden="true"></div>
-    <header class="drawer-head">
-      <span class="drawer-mark">tierra</span>
-      <h2 class="drawer-title">Tierra</h2>
-      <span class="drawer-tag">Cuerpo</span>
-    </header>
-    <section class="drawer-content" id="drawer-tierra-content"></section>
-  </aside>
+  <!-- DECK: contenedor único con strip horizontal. Las páginas se inyectan
+       dinámicamente desde WASM. vista-web maneja swipe + snap. -->
+  <div id="deck" class="deck vista-deck" aria-hidden="true">
+    <div id="deck-strip" class="deck-strip vista-strip" role="region" aria-label="Vistas abiertas"></div>
+  </div>
 
-  <aside id="drawer-agua" class="drawer drawer-agua" data-element="agua" aria-hidden="true">
-    <button class="drawer-close" data-close-drawer aria-label="Cerrar Agua">×</button>
-    <div class="drawer-ambience" aria-hidden="true"></div>
-    <header class="drawer-head">
-      <span class="drawer-mark">agua</span>
-      <h2 class="drawer-title">Agua</h2>
-      <span class="drawer-tag">Espiritualidad aplicada</span>
-    </header>
-    <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">
+  <!-- TASKBAR estilo Windows: home + GioSer + tabs + copyleft -->
+  <nav class="taskbar" aria-label="Barra de vistas">
     <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"/>
@@ -115,8 +70,19 @@
         <circle cx="12" cy="17" r="0.8" fill="currentColor"/>
       </svg>
     </button>
+
+    <a class="taskbar-brand" data-home href="#home" aria-label="GioSer · home">
+      <span>Gio</span><span class="brand-dot">·</span><span>Ser</span>
+    </a>
+
     <span class="taskbar-divider" aria-hidden="true"></span>
     <ul class="taskbar-list" id="taskbar-list" role="presentation"></ul>
+    <span class="taskbar-spacer" aria-hidden="true"></span>
+
+    <a class="taskbar-credit" href="https://sergio.gioser.net" target="_blank" rel="noopener noreferrer" aria-label="Copyleft sergio arroba gioser punto net">
+      <span class="copyleft-mark" aria-hidden="true">©</span>
+      <span class="taskbar-credit-text">sergio@gioser.net</span>
+    </a>
   </nav>
 
   <script type="module">
diff --git a/crates/apps/gioser-web/src/lib.rs b/crates/apps/gioser-web/src/lib.rs
index a9a516b..ffdcac4 100644
--- a/crates/apps/gioser-web/src/lib.rs
+++ b/crates/apps/gioser-web/src/lib.rs
@@ -1,22 +1,27 @@
 //! Entrypoint WASM de la landing GioSer.
 //!
-//! Responsabilidades:
-//! - 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.
+//! Capas:
+//! - **Canvas WebGL**: chacana animada (gioser-canvas-web).
+//! - **Tips**: 4 botones DOM en los cardinales, posicionados cada frame.
+//! - **Deck** (vista-web): contenedor único con páginas swipeable estilo
+//!   Flutter `PageView`. Cada elemento del logo es una página dinámica.
+//! - **Taskbar**: barra abajo con home + brand "GioSer" + tabs activas +
+//!   copyleft/email a la derecha. Sincronizada por WASM.
+//!
+//! Acciones (todas pasan por `AppState`):
+//! - `open_or_switch`   — click en tip o abrir nueva pestaña.
+//! - `restore_from_tab` — click en cajita de la taskbar.
+//! - `minimize`         — botón ─ de la página o Escape.
+//! - `close`            — botón × de la página, remueve del taskbar.
+//! - `home`             — botón casa o brand, minimiza todo (mantiene tabs).
+//! - `on_swipe`         — callback de vista-web cuando el snap cambia.
 
 use std::cell::RefCell;
 use std::rc::Rc;
 
 use gioser_canvas_web::{tips, Renderer};
 use pluma_reader_web::Reader;
+use vista_web::Deck;
 use wasm_bindgen::prelude::*;
 use wasm_bindgen::JsCast;
 use web_sys::{
@@ -24,121 +29,175 @@ use web_sys::{
     PointerEvent, Window,
 };
 
-/// 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).
+struct DeckState {
+    /// Pestañas abiertas en orden de apertura. Coincide con páginas en el strip.
+    pages: Vec<String>,
+    /// Cuál es la página visible. `None` = deck minimizado (pestañas siguen
+    /// en la taskbar) o sin pestañas.
     active: Option<String>,
 }
 
 struct AppState {
     document: Document,
-    state: RefCell<TaskbarState>,
+    deck: Deck,
+    state: RefCell<DeckState>,
 }
 
 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());
+    fn open_or_switch(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) {
+        let was_visible = self.state.borrow().active.is_some();
+        let was_in_pages = self.state.borrow().pages.iter().any(|e| e == element);
+        if !was_in_pages {
+            self.ensure_page_dom(element);
+            self.state.borrow_mut().pages.push(element.to_string());
         }
-        self.sync();
+        self.state.borrow_mut().active = Some(element.to_string());
+        let idx = self
+            .state
+            .borrow()
+            .pages
+            .iter()
+            .position(|e| e == element)
+            .unwrap_or(0);
+        if was_visible {
+            self.deck.goto(idx, true);
+        } else {
+            self.deck.goto(idx, false);
+            self.show_deck(origin_x, origin_y);
+        }
+        self.sync_active_class();
+        self.sync_taskbar();
         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());
+    fn restore_from_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
+        let idx_opt = self
+            .state
+            .borrow()
+            .pages
+            .iter()
+            .position(|e| e == element);
+        let Some(idx) = idx_opt else { return };
+        let was_visible = self.state.borrow().active.is_some();
+        self.state.borrow_mut().active = Some(element.to_string());
+        if was_visible {
+            self.deck.goto(idx, true);
+        } else {
+            self.deck.goto(idx, false);
+            self.show_deck(origin_x, origin_y);
         }
-        drop(s);
-        self.sync();
+        self.sync_active_class();
+        self.sync_taskbar();
     }
 
-    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();
+    fn minimize(&self, origin_x: f64, origin_y: f64) {
+        self.state.borrow_mut().active = None;
+        self.sync_active_class();
+        self.sync_taskbar();
+        self.hide_deck(origin_x, origin_y);
+    }
+
+    fn close(&self, element: &str, origin_x: f64, origin_y: f64) {
+        let was_active = self.state.borrow().active.as_deref() == Some(element);
+        self.state.borrow_mut().pages.retain(|e| e != element);
+        self.remove_page_dom(element);
+        if was_active {
+            let pages_now: Vec<String> = self.state.borrow().pages.clone();
+            let new_active = pages_now.last().cloned();
+            self.state.borrow_mut().active = new_active.clone();
+            if let Some(new_active) = new_active {
+                let idx = pages_now
+                    .iter()
+                    .position(|e| e == &new_active)
+                    .unwrap_or(0);
+                self.deck.goto(idx, true);
+            } else {
+                self.hide_deck(origin_x, origin_y);
+            }
         }
-        drop(s);
-        self.sync();
+        self.sync_active_class();
+        self.sync_taskbar();
     }
 
     fn home(&self) {
-        let mut s = self.state.borrow_mut();
-        s.open.clear();
-        s.active = None;
-        drop(s);
-        self.sync();
+        // Minimiza el deck sin cerrar las pestañas (estilo "show desktop").
+        let (ox, oy) = self
+            .element_center(".taskbar-home")
+            .unwrap_or((24.0, self.viewport_height() - 26.0));
+        self.minimize(ox, oy);
     }
 
-    fn active(&self) -> Option<String> {
-        self.state.borrow().active.clone()
+    fn on_swipe(&self, new_index: usize) {
+        let element = self.state.borrow().pages.get(new_index).cloned();
+        if let Some(element) = element {
+            self.state.borrow_mut().active = Some(element);
+            self.sync_active_class();
+            self.sync_taskbar();
+        }
     }
 
-    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 show_deck(&self, x: f64, y: f64) {
+        self.set_deck_origin(x, y);
+        if let Some(deck) = self.deck_el() {
+            let _ = deck.class_list().add_1("open");
+            let _ = deck.set_attribute("aria-hidden", "false");
+        }
+        if let Some(body) = self.document.body() {
+            let _ = body.class_list().add_1("deck-visible");
         }
     }
 
-    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");
+    fn hide_deck(&self, x: f64, y: f64) {
+        self.set_deck_origin(x, y);
+        if let Some(deck) = self.deck_el() {
+            let _ = deck.class_list().remove_1("open");
+            let _ = deck.set_attribute("aria-hidden", "true");
         }
-        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);
-            }
+        if let Some(body) = self.document.body() {
+            let _ = body.class_list().remove_1("deck-visible");
+        }
+    }
+
+    fn deck_el(&self) -> Option<HtmlElement> {
+        self.document
+            .get_element_by_id("deck")
+            .and_then(|e| e.dyn_into::<HtmlElement>().ok())
+    }
+
+    fn set_deck_origin(&self, x: f64, y: f64) {
+        if let Some(deck) = self.deck_el() {
+            let _ = deck.style().set_property("--origin-x", &format!("{:.1}px", x));
+            let _ = deck.style().set_property("--origin-y", &format!("{:.1}px", y));
         }
-        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();
-                    }
+    }
+
+    fn sync_active_class(&self) {
+        if let Some(body) = self.document.body() {
+            let active = self.state.borrow().active.clone();
+            for &e in &ELEMENTS {
+                let cls = format!("deck-active-{}", e);
+                if active.as_deref() == Some(e) {
+                    let _ = body.class_list().add_1(&cls);
+                } else {
+                    let _ = body.class_list().remove_1(&cls);
                 }
             }
         }
+    }
+
+    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.open {
+            for e in &s.pages {
                 let label = e.to_uppercase();
                 let active = if s.active.as_deref() == Some(e.as_str()) {
                     " active"
@@ -154,8 +213,57 @@ impl AppState {
         }
     }
 
+    fn ensure_page_dom(&self, element: &str) {
+        let sel = format!(".deck-page[data-element=\"{}\"]", element);
+        if self
+            .document
+            .query_selector(&sel)
+            .ok()
+            .flatten()
+            .is_some()
+        {
+            return;
+        }
+        let Some(strip) = self.document.get_element_by_id("deck-strip") else {
+            return;
+        };
+        let (title, tag) = match element {
+            "aire" => ("Aire", "Software · IA · Aspiración"),
+            "fuego" => ("Fuego", "Inspiración"),
+            "tierra" => ("Tierra", "Cuerpo"),
+            "agua" => ("Agua", "Espiritualidad aplicada"),
+            _ => return,
+        };
+        let html = format!(
+            "<article class=\"deck-page\" data-element=\"{el}\" id=\"deck-page-{el}\">\
+                <div class=\"page-controls\">\
+                    <button class=\"page-control-btn page-minimize\" data-minimize=\"{el}\" type=\"button\" aria-label=\"Minimizar {title}\">\
+                        <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M5 19 H19\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/></svg>\
+                    </button>\
+                    <button class=\"page-control-btn page-close\" data-close-page=\"{el}\" type=\"button\" aria-label=\"Cerrar {title}\">×</button>\
+                </div>\
+                <div class=\"page-ambience\" aria-hidden=\"true\"></div>\
+                <header class=\"page-head\">\
+                    <span class=\"page-mark\">{el}</span>\
+                    <h2 class=\"page-title\">{title}</h2>\
+                    <span class=\"page-tag\">{tag}</span>\
+                </header>\
+                <section class=\"page-content\" id=\"page-{el}-content\"></section>\
+            </article>",
+            el = element, title = title, tag = tag
+        );
+        let _ = strip.insert_adjacent_html("beforeend", &html);
+    }
+
+    fn remove_page_dom(&self, element: &str) {
+        let id = format!("deck-page-{}", element);
+        if let Some(el) = self.document.get_element_by_id(&id) {
+            el.remove();
+        }
+    }
+
     fn load_md_if_empty(&self, element: &str, md_url: &str) {
-        let content_id = format!("drawer-{}-content", element);
+        let content_id = format!("page-{}-content", element);
         let Some(content_el) = self.document.get_element_by_id(&content_id) else {
             return;
         };
@@ -163,9 +271,8 @@ impl AppState {
             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;
+            return; // ya hidratado
         }
         let reader = Reader::new(content);
         let element_owned = element.to_string();
@@ -176,6 +283,27 @@ impl AppState {
             }
         });
     }
+
+    fn element_center(&self, selector: &str) -> Option<(f64, f64)> {
+        let el = self.document.query_selector(selector).ok().flatten()?;
+        let rect = el.get_bounding_client_rect();
+        Some((
+            rect.left() + rect.width() / 2.0,
+            rect.top() + rect.height() / 2.0,
+        ))
+    }
+
+    fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
+        let sel = format!(".taskbar-item[data-task=\"{}\"]", element);
+        self.element_center(&sel)
+    }
+
+    fn viewport_height(&self) -> f64 {
+        web_sys::window()
+            .and_then(|w| w.inner_height().ok())
+            .and_then(|v| v.as_f64())
+            .unwrap_or(720.0)
+    }
 }
 
 #[wasm_bindgen(start)]
@@ -202,17 +330,33 @@ pub fn boot() -> Result<(), JsValue> {
         );
     }
 
+    // Mount vista deck
+    let strip_el: HtmlElement = document
+        .get_element_by_id("deck-strip")
+        .ok_or_else(|| JsValue::from_str("no #deck-strip"))?
+        .dyn_into()?;
+    let deck = Deck::mount(strip_el)?;
+
     let app = Rc::new(AppState {
         document: document.clone(),
+        deck: deck.clone(),
         state: RefCell::default(),
     });
 
+    // vista on_change → on_swipe del app
+    {
+        let app2 = app.clone();
+        deck.on_change(move |idx| {
+            app2.on_swipe(idx);
+        });
+    }
+
     install_resize(&window, &canvas, &renderer)?;
     install_mouse(&document, &canvas, &renderer)?;
     install_canvas_pointer(&canvas, &renderer)?;
     install_canvas_leave(&canvas, &renderer)?;
     install_tip_clicks(&document, &app)?;
-    install_drawer_close_buttons(&document, &app)?;
+    install_deck_delegation(&document, &app)?;
     install_taskbar(&document, &app)?;
     install_keyboard(&document, &app)?;
     install_raf(&window, &document, &canvas, &renderer);
@@ -261,7 +405,6 @@ fn install_mouse(
     Ok(())
 }
 
-/// Pointer down dentro del aro → impulso de vibración (click/tap shake).
 fn install_canvas_pointer(
     canvas: &HtmlCanvasElement,
     renderer: &Rc<RefCell<Renderer>>,
@@ -286,7 +429,6 @@ fn install_canvas_pointer(
     Ok(())
 }
 
-/// Mouse sale del canvas → tilt vuelve al frente con rebote del spring.
 fn install_canvas_leave(
     canvas: &HtmlCanvasElement,
     renderer: &Rc<RefCell<Renderer>>,
@@ -319,7 +461,7 @@ fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsV
             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);
+            app2.open_or_switch(&element, cx, cy, &md_url);
         });
         el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
         cb.forget();
@@ -327,36 +469,56 @@ fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsV
     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;
+/// Un listener en el deck delega clicks de minimize y close en cada página.
+/// Las páginas se crean dinámicamente, así que no podemos adjuntar listeners
+/// por botón en boot.
+fn install_deck_delegation(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+    let Some(deck_el) = document.get_element_by_id("deck") else {
+        return Ok(());
+    };
+    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 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| {
+        // Minimize
+        if let Ok(Some(btn)) = target_el.closest("[data-minimize]") {
             e.stop_propagation();
-            app2.close_tab(&element_attr);
-        });
-        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
-        cb.forget();
-    }
+            let element = btn.get_attribute("data-minimize").unwrap_or_default();
+            // Origin = la cajita correspondiente en la taskbar (efecto
+            // visual: la página se "encoge" hacia su entrada del taskbar).
+            let origin = app2
+                .taskbar_item_center(&element)
+                .unwrap_or_else(|| center_of_element(&btn));
+            app2.minimize(origin.0, origin.1);
+            return;
+        }
+        // Close
+        if let Ok(Some(btn)) = target_el.closest("[data-close-page]") {
+            e.stop_propagation();
+            let element = btn.get_attribute("data-close-page").unwrap_or_default();
+            let origin = app2
+                .taskbar_item_center(&element)
+                .unwrap_or_else(|| center_of_element(&btn));
+            app2.close(&element, origin.0, origin.1);
+        }
+    });
+    deck_el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+    cb.forget();
     Ok(())
 }
 
+fn center_of_element(el: &Element) -> (f64, f64) {
+    let rect = el.get_bounding_client_rect();
+    (
+        rect.left() + rect.width() / 2.0,
+        rect.top() + rect.height() / 2.0,
+    )
+}
+
 fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
-    // Botón home
+    // 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 };
@@ -364,13 +526,15 @@ fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValu
             continue;
         };
         let app2 = app.clone();
-        let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
+        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
+            e.prevent_default();
             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.
+
+    // 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| {
@@ -385,7 +549,13 @@ fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValu
                 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);
+                // 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())?;
@@ -398,8 +568,8 @@ fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsVal
     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);
+            if app2.state.borrow().active.is_some() {
+                app2.home();
             }
         }
     });
@@ -424,7 +594,6 @@ fn install_raf(
         renderer.borrow_mut().render(time_ms);
         let r = renderer.borrow();
         position_tips(&document, &canvas, &r);
-        update_tilt_css(&document, &r);
         drop(r);
         if let Some(cb) = f.borrow().as_ref() {
             let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
@@ -457,7 +626,6 @@ 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;
@@ -480,23 +648,6 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
     }
 }
 
-fn update_tilt_css(document: &Document, renderer: &Renderer) {
-    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>() {
-            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));
-        }
-    }
-}
-
 fn install_panic_hook() {
     static SET: std::sync::Once = std::sync::Once::new();
     SET.call_once(|| {
diff --git a/crates/apps/gioser-web/styles.css b/crates/apps/gioser-web/styles.css
index 84b4ca3..4b8a005 100644
--- a/crates/apps/gioser-web/styles.css
+++ b/crates/apps/gioser-web/styles.css
@@ -11,6 +11,9 @@
 
   --ease-emerge: cubic-bezier(0.22, 0.61, 0.20, 1);
   --ease-magma: cubic-bezier(0.32, 0, 0.05, 1);
+  --ease-page: cubic-bezier(0.22, 0.61, 0.36, 1);
+
+  --taskbar-height: 52px;
 }
 
 * { box-sizing: border-box; }
@@ -37,52 +40,22 @@ html, body {
   z-index: 0;
   transition: opacity 600ms var(--ease-emerge), filter 600ms var(--ease-emerge);
 }
-
-/* === Marca central: GioSer sobre la superficie de la chacana === */
-.brand {
-  position: fixed;
-  top: 50%;
-  left: 50%;
-  z-index: 6;
-  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.
-     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))
-    rotateZ(var(--tilt-z, 0deg));
-  transition: transform 30ms linear;
-}
-.brand-title {
-  font-family: 'Cinzel', serif;
-  font-weight: 700;
-  font-size: clamp(2.6rem, 6vw, 5.4rem);
-  margin: 0;
-  letter-spacing: 0.08em;
-  color: #f4eedf;
-  text-shadow:
-    0 0 22px rgba(216, 168, 93, 0.55),
-    0 0 50px rgba(183, 126, 52, 0.30),
-    0 0 80px rgba(216, 168, 93, 0.15);
-}
-.brand-dot {
-  color: var(--gold);
-  margin: 0 0.05em;
-  text-shadow:
-    0 0 14px var(--gold),
-    0 0 32px rgba(245, 144, 86, 0.55);
+body.deck-visible #gioser-canvas {
+  opacity: 0.30;
+  filter: blur(4px) saturate(80%);
 }
 
-/* === Tips (botones cardinales) === */
+/* === Tips (botones cardinales sobre el aro) === */
 #tips {
   position: fixed;
   inset: 0;
   pointer-events: none;
   z-index: 5;
+  transition: opacity 250ms ease;
+}
+body.deck-visible #tips {
+  opacity: 0;
+  pointer-events: none;
 }
 
 .tip {
@@ -116,8 +89,7 @@ html, body {
   transition:
     box-shadow 350ms var(--ease-emerge),
     border-color 350ms var(--ease-emerge),
-    background  350ms var(--ease-emerge),
-    opacity 300ms ease;
+    background  350ms var(--ease-emerge);
 }
 .tip::before {
   content: "";
@@ -172,96 +144,112 @@ html, body {
 .tip-agua   { color: var(--agua); }
 .tip-tierra { color: var(--tierra); }
 
-/* Cuando un drawer está abierto, los tips se ocultan suavemente. */
-body.drawer-active .tip {
-  opacity: 0;
-  pointer-events: none;
-  transition: opacity 250ms ease;
-}
-body.drawer-active #gioser-canvas {
-  opacity: 0.35;
-  filter: blur(4px) saturate(80%);
-}
-body.drawer-active .brand {
-  opacity: 0;
-  transition: opacity 250ms ease;
-}
-
-/* === Drawers (visor MD full-screen) === */
-.drawer {
+/* === DECK: contenedor único de páginas swipeable === */
+.deck {
   position: fixed;
   top: 0;
   left: 0;
   right: 0;
-  bottom: 52px; /* reservamos la altura de la taskbar */
+  bottom: var(--taskbar-height);
   z-index: 100;
   pointer-events: none;
   opacity: 0;
   visibility: hidden;
   transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
   transform: scale(0.0);
+  overflow: hidden;
+  touch-action: pan-y;
   background:
-    radial-gradient(ellipse at center, var(--drawer-glow, rgba(216, 168, 93, 0.15)), transparent 65%),
+    radial-gradient(ellipse at center, var(--deck-glow, rgba(216, 168, 93, 0.15)), transparent 65%),
     rgba(6, 5, 13, 0.96);
   backdrop-filter: blur(28px) saturate(140%);
   -webkit-backdrop-filter: blur(28px) saturate(140%);
   transition:
-    transform 700ms var(--ease-magma),
+    transform 600ms var(--ease-magma),
     opacity 450ms ease,
-    visibility 0s 700ms;
-  overflow: hidden;
+    visibility 0s 600ms;
 }
-.drawer.open {
+.deck.open {
   pointer-events: auto;
   opacity: 1;
   visibility: visible;
   transform: scale(1);
   transition:
-    transform 700ms var(--ease-magma),
+    transform 600ms var(--ease-magma),
     opacity 450ms ease,
     visibility 0s;
 }
+/* Acento del deck según elemento activo: glow radial del color. */
+body.deck-active-aire   .deck { --deck-glow: rgba(208, 219, 255, 0.22); }
+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); }
 
-.drawer-aire   { --drawer-glow: rgba(208, 219, 255, 0.22); --drawer-accent: var(--aire); }
-.drawer-fuego  { --drawer-glow: rgba(245, 144, 86, 0.28);  --drawer-accent: var(--fuego); }
-.drawer-agua   { --drawer-glow: rgba(108, 208, 243, 0.22); --drawer-accent: var(--agua); }
-.drawer-tierra { --drawer-glow: rgba(212, 152, 115, 0.24); --drawer-accent: var(--tierra); }
+/* Strip horizontal con páginas — vista-web traslada esto. */
+.deck-strip {
+  display: flex;
+  flex-direction: row;
+  width: 100%;
+  height: 100%;
+  transform: translate3d(var(--vista-offset, 0px), 0, 0);
+  transition: transform 360ms var(--ease-page);
+  will-change: transform;
+}
+.deck-strip.vista-dragging,
+.deck-strip.vista-instant {
+  transition: none;
+}
 
-/* Ambience: capa decorativa con efectos según elemento. */
-.drawer-ambience {
+.deck-page {
+  flex: 0 0 100%;
+  width: 100%;
+  height: 100%;
+  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;
+}
+.deck-page[data-element="aire"]   { --page-accent: var(--aire); }
+.deck-page[data-element="fuego"]  { --page-accent: var(--fuego); }
+.deck-page[data-element="agua"]   { --page-accent: var(--agua); }
+.deck-page[data-element="tierra"] { --page-accent: var(--tierra); }
+
+/* Ambience por página */
+.page-ambience {
   position: absolute;
   inset: 0;
   pointer-events: none;
   z-index: 0;
 }
-.drawer-aire .drawer-ambience {
+.deck-page[data-element="aire"] .page-ambience {
   background:
     radial-gradient(circle at 18% 22%, rgba(208, 219, 255, 0.20), transparent 38%),
     radial-gradient(circle at 78% 68%, rgba(208, 219, 255, 0.14), transparent 40%),
     radial-gradient(circle at 45% 90%, rgba(180, 200, 255, 0.10), transparent 45%);
   animation: aire-drift 28s ease-in-out infinite alternate;
 }
-.drawer-fuego .drawer-ambience {
+.deck-page[data-element="fuego"] .page-ambience {
   background:
     radial-gradient(circle at 50% 100%, rgba(245, 144, 86, 0.35), transparent 55%),
     radial-gradient(circle at 25% 80%, rgba(255, 90, 40, 0.18), transparent 35%),
     radial-gradient(circle at 80% 85%, rgba(255, 140, 60, 0.18), transparent 35%);
   animation: fuego-flicker 5s ease-in-out infinite;
 }
-.drawer-agua .drawer-ambience {
+.deck-page[data-element="agua"] .page-ambience {
   background:
     radial-gradient(ellipse at 50% 95%, rgba(60, 160, 230, 0.30), transparent 60%),
     radial-gradient(ellipse at 20% 70%, rgba(108, 208, 243, 0.15), transparent 50%),
     radial-gradient(ellipse at 80% 75%, rgba(108, 208, 243, 0.12), transparent 50%);
   animation: agua-tide 14s ease-in-out infinite alternate;
 }
-.drawer-tierra .drawer-ambience {
+.deck-page[data-element="tierra"] .page-ambience {
   background:
     radial-gradient(ellipse at 50% 100%, rgba(120, 80, 40, 0.40), transparent 60%),
     radial-gradient(ellipse at 22% 88%, rgba(180, 130, 80, 0.20), transparent 45%),
     radial-gradient(ellipse at 78% 88%, rgba(150, 100, 60, 0.22), transparent 45%);
 }
-
 @keyframes aire-drift {
   from { transform: translate(-4%, -1%); }
   to   { transform: translate(4%, 2%); }
@@ -276,15 +264,56 @@ body.drawer-active .brand {
   to   { transform: translateY(-3%); }
 }
 
-/* === Drawer head + close === */
-.drawer-head {
+/* Head + controls */
+.page-controls {
+  position: absolute;
+  top: 1.8vh;
+  right: 1.8vw;
+  z-index: 5;
+  display: flex;
+  gap: 0.5rem;
+}
+.page-control-btn {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  color: var(--page-accent, var(--gold));
+  font-size: 1.2rem;
+  line-height: 1;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  font-family: 'Inter', sans-serif;
+  transition:
+    background 200ms ease,
+    border-color 200ms ease,
+    transform 250ms var(--ease-emerge);
+}
+.page-control-btn:hover {
+  background: rgba(255, 255, 255, 0.10);
+  border-color: currentColor;
+}
+.page-minimize svg {
+  width: 18px;
+  height: 18px;
+}
+.page-close {
+  font-size: 1.6rem;
+}
+.page-close:hover { transform: rotate(90deg); }
+
+.page-head {
   position: relative;
   z-index: 2;
   text-align: center;
   padding: 7vh 8vw 2vh;
-  color: var(--drawer-accent);
+  color: var(--page-accent, var(--gold));
 }
-.drawer-mark {
+.page-mark {
   display: inline-block;
   font-family: 'Inter', sans-serif;
   font-size: 0.7rem;
@@ -294,16 +323,16 @@ body.drawer-active .brand {
   margin-bottom: 0.4rem;
   text-indent: 0.55em;
 }
-.drawer-title {
+.page-title {
   font-family: 'Cinzel', serif;
   font-weight: 700;
   font-size: clamp(2.4rem, 6vw, 4.6rem);
   margin: 0;
   letter-spacing: 0.08em;
-  color: var(--drawer-accent);
+  color: var(--page-accent, var(--gold));
   text-shadow: 0 0 28px currentColor, 0 0 56px rgba(255, 255, 255, 0.10);
 }
-.drawer-tag {
+.page-tag {
   display: block;
   font-family: 'Inter', sans-serif;
   font-size: 0.78rem;
@@ -313,54 +342,18 @@ body.drawer-active .brand {
   margin-top: 0.7rem;
   text-indent: 0.32em;
 }
-
-.drawer-close {
-  position: absolute;
-  top: 2.4vh;
-  right: 2.4vw;
-  z-index: 5;
-  width: 48px;
-  height: 48px;
-  border-radius: 50%;
-  background: rgba(255, 255, 255, 0.04);
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  color: var(--drawer-accent);
-  font-size: 1.6rem;
-  line-height: 1;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: background 200ms ease, transform 250ms var(--ease-emerge), border-color 200ms ease;
-  font-family: 'Inter', sans-serif;
-}
-.drawer-close:hover {
-  background: rgba(255, 255, 255, 0.10);
-  border-color: currentColor;
-  transform: rotate(90deg);
-}
-
-/* === Drawer content (MD render via pluma-reader-web) === */
-.drawer-content {
+.page-content {
   position: relative;
   z-index: 2;
-  max-height: calc(100vh - 22vh);
-  overflow-y: auto;
   padding: 1vh 10vw 8vh;
   opacity: 0;
-  transition: opacity 500ms ease 250ms;
+  transition: opacity 400ms ease 250ms;
 }
-.drawer.open .drawer-content {
+.deck.open .deck-page .page-content {
   opacity: 1;
 }
-.drawer-content::-webkit-scrollbar { width: 6px; }
-.drawer-content::-webkit-scrollbar-thumb {
-  background: var(--drawer-accent);
-  border-radius: 3px;
-  opacity: 0.4;
-}
 
-/* === pluma-doc: estilos del visor MD === */
+/* === pluma-doc dentro de la página === */
 .pluma-doc {
   max-width: 760px;
   margin: 0 auto;
@@ -375,7 +368,7 @@ body.drawer-active .brand {
   font-family: 'Cinzel', serif;
   font-weight: 700;
   font-size: clamp(1.7rem, 3vw, 2.4rem);
-  color: var(--drawer-accent);
+  color: var(--page-accent);
   text-shadow: 0 0 18px currentColor;
   letter-spacing: 0.04em;
   margin-top: 1.4em;
@@ -384,7 +377,7 @@ body.drawer-active .brand {
   font-family: 'Cinzel', serif;
   font-weight: 500;
   font-size: 1.5rem;
-  color: var(--drawer-accent);
+  color: var(--page-accent);
   letter-spacing: 0.04em;
   margin-top: 1.6em;
   padding-bottom: 0.3em;
@@ -400,7 +393,7 @@ body.drawer-active .brand {
 }
 .pluma-doc p { margin: 0; }
 .pluma-doc a {
-  color: var(--drawer-accent);
+  color: var(--page-accent);
   text-decoration: none;
   border-bottom: 1px solid currentColor;
   transition: opacity 200ms ease;
@@ -414,12 +407,12 @@ body.drawer-active .brand {
   padding: 0.12em 0.45em;
   border-radius: 4px;
   font-size: 0.92em;
-  color: var(--drawer-accent);
+  color: var(--page-accent);
 }
 .pluma-doc pre {
   background: rgba(0, 0, 0, 0.45);
   border: 1px solid rgba(255, 255, 255, 0.08);
-  border-left: 3px solid var(--drawer-accent);
+  border-left: 3px solid var(--page-accent);
   border-radius: 8px;
   padding: 1rem 1.2rem;
   overflow-x: auto;
@@ -431,7 +424,7 @@ body.drawer-active .brand {
   padding: 0;
 }
 .pluma-doc blockquote {
-  border-left: 3px solid var(--drawer-accent);
+  border-left: 3px solid var(--page-accent);
   padding: 0.4em 1.2em;
   color: rgba(232, 234, 245, 0.75);
   font-style: italic;
@@ -440,11 +433,11 @@ body.drawer-active .brand {
 }
 .pluma-doc ul, .pluma-doc ol { padding-left: 1.6em; }
 .pluma-doc li { margin: 0.4em 0; }
-.pluma-doc li::marker { color: var(--drawer-accent); }
+.pluma-doc li::marker { color: var(--page-accent); }
 .pluma-doc hr {
   border: none;
   height: 1px;
-  background: linear-gradient(to right, transparent, var(--drawer-accent), transparent);
+  background: linear-gradient(to right, transparent, var(--page-accent), transparent);
   margin: 2em 0;
 }
 .pluma-doc table {
@@ -458,11 +451,10 @@ body.drawer-active .brand {
   text-align: left;
 }
 .pluma-doc th {
-  color: var(--drawer-accent);
+  color: var(--page-accent);
   font-weight: 600;
   letter-spacing: 0.04em;
 }
-
 .pluma-loading, .pluma-error {
   display: flex;
   align-items: center;
@@ -478,34 +470,27 @@ body.drawer-active .brand {
   width: 28px;
   height: 28px;
   margin-right: 1rem;
-  border: 1px solid var(--drawer-accent);
+  border: 1px solid var(--page-accent);
   border-top-color: transparent;
   border-radius: 50%;
   animation: pluma-spin 1s linear infinite;
 }
-.pluma-error {
-  color: var(--fuego);
-  font-style: italic;
-}
-@keyframes pluma-spin {
-  to { transform: rotate(360deg); }
-}
+.pluma-error { color: var(--fuego); font-style: italic; }
+@keyframes pluma-spin { to { transform: rotate(360deg); } }
 
-/* === Responsive === */
 /* === Taskbar estilo Windows === */
 .taskbar {
   position: fixed;
   left: 0;
   right: 0;
   bottom: 0;
-  height: 52px;
+  height: var(--taskbar-height);
   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));
+  gap: 0.5rem;
+  padding: 0 0.8rem;
+  background: linear-gradient(to top, rgba(6, 5, 13, 0.94), rgba(8, 6, 22, 0.80));
   backdrop-filter: blur(24px) saturate(140%);
   -webkit-backdrop-filter: blur(24px) saturate(140%);
   border-top: 1px solid rgba(216, 168, 93, 0.22);
@@ -538,12 +523,34 @@ body.drawer-active .brand {
   filter: drop-shadow(0 0 6px currentColor);
 }
 
+.taskbar-brand {
+  font-family: 'Cinzel', serif;
+  font-weight: 700;
+  font-size: 1.30rem;
+  letter-spacing: 0.07em;
+  color: #f4eedf;
+  text-decoration: none;
+  text-shadow: 0 0 14px rgba(216, 168, 93, 0.45);
+  padding: 0 0.55rem;
+  user-select: none;
+  transition: text-shadow 220ms ease, color 220ms ease;
+  white-space: nowrap;
+}
+.taskbar-brand:hover {
+  color: #ffffff;
+  text-shadow: 0 0 20px rgba(216, 168, 93, 0.7), 0 0 36px rgba(245, 144, 86, 0.30);
+}
+.taskbar-brand .brand-dot {
+  color: var(--gold);
+  margin: 0 0.05em;
+}
+
 .taskbar-divider {
   display: inline-block;
   width: 1px;
   height: 26px;
   background: rgba(255, 255, 255, 0.12);
-  margin: 0 0.2rem;
+  margin: 0 0.25rem;
 }
 
 .taskbar-list {
@@ -575,6 +582,7 @@ body.drawer-active .brand {
   font-weight: 600;
   cursor: pointer;
   transition: all 260ms var(--ease-emerge);
+  white-space: nowrap;
 }
 .taskbar-item:hover {
   background: rgba(255, 255, 255, 0.09);
@@ -600,16 +608,51 @@ body.drawer-active .brand {
 .taskbar-item[data-task="tierra"] { --task-color: var(--tierra); }
 .taskbar-item[data-task="agua"]   { --task-color: var(--agua); }
 
+.taskbar-spacer {
+  flex: 1;
+  min-width: 0.6rem;
+}
+
+.taskbar-credit {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4rem;
+  font-family: 'Inter', sans-serif;
+  font-size: 0.78rem;
+  letter-spacing: 0.04em;
+  color: rgba(216, 168, 93, 0.75);
+  text-decoration: none;
+  padding: 0.35rem 0.7rem;
+  border-radius: 8px;
+  border: 1px solid transparent;
+  transition: all 220ms var(--ease-emerge);
+  white-space: nowrap;
+}
+.taskbar-credit:hover {
+  color: var(--gold);
+  border-color: rgba(216, 168, 93, 0.25);
+  background: rgba(216, 168, 93, 0.06);
+}
+.copyleft-mark {
+  display: inline-block;
+  font-size: 1rem;
+  /* © con escala horizontal -1 = copyleft visual. */
+  transform: scaleX(-1);
+  color: currentColor;
+}
+
 @media (max-width: 720px) {
   .tip { min-width: 110px; padding: 0.7rem 0.9rem; }
   .tip-glyph { width: 36px; height: 36px; }
   .tip-label { font-size: 0.72rem; }
   .tip-sub { display: none; }
-  .brand-title { font-size: clamp(2rem, 10vw, 3rem); }
-  .drawer-head { padding: 5vh 5vw 1vh; }
-  .drawer-content { padding: 0 5vw 5vh; }
+  .page-head { padding: 5vh 5vw 1vh; }
+  .page-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; }
+  .taskbar-brand { font-size: 1.05rem; padding: 0 0.3rem; }
+  .taskbar-credit-text { display: none; }
+  .deck { bottom: 46px; }
+  :root { --taskbar-height: 46px; }
 }
diff --git a/crates/modules/vista/vista-web/Cargo.toml b/crates/modules/vista/vista-web/Cargo.toml
new file mode 100644
index 0000000..0357ccc
--- /dev/null
+++ b/crates/modules/vista/vista-web/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "vista-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",
+    "CssStyleDeclaration",
+    "DomTokenList",
+    "Event",
+    "EventTarget",
+    "PointerEvent",
+    "MouseEvent",
+]
diff --git a/crates/modules/vista/vista-web/src/lib.rs b/crates/modules/vista/vista-web/src/lib.rs
new file mode 100644
index 0000000..91eb60a
--- /dev/null
+++ b/crates/modules/vista/vista-web/src/lib.rs
@@ -0,0 +1,282 @@
+//! Vista — deck horizontal de páginas con swipe estilo Flutter `PageView`.
+//!
+//! Agnóstico del dominio: opera sobre un elemento DOM "strip" cuyos hijos
+//! son las páginas. Cada hijo debe tener `flex: 0 0 100%` o equivalente
+//! para que el deck pueda calcular el offset correcto.
+//!
+//! Comportamiento:
+//! - Drag horizontal (mouse o touch) → strip se traslada con el dedo.
+//! - Release → snap a la página más cercana, animación CSS.
+//! - `goto(idx)` → scrolla programáticamente (click en tabs externos).
+//! - Diferencia entre gesto vertical y horizontal: si el primer movimiento
+//!   es vertical, cede al native scroll del contenido (cada página puede
+//!   tener su propio overflow-y).
+//!
+//! No inyecta CSS — el caller provee:
+//! ```css
+//! .vista-deck { overflow: hidden; touch-action: pan-y; }
+//! .vista-strip {
+//!     display: flex;
+//!     width: 100%;
+//!     height: 100%;
+//!     transform: translate3d(var(--vista-offset, 0px), 0, 0);
+//!     transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1);
+//! }
+//! .vista-strip.vista-dragging,
+//! .vista-strip.vista-instant { transition: none; }
+//! .vista-page { flex: 0 0 100%; height: 100%; overflow-y: auto; }
+//! ```
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{Event, HtmlElement, PointerEvent};
+
+/// Umbral en pixels para confirmar gesto horizontal vs vertical.
+const DRAG_DECISION_PX: f64 = 8.0;
+/// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe".
+const HORIZONTAL_BIAS: f64 = 1.3;
+
+#[derive(Clone)]
+pub struct Deck {
+    strip: HtmlElement,
+    inner: Rc<RefCell<Inner>>,
+}
+
+struct Inner {
+    current_index: usize,
+    pointer_start: Option<(f64, f64, i32)>, // (x, y, pointer_id)
+    drag_active: bool,
+    drag_start_offset: f64,
+    on_change: Option<Box<dyn FnMut(usize)>>,
+}
+
+impl Deck {
+    /// Monta el deck sobre el `strip`. El strip debe tener
+    /// `display: flex; transform: translate3d(var(--vista-offset, 0px), ...);`
+    /// y children con `flex: 0 0 100%`.
+    pub fn mount(strip: HtmlElement) -> Result<Self, JsValue> {
+        let inner = Rc::new(RefCell::new(Inner {
+            current_index: 0,
+            pointer_start: None,
+            drag_active: false,
+            drag_start_offset: 0.0,
+            on_change: None,
+        }));
+
+        install_pointerdown(&strip, &inner)?;
+        install_pointermove(&strip, &inner)?;
+        install_pointerend(&strip, &inner, "pointerup")?;
+        install_pointerend(&strip, &inner, "pointercancel")?;
+        install_pointerend(&strip, &inner, "pointerleave")?;
+        install_resize(&strip, &inner)?;
+
+        Ok(Self { strip, inner })
+    }
+
+    /// Navega a la página por índice. Con `smooth=false` se aplica
+    /// `.vista-instant` un frame para saltar sin transición.
+    pub fn goto(&self, index: usize, smooth: bool) {
+        let width = self.strip.client_width() as f64;
+        let offset = -(index as f64) * width;
+        if !smooth {
+            let _ = self.strip.class_list().add_1("vista-instant");
+        }
+        let _ = self
+            .strip
+            .style()
+            .set_property("--vista-offset", &format!("{}px", offset));
+        if !smooth {
+            // Quitamos `vista-instant` en el siguiente frame para restaurar la
+            // transición ordinaria.
+            let strip = self.strip.clone();
+            let cb = Closure::once(Box::new(move || {
+                let _ = strip.class_list().remove_1("vista-instant");
+            }) as Box<dyn FnOnce()>);
+            if let Some(w) = web_sys::window() {
+                let _ = w.request_animation_frame(cb.as_ref().unchecked_ref());
+            }
+            cb.forget();
+        }
+        let mut i = self.inner.borrow_mut();
+        let changed = i.current_index != index;
+        i.current_index = index;
+        if changed {
+            if let Some(cb) = i.on_change.as_mut() {
+                cb(index);
+            }
+        }
+    }
+
+    /// Índice de página actualmente visible.
+    pub fn current_index(&self) -> usize {
+        self.inner.borrow().current_index
+    }
+
+    /// Cantidad de páginas hijas del strip (live: lee el DOM).
+    pub fn page_count(&self) -> u32 {
+        self.strip.child_element_count()
+    }
+
+    /// Registra (o reemplaza) el callback de cambio de página. Se dispara
+    /// tras el snap de un swipe, o tras un `goto()` que cambie de índice.
+    pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
+        self.inner.borrow_mut().on_change = Some(Box::new(cb));
+    }
+
+    pub fn strip(&self) -> &HtmlElement {
+        &self.strip
+    }
+}
+
+fn install_pointerdown(
+    strip: &HtmlElement,
+    inner: &Rc<RefCell<Inner>>,
+) -> Result<(), JsValue> {
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let mut i = inner2.borrow_mut();
+        let width = strip2.client_width() as f64;
+        i.pointer_start = Some((
+            e.client_x() as f64,
+            e.client_y() as f64,
+            e.pointer_id(),
+        ));
+        i.drag_active = false;
+        i.drag_start_offset = -(i.current_index as f64) * width;
+    });
+    strip.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+fn install_pointermove(
+    strip: &HtmlElement,
+    inner: &Rc<RefCell<Inner>>,
+) -> Result<(), JsValue> {
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let mut i = inner2.borrow_mut();
+        let Some((sx, sy, pid)) = i.pointer_start else {
+            return;
+        };
+        let dx = e.client_x() as f64 - sx;
+        let dy = e.client_y() as f64 - sy;
+        if !i.drag_active {
+            let abs_dx = dx.abs();
+            let abs_dy = dy.abs();
+            if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS {
+                // Empieza drag horizontal.
+                i.drag_active = true;
+                let _ = strip2.class_list().add_1("vista-dragging");
+                // Capturar pointer para que los `move` sigan llegando aunque
+                // el cursor se vaya del strip.
+                let _ = strip2.set_pointer_capture(pid);
+            } else if abs_dy > DRAG_DECISION_PX {
+                // Movimiento vertical predominante — cancelar este drag.
+                i.pointer_start = None;
+                return;
+            } else {
+                return;
+            }
+        }
+        if i.drag_active {
+            let offset = i.drag_start_offset + dx;
+            let _ = strip2
+                .style()
+                .set_property("--vista-offset", &format!("{}px", offset));
+            e.prevent_default();
+        }
+    });
+    strip.add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+fn install_pointerend(
+    strip: &HtmlElement,
+    inner: &Rc<RefCell<Inner>>,
+    event_name: &str,
+) -> Result<(), JsValue> {
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+        let mut i = inner2.borrow_mut();
+        let was_active = i.drag_active;
+        i.drag_active = false;
+        i.pointer_start = None;
+        if !was_active {
+            return;
+        }
+        let _ = strip2.class_list().remove_1("vista-dragging");
+        let _ = strip2.release_pointer_capture(e.pointer_id());
+        let width = strip2.client_width() as f64;
+        let offset = current_offset_px(&strip2);
+        let n_pages = strip2.child_element_count() as usize;
+        if width <= 0.0 || n_pages == 0 {
+            return;
+        }
+        // Snap a la página más cercana (clamp a [0, n-1]).
+        let raw = -offset / width;
+        let target = raw.round().max(0.0) as usize;
+        let target = target.min(n_pages - 1);
+        let snapped_offset = -(target as f64) * width;
+        let _ = strip2
+            .style()
+            .set_property("--vista-offset", &format!("{}px", snapped_offset));
+        let changed = i.current_index != target;
+        i.current_index = target;
+        if changed {
+            if let Some(cb) = i.on_change.as_mut() {
+                cb(target);
+            }
+        }
+    });
+    strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+fn install_resize(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+    let Some(window) = web_sys::window() else {
+        return Ok(());
+    };
+    let strip2 = strip.clone();
+    let inner2 = inner.clone();
+    let cb = Closure::<dyn FnMut()>::new(move || {
+        // Reposicionar sin animación para que un resize no cause un slide.
+        let idx = inner2.borrow().current_index;
+        let _ = strip2.class_list().add_1("vista-instant");
+        let width = strip2.client_width() as f64;
+        let offset = -(idx as f64) * width;
+        let _ = strip2
+            .style()
+            .set_property("--vista-offset", &format!("{}px", offset));
+        let strip_for_clear = strip2.clone();
+        let cb2 = Closure::once(Box::new(move || {
+            let _ = strip_for_clear.class_list().remove_1("vista-instant");
+        }) as Box<dyn FnOnce()>);
+        if let Some(w) = web_sys::window() {
+            let _ = w.request_animation_frame(cb2.as_ref().unchecked_ref());
+        }
+        cb2.forget();
+    });
+    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
+    cb.forget();
+    Ok(())
+}
+
+/// Lee `--vista-offset` del strip como número de pixels (default 0).
+fn current_offset_px(strip: &HtmlElement) -> f64 {
+    let s = strip.style().get_property_value("--vista-offset").unwrap_or_default();
+    let trimmed = s.trim().trim_end_matches("px");
+    trimmed.parse::<f64>().unwrap_or(0.0)
+}
+
+/// Suprime warnings de uso no-utilizado durante compilación host (no-WASM).
+#[doc(hidden)]
+pub fn __unused_event_marker(_e: &Event) {}