Hero becomes a content-owned module; site asset proxy; gesture growth fix
Test / test (push) Successful in 24s
Test / test (push) Successful in 24s
site.yaml's hero is now plain | module, where module names a JavaScript file the content repo ships (mount(container) -> handle with stop()). Portal serves content assets same-origin at /site/<path> (Gitea raw sends no CORS headers), starts the module at HTML parse time, adopts it on hydration, mounts fresh via the inline script's __mountHero on client-side navigation, and stops it on leave. The YES canvas (yes.js) and the gesture hero mode leave the engine - uhhm/questions ships YES as its hero.js, redoal/questions ships a sine-swings band. Gesture form canvas no longer balloons after a stroke: the wrap's aspect-ratio reservation is scoped to :empty (pre-mount) so it can't turn echo-strip height into width inside the flex field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
307fd7e753
commit
1afd34c22e
+71
-131
@@ -407,32 +407,6 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
|
||||
// (ported to `public/yes.js`, an ES module now instead of a page-owning
|
||||
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
|
||||
// an existing JS module from Rust, rather than re-implementing canvas
|
||||
// animation logic here.
|
||||
#[cfg(feature = "hydrate")]
|
||||
mod yes {
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// raw_module (a runtime URL, not a bundled snippet) on purpose:
|
||||
// the file lives in public/ so it's served at the stable /yes.js
|
||||
// - the same URL the hero's inline early-mount script imports.
|
||||
// A bundled snippet would sit under a per-build hashed
|
||||
// /pkg/snippets/ path the inline script couldn't know.
|
||||
#[wasm_bindgen(raw_module = "/yes.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = RasterizedYES)]
|
||||
pub type RasterizedYes;
|
||||
|
||||
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
|
||||
pub fn new() -> RasterizedYes;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
pub fn stop(this: &RasterizedYes);
|
||||
}
|
||||
}
|
||||
|
||||
// Same idiomatic-typed-module approach as `yes` above, for the
|
||||
// prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
|
||||
@@ -478,12 +452,6 @@ mod gesture {
|
||||
relay_url: &str,
|
||||
) -> GestureWidget;
|
||||
|
||||
#[wasm_bindgen(js_name = mountGestureHero)]
|
||||
pub fn mount_gesture_hero(
|
||||
container: &web_sys::HtmlDivElement,
|
||||
relay_url: &str,
|
||||
) -> GestureWidget;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
pub fn stop(this: &GestureWidget);
|
||||
}
|
||||
@@ -491,131 +459,103 @@ mod gesture {
|
||||
|
||||
#[component]
|
||||
fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView {
|
||||
// Only the landing page gets a full interactive piece - it's the
|
||||
// one page a hero is actually "the" hero for; other pages
|
||||
// (thank-you pages, /review) get the plain dark header below.
|
||||
// WHICH piece is the content repo's call (`site.yaml`'s
|
||||
// hero.kind): the YES canvas (default - uhhm's look), a redoal
|
||||
// gesture canvas, or nothing.
|
||||
let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() };
|
||||
let show_yes = hero_kind == "yes";
|
||||
let show_gesture = hero_kind == "gesture";
|
||||
// Only the landing page gets a piece - it's the one page a hero is
|
||||
// actually "the" hero for; every other page gets the plain header.
|
||||
// WHAT the piece is belongs to the content repo: site.yaml's
|
||||
// `hero: {kind: module, module: hero.js}` names a JavaScript module
|
||||
// (served same-origin at /site/<path>) exporting
|
||||
// `mount(container) -> handle` with `handle.stop()`. Portal knows
|
||||
// nothing about what it draws.
|
||||
let module = if landing && site.hero.kind == "module" {
|
||||
site.hero.module.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_module = module.is_some();
|
||||
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
|
||||
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
|
||||
|
||||
// Starts the YES animation once the canvas elements exist, and
|
||||
// explicitly stops the requestAnimationFrame loop on unmount - the
|
||||
// original page-owning script never needed this since navigating
|
||||
// away meant a full document unload, which doesn't happen in an
|
||||
// SPA.
|
||||
let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new();
|
||||
let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||
let piece_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
||||
#[cfg(feature = "hydrate")]
|
||||
if show_yes {
|
||||
// `on_cleanup` requires Send + Sync (even single-threaded, wasm),
|
||||
// which a JS-backed value never is - `StoredValue`'s `LocalStorage`
|
||||
// variant is the established way around this (same pattern
|
||||
// cnats' WebRTC call state uses for its own non-Send peer map).
|
||||
let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> =
|
||||
if has_module {
|
||||
use wasm_bindgen::JsCast;
|
||||
// Handle = whatever the module's mount() returned; kept as a
|
||||
// raw JsValue so the contract stays "has a stop()", nothing
|
||||
// typed on the Rust side.
|
||||
let handle: StoredValue<Option<wasm_bindgen::JsValue>, LocalStorage> =
|
||||
StoredValue::new_local(None);
|
||||
// Gated on the canvas NodeRef resolving, not just "the Effect
|
||||
// ran" - on a fresh page load the DOM is already there by the
|
||||
// time this fires, but navigating back to `/` client-side hit a
|
||||
// real race: the Effect ran before the new view's <canvas> was
|
||||
// actually inserted, RasterizedYES::new() (yes.js) did an
|
||||
// unchecked `document.getElementById(...).getContext(...)` on
|
||||
// null and threw mid-reactive-update - which then corrupted
|
||||
// wasm_bindgen_futures' single-threaded executor state badly
|
||||
// enough to panic ("RefCell already borrowed") on the next
|
||||
// tick. Waiting for the NodeRef itself guarantees DOM presence
|
||||
// the same way the prosekit editor's own mount Effect does.
|
||||
Effect::new(move |_| {
|
||||
if raster_ref.get().is_none() {
|
||||
let Some(container) = piece_ref.get() else {
|
||||
return;
|
||||
};
|
||||
if handle.with_value(|h| h.is_some()) {
|
||||
return;
|
||||
}
|
||||
if instance.with_value(|i| i.is_some()) {
|
||||
return;
|
||||
}
|
||||
// Adopt the hero's inline early-mount instance (started at
|
||||
// HTML parse time, long before this wasm was even fetched)
|
||||
// instead of starting a second animation. The flag covers
|
||||
// the opposite ordering too: an inline script that runs
|
||||
// after this sees it and stays inert.
|
||||
use wasm_bindgen::JsCast;
|
||||
// Adopt the inline early-mount's instance (started at HTML
|
||||
// parse time) if it's there; otherwise (client-side
|
||||
// navigation back to /) mount fresh through the helper the
|
||||
// inline script left on window. Either order is safe.
|
||||
let global = js_sys::global();
|
||||
let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into());
|
||||
let early = js_sys::Reflect::get(&global, &"__yesEarly".into())
|
||||
let _ = js_sys::Reflect::set(&global, &"__heroAdopted".into(), &true.into());
|
||||
let early = js_sys::Reflect::get(&global, &"__heroEarly".into())
|
||||
.ok()
|
||||
.filter(|v| !v.is_undefined() && !v.is_null());
|
||||
let inst = match early {
|
||||
match early {
|
||||
Some(v) => {
|
||||
let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into());
|
||||
v.unchecked_into::<yes::RasterizedYes>()
|
||||
let _ = js_sys::Reflect::delete_property(&global, &"__heroEarly".into());
|
||||
handle.set_value(Some(v));
|
||||
}
|
||||
None => yes::RasterizedYes::new(),
|
||||
};
|
||||
instance.set_value(Some(inst));
|
||||
None => {
|
||||
if let Ok(mounter) = js_sys::Reflect::get(&global, &"__mountHero".into()) {
|
||||
if let Some(f) = mounter.dyn_ref::<js_sys::Function>() {
|
||||
if let Ok(promise) = f.call1(&global, &container) {
|
||||
let promise: js_sys::Promise = promise.unchecked_into();
|
||||
leptos::task::spawn_local(async move {
|
||||
if let Ok(h) =
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await
|
||||
{
|
||||
handle.set_value(Some(h));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
on_cleanup(move || {
|
||||
instance.update_value(|opt| {
|
||||
if let Some(inst) = opt.take() {
|
||||
inst.stop();
|
||||
handle.update_value(|opt| {
|
||||
if let Some(h) = opt.take() {
|
||||
if let Ok(stop) = js_sys::Reflect::get(&h, &"stop".into()) {
|
||||
if let Some(f) = stop.dyn_ref::<js_sys::Function>() {
|
||||
let _ = f.call0(&h);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The gesture hero mounts the same widget the `type: gesture`
|
||||
// requirement uses, in hero mode (no form field; echoes render as
|
||||
// ambient strokes on the canvas itself). Same StoredValue +
|
||||
// on_cleanup shape as the YES piece - it may own a live WebSocket.
|
||||
#[cfg(feature = "hydrate")]
|
||||
if show_gesture {
|
||||
let relay = site.hero.relay.clone().unwrap_or_default();
|
||||
let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> =
|
||||
StoredValue::new_local(None);
|
||||
Effect::new(move |_| {
|
||||
let Some(container) = gesture_ref.get() else {
|
||||
return;
|
||||
};
|
||||
if widget.with_value(|w| w.is_some()) {
|
||||
return;
|
||||
}
|
||||
widget.set_value(Some(gesture::mount_gesture_hero(&container, &relay)));
|
||||
});
|
||||
on_cleanup(move || {
|
||||
widget.update_value(|opt| {
|
||||
if let Some(w) = opt.take() {
|
||||
w.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// The inline script starts the piece at HTML parse time instead of
|
||||
// waiting out the wasm bundle; it also leaves __mountHero for the
|
||||
// hydrate side's client-side-navigation case. Module path is
|
||||
// validated to plain segments by SiteConfig::validate, so it can't
|
||||
// break out of the string here.
|
||||
let early_script = module.as_ref().map(|m| {
|
||||
format!(
|
||||
"const url = '/site/{m}'; window.__mountHero = (el) => import(url).then((mod) => mod.mount(el)); if (!window.__heroAdopted && !window.__heroEarly) window.__mountHero(document.querySelector('.hero-piece')).then((h) => {{ if (!window.__heroAdopted) window.__heroEarly = h; else if (h && h.stop) h.stop(); }});"
|
||||
)
|
||||
});
|
||||
|
||||
view! {
|
||||
<header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture>
|
||||
{show_yes
|
||||
<header class="hero" class:hero-module=has_module>
|
||||
{has_module
|
||||
.then(|| {
|
||||
view! {
|
||||
<div class="hero-canvas" aria-hidden="true">
|
||||
<canvas id="lineCanvas"></canvas>
|
||||
<canvas id="rasterCanvas" node_ref=raster_ref></canvas>
|
||||
</div>
|
||||
// Starts the animation at HTML parse time
|
||||
// instead of waiting out the wasm bundle's
|
||||
// fetch + hydration; the hydrate Effect above
|
||||
// adopts (never duplicates) the instance, and
|
||||
// the guards make either execution order safe.
|
||||
<script
|
||||
type="module"
|
||||
inner_html="import('/yes.js').then((m) => { if (!window.__yesAdopted && !window.__yesEarly) window.__yesEarly = new m.RasterizedYES(); });"
|
||||
></script>
|
||||
<div class="hero-piece" aria-hidden="true" node_ref=piece_ref></div>
|
||||
<script type="module" inner_html=early_script.unwrap_or_default()></script>
|
||||
}
|
||||
})}
|
||||
{show_gesture
|
||||
.then(|| {
|
||||
view! { <div class="hero-draw" node_ref=gesture_ref></div> }
|
||||
})}
|
||||
<div class="hero-copy">
|
||||
<a class="wordmark" href="/">
|
||||
<img src=wordmark alt=site_title/>
|
||||
|
||||
Reference in New Issue
Block a user