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
+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(|| {