feat(gioser): sol detrás, título central, drawers MD + pluma agnóstico
Visual de la chacana retrabajado contra chakana.png de referencia:
- Sol detrás (gauss + corona, masked al interior de la chacana — sólo
asoma por la superficie de la cruz, no se cuela afuera).
- Doble outline dorado (línea principal + paralela offset 0.020), color
CHACANA_LINE pasa de cyan helado a dorado-ámbar del logo.
- Interior con niebla violeta-noche (u_dark_color) y rayos radiales
sutiles desde el centro, modulados por sin(t * 0.3).
- Aro doble exterior: ring fino interior + ring grueso con 4 grupos de
3 puntos cardinales (calculados angularmente, no rayos largos).
- WORLD_SCALE 1.45→1.05, MAX_TILT 35°→28° (más sólido, menos caricaturesco).
Título "GioSer" centrado dentro de la superficie de la chacana, sin
subtítulo. Se inclina junto con la chacana vía CSS perspective +
rotateX/rotateY desde u-tilt-x/y inyectadas cada frame por WASM.
Botones (4 tips):
- Reposicionados a `arm_extent * 1.32` (entre punta y aro grueso).
- Bigger: min-width 168px, glyph 54px, label Cinzel 0.95rem.
- Doble anillo en hover (::before con border + glow).
- Cuando un drawer se abre, fade-out de tips + canvas + brand.
Drawers MD (uno por elemento):
- `<aside class="drawer drawer-{element}">` con transform-origin desde
CSS vars (--origin-x/y) seteadas por WASM al click — crece desde la
posición exacta del botón hasta fullscreen en 700ms con cubic-bezier.
- Ambience por elemento: AIRE (radial drift), FUEGO (flicker keyframe),
AGUA (tide vertical), TIERRA (warm earth gradient).
- Cerrado con botón X, Escape o data-close-drawer.
- Carga MD desde ./md/{element}.md via spawn_local + Reader::open_url.
Pluma (visor MD agnóstico, dos crates nuevos):
- `crates/modules/pluma/pluma-md` — wrapper sobre pulldown-cmark 0.12.
API: to_html(), to_themed_html(md, theme) con sanitización del theme,
events() para AST stream. GFM completo. No deps web. 5 tests.
- `crates/modules/pluma/pluma-reader-web` — toma HtmlElement, expone
open_url async (fetch via wasm-bindgen-futures), render_md sync,
show_loading/show_error. NO inyecta CSS — el host estiliza
`.pluma-doc[data-pluma-theme="..."]` con sus colores.
CSS pluma-doc completo: h1/h2/h3, code/pre con border-left accent,
blockquote, tables, lists, hr gradient. Loader spinner + error state.
Placeholders en md/{aire,fuego,tierra,agua}.md con texto seed.
Workspace verde + 18 tests (6 geom + 4 palette + 3 physics + 5 pluma-md).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fce630c8d0a50843b6d447531efb106a627fd1c7 · by sergio · 2026-05-13T23:38:37Z (5 months ago) · parent 3dbdfb357 · browse the tree at this commit
Semantic delta · 43
crates/apps/gioser-web/src/lib.rs
crates/modules/gioser/gioser-canvas-web/src/lib.rs
crates/modules/gioser/gioser-palette/src/lib.rs
crates/modules/gioser/gioser-shaders/src/lib.rs
crates/modules/pluma/pluma-md/src/lib.rs
crates/modules/pluma/pluma-reader-web/src/lib.rs
diff --git a/Cargo.lock b/Cargo.lock
index 2b6d924..9383dcb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3950,7 +3950,9 @@ version = "0.1.0"
dependencies = [
"gioser-canvas-web",
"js-sys",
+ "pluma-reader-web",
"wasm-bindgen",
+ "wasm-bindgen-futures",
"web-sys",
]
@@ -7995,6 +7997,24 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "pluma-md"
+version = "0.1.0"
+dependencies = [
+ "pulldown-cmark",
+]
+
+[[package]]
+name = "pluma-reader-web"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "pluma-md",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "png"
version = "0.17.16"
@@ -8291,6 +8311,24 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags 2.11.1",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
[[package]]
name = "pxfm"
version = "0.1.29"
diff --git a/Cargo.toml b/Cargo.toml
index b5165d8..eda49e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -117,6 +117,12 @@ members = [
"crates/modules/gioser/gioser-shaders",
"crates/modules/gioser/gioser-canvas-web",
+ # ============================================================
+ # modules/pluma/ — markdown agnóstico + visor web elegante
+ # ============================================================
+ "crates/modules/pluma/pluma-md",
+ "crates/modules/pluma/pluma-reader-web",
+
# ============================================================
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
# ============================================================
@@ -231,10 +237,14 @@ directories = "5"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
+# === Markdown (pluma) ===
+pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
+
# ============================================================
# Intra-workspace deps de yahweh (referenciadas por workspace = true)
# ============================================================
diff --git a/crates/apps/gioser-web/Cargo.toml b/crates/apps/gioser-web/Cargo.toml
index 0b2f3d9..e666b52 100644
--- a/crates/apps/gioser-web/Cargo.toml
+++ b/crates/apps/gioser-web/Cargo.toml
@@ -11,7 +11,9 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
gioser-canvas-web = { path = "../../modules/gioser/gioser-canvas-web" }
+pluma-reader-web = { path = "../../modules/pluma/pluma-reader-web" }
wasm-bindgen.workspace = true
+wasm-bindgen-futures.workspace = true
js-sys.workspace = true
[dependencies.web-sys]
@@ -23,9 +25,13 @@ features = [
"HtmlElement",
"HtmlCanvasElement",
"CssStyleDeclaration",
- "MouseEvent",
+ "DomTokenList",
+ "DomRect",
+ "Event",
"EventTarget",
+ "MouseEvent",
+ "KeyboardEvent",
+ "NodeList",
"Performance",
"console",
]
-
diff --git a/crates/apps/gioser-web/index.html b/crates/apps/gioser-web/index.html
index abb54b9..7d5173a 100644
--- a/crates/apps/gioser-web/index.html
+++ b/crates/apps/gioser-web/index.html
@@ -7,67 +7,110 @@
<link rel="stylesheet" href="./styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@300;500&display=swap">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@300;500;600&family=JetBrains+Mono:wght@400;600&display=swap">
</head>
<body>
<canvas id="gioser-canvas" aria-hidden="true"></canvas>
- <header class="brand">
+ <header id="brand" class="brand">
<h1 class="brand-title">Gio<span class="brand-dot">·</span>Ser</h1>
- <p class="brand-sub">EN EL CENTRO · EL SER</p>
</header>
<main id="tips" aria-label="Cuatro elementos">
- <a id="tip-aire" class="tip tip-aire" href="#aire" aria-label="Aire">
- <svg viewBox="0 0 40 40" class="tip-glyph">
- <path d="M6 14 q 7 -10 14 0 t 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
- <path d="M6 22 q 7 -10 14 0 t 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.75"/>
- <circle cx="32" cy="9" r="2.2" fill="none" stroke="currentColor" stroke-width="1.2"/>
+ <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">
+ <path d="M6 18 q 9 -12 18 0 t 18 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+ <path d="M6 28 q 9 -12 18 0 t 18 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.75"/>
+ <path d="M6 38 q 9 -12 18 0 t 18 0" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" opacity="0.45"/>
</svg>
<span class="tip-label">AIRE</span>
<span class="tip-sub">Software · IA</span>
</a>
- <a id="tip-fuego" class="tip tip-fuego" href="#fuego" aria-label="Fuego">
- <svg viewBox="0 0 40 40" class="tip-glyph">
- <path d="M20 4 q -10 10 -5 20 q 3 -5 5 -5 q 1 8 5 10 q 8 -8 0 -18 q -4 5 -5 -7 z"
- fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
- <circle cx="20" cy="22" r="2.5" fill="currentColor" opacity="0.55"/>
+ <a id="tip-fuego" class="tip tip-fuego" href="#fuego" data-md="./md/fuego.md" aria-label="Fuego — Inspiración">
+ <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+ <path d="M24 4 q -12 12 -6 24 q 3 -6 6 -6 q 1 10 6 12 q 10 -10 0 -22 q -4 6 -6 -8 z"
+ fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
+ <circle cx="24" cy="28" r="3" fill="currentColor" opacity="0.5"/>
</svg>
<span class="tip-label">FUEGO</span>
<span class="tip-sub">Inspiración</span>
</a>
- <a id="tip-tierra" class="tip tip-tierra" href="#tierra" aria-label="Tierra">
- <svg viewBox="0 0 40 40" class="tip-glyph">
- <path d="M4 30 l 10 -14 l 6 7 l 5 -10 l 11 17 z"
- fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
- <path d="M20 30 v 6 M16 32 l -3 4 M24 32 l 3 4 M20 36 v 1"
- fill="none" stroke="currentColor" stroke-width="1.1" opacity="0.7"/>
+ <a id="tip-tierra" class="tip tip-tierra" href="#tierra" data-md="./md/tierra.md" aria-label="Tierra — Cuerpo">
+ <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+ <path d="M4 36 l 12 -16 l 8 9 l 6 -12 l 14 19 z"
+ fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
+ <path d="M24 36 v 8 M19 38 l -4 5 M29 38 l 4 5 M24 44 v 2"
+ fill="none" stroke="currentColor" stroke-width="1.3" opacity="0.7"/>
</svg>
<span class="tip-label">TIERRA</span>
<span class="tip-sub">Cuerpo</span>
</a>
- <a id="tip-agua" class="tip tip-agua" href="#agua" aria-label="Agua">
- <svg viewBox="0 0 40 40" class="tip-glyph">
- <path d="M5 20 q 5 -8 10 0 t 10 0 t 10 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
- <path d="M5 27 q 5 -8 10 0 t 10 0 t 10 0" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" opacity="0.75"/>
- <path d="M5 34 q 5 -8 10 0 t 10 0 t 10 0" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.5"/>
+ <a id="tip-agua" class="tip tip-agua" href="#agua" data-md="./md/agua.md" aria-label="Agua — Espiritualidad aplicada">
+ <svg viewBox="0 0 48 48" class="tip-glyph" aria-hidden="true">
+ <path d="M6 22 q 6 -10 12 0 t 12 0 t 12 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+ <path d="M6 30 q 6 -10 12 0 t 12 0 t 12 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" opacity="0.75"/>
+ <path d="M6 38 q 6 -10 12 0 t 12 0 t 12 0" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" opacity="0.5"/>
</svg>
<span class="tip-label">AGUA</span>
<span class="tip-sub">Espiritualidad</span>
</a>
</main>
- <footer class="quote">aire · agua · fuego · tierra</footer>
+ <!-- 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>
+
+ <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>
<script type="module">
import init from "./pkg/gioser_web.js";
init().catch(err => {
console.error(err);
document.body.insertAdjacentHTML("beforeend",
- '<pre style="color:#f59056;position:fixed;left:1rem;bottom:1rem;max-width:90vw;white-space:pre-wrap;font-family:monospace">' +
+ '<pre style="color:#f59056;position:fixed;left:1rem;bottom:1rem;max-width:90vw;white-space:pre-wrap;font-family:monospace;z-index:9999">' +
String(err) + '</pre>');
});
</script>
diff --git a/crates/apps/gioser-web/md/agua.md b/crates/apps/gioser-web/md/agua.md
new file mode 100644
index 0000000..43d52bb
--- /dev/null
+++ b/crates/apps/gioser-web/md/agua.md
@@ -0,0 +1,25 @@
+# Agua
+
+> *Lo que fluye. Lo que une dentro y afuera.*
+
+El **Agua** es el dominio de la **espiritualidad aplicada**: las
+prácticas, lecturas y tradiciones que sostienen la atención y dan
+sentido al hacer. No es decoración mística: es la práctica concreta
+de mantenerse permeable, vivo, conectado.
+
+## Espiritualidad aplicada
+
+Aplicada significa que no se queda en libros: pasa por la práctica
+diaria — la lectura, la meditación, la ceremonia, la conversación
+honda. El agua moja todos los otros ejes.
+
+## Lo que vive acá
+
+- Notas de lectura sobre filosofía, mística, sabiduría andina.
+- Diario de prácticas (meditación, ceremonias, retiros).
+- Conversaciones con maestros y comunidades.
+
+## Próximamente
+
+*Acá se va a ir armando una bitácora de lecturas y prácticas. Por
+ahora el placeholder verifica el render bajo el tema **agua**.*
diff --git a/crates/apps/gioser-web/md/aire.md b/crates/apps/gioser-web/md/aire.md
new file mode 100644
index 0000000..8147ce8
--- /dev/null
+++ b/crates/apps/gioser-web/md/aire.md
@@ -0,0 +1,26 @@
+# Aire
+
+> *Lo que respira el sistema. Lo que sube.*
+
+El **Aire** es el dominio del **software público y la IA**. Es la capa
+intangible que transporta pensamiento — los bits que vuelan entre
+máquinas, las inferencias que destilan sentido del ruido, las APIs
+que conversan sin verse.
+
+## Aspiración
+
+El Aire **aspira**: empuja hacia arriba. Es el movimiento de subir el
+nivel de abstracción, de hacer que una cosa difícil parezca obvia, de
+regalarle al usuario una herramienta que no le pesa.
+
+## Lo que vive acá
+
+- Herramientas open source que **GioSer** publica y mantiene.
+- Modelos de IA que asisten al ciclo de creación.
+- Documentación, ensayos, manifiestos.
+
+## Próximamente
+
+*Esta sección se va a llenar con los proyectos concretos del eje aire.*
+Por ahora, este placeholder vive en `md/aire.md` y se renderiza vía
+`pluma-md` con tema *aire*.
diff --git a/crates/apps/gioser-web/md/fuego.md b/crates/apps/gioser-web/md/fuego.md
new file mode 100644
index 0000000..f78e4fb
--- /dev/null
+++ b/crates/apps/gioser-web/md/fuego.md
@@ -0,0 +1,25 @@
+# Fuego
+
+> *Lo que enciende. Lo que transforma.*
+
+El **Fuego** es el dominio de la **inspiración**. Es la chispa que
+convierte una idea en gesto, una frase en ritual, un problema en
+prototipo. Sin fuego, los otros tres elementos se enfrían y se quedan
+contemplándose.
+
+## Inspiración
+
+El fuego no se planea, se **atiende**. Llega — y la respuesta es no
+dejarlo pasar. Acá viven los ensayos, los videos, los manifiestos y
+los experimentos que nacieron porque algo prendió.
+
+## Lo que vive acá
+
+- Charlas, ensayos cortos, posts crudos.
+- Bocetos visuales, exploraciones tipográficas.
+- Documentos de manifiesto sobre cómo trabajar y para qué.
+
+## Próximamente
+
+*Voy a ir enlazando archivos `.md` específicos acá. Por ahora este
+texto sirve para verificar el render bajo el tema **fuego**.*
diff --git a/crates/apps/gioser-web/md/tierra.md b/crates/apps/gioser-web/md/tierra.md
new file mode 100644
index 0000000..49cb2a4
--- /dev/null
+++ b/crates/apps/gioser-web/md/tierra.md
@@ -0,0 +1,26 @@
+# Tierra
+
+> *El cuerpo. La materia. Lo que sostiene.*
+
+La **Tierra** es el dominio del **cuerpo**. Es lo que se toca, lo que
+huele, lo que se siembra. El eje terrestre de GioSer recuerda que
+todo proyecto —por muy abstracto que parezca— pasa por un cuerpo que
+respira, come, descansa y se conmueve.
+
+## Cuerpo
+
+El cuerpo no es una metáfora: es donde aterriza el aire, donde el
+agua se vuelve vida, donde el fuego deja huella. Cuidarlo es parte
+del trabajo.
+
+## Lo que vive acá
+
+- Prácticas, rutinas, recetas.
+- Materialidad: objetos, lugares, oficios.
+- Salud y reposo como infraestructura.
+
+## Próximamente
+
+*Esta sección va a recibir notas, fotos y enlaces a oficios y
+prácticas concretas. Por ahora el placeholder verifica el tema
+**tierra**.*
diff --git a/crates/apps/gioser-web/src/lib.rs b/crates/apps/gioser-web/src/lib.rs
index 034f554..c66dcca 100644
--- a/crates/apps/gioser-web/src/lib.rs
+++ b/crates/apps/gioser-web/src/lib.rs
@@ -1,17 +1,29 @@
//! Entrypoint WASM de la landing GioSer.
//!
-//! Monta el canvas, instala listeners de mouse y resize, corre el loop
-//! con `requestAnimationFrame`, y reposiciona los 4 botones DOM
-//! `#tip-{aire|fuego|tierra|agua}` sobre las puntas proyectadas
-//! de la chacana cada frame.
+//! Responsabilidades:
+//! - Montar canvas WebGL2 + listeners de mouse/resize/RAF loop.
+//! - Reposicionar los 4 tips DOM (botones cardinales) sobre las posiciones
+//! proyectadas del aro de la chacana cada frame.
+//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt.
+//! - Manejar click sobre cada tip → animar drawer expandiéndose desde la
+//! posición del botón hasta fullscreen, cargar el .md asociado vía
+//! `pluma-reader-web` y renderearlo themed por elemento.
+//! - Cerrar drawer con close button, Escape o backdrop click.
use std::cell::RefCell;
use std::rc::Rc;
use gioser_canvas_web::{tips, Renderer};
+use pluma_reader_web::Reader;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
-use web_sys::{Document, HtmlCanvasElement, HtmlElement, MouseEvent, Window};
+use web_sys::{
+ Document, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent, Window,
+};
+
+/// Factor radial sobre `arm_extent` donde se anclan los botones DOM.
+/// Queda entre la punta de la chacana y el aro grueso.
+const BUTTON_RADIUS_FACTOR: f32 = 1.32;
#[wasm_bindgen(start)]
pub fn boot() -> Result<(), JsValue> {
@@ -34,6 +46,7 @@ pub fn boot() -> Result<(), JsValue> {
install_resize(&window, &canvas, &renderer)?;
install_mouse(&document, &canvas, &renderer)?;
+ install_drawer_handlers(&document)?;
install_raf(&window, &document, &canvas, &renderer);
Ok(())
@@ -66,7 +79,6 @@ fn install_mouse(
let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
let w = canvas.client_width().max(1) as f32;
let h = canvas.client_height().max(1) as f32;
- // Origen en el centro del canvas, +y arriba.
let x = e.client_x() as f32 - w * 0.5;
let y = h * 0.5 - e.client_y() as f32;
r.borrow_mut().set_mouse_px(x, y);
@@ -76,6 +88,116 @@ fn install_mouse(
Ok(())
}
+fn install_drawer_handlers(document: &Document) -> Result<(), JsValue> {
+ let doc = document.clone();
+
+ // Clicks en los 4 tips → open drawer.
+ let tips = document.query_selector_all(".tip[data-md]")?;
+ for i in 0..tips.length() {
+ let Some(node) = tips.item(i) else { continue };
+ let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
+ let id = el.id();
+ let element = id.strip_prefix("tip-").unwrap_or("").to_string();
+ let md_url = el.get_attribute("data-md").unwrap_or_default();
+ let d = doc.clone();
+ let el_for_rect = el.clone();
+ let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
+ e.prevent_default();
+ open_drawer(&d, &element, &el_for_rect, &md_url);
+ });
+ el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ }
+
+ // Cualquier elemento marcado con `data-close-drawer` cierra.
+ let closes = document.query_selector_all("[data-close-drawer]")?;
+ for i in 0..closes.length() {
+ let Some(node) = closes.item(i) else { continue };
+ let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
+ let d = doc.clone();
+ let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
+ e.stop_propagation();
+ close_drawers(&d);
+ });
+ el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ }
+
+ // Escape cierra.
+ let d = doc.clone();
+ let kcb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
+ if e.key() == "Escape" {
+ close_drawers(&d);
+ }
+ });
+ document.add_event_listener_with_callback("keydown", kcb.as_ref().unchecked_ref())?;
+ kcb.forget();
+
+ Ok(())
+}
+
+fn open_drawer(doc: &Document, element: &str, button: &HtmlElement, md_url: &str) {
+ let rect = button.get_bounding_client_rect();
+ let cx = rect.left() + rect.width() / 2.0;
+ let cy = rect.top() + rect.height() / 2.0;
+ let drawer_id = format!("drawer-{}", element);
+ let Some(drawer_el) = doc.get_element_by_id(&drawer_id) else {
+ return;
+ };
+ let drawer: HtmlElement = drawer_el.unchecked_into();
+ let _ = drawer
+ .style()
+ .set_property("--origin-x", &format!("{:.1}px", cx));
+ let _ = drawer
+ .style()
+ .set_property("--origin-y", &format!("{:.1}px", cy));
+ let _ = drawer.class_list().add_1("open");
+ drawer.set_attribute("aria-hidden", "false").ok();
+
+ if let Some(body) = doc.body() {
+ let _ = body.class_list().add_1("drawer-active");
+ let _ = body
+ .class_list()
+ .add_1(&format!("drawer-active-{}", element));
+ }
+
+ // Carga del .md en background.
+ let content_id = format!("drawer-{}-content", element);
+ if let Some(content_el) = doc.get_element_by_id(&content_id) {
+ let content: HtmlElement = content_el.unchecked_into();
+ let reader = Reader::new(content);
+ let element_owned = element.to_string();
+ let md_url_owned = md_url.to_string();
+ wasm_bindgen_futures::spawn_local(async move {
+ if let Err(e) = reader.open_url(&md_url_owned, &element_owned).await {
+ web_sys::console::warn_1(&e);
+ }
+ });
+ }
+}
+
+fn close_drawers(doc: &Document) {
+ let Ok(drawers) = doc.query_selector_all(".drawer.open") else {
+ return;
+ };
+ for i in 0..drawers.length() {
+ let Some(node) = drawers.item(i) else { continue };
+ let Ok(el) = node.dyn_into::<HtmlElement>() else {
+ continue;
+ };
+ let _ = el.class_list().remove_1("open");
+ let _ = el.set_attribute("aria-hidden", "true");
+ }
+ if let Some(body) = doc.body() {
+ let _ = body.class_list().remove_1("drawer-active");
+ for e in ["aire", "fuego", "tierra", "agua"] {
+ let _ = body
+ .class_list()
+ .remove_1(&format!("drawer-active-{}", e));
+ }
+ }
+}
+
fn install_raf(
window: &Window,
document: &Document,
@@ -89,8 +211,14 @@ fn install_raf(
let document = document.clone();
let window2 = window.clone();
*g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
+ let r = renderer.borrow_mut();
+ // r es Mut, ojo: el render necesita mut, lo hacemos antes de paint.
+ drop(r);
renderer.borrow_mut().render(time_ms);
- position_tips(&document, &canvas, &renderer.borrow());
+ 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());
}
@@ -119,7 +247,7 @@ fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
}
fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
- let clips = renderer.tips_ndc();
+ 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;
for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
@@ -138,6 +266,21 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
}
}
+fn update_tilt_css(document: &Document, renderer: &Renderer) {
+ let (pitch, yaw) = renderer.tilt_degrees();
+ if let Some(brand) = document.get_element_by_id("brand") {
+ if let Ok(brand) = brand.dyn_into::<HtmlElement>() {
+ // CSS rotateX usa el mismo signo que nuestra pitch (mouse up tilts top toward viewer).
+ let _ = brand
+ .style()
+ .set_property("--tilt-x", &format!("{:.2}deg", pitch));
+ let _ = brand
+ .style()
+ .set_property("--tilt-y", &format!("{:.2}deg", yaw));
+ }
+ }
+}
+
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 aaa9b5c..fd71309 100644
--- a/crates/apps/gioser-web/styles.css
+++ b/crates/apps/gioser-web/styles.css
@@ -1,11 +1,16 @@
+/* === Tokens === */
:root {
--bg: #06050d;
--fg: #e8eaf5;
+ --gold: #d8a85d;
+ --gold-deep: #b77e34;
--aire: #d0dbff;
--agua: #6cd0f3;
--fuego: #f59056;
--tierra: #d49873;
- --rim: #d8a85d;
+
+ --ease-emerge: cubic-bezier(0.22, 0.61, 0.20, 1);
+ --ease-magma: cubic-bezier(0.32, 0, 0.05, 1);
}
* { box-sizing: border-box; }
@@ -20,9 +25,9 @@ html, body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-weight: 300;
overflow: hidden;
- cursor: default;
}
+/* === Canvas WebGL === */
#gioser-canvas {
position: fixed;
inset: 0;
@@ -30,47 +35,47 @@ html, body {
height: 100vh;
display: block;
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: 4vh;
+ top: 50%;
left: 50%;
- transform: translateX(-50%);
- text-align: center;
- z-index: 10;
+ 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 */
+ transform:
+ translate(-50%, -50%)
+ perspective(900px)
+ rotateX(var(--tilt-x, 0deg))
+ rotateY(var(--tilt-y, 0deg));
+ transition: transform 30ms linear;
}
-
.brand-title {
font-family: 'Cinzel', serif;
font-weight: 700;
- font-size: clamp(2.4rem, 5vw, 4.4rem);
+ font-size: clamp(2.6rem, 6vw, 5.4rem);
margin: 0;
letter-spacing: 0.08em;
- color: var(--fg);
+ color: #f4eedf;
text-shadow:
- 0 0 18px rgba(108, 208, 243, 0.35),
- 0 0 40px rgba(216, 168, 93, 0.18);
+ 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(--rim);
+ color: var(--gold);
margin: 0 0.05em;
- text-shadow: 0 0 14px var(--rim), 0 0 28px rgba(245, 144, 86, 0.5);
-}
-
-.brand-sub {
- font-family: 'Inter', sans-serif;
- font-weight: 300;
- letter-spacing: 0.5em;
- font-size: clamp(0.65rem, 0.9vw, 0.8rem);
- margin: 0.55rem 0 0;
- color: rgba(232, 234, 245, 0.6);
- text-indent: 0.5em;
+ text-shadow:
+ 0 0 14px var(--gold),
+ 0 0 32px rgba(245, 144, 86, 0.55);
}
+/* === Tips (botones cardinales) === */
#tips {
position: fixed;
inset: 0;
@@ -85,107 +90,409 @@ html, body {
text-decoration: none;
color: var(--fg);
user-select: none;
+ cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
- gap: 0.35rem;
- padding: 0.7rem 1.0rem 0.55rem;
- min-width: 120px;
+ gap: 0.55rem;
+ padding: 1.1rem 1.6rem 0.95rem;
+ min-width: 168px;
+
+ background:
+ radial-gradient(ellipse at top, rgba(255, 255, 255, 0.04), transparent 65%),
+ rgba(8, 6, 22, 0.55);
+ backdrop-filter: blur(12px) saturate(140%);
+ -webkit-backdrop-filter: blur(12px) saturate(140%);
+ border-radius: 20px;
+ border: 1px solid rgba(216, 168, 93, 0.25);
- background: rgba(8, 6, 22, 0.42);
- backdrop-filter: blur(8px) saturate(120%);
- -webkit-backdrop-filter: blur(8px) saturate(120%);
- border-radius: 14px;
- border: 1px solid rgba(255, 255, 255, 0.07);
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
+ box-shadow:
+ 0 12px 36px rgba(0, 0, 0, 0.40),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.04);
transition:
- box-shadow 260ms ease,
- border-color 260ms ease,
- background 260ms ease,
- letter-spacing 260ms ease;
+ box-shadow 350ms var(--ease-emerge),
+ border-color 350ms var(--ease-emerge),
+ background 350ms var(--ease-emerge),
+ opacity 300ms ease;
}
-
.tip::before {
content: "";
position: absolute;
- inset: -1px;
- border-radius: inherit;
- padding: 1px;
- background: linear-gradient(135deg,
- rgba(255,255,255,0.18),
- rgba(255,255,255,0.0) 40%,
- rgba(216, 168, 93, 0.18));
- -webkit-mask:
- linear-gradient(#000 0 0) content-box,
- linear-gradient(#000 0 0);
- -webkit-mask-composite: xor;
- mask-composite: exclude;
+ inset: -10px;
+ border-radius: 26px;
+ border: 1px solid currentColor;
+ opacity: 0;
+ transition: opacity 400ms ease, inset 400ms var(--ease-emerge);
pointer-events: none;
- opacity: 0.7;
}
-
.tip:hover {
- border-color: rgba(255, 255, 255, 0.22);
- background: rgba(20, 14, 40, 0.6);
+ border-color: currentColor;
+ background:
+ radial-gradient(ellipse at top, rgba(255, 255, 255, 0.07), transparent 65%),
+ rgba(20, 14, 40, 0.72);
+}
+.tip:hover::before {
+ opacity: 0.45;
+ inset: -16px;
}
-
.tip-glyph {
- width: 42px;
- height: 42px;
+ width: 54px;
+ height: 54px;
color: currentColor;
- filter: drop-shadow(0 0 6px currentColor);
- transition: filter 240ms ease;
+ filter: drop-shadow(0 0 6px currentColor) drop-shadow(0 0 16px currentColor);
+ transition: filter 320ms ease, transform 350ms var(--ease-emerge);
}
.tip:hover .tip-glyph {
- filter: drop-shadow(0 0 12px currentColor) drop-shadow(0 0 20px currentColor);
+ filter: drop-shadow(0 0 14px currentColor) drop-shadow(0 0 28px currentColor);
+ transform: translateY(-3px);
}
-
.tip-label {
font-family: 'Cinzel', serif;
- font-size: 0.74rem;
- letter-spacing: 0.36em;
+ font-size: 0.95rem;
+ letter-spacing: 0.42em;
font-weight: 600;
- text-indent: 0.36em;
+ text-indent: 0.42em;
+ margin-top: 0.15rem;
}
-
.tip-sub {
font-family: 'Inter', sans-serif;
- font-size: 0.62rem;
- letter-spacing: 0.18em;
+ font-size: 0.7rem;
+ letter-spacing: 0.22em;
font-weight: 300;
- color: rgba(232, 234, 245, 0.55);
+ color: rgba(232, 234, 245, 0.62);
text-transform: uppercase;
+ text-indent: 0.22em;
}
-
.tip-aire { color: var(--aire); }
-.tip-aire:hover { box-shadow: 0 0 32px rgba(208, 219, 255, 0.45); }
.tip-fuego { color: var(--fuego); }
-.tip-fuego:hover { box-shadow: 0 0 32px rgba(245, 144, 86, 0.45); }
.tip-agua { color: var(--agua); }
-.tip-agua:hover { box-shadow: 0 0 32px rgba(108, 208, 243, 0.45); }
.tip-tierra { color: var(--tierra); }
-.tip-tierra:hover { box-shadow: 0 0 32px rgba(212, 152, 115, 0.45); }
-.quote {
+/* 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 {
position: fixed;
- bottom: 3vh;
- left: 50%;
- transform: translateX(-50%);
- font-family: 'Cinzel', serif;
+ inset: 0;
+ z-index: 100;
+ pointer-events: none;
+ opacity: 0;
+ visibility: hidden;
+ transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
+ transform: scale(0.0);
+ background:
+ radial-gradient(ellipse at center, var(--drawer-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),
+ opacity 450ms ease,
+ visibility 0s 700ms;
+ overflow: hidden;
+}
+.drawer.open {
+ pointer-events: auto;
+ opacity: 1;
+ visibility: visible;
+ transform: scale(1);
+ transition:
+ transform 700ms var(--ease-magma),
+ opacity 450ms ease,
+ visibility 0s;
+}
+
+.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); }
+
+/* Ambience: capa decorativa con efectos según elemento. */
+.drawer-ambience {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+}
+.drawer-aire .drawer-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 {
+ 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 {
+ 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 {
+ 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%); }
+}
+@keyframes fuego-flicker {
+ 0%, 100% { opacity: 0.85; transform: scaleY(1.00); }
+ 35% { opacity: 1.00; transform: scaleY(1.04); }
+ 60% { opacity: 0.92; transform: scaleY(0.98); }
+}
+@keyframes agua-tide {
+ from { transform: translateY(0); }
+ to { transform: translateY(-3%); }
+}
+
+/* === Drawer head + close === */
+.drawer-head {
+ position: relative;
+ z-index: 2;
+ text-align: center;
+ padding: 7vh 8vw 2vh;
+ color: var(--drawer-accent);
+}
+.drawer-mark {
+ display: inline-block;
+ font-family: 'Inter', sans-serif;
font-size: 0.7rem;
letter-spacing: 0.55em;
- color: rgba(232, 234, 245, 0.42);
- pointer-events: none;
- user-select: none;
- z-index: 10;
+ text-transform: uppercase;
+ color: rgba(232, 234, 245, 0.6);
+ margin-bottom: 0.4rem;
text-indent: 0.55em;
}
+.drawer-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);
+ text-shadow: 0 0 28px currentColor, 0 0 56px rgba(255, 255, 255, 0.10);
+}
+.drawer-tag {
+ display: block;
+ font-family: 'Inter', sans-serif;
+ font-size: 0.78rem;
+ letter-spacing: 0.32em;
+ text-transform: uppercase;
+ color: rgba(232, 234, 245, 0.55);
+ 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 {
+ position: relative;
+ z-index: 2;
+ max-height: calc(100vh - 22vh);
+ overflow-y: auto;
+ padding: 1vh 10vw 8vh;
+ opacity: 0;
+ transition: opacity 500ms ease 250ms;
+}
+.drawer.open .drawer-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 {
+ max-width: 760px;
+ margin: 0 auto;
+ font-family: 'Inter', sans-serif;
+ font-weight: 300;
+ font-size: 1.05rem;
+ line-height: 1.78;
+ color: rgba(232, 234, 245, 0.92);
+}
+.pluma-doc > * + * { margin-top: 1.0em; }
+.pluma-doc h1 {
+ font-family: 'Cinzel', serif;
+ font-weight: 700;
+ font-size: clamp(1.7rem, 3vw, 2.4rem);
+ color: var(--drawer-accent);
+ text-shadow: 0 0 18px currentColor;
+ letter-spacing: 0.04em;
+ margin-top: 1.4em;
+}
+.pluma-doc h2 {
+ font-family: 'Cinzel', serif;
+ font-weight: 500;
+ font-size: 1.5rem;
+ color: var(--drawer-accent);
+ letter-spacing: 0.04em;
+ margin-top: 1.6em;
+ padding-bottom: 0.3em;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.10);
+}
+.pluma-doc h3 {
+ font-family: 'Inter', sans-serif;
+ font-weight: 600;
+ font-size: 1.18rem;
+ color: rgba(255, 255, 255, 0.92);
+ letter-spacing: 0.03em;
+ margin-top: 1.6em;
+}
+.pluma-doc p { margin: 0; }
+.pluma-doc a {
+ color: var(--drawer-accent);
+ text-decoration: none;
+ border-bottom: 1px solid currentColor;
+ transition: opacity 200ms ease;
+}
+.pluma-doc a:hover { opacity: 0.7; }
+.pluma-doc strong { color: rgba(255, 255, 255, 0.98); font-weight: 600; }
+.pluma-doc em { color: rgba(255, 255, 255, 0.92); }
+.pluma-doc code {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ background: rgba(255, 255, 255, 0.06);
+ padding: 0.12em 0.45em;
+ border-radius: 4px;
+ font-size: 0.92em;
+ color: var(--drawer-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-radius: 8px;
+ padding: 1rem 1.2rem;
+ overflow-x: auto;
+ font-size: 0.92rem;
+}
+.pluma-doc pre code {
+ background: transparent;
+ color: rgba(232, 234, 245, 0.92);
+ padding: 0;
+}
+.pluma-doc blockquote {
+ border-left: 3px solid var(--drawer-accent);
+ padding: 0.4em 1.2em;
+ color: rgba(232, 234, 245, 0.75);
+ font-style: italic;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 0 6px 6px 0;
+}
+.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 hr {
+ border: none;
+ height: 1px;
+ background: linear-gradient(to right, transparent, var(--drawer-accent), transparent);
+ margin: 2em 0;
+}
+.pluma-doc table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 0.95rem;
+}
+.pluma-doc th, .pluma-doc td {
+ padding: 0.55em 0.9em;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ text-align: left;
+}
+.pluma-doc th {
+ color: var(--drawer-accent);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+.pluma-loading, .pluma-error {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 30vh;
+ color: rgba(232, 234, 245, 0.55);
+ font-family: 'Cinzel', serif;
+ letter-spacing: 0.4em;
+ font-size: 0.9rem;
+}
+.pluma-loading::before {
+ content: "";
+ width: 28px;
+ height: 28px;
+ margin-right: 1rem;
+ border: 1px solid var(--drawer-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); }
+}
+/* === Responsive === */
@media (max-width: 720px) {
- .tip { min-width: 90px; padding: 0.5rem 0.7rem; }
- .tip-glyph { width: 32px; height: 32px; }
- .tip-label { font-size: 0.6rem; }
+ .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; }
}
diff --git a/crates/modules/gioser/gioser-canvas-web/src/lib.rs b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
index 3b6656d..53e77e3 100644
--- a/crates/modules/gioser/gioser-canvas-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
@@ -1,20 +1,14 @@
//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
//!
-//! Es agnóstico del DOM: el caller monta el `<canvas>`, pasa eventos de mouse,
-//! y llama `render(time_ms)` desde un `requestAnimationFrame`.
+//! Es agnóstico del DOM: el caller monta el `<canvas>`, le pasa eventos
+//! de mouse y llama `render(time_ms)` desde un `requestAnimationFrame`.
//!
//! ```ignore
//! let mut r = Renderer::new(&canvas)?;
//! r.resize(w, h);
-//! // por cada mousemove:
//! r.set_mouse_px(dx, dy);
-//! // por cada frame:
//! r.render(time_ms);
//! ```
-//!
-//! El layout sigue el patrón de `yahweh_launcher::launch_app(title, size, factory)`:
-//! una sola línea para arrancar, escena auto-contenida. Cuando exista `yahweh-web`,
-//! este renderer es el "factory" que recibirá.
use gioser_geom::ChacanaSpec;
use gioser_palette::{cosmos, Rgb};
@@ -31,15 +25,16 @@ use web_sys::{
};
const RAD: f32 = core::f32::consts::PI / 180.0;
-/// Inclinación máxima en cada eje. 35° hace que las puntas se desplacen
-/// visiblemente en pantalla — los botones DOM cabalgan ese movimiento.
-const MAX_TILT_DEG: f32 = 35.0;
-/// Escala mundo→viewport: la chacana clásica con arm_extent ≈ 0.65 ocupa
-/// ~94% del eje vertical del viewport, dejando aire para aros y glow.
-const WORLD_SCALE: f32 = 1.45;
+const DEG: f32 = 180.0 / core::f32::consts::PI;
+/// Inclinación máxima en cada eje. 28° = movimiento bien legible pero
+/// no caricaturesco; la chacana se siente "pesada y noble".
+const MAX_TILT_DEG: f32 = 28.0;
+/// Escala mundo→viewport: con arm_extent=0.65 + aro a 1.45×, la chacana
+/// + aro entran cómodos con margen para botones DOM más allá del aro.
+const WORLD_SCALE: f32 = 1.05;
-/// Re-export para apps: identidad (id, color, label) de cada punta cardinal,
-/// en el orden `[N, E, S, W]` de `ChacanaSpec::tips()`.
+/// Identidad de cada cardinal (id, color de acento, label visible).
+/// Orden `[N, E, S, W]` coincide con `ChacanaSpec::tips()`.
pub mod tips {
use gioser_palette::{elements, Rgb};
pub const ORDER: [(&str, Rgb, &str); 4] = [
@@ -180,6 +175,7 @@ impl Renderer {
"u_line_color",
"u_rim_color",
"u_sun_color",
+ "u_dark_color",
"u_sun_pulse",
],
)
@@ -190,9 +186,7 @@ impl Renderer {
let (chacana_vao, chacana_quad_count) =
upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
- // Spring sub-crítico, frecuencia baja: sensación de cuerpo pesado
- // que se inclina hacia el cursor con overshoot orgánico (~10%).
- let tilt = SpringDamper2::new(1.8, 0.62);
+ let tilt = SpringDamper2::new(1.7, 0.65);
Ok(Self {
gl,
@@ -216,7 +210,6 @@ impl Renderer {
.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
}
- /// Mouse en pixeles desde el centro del canvas (+x derecha, +y arriba).
pub fn set_mouse_px(&mut self, x: f32, y: f32) {
let (w, h) = self.viewport;
if h == 0 {
@@ -227,20 +220,28 @@ impl Renderer {
let mx = (x / half_h).clamp(-aspect, aspect);
let my = (y / half_h).clamp(-1.0, 1.0);
self.mouse = (mx, my);
-
let max_tilt = MAX_TILT_DEG * RAD;
- // Pitch (+rotX) cuando mouse arriba: el tope se acerca al viewer.
- // Yaw (-rotY) cuando mouse derecha: el lado derecho se acerca.
let target = [my * max_tilt, -mx * max_tilt / aspect];
self.tilt.set_target(target);
}
- /// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en orden N/E/S/W.
- /// El caller la usa para anclar el DOM.
+ /// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`.
pub fn tips_ndc(&self) -> [(f32, f32); 4] {
+ self.points_ndc(&self.chacana.tips())
+ }
+
+ /// Posición NDC de un punto en cualquier radio cardinal (factor sobre
+ /// `arm_extent`). Útil para anclar los botones DOM más allá de la chacana
+ /// pero dentro del aro.
+ pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] {
+ let r = self.chacana.arm_extent() * radius_factor;
+ self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)])
+ }
+
+ fn points_ndc(&self, pts: &[(f32, f32); 4]) -> [(f32, f32); 4] {
let mvp = self.build_mvp();
let mut out = [(0.0_f32, 0.0_f32); 4];
- for (i, t) in self.chacana.tips().iter().enumerate() {
+ for (i, t) in pts.iter().enumerate() {
let p = mvp * Vec4::new(t.0, t.1, 0.0, 1.0);
let w = if p.w == 0.0 { 1.0 } else { p.w };
out[i] = (p.x / w, p.y / w);
@@ -252,12 +253,17 @@ impl Renderer {
&self.chacana
}
- /// Posición normalizada del mouse en clip-space; útil para que el caller
- /// añada parallax sutil a elementos DOM independientes de la chacana.
pub fn mouse_clip(&self) -> (f32, f32) {
self.mouse
}
+ /// Devuelve `(pitch_deg, yaw_deg)` actuales del spring de tilt.
+ /// El caller los inyecta como CSS vars en el contenedor del título para
+ /// que el HTML se tumbe junto con la chacana renderizada en GL.
+ pub fn tilt_degrees(&self) -> (f32, f32) {
+ (self.tilt.position[0] * DEG, self.tilt.position[1] * DEG)
+ }
+
fn build_mvp(&self) -> Mat4 {
let (w, h) = self.viewport;
let aspect = w as f32 / h as f32;
@@ -276,7 +282,6 @@ impl Renderer {
((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
};
self.last_time_ms = time_ms;
-
let sub = 4;
let sub_dt = dt / sub as f32;
for _ in 0..sub {
@@ -293,7 +298,7 @@ impl Renderer {
gl.clear_color(0.02, 0.015, 0.04, 1.0);
gl.clear(GL::COLOR_BUFFER_BIT);
- // ---- Cosmos ----
+ // Cosmos
gl.use_program(Some(&self.cosmos_prog.program));
if let Some(u) = self.cosmos_prog.u("u_resolution") {
gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
@@ -311,7 +316,7 @@ impl Renderer {
gl.bind_vertex_array(Some(&self.cosmos_vao));
gl.draw_arrays(GL::TRIANGLES, 0, 6);
- // ---- Chacana (blend aditivo para que el glow sume luz) ----
+ // Chacana (blend aditivo para que dorado y sol sumen luz al cosmos)
gl.blend_func(GL::SRC_ALPHA, GL::ONE);
gl.use_program(Some(&self.chacana_prog.program));
let mvp = self.build_mvp();
@@ -333,6 +338,7 @@ impl Renderer {
upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
+ upload_rgb(gl, self.chacana_prog.u("u_dark_color"), cosmos::CHACANA_DARK);
if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
gl.uniform1f(Some(u), self.sun_pulse);
}
diff --git a/crates/modules/gioser/gioser-palette/src/lib.rs b/crates/modules/gioser/gioser-palette/src/lib.rs
index 565077e..48d6174 100644
--- a/crates/modules/gioser/gioser-palette/src/lib.rs
+++ b/crates/modules/gioser/gioser-palette/src/lib.rs
@@ -87,12 +87,14 @@ pub mod cosmos {
pub const NEBULA_A: Rgb = Rgb(0.220, 0.130, 0.380);
/// Nebulosa exterior — azul profundo.
pub const NEBULA_B: Rgb = Rgb(0.080, 0.180, 0.320);
- /// Núcleo solar central.
- pub const SUN_CORE: Rgb = Rgb(1.000, 0.860, 0.520);
- /// Líneas de la chacana — cyan helado.
- pub const CHACANA_LINE: Rgb = Rgb(0.55, 0.92, 1.00);
- /// Aro de fuego del logo — dorado-ámbar.
- pub const CHACANA_RIM: Rgb = Rgb(0.95, 0.65, 0.32);
+ /// Núcleo solar central — amarillo cálido, base del halo dorado.
+ pub const SUN_CORE: Rgb = Rgb(1.000, 0.870, 0.540);
+ /// Línea principal de la chacana — dorado/ámbar luminoso (color del logo).
+ pub const CHACANA_LINE: Rgb = Rgb(0.96, 0.74, 0.40);
+ /// Aro/rim cálido más profundo — ámbar tostado.
+ pub const CHACANA_RIM: Rgb = Rgb(0.88, 0.58, 0.28);
+ /// Niebla oscura del interior de la chacana — violeta-negro translúcido.
+ pub const CHACANA_DARK: Rgb = Rgb(0.04, 0.03, 0.10);
/// Polvo de estrellas.
pub const STARDUST: Rgb = Rgb(0.85, 0.88, 1.00);
}
diff --git a/crates/modules/gioser/gioser-shaders/src/lib.rs b/crates/modules/gioser/gioser-shaders/src/lib.rs
index acf0994..6c684a6 100644
--- a/crates/modules/gioser/gioser-shaders/src/lib.rs
+++ b/crates/modules/gioser/gioser-shaders/src/lib.rs
@@ -1,9 +1,8 @@
//! Fuentes GLSL ES 3.00 para GioSer.
//!
-//! Cada `const &str` es un shader completo, listo para pasar a
-//! `gl.shaderSource()`. No dependemos de ningún backend; el cliente
-//! decide cómo compilarlos. Convención: precision `highp float`,
-//! atributo `a_pos`, varying `v_*`, uniforms `u_*`.
+//! Cada `const &str` es un shader completo listo para `gl.shaderSource()`.
+//! Convención: precision `highp float`, atributo `a_pos`, varying `v_*`,
+//! uniforms `u_*`.
#![no_std]
@@ -20,9 +19,9 @@ void main() {
}
";
-/// Fragment del fondo cósmico: nubes FBM en 3 capas, 3 estratos de
-/// estrellas con titilación independiente, viñeta, y 4 meteoros
-/// procedurales que cruzan el cielo periódicamente.
+/// Fragment del fondo cósmico: nubes FBM en 3 capas con drift visible,
+/// 3 estratos de estrellas con titilación independiente, viñeta radial,
+/// 4 meteoros procedurales con vida cíclica.
pub const FS_COSMOS: &str = "#version 300 es
precision highp float;
in vec2 v_clip;
@@ -63,7 +62,6 @@ float fbm(vec2 p) {
return v;
}
-// Meteoro procedural: trazo brillante con cola, vida 1.6s, respawnea solo.
float meteor(vec2 uv, float seed) {
float period = 6.5 + 4.0 * hash11(seed * 17.0);
float t_seeded = u_time + seed * 19.0;
@@ -81,20 +79,16 @@ float meteor(vec2 uv, float seed) {
hash21(vec2(seed + 1.0, epoch)) * 1.6 - 0.8,
-0.7 - hash21(vec2(seed + 2.0, epoch)) * 0.6
));
-
vec2 head = origin + dir * t * 2.1;
vec2 tail = head - dir * 0.24;
-
vec2 pa = uv - tail;
vec2 ba = head - tail;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
float dist = length(pa - ba * h);
-
float perpGlow = exp(-dist * 420.0);
float trailFalloff = smoothstep(0.0, 1.0, h);
float headPulse = exp(-dist * 900.0);
float lifeFade = sin(t * 3.14159);
-
return (perpGlow * trailFalloff + headPulse * 1.4) * lifeFade;
}
@@ -103,11 +97,9 @@ void main() {
vec2 uv = v_clip;
uv.x *= aspect;
- // === NUBES (drift visible, 5× más rápido que la versión anterior) ===
vec2 d1 = vec2( u_time * 0.055, u_time * 0.022) + u_parallax * 0.10;
vec2 d2 = vec2(-u_time * 0.085, u_time * 0.058) + u_parallax * 0.22;
vec2 d3 = vec2( u_time * 0.130, -u_time * 0.095) + u_parallax * 0.40;
-
float n1 = fbm(uv * 0.85 + d1);
float n2 = fbm(uv * 2.05 + d2);
float n3 = fbm(uv * 4.40 + d3);
@@ -117,12 +109,9 @@ void main() {
color = mix(color, u_nebula_b, pow(n2, 1.85) * 0.62);
color += u_nebula_a * pow(n3, 3.0) * 0.28;
- // Viñeta radial.
float r = length(v_clip);
color *= 1.0 - smoothstep(0.55, 1.40, r) * 0.85;
- // === ESTRELLAS — 3 estratos con titilación distinta ===
- // Brillantes, pocas, titilan rápido.
{
vec2 sgrid = uv * 75.0;
vec2 sid = floor(sgrid);
@@ -131,7 +120,6 @@ void main() {
float mask = smoothstep(0.9935, 0.999, sh);
color += u_stardust * mask * tw * 1.15;
}
- // Medianas, densas, titilan lento.
{
vec2 sgrid = uv * 135.0 + vec2(7.0, 11.0);
vec2 sid = floor(sgrid);
@@ -140,7 +128,6 @@ void main() {
float mask = smoothstep(0.987, 0.994, sh);
color += u_stardust * mask * tw * 0.75;
}
- // Polvo de fondo, muchas, casi sin twinkle.
{
vec2 sgrid = uv * 260.0 + vec2(13.0, 3.0);
vec2 sid = floor(sgrid);
@@ -150,7 +137,6 @@ void main() {
color += u_stardust * mask * tw * 0.40;
}
- // === METEOROS (4 procedurales, respawn independiente) ===
float meteors = 0.0;
meteors += meteor(uv, 0.31);
meteors += meteor(uv, 1.73);
@@ -174,10 +160,20 @@ void main() {
}
";
-/// Fragment de la chacana mística: SDF de 2 escalones por brazo,
-/// líneas glow + aro + rayos zodiacales + sol central pulsante.
-/// Uniforms: `u_time`, `u_thickness` (s), `u_center_half` (c), `u_arm_extent`,
-/// `u_line_color`, `u_rim_color`, `u_sun_color`, `u_sun_pulse`.
+/// Fragment de la chacana mística (estética dorada del logo GioSer):
+/// 1. **Sol detrás**: halo gauss + corona, visible SÓLO dentro de la superficie
+/// de la chacana (clip por SDF), apenas asomando por las junturas de los pasos.
+/// 2. **Doble outline**: dos líneas paralelas en dorado/ámbar — la chacana se
+/// siente "grabada" sobre el cielo.
+/// 3. **Interior**: niebla oscura translúcida con sutiles rayos radiales
+/// desde el centro (el sol los proyecta a través de la superficie).
+/// 4. **Aro doble exterior**: ring fino + ring grueso (este último con marcas
+/// cardinales de 3 puntos cada una, como en el logo).
+///
+/// Uniforms:
+/// `u_time`, `u_thickness` (s), `u_center_half` (c), `u_arm_extent` (L),
+/// `u_line_color` (gold rim), `u_rim_color` (gold rim oscuro),
+/// `u_sun_color`, `u_sun_pulse`, `u_dark_color` (interior fill).
pub const FS_CHACANA: &str = "#version 300 es
precision highp float;
in vec2 v_world;
@@ -189,26 +185,26 @@ uniform float u_arm_extent;
uniform vec3 u_line_color;
uniform vec3 u_rim_color;
uniform vec3 u_sun_color;
+uniform vec3 u_dark_color;
uniform float u_sun_pulse;
+const float PI = 3.14159265;
+
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
-// Chacana de 2 escalones (mística clásica): centro 2c×2c + 4 brazos
-// con 2 niveles. Inner level half-width = 2s, outer (tip) = s.
+// Chacana de 2 escalones (mística clásica de Tiwanaku).
float sdChacana(vec2 p, float s, float c) {
float d = sdBox(p, vec2(c, c));
float hd = 0.5 * s;
- // Nivel interno (más ancho, pegado al centro).
float mid1 = c + 0.5 * s;
float hw1 = 2.0 * s;
- d = min(d, sdBox(p - vec2(0.0, mid1), vec2(hw1, hd))); // N
- d = min(d, sdBox(p - vec2(0.0, -mid1), vec2(hw1, hd))); // S
- d = min(d, sdBox(p - vec2( mid1, 0.0), vec2(hd, hw1))); // E
- d = min(d, sdBox(p - vec2(-mid1, 0.0), vec2(hd, hw1))); // W
- // Punta (más angosta, externa).
+ d = min(d, sdBox(p - vec2(0.0, mid1), vec2(hw1, hd)));
+ d = min(d, sdBox(p - vec2(0.0, -mid1), vec2(hw1, hd)));
+ d = min(d, sdBox(p - vec2( mid1, 0.0), vec2(hd, hw1)));
+ d = min(d, sdBox(p - vec2(-mid1, 0.0), vec2(hd, hw1)));
float mid2 = c + 1.5 * s;
float hw2 = 1.0 * s;
d = min(d, sdBox(p - vec2(0.0, mid2), vec2(hw2, hd)));
@@ -218,66 +214,90 @@ float sdChacana(vec2 p, float s, float c) {
return d;
}
+// 3 puntos pequeños en cada uno de los 4 cardinales sobre el aro grueso.
+float cardinal_dots(vec2 p, float ringR, float dotSize) {
+ float r = length(p);
+ float ang = atan(p.y, p.x);
+ // Acercamiento al aro (gauss tight en r=ringR).
+ float on_ring = exp(-((r - ringR) * (r - ringR)) / (2.0 * dotSize * dotSize));
+ float dots = 0.0;
+ // 4 cardinales en ángulos 0, π/2, π, -π/2.
+ for (int i = 0; i < 4; i++) {
+ float base = float(i) * (PI * 0.5);
+ // 3 puntos por cardinal, offset angular pequeño.
+ for (int j = -1; j <= 1; j++) {
+ float a = base + float(j) * 0.075;
+ float da = ang - a;
+ da = da - 2.0 * PI * floor((da + PI) / (2.0 * PI));
+ dots += exp(-(da * da) / (2.0 * 0.012 * 0.012));
+ }
+ }
+ return on_ring * dots;
+}
+
void main() {
vec2 p = v_world;
float d = sdChacana(p, u_thickness, u_center_half);
float r = length(p);
- // Línea principal: gaussiana sobre el borde de la chacana.
- float lineW = 0.011;
- float line = exp(-(d * d) / (2.0 * lineW * lineW));
-
- // Glow exterior cae suave hacia el infinito.
- float glow = exp(-max(d, 0.0) * 8.0) * 0.55;
-
- // Fill interior, una niebla cyan muy tenue.
- float fill = smoothstep(0.0, -0.025, d);
-
- // Aro circular que envuelve la chacana (rasgo del logo).
- float ringR_outer = u_arm_extent * 1.32;
- float ringD_outer = abs(r - ringR_outer);
- float ring_outer = exp(-(ringD_outer * ringD_outer) / (2.0 * 0.008 * 0.008)) * 0.80;
-
- // Aro interior fino (segundo orbital).
- float ringR_inner = u_arm_extent * 1.18;
- float ringD_inner = abs(r - ringR_inner);
- float ring_inner = exp(-(ringD_inner * ringD_inner) / (2.0 * 0.0035 * 0.0035)) * 0.42;
-
- // Ventana radial entre arm_extent y el aro exterior — para rayos y muescas.
+ // === SOL DETRÁS ===
+ // Halo grande, sólo visible dentro de la superficie de la chacana.
+ float inside = 1.0 - smoothstep(-0.004, 0.004, d);
+ float sunR = u_thickness * 0.42;
+ float sun = exp(-(r * r) / (2.0 * sunR * sunR));
+ float corR = u_center_half * 0.75;
+ float corona = exp(-(r * r) / (2.0 * corR * corR));
+ float halo = sun * (1.15 + 0.20 * u_sun_pulse) + corona * (0.55 + 0.15 * u_sun_pulse);
+
+ // Rayos radiales sutiles desde el centro, sólo visibles donde la superficie
+ // de la chacana los recibe.
float ang = atan(p.y, p.x);
- float band = smoothstep(u_arm_extent * 1.00, u_arm_extent * 1.10, r)
- * (1.0 - smoothstep(ringR_outer * 0.92, ringR_outer * 1.00, r));
-
- // Rayos: 12 divisiones (meses andinos / horas), modulados en el tiempo.
- float rays = pow(abs(cos(ang * 6.0)), 24.0) * band
- * (0.55 + 0.45 * sin(u_time * 0.7));
-
- // Marcas cardinales (4 muescas finas) — exponente alto = picos angostos.
- float card = pow(abs(cos(ang * 2.0)), 120.0) * band * 1.10;
-
- // Sol central: gauss tight + corona suave + pulso.
- float sunR = u_thickness * 0.50;
- float sunDist = r;
- float sun = exp(-(sunDist * sunDist) / (2.0 * sunR * sunR));
- float corR = sunR * 5.0;
- float corona = exp(-(sunDist * sunDist) / (2.0 * corR * corR)) * 0.50;
- float sunMix = sun * (1.0 + 0.2 * u_sun_pulse) + corona * (0.7 + 0.3 * u_sun_pulse);
+ float radial = pow(abs(cos(ang * 4.0 + sin(u_time * 0.3) * 0.2)), 8.0)
+ * smoothstep(0.0, u_center_half * 0.8, r)
+ * (1.0 - smoothstep(u_center_half * 0.85, u_center_half * 1.2, r))
+ * 0.30;
+
+ // === DOBLE OUTLINE ===
+ // Línea interior (sobre la SDF=0).
+ float lineW1 = 0.0085;
+ float line_in = exp(-(d * d) / (2.0 * lineW1 * lineW1));
+ // Línea exterior paralela, offset 0.018 hacia afuera.
+ float dOff = d - 0.020;
+ float lineW2 = 0.005;
+ float line_out = exp(-(dOff * dOff) / (2.0 * lineW2 * lineW2));
+ float line = line_in * 1.0 + line_out * 0.65;
+
+ // Glow exterior leve.
+ float glow = exp(-max(d, 0.0) * 14.0) * 0.30;
+
+ // === AROS EXTERIORES ===
+ float ringR_main = u_arm_extent * 1.45;
+ float ringD_main = abs(r - ringR_main);
+ float ring_main = exp(-(ringD_main * ringD_main) / (2.0 * 0.005 * 0.005));
+
+ float ringR_inner = u_arm_extent * 1.30;
+ float ringD_inner = abs(r - ringR_inner);
+ float ring_inner = exp(-(ringD_inner * ringD_inner) / (2.0 * 0.003 * 0.003)) * 0.40;
- // Halo del centro: cuadrado oscuro detrás de la chacana para profundidad.
- float coreShadow = smoothstep(u_center_half * 0.95, u_center_half * 0.3, max(abs(p.x), abs(p.y))) * 0.20;
+ // 4 grupos de 3 puntos cardinales sobre el aro principal.
+ float dots = cardinal_dots(p, ringR_main, 0.008) * 1.10;
+ // === COMPOSICIÓN ===
vec3 col = vec3(0.0);
- col += u_line_color * line * 1.55;
- col += u_rim_color * glow * 1.05;
- col += u_line_color * ring_outer * 1.00;
- col += u_rim_color * ring_inner * 1.15;
- col += u_rim_color * rays * 1.20;
- col += u_line_color * card * 1.30;
- col += u_sun_color * sunMix * 1.45;
- col += vec3(0.05, 0.08, 0.14) * (fill + coreShadow) * 0.6;
+ // Sol detrás (clip a interior).
+ col += u_sun_color * halo * inside * 1.55;
+ col += u_line_color * radial * inside * 0.6;
+ // Niebla oscura translúcida en el interior para profundidad.
+ col += u_dark_color * inside * 0.20;
+ // Líneas y aros.
+ col += u_line_color * line * 1.70;
+ col += u_line_color * glow * 0.95;
+ col += u_line_color * ring_main * 1.45;
+ col += u_rim_color * ring_inner * 1.05;
+ col += u_line_color * dots * 1.85;
float alpha = clamp(
- line * 1.2 + glow + ring_outer + ring_inner + rays + card + sunMix + fill * 0.5,
+ halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12,
0.0, 1.0);
fragColor = vec4(col, alpha);
}
@@ -288,10 +308,9 @@ pub const FULLSCREEN_QUAD: [f32; 12] = [
-1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0,
];
-/// Quad ligeramente mayor que la chacana para no recortar aros ni glow.
-/// `arm_extent` es la distancia centro→punta; multiplicamos por un factor
-/// que cubre el aro exterior (1.32×) más halo.
+/// Quad ligeramente mayor que la chacana + aros + glow.
pub fn chacana_quad(arm_extent: f32) -> [f32; 12] {
- let e = arm_extent * 1.65;
+ // Aro principal vive a 1.45 * arm_extent; sumamos margen para el glow.
+ let e = arm_extent * 1.70;
[-e, -e, e, -e, e, e, -e, -e, e, e, -e, e]
}
diff --git a/crates/modules/pluma/pluma-md/Cargo.toml b/crates/modules/pluma/pluma-md/Cargo.toml
new file mode 100644
index 0000000..bde0357
--- /dev/null
+++ b/crates/modules/pluma/pluma-md/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "pluma-md"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+pulldown-cmark = { workspace = true }
diff --git a/crates/modules/pluma/pluma-md/src/lib.rs b/crates/modules/pluma/pluma-md/src/lib.rs
new file mode 100644
index 0000000..ed3d2dd
--- /dev/null
+++ b/crates/modules/pluma/pluma-md/src/lib.rs
@@ -0,0 +1,90 @@
+//! Pluma — parser markdown agnóstico, listo para envolver en cualquier viewer.
+//!
+//! Es deliberadamente delgado: wrappea `pulldown-cmark` (todas las
+//! extensiones GFM habilitadas) y emite HTML envuelto en `<div class="pluma-doc">`
+//! con un `data-pluma-theme="…"` para que el CSS del viewer aplique colores
+//! por tema sin necesidad de re-renderear.
+//!
+//! No tiene deps de web/DOM/wasm: corre igual en server, terminal, WASM o
+//! tests. Si necesitás emitir Markdown-AST en lugar de HTML, usá la API
+//! `events()` y construí tu propio renderer.
+
+use pulldown_cmark::{html, Event, Options, Parser};
+
+/// Opciones por default — GFM completo: tables, footnotes, tasklists, strikethrough,
+/// smart punctuation, heading anchors.
+pub fn default_options() -> Options {
+ Options::ENABLE_TABLES
+ | Options::ENABLE_FOOTNOTES
+ | Options::ENABLE_STRIKETHROUGH
+ | Options::ENABLE_TASKLISTS
+ | Options::ENABLE_SMART_PUNCTUATION
+ | Options::ENABLE_HEADING_ATTRIBUTES
+}
+
+/// Markdown → HTML "crudo" (sin wrapper de tema).
+pub fn to_html(md: &str) -> String {
+ let mut out = String::with_capacity(md.len() * 2);
+ let parser = Parser::new_ext(md, default_options());
+ html::push_html(&mut out, parser);
+ out
+}
+
+/// Markdown → HTML envuelto en `<div class="pluma-doc" data-pluma-theme="…">`.
+/// El `theme` es un string opaco (ej. "aire", "fuego") que el CSS del viewer
+/// matchea via `[data-pluma-theme="aire"]`.
+pub fn to_themed_html(md: &str, theme: &str) -> String {
+ let body = to_html(md);
+ let safe_theme: String = theme
+ .chars()
+ .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
+ .collect();
+ format!(
+ r#"<article class="pluma-doc" data-pluma-theme="{theme}">{body}</article>"#,
+ theme = safe_theme,
+ body = body
+ )
+}
+
+/// Devuelve un iterador de eventos pulldown-cmark (AST stream).
+/// Útil si querés renderear a algo distinto que HTML.
+pub fn events(md: &str) -> impl Iterator<Item = Event<'_>> {
+ Parser::new_ext(md, default_options())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn renders_h1() {
+ let html = to_html("# Hola");
+ assert!(html.contains("<h1>Hola</h1>"), "got {}", html);
+ }
+
+ #[test]
+ fn renders_list() {
+ let html = to_html("- a\n- b\n");
+ assert!(html.contains("<li>a</li>"));
+ assert!(html.contains("<li>b</li>"));
+ }
+
+ #[test]
+ fn themed_wrapper_sanitizes_theme_name() {
+ let html = to_themed_html("# x", "aire<script>");
+ assert!(html.contains(r#"data-pluma-theme="airescript""#));
+ }
+
+ #[test]
+ fn renders_code_fence() {
+ let html = to_html("```rust\nfn main(){}\n```");
+ assert!(html.contains("<pre><code") && html.contains("fn main"));
+ }
+
+ #[test]
+ fn renders_table_gfm() {
+ let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
+ let html = to_html(md);
+ assert!(html.contains("<table>"), "got {}", html);
+ }
+}
diff --git a/crates/modules/pluma/pluma-reader-web/Cargo.toml b/crates/modules/pluma/pluma-reader-web/Cargo.toml
new file mode 100644
index 0000000..47faec3
--- /dev/null
+++ b/crates/modules/pluma/pluma-reader-web/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pluma-reader-web"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+pluma-md = { path = "../pluma-md" }
+wasm-bindgen.workspace = true
+wasm-bindgen-futures.workspace = true
+js-sys.workspace = true
+
+[dependencies.web-sys]
+workspace = true
+features = [
+ "Window",
+ "Document",
+ "Element",
+ "HtmlElement",
+ "CssStyleDeclaration",
+ "Response",
+ "console",
+]
diff --git a/crates/modules/pluma/pluma-reader-web/src/lib.rs b/crates/modules/pluma/pluma-reader-web/src/lib.rs
new file mode 100644
index 0000000..4b242ef
--- /dev/null
+++ b/crates/modules/pluma/pluma-reader-web/src/lib.rs
@@ -0,0 +1,88 @@
+//! Pluma reader — visor de markdown elegante para WASM/web.
+//!
+//! Toma un `<div>` que actúa como contenedor y le inyecta el HTML
+//! producido por `pluma-md`. El styling (fonts, colores, animaciones)
+//! lo provee el CSS del host: este crate no inyecta estilos, sólo
+//! marcado y `data-pluma-theme="…"` para que el CSS reaccione.
+//!
+//! Patrón de uso:
+//!
+//! ```ignore
+//! let container = document.get_element_by_id("drawer-aire-content")?
+//! .dyn_into::<HtmlElement>()?;
+//! let reader = Reader::new(container);
+//! reader.show_loading();
+//! wasm_bindgen_futures::spawn_local(async move {
+//! let _ = reader.open_url("./md/aire.md", "aire").await;
+//! });
+//! ```
+
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use wasm_bindgen_futures::JsFuture;
+use web_sys::{HtmlElement, Response};
+
+pub struct Reader {
+ container: HtmlElement,
+}
+
+impl Reader {
+ pub fn new(container: HtmlElement) -> Self {
+ Self { container }
+ }
+
+ pub fn container(&self) -> &HtmlElement {
+ &self.container
+ }
+
+ /// Inyecta un mensaje de carga mientras se resuelve `open_url`.
+ pub fn show_loading(&self) {
+ self.container.set_inner_html(
+ r#"<div class="pluma-loading" aria-live="polite">…</div>"#,
+ );
+ }
+
+ /// Inyecta un mensaje de error visible.
+ pub fn show_error(&self, msg: &str) {
+ let safe: String = msg.replace('<', "<").replace('>', ">");
+ self.container.set_inner_html(&format!(
+ r#"<div class="pluma-error">{}</div>"#,
+ safe
+ ));
+ }
+
+ /// Renderea un string markdown directamente, sin fetch.
+ pub fn render_md(&self, md: &str, theme: &str) {
+ let html = pluma_md::to_themed_html(md, theme);
+ self.container.set_inner_html(&html);
+ }
+
+ /// Inyecta HTML pre-renderizado (sin parsear). Útil si el caller ya
+ /// hizo el parse en otro lado.
+ pub fn render_html(&self, html: &str) {
+ self.container.set_inner_html(html);
+ }
+
+ /// Limpia el contenedor.
+ pub fn clear(&self) {
+ self.container.set_inner_html("");
+ }
+
+ /// Fetcha la URL, parsea el markdown y lo renderea con el tema dado.
+ /// El loader muestra un placeholder mientras la promesa está pendiente.
+ pub async fn open_url(&self, url: &str, theme: &str) -> Result<(), JsValue> {
+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
+ self.show_loading();
+ let resp_value = JsFuture::from(window.fetch_with_str(url)).await?;
+ let resp: Response = resp_value.dyn_into()?;
+ if !resp.ok() {
+ let err = format!("HTTP {} para {}", resp.status(), url);
+ self.show_error(&err);
+ return Err(JsValue::from_str(&err));
+ }
+ let text_value = JsFuture::from(resp.text()?).await?;
+ let md = text_value.as_string().unwrap_or_default();
+ self.render_md(&md, theme);
+ Ok(())
+ }
+}