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>
This commit is contained in:
sergio
2026-05-13 23:38:37 +00:00
parent 3dbdfb357b
commit fce630c8d0
17 changed files with 1132 additions and 243 deletions
Generated
+38
View File
@@ -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"
+10
View File
@@ -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)
# ============================================================
+8 -2
View File
@@ -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",
]
+69 -26
View File
@@ -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>
+25
View File
@@ -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**.*
+26
View File
@@ -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*.
+25
View File
@@ -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**.*
+26
View File
@@ -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**.*
+151 -8
View File
@@ -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(|| {
+396 -89
View File
@@ -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; }
}
@@ -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);
}
@@ -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);
}
+100 -81
View File
@@ -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]
}
+10
View File
@@ -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 }
+90
View File
@@ -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);
}
}
@@ -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",
]
@@ -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('<', "&lt;").replace('>', "&gt;");
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(())
}
}