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:
@@ -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(|| {
|
||||
|
||||
Reference in New Issue
Block a user