Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-wasm-demo/src/lib.rs

//! **facett-wasm-demo** — the reusable web + native demo HOST for the constellation.
//!
//! This is the *"one panel, N hosts"* edge, packaged so any repo (`nornir`, `holger`,
//! `dwarves`, …) stands up its own Codeberg-Pages demo with a few lines of glue instead
//! of re-deriving the eframe boot each time. A panel is a [`facett_core::Facet`]
//! (`title` / `ui` / `state_json`); this crate mounts it onto:
//!
//! - a **native window** (the desktop host, [`NativeDemo`]),
//! - a **wasm / WebGPU canvas** (the web host, [`WebDemo`]) with a pretty panic hook and
//!   a `…/#<tab>` URL-hash deep-link, and
//! - a per-frame **state hook** ([`publish_state`] / [`latest_state`]) a headless drive
//!   reads to see what actually ran in the shipped bundle.
//!
//! The panel's data (its *fixture*) is whatever the consumer builds the `Facet` from —
//! the harness never touches a backend, so it runs with no network and no filesystem
//! (browser-safe). gRPC and direct-Rust calls stay first-class *alongside* this UI edge;
//! the harness is only the canvas host.
//!
//! ## Minimal glue (copy `examples/panel.rs`)
//!
//! ```ignore
//! use facett_wasm_demo::{FacetApp, latest_state};
//!
//! // 1. your panel (a facett-core Facet), built from a fixture.
//! let panel = MyPanel::from_fixture(fixture);
//!
//! // 2a. native window:
//! #[cfg(not(target_arch = "wasm32"))]
//! facett_wasm_demo::NativeDemo::new("my-demo").run(move |_cc| FacetApp::boxed(Box::new(panel)))?;
//!
//! // 2b. web canvas (call from a `#[wasm_bindgen(start)]`):
//! #[cfg(target_arch = "wasm32")]
//! facett_wasm_demo::WebDemo::new("my_canvas").mount(move |_cc, _hash| FacetApp::boxed(Box::new(panel)));
//!
//! // 3. export the state hook so a headless drive can read it:
//! #[cfg(target_arch = "wasm32")]
//! #[wasm_bindgen::prelude::wasm_bindgen(js_name = __my_state)]
//! pub fn my_state() -> String { latest_state() }
//! ```
//!
//! A consumer that already has an [`eframe::App`] (a whole deck, say) skips [`FacetApp`]
//! and hands `Box::new(app)` to `run` / `mount` directly.

use std::sync::Mutex;

// ── the per-frame state hook (both targets; wasm-safe, no filesystem) ─────────
//
// A process-global string the running app republishes every frame. On wasm there is
// no filesystem, so the classic `$FACETT_DEMO_STATE` dump is a no-op — instead the
// consumer exports a tiny `#[wasm_bindgen]` fn returning [`latest_state`], and a
// headless Chromium drive reads `window.__<name>_state()` to see exactly what the
// live bundle rendered + ran. Mirrors facett-demo's `wasm_state` module, lifted here
// so every repo shares one implementation.

static LATEST_STATE: Mutex<String> = Mutex::new(String::new());

/// Publish the app's latest observable state (a JSON string). Call once per frame —
/// [`FacetApp`] does this for you. Cheap and lock-guarded; a poisoned lock is ignored.
pub fn publish_state(json: String) {
    if let Ok(mut g) = LATEST_STATE.lock() {
        *g = json;
    }
}

/// Read the latest published state — the backing store for a consumer's exported
/// `window.__<name>_state()` JS hook (and the native reader in tests).
pub fn latest_state() -> String {
    LATEST_STATE.lock().map(|g| g.clone()).unwrap_or_default()
}

// ── FacetApp: adapt a `Facet` panel into an `eframe::App` ─────────────────────

/// Wraps a single [`facett_core::Facet`] panel as an [`eframe::App`]: it paints the
/// panel over the whole viewport each frame and republishes its `state_json` into the
/// [`publish_state`] store, so the web demo's state hook and a headless drive observe
/// what the panel rendered. This is the adapter that makes "a panel + a fixture + a
/// canvas" a one-liner.
pub struct FacetApp {
    facet: Box<dyn facett_core::Facet>,
    publish: bool,
}

impl FacetApp {
    /// Wrap `facet`; the panel's `state_json` is published every frame.
    pub fn new(facet: Box<dyn facett_core::Facet>) -> Self {
        Self { facet, publish: true }
    }

    /// [`FacetApp::new`] already boxed as a `Box<dyn eframe::App>` — the exact type
    /// [`NativeDemo::run`] / [`WebDemo::mount`] factories return.
    pub fn boxed(facet: Box<dyn facett_core::Facet>) -> Box<dyn eframe::App> {
        Box::new(Self::new(facet))
    }

    /// Disable the per-frame state publish (for a panel with no observable state, or
    /// when the consumer publishes its own aggregate state elsewhere).
    pub fn without_state_hook(mut self) -> Self {
        self.publish = false;
        self
    }
}

impl eframe::App for FacetApp {
    // eframe 0.34's `App` hands a full-viewport `Ui` (no margin/background); the panel
    // draws straight into it, exactly as facett-demo's `DemoApp` does.
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        self.facet.ui(ui);
        if self.publish {
            publish_state(serde_json::to_string(&self.facet.state_json()).unwrap_or_default());
        }
    }
}

// ── native host ──────────────────────────────────────────────────────────────

/// The desktop host: opens a native eframe window and runs the app in it.
#[cfg(not(target_arch = "wasm32"))]
pub struct NativeDemo {
    title: String,
    inner_size: [f32; 2],
    min_inner_size: [f32; 2],
}

#[cfg(not(target_arch = "wasm32"))]
impl NativeDemo {
    /// A window titled `title` at a sensible default size (1100×720, min 640×420).
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), inner_size: [1100.0, 720.0], min_inner_size: [640.0, 420.0] }
    }

    /// Override the initial window size.
    pub fn with_inner_size(mut self, size: [f32; 2]) -> Self {
        self.inner_size = size;
        self
    }

    /// Run the window, building the app from the eframe `CreationContext` (where a
    /// consumer installs a theme, a wgpu render-state renderer, etc.). Blocks until
    /// the window closes.
    pub fn run(
        self,
        app_factory: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'static,
    ) -> eframe::Result<()> {
        let options = eframe::NativeOptions {
            viewport: egui::ViewportBuilder::default()
                .with_inner_size(self.inner_size)
                .with_min_inner_size(self.min_inner_size)
                .with_title(&self.title),
            // The renderer configuration is the workspace policy's, not this crate's —
            // same line the web host below passes. With the eframe dep on
            // `wgpu_no_default_features` (LAW 4: no GL) the default configuration
            // would ask a backend-less instance for an adapter; the policy's mask is
            // `Backends::PRIMARY` over the workspace `wgpu` feature list.
            wgpu_options: facett_core::render::gpu::facett_wgpu_options(),
            ..Default::default()
        };
        eframe::run_native(&self.title, options, Box::new(move |cc| Ok(app_factory(cc))))
    }
}

// ── web host ─────────────────────────────────────────────────────────────────

/// The JS global the harness publishes the live backend name onto (`"webgpu"`), set
/// the moment `WebRunner` starts. A headless drive reads this to assert the port
/// happened rather than trusting a comment; nothing in a consumer has to export it.
#[cfg(target_arch = "wasm32")]
pub const BACKEND_GLOBAL: &str = "__facett_backend";

/// The JS global the harness publishes a mount FAILURE onto — the typed `facet-gpu-1`
/// text from [`facett_core::render::GpuUnavailable`], or the renderer's own start
/// error. Empty/absent means the mount is fine.
///
/// This exists because WebGPU can be *absent* in a way WebGL rarely was. Without it a
/// failed mount is a blank canvas, and a headless driver waiting on the state hook
/// hangs to its timeout with nothing to report — a false-red that looks like a
/// timeout bug instead of "this browser has no adapter".
#[cfg(target_arch = "wasm32")]
pub const ERROR_GLOBAL: &str = "__facett_gpu_error";

/// Write `value` onto `window.<key>` so the page loader and a headless drive can read
/// it with no per-consumer glue.
#[cfg(target_arch = "wasm32")]
fn set_global(key: &str, value: &str) {
    if let Some(window) = web_sys::window() {
        let _ = js_sys::Reflect::set(
            window.as_ref(),
            &wasm_bindgen::JsValue::from_str(key),
            &wasm_bindgen::JsValue::from_str(value),
        );
    }
}

/// Why WebGPU is unavailable, or `None` when `navigator.gpu` is present.
///
/// Returns the workspace's TYPED [`facett_core::render::GpuUnavailable`] rather than a
/// bespoke string, so a demo canvas reports a missing adapter with the same
/// `facet-gpu-1` code, banner and remedy as every other facett surface — one
/// vocabulary for one failure.
///
/// `navigator.gpu` is read reflectively: web-sys's own WebGPU bindings sit behind
/// `--cfg=web_sys_unstable_apis`, and only the property's EXISTENCE is needed here.
///
/// Note what the absence actually means. WebGPU is **secure-context only**, so a page
/// opened from `file://` — or served over plain `http:` from anything that is not a
/// loopback host — has no `navigator.gpu` at all, which is indistinguishable at this
/// level from a browser too old to have it. Their remedies are completely different,
/// so the `isSecureContext` line below exists to tell them apart. (This is also the
/// classic false negative when probing a demo bundle: `about:blank` reports "no
/// WebGPU" no matter how new the browser is.)
#[cfg(target_arch = "wasm32")]
fn webgpu_unavailable() -> Option<facett_core::render::GpuUnavailable> {
    use facett_core::render::{gpu::preferred_backends, GpuUnavailable};

    let window = web_sys::window()?;
    let navigator = window.navigator();
    let has_gpu =
        js_sys::Reflect::get(navigator.as_ref(), &wasm_bindgen::JsValue::from_str("gpu"))
            .map(|v| !v.is_undefined() && !v.is_null())
            .unwrap_or(false);
    if has_gpu {
        return None;
    }
    let secure = js_sys::Reflect::get(
        window.as_ref(),
        &wasm_bindgen::JsValue::from_str("isSecureContext"),
    )
    .ok()
    .and_then(|v| v.as_bool())
    .unwrap_or(false);
    // The mask comes from the shared policy so the message names the backends that
    // were actually asked for (`BROWSER_WEBGPU`) rather than a hardcoded guess.
    let requested = if secure {
        format!("{:?}", preferred_backends())
    } else {
        // Say WHY it is missing here; `remedy()` alone would send the operator off to
        // upgrade a browser that is already new enough.
        format!(
            "{:?} (page is NOT a secure context — serve over https:// or http://127.0.0.1)",
            preferred_backends()
        )
    };
    Some(GpuUnavailable::NothingEnumerated { requested })
}

/// The web host: mounts the app onto a `<canvas>` via eframe's `WebRunner` on the
/// **wgpu / WebGPU** backend. Installs a pretty panic hook and passes the URL-hash
/// deep-link to the factory.
///
/// ## WebGPU, and why there is no quiet fallback
/// On wasm32 eframe's `wgpu` backend renders through the browser's WebGPU
/// (`navigator.gpu`). WebGL is gone from this harness, and because every
/// `<repo>-demo` in the constellation mounts on it, it is gone from their bundles too.
///
/// The backend choice is not made here: `facett_core::render::gpu`'s
/// `preferred_backends()` / `facett_wgpu_options()` own it for the whole workspace and
/// on wasm32 that is `Backends::BROWSER_WEBGPU`. That MASK is the real guarantee —
/// cargo features decide what is *available*, the mask decides what is *allowed*, so
/// wgpu cannot silently pick a WebGL2 device even if one were compiled in.
///
/// [`mount`](WebDemo::mount) **pre-flights the adapter** and, on failure, publishes the
/// typed [`GpuUnavailable`](facett_core::render::GpuUnavailable) on
/// [`ERROR_GLOBAL`] instead of leaving a blank canvas — a silent *fallback* would let a
/// green robot row claim a WebGPU port that never happened. [`BACKEND_GLOBAL`] carries
/// the backend actually in use so a test can assert on it rather than trust this doc.
#[cfg(target_arch = "wasm32")]
pub struct WebDemo {
    canvas_id: String,
}

#[cfg(target_arch = "wasm32")]
impl WebDemo {
    /// Target the `<canvas id="…">` the page's loader created.
    pub fn new(canvas_id: impl Into<String>) -> Self {
        Self { canvas_id: canvas_id.into() }
    }

    /// Boot the canvas. `app_factory` receives the eframe `CreationContext` and the
    /// current URL-hash fragment (`…/#OSM 3D` → `Some("OSM 3D")`) so the app can open
    /// a deep-linked tab on load. Spawns onto the browser event loop and returns.
    ///
    /// Every failure path writes [`ERROR_GLOBAL`] as well as logging, so a mount that
    /// cannot happen says so instead of leaving the loader spinning.
    pub fn mount(
        self,
        app_factory: impl FnOnce(&eframe::CreationContext<'_>, Option<String>) -> Box<dyn eframe::App>
        + 'static,
    ) {
        use wasm_bindgen::JsCast as _;
        // Opaque wasm panics → readable browser-console traces.
        console_error_panic_hook::set_once();
        let canvas_id = self.canvas_id;
        let hash = url_hash_fragment();
        wasm_bindgen_futures::spawn_local(async move {
            // Pre-flight BEFORE touching the canvas: a typed reason beats a blank frame.
            if let Some(why) = webgpu_unavailable() {
                log::error!("facett-wasm-demo: {why}");
                set_global(ERROR_GLOBAL, &why.to_string());
                // Record it in the shared slot too, so this canvas's failure reports
                // exactly like any other facett surface's.
                facett_core::render::record_gpu_unavailable(why);
                return;
            }
            let document = web_sys::window()
                .expect("no `window`")
                .document()
                .expect("no `document`");
            let canvas = document
                .get_element_by_id(&canvas_id)
                .unwrap_or_else(|| panic!("missing <canvas id=\"{canvas_id}\">"))
                .dyn_into::<web_sys::HtmlCanvasElement>()
                .unwrap_or_else(|_| panic!("#{canvas_id} is not a <canvas>"));
            // The renderer configuration is the workspace policy's, not this crate's.
            let web_options = eframe::WebOptions {
                wgpu_options: facett_core::render::gpu::facett_wgpu_options(),
                ..Default::default()
            };
            set_global(BACKEND_GLOBAL, WEB_BACKEND);
            let result = eframe::WebRunner::new()
                .start(canvas, web_options, Box::new(move |cc| Ok(app_factory(cc, hash))))
                .await;
            if let Err(e) = result {
                // Requesting the adapter/device happens inside `start`, so this is
                // where "WebGPU exists but gave us no usable device" surfaces.
                let msg = format!("WebGPU renderer failed to start: {e:?}");
                log::error!("facett-wasm-demo: {msg}");
                set_global(ERROR_GLOBAL, &msg);
                set_global(BACKEND_GLOBAL, "");
            }
        });
    }
}

/// The backend the web host renders with. A constant, but a *published* one (see
/// [`BACKEND_GLOBAL`]): the port off WebGL is only really proven if a driver can read
/// the backend back out of the running bundle.
#[cfg(target_arch = "wasm32")]
pub const WEB_BACKEND: &str = "webgpu";

/// The current URL-hash fragment, cleaned for use as a tab name: `…/#OSM%203D` (or
/// `#OSM+3D`) → `Some("OSM 3D")`; no hash → `None`. The deep-link a shared link or a
/// headless screenshotter uses to land on a specific tab.
#[cfg(target_arch = "wasm32")]
pub fn url_hash_fragment() -> Option<String> {
    web_sys::window()
        .and_then(|w| w.location().hash().ok())
        .map(|h| h.trim_start_matches('#').replace("%20", " ").replace('+', " ").trim().to_string())
        .filter(|s| !s.is_empty())
}

// ── static-site scaffold: the index.html the wasm-bindgen bundle loads ────────
//
// Native-only (it writes files). A consumer's `xtask` calls this after `wasm-bindgen`
// so every repo's Pages `index.html` is generated from one template — no per-repo HTML
// drift. Mirrors facett's own `xtask::write_index_html`.

/// Parameters for [`IndexHtml::render`] — the static loader for a wasm-bindgen
/// `--target web` bundle: a full-viewport canvas + a spinner + a cache-busted import.
#[cfg(not(target_arch = "wasm32"))]
pub struct IndexHtml<'a> {
    /// The wasm-bindgen output stem: it emits `<stem>.js` + `<stem>_bg.wasm`.
    pub stem: &'a str,
    /// The `<canvas id>` the wasm boot grabs (must match [`WebDemo::new`]).
    pub canvas_id: &'a str,
    /// Page `<title>`.
    pub title: &'a str,
    /// `<meta name=description>`.
    pub description: &'a str,
    /// The exported JS state-hook name to lift onto `window` (e.g. `__my_state`).
    pub state_export: &'a str,
    /// A per-deploy cache-buster appended as `?v=<val>` to the JS + wasm URLs (a short
    /// git sha). Browsers key the wasm by its constant filename, so without this a
    /// redeploy serves the STALE bundle.
    pub cache_bust: &'a str,
}

#[cfg(not(target_arch = "wasm32"))]
impl IndexHtml<'_> {
    /// Render the `index.html` string.
    pub fn render(&self) -> String {
        let Self { stem, canvas_id, title, description, state_export, cache_bust } = *self;
        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
  <!-- Never cache the document itself, or a cached index.html keeps the old ?v= and
       you keep seeing the old build. -->
  <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
  <meta http-equiv="Pragma" content="no-cache" />
  <meta http-equiv="Expires" content="0" />
  <title>{title}</title>
  <meta name="description" content="{description}" />
  <style>
    html, body {{ margin: 0; height: 100%; overflow: hidden; background: #0b0d10; }}
    canvas {{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; }}
    #loading {{ position: absolute; inset: 0; display: flex; align-items: center;
      justify-content: center; color: #9aa4b2; font: 500 15px/1.4 system-ui, sans-serif;
      letter-spacing: .02em; }}
    #loading .dot {{ animation: blink 1s infinite both; }}
    @keyframes blink {{ 0%, 100% {{ opacity: .25 }} 50% {{ opacity: 1 }} }}
  </style>
</head>
<body>
  <div id="loading">loading<span class="dot">…</span></div>
  <canvas id="{canvas_id}"></canvas>
  <script type="module">
    // With `--target web`, wasm-bindgen exports are module members, not globals — so
    // lift the state hook onto `window` for a headless drive / the browser console.
    import init, {{ {state_export} }} from './{stem}.js?v={cache_bust}';
    window.{state_export} = {state_export};
    // The canvas renders on WebGPU. That backend can be genuinely ABSENT (an old
    // browser, no adapter, or a page that is not a secure context), and the mount is
    // async — so `init` resolving does NOT mean a canvas appeared. Poll the harness's
    // globals instead of assuming: `__facett_gpu_error` carries the typed facet-gpu-1
    // reason, and showing it beats the blank rectangle a silent failure leaves behind.
    init('./{stem}_bg.wasm?v={cache_bust}')
      .then(() => {{
        const l = document.getElementById('loading');
        const deadline = Date.now() + 20000;
        const tick = () => {{
          const err = window.__facett_gpu_error;
          if (err) {{
            if (l) l.textContent = err;
            console.error(err);
            return;
          }}
          if (window.__facett_backend) {{ if (l) l.remove(); return; }}
          if (Date.now() > deadline) {{
            if (l) l.textContent = 'the demo never started — see the browser console';
            return;
          }}
          setTimeout(tick, 100);
        }};
        tick();
      }})
      .catch((err) => {{
        const l = document.getElementById('loading');
        if (l) l.textContent = 'failed to load the demo — see the browser console';
        console.error(err);
      }});
  </script>
</body>
</html>
"#
        )
    }

    /// Write `index.html` (+ Codeberg-Pages `.nojekyll`) into `dir`.
    pub fn write(&self, dir: &std::path::Path) -> std::io::Result<()> {
        std::fs::write(dir.join("index.html"), self.render())?;
        std::fs::write(dir.join(".nojekyll"), "")?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn state_store_round_trips() {
        publish_state("{\"a\":1}".to_string());
        assert_eq!(latest_state(), "{\"a\":1}");
        publish_state(String::new());
        assert_eq!(latest_state(), "");
    }

    #[test]
    fn facet_app_publishes_wrapped_panel_state() {
        struct P;
        impl facett_core::Facet for P {
            fn title(&self) -> &str {
                "p"
            }
            fn ui(&mut self, _ui: &mut egui::Ui) {}
            fn state_json(&self) -> serde_json::Value {
                serde_json::json!({ "ok": true })
            }
        }
        // The adapter is constructible and carries the panel; the boxed form is the
        // exact `Box<dyn eframe::App>` the host factories return.
        let app = FacetApp::new(Box::new(P));
        assert!(app.publish);
        let _boxed: Box<dyn eframe::App> = FacetApp::boxed(Box::new(P));
    }

    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn index_html_has_canvas_import_and_cachebust() {
        let html = IndexHtml {
            stem: "my-demo",
            canvas_id: "my_canvas",
            title: "my demo",
            description: "d",
            state_export: "__my_state",
            cache_bust: "abc1234",
        }
        .render();
        assert!(html.contains(r#"<canvas id="my_canvas">"#));
        assert!(html.contains("import init, { __my_state } from './my-demo.js?v=abc1234'"));
        assert!(html.contains("./my-demo_bg.wasm?v=abc1234"));
        assert!(html.contains("window.__my_state = __my_state"));
    }

    /// The loader must READ the harness's two globals. Without this the WebGPU port
    /// regresses invisibly: a page whose mount pre-flight refused would sit on the
    /// spinner forever and look exactly like a slow build.
    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn index_html_surfaces_the_gpu_error_and_backend_globals() {
        let html = IndexHtml {
            stem: "my-demo",
            canvas_id: "my_canvas",
            title: "my demo",
            description: "d",
            state_export: "__my_state",
            cache_bust: "abc1234",
        }
        .render();
        // The exact names `WebDemo::mount` publishes on wasm32 (`ERROR_GLOBAL` /
        // `BACKEND_GLOBAL`); a rename on either side must break this test.
        assert!(html.contains("window.__facett_gpu_error"), "loader must show the typed GPU error");
        assert!(html.contains("window.__facett_backend"), "loader must wait on the live backend");
        // And it must not have quietly regrown a WebGL story.
        assert!(!html.to_lowercase().contains("webgl"));
    }
}