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 @@ - + -
+

Gio·Ser

-

EN EL CENTRO · EL SER

- - - - - + + AIRE Software · IA - - - - + + FUEGO Inspiración - - - - + + TIERRA Cuerpo - - - - - + + AGUA Espiritualidad
- + + + + + + + + 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::::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::() 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::::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::() else { continue }; + let d = doc.clone(); + let cb = Closure::::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::::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::() 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::::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::() { + // 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: 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); + 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); + + 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-fuego { color: var(--fuego); } +.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; } -.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 { +/* === 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 ``, pasa eventos de mouse, -//! y llama `render(time_ms)` desde un `requestAnimationFrame`. +//! Es agnóstico del DOM: el caller monta el ``, 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)); + // === 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); - // 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. + // 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)); + 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; - // 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)); + // === 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; - // Marcas cardinales (4 muescas finas) — exponente alto = picos angostos. - float card = pow(abs(cos(ang * 2.0)), 120.0) * band * 1.10; + // Glow exterior leve. + float glow = exp(-max(d, 0.0) * 14.0) * 0.30; - // 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); + // === 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)); - // 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; + 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; + // 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 `
` +//! 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 `
`. +/// 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#"
{body}
"#, + 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> { + Parser::new_ext(md, default_options()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_h1() { + let html = to_html("# Hola"); + assert!(html.contains("

Hola

"), "got {}", html); + } + + #[test] + fn renders_list() { + let html = to_html("- a\n- b\n"); + assert!(html.contains("
  • a
  • ")); + assert!(html.contains("
  • b
  • ")); + } + + #[test] + fn themed_wrapper_sanitizes_theme_name() { + let html = to_themed_html("# x", "aire