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

nordisk/facett

facet-wrapped/src/wasm.rs

//! The **wasm boundary** (compiled only for `wasm32`) — a thin `wasm-bindgen` layer
//! over the [`Registry`](crate::registry::Registry).
//!
//! Two kinds of export:
//!
//! * **discovery + pre-flight**, canvas-free: enumerate components, read a fixture's
//!   state, and ask whether a payload is renderable *before* mounting anything;
//! * **the mount**, which starts an eframe/egui canvas driving the chosen component —
//!   on the **wgpu/WebGPU** path, so what appears is the real component and not a
//!   picture of one.
//!
//! ## WebGPU, and why there is no quiet fallback
//! The canvas is driven by eframe's `wgpu` backend, which on wasm32 renders through
//! the browser's WebGPU (`navigator.gpu`). WebGL is gone from this crate.
//!
//! WebGPU can be *absent* in a way WebGL rarely was: it needs a secure context
//! (`https:` or a `localhost` origin) and, headless, a working adapter. So the mount
//! **pre-flights the adapter** and, on failure, records the workspace's typed
//! `facet-gpu-1` [`GpuUnavailable`](facett_core::render::GpuUnavailable) in
//! [`facet_error`] instead of leaving a blank canvas. That matters for the robots: a
//! silent failure would hang a driver waiting for [`facet_mounted`] forever, and a
//! silent *fallback* would let a green robot row claim a WebGPU port that never
//! happened. [`facet_adapter`] reports the **physical device** the canvas opened — its
//! name, its vendor and whether it is a software rasteriser — so a test can assert on
//! the renderer rather than trust this comment. [`facet_backend`] answers the narrower
//! question of what kind of surface this is, and cannot tell a Radeon from SwiftShader.
//!
//! The backend choice itself 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` — WebGPU only, no WebGL.
//!
//! [`facet_state`] builds the mounted component's `state_json` when a host asks for
//! it (not on every frame), so a headless driver reads exactly what the running
//! canvas rendered and a frame does not pay for a readout nobody takes. That is the seam robot
//! tests drive: assert on real state, never on a screenshot or a substring.
//!
//! A live mount that cannot render the caller's data **refuses**: nothing is mounted
//! and [`facet_error`] carries the typed reason. The host shows that text instead of
//! a canvas. A fixture is never substituted for the caller's data.

use std::sync::Mutex;

use wasm_bindgen::prelude::*;

use facett_core::{Facet, LookTheme};

use crate::registry::Registry;

/// The name of the component currently on the canvas.
static MOUNTED: Mutex<String> = Mutex::new(String::new());
/// The last mount refusal (empty when the last mount succeeded).
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());

thread_local! {
    /// The LIVE mounted component, shared by the running canvas and [`facet_state`].
    ///
    /// The state used to be serialised on EVERY frame into a string slot, whether or
    /// not anyone read it. MEASURED 2026-09-28 (oden, native, 10 000-node graph):
    /// graphnav's `state_json` cost 9.3 s (its node table computed betweenness), so a
    /// 10k graph never painted a second frame in the browser. The state is now built
    /// when a host ASKS for it, and a frame pays nothing for it.
    static LIVE: std::cell::RefCell<Option<std::rc::Rc<std::cell::RefCell<Box<dyn Facet>>>>> =
        const { std::cell::RefCell::new(None) };
    /// The component [`facet_check`] just built, with the name and payload it was built
    /// from. The loader always checks and then mounts the SAME payload, which used to
    /// parse and build it twice — for graph3d at 10 000 nodes that is two CPU force
    /// layouts. A mount (or a live update) of an identical payload takes this one.
    static CHECKED: std::cell::RefCell<Option<(String, String, Box<dyn Facet>)>> =
        const { std::cell::RefCell::new(None) };
    /// Timing counters for [`facet_perf`].
    static PERF: std::cell::RefCell<Perf> = std::cell::RefCell::new(Perf::default());
    /// A replacement component built by [`facet_update_data`], waiting for the running
    /// canvas to swap it in on its next frame. `Box<dyn Facet>` is not `Send`, and on
    /// wasm32 there is one thread, so a thread-local is the honest home for it.
    static PENDING: std::cell::RefCell<Option<(String, Box<dyn Facet>)>> =
        const { std::cell::RefCell::new(None) };
    /// The running canvas's egui context, so an update can request the frame that
    /// consumes it (a component with no animation would otherwise wait for the next
    /// pointer event to show new data).
    static CTX: std::cell::RefCell<Option<egui::Context>> = const { std::cell::RefCell::new(None) };
}

fn set(slot: &Mutex<String>, v: &str) {
    if let Ok(mut g) = slot.lock() {
        g.clear();
        g.push_str(v);
    }
}

fn get(slot: &Mutex<String>) -> String {
    slot.lock().map(|g| g.clone()).unwrap_or_default()
}

/// What [`facet_perf`] reports: where the time of a mount and of each frame went.
#[derive(Default, serde::Serialize)]
struct Perf {
    /// Frames the mounted component has drawn.
    frames: u64,
    /// The component's own `ui` time (not egui's tessellation, not the GPU submit).
    ui_ms_last: f64,
    ui_ms_total: f64,
    ui_ms_max: f64,
    /// The last `facet_check`: payload parse and component build.
    check_parse_ms: f64,
    check_build_ms: f64,
    /// The last mount or update: parse and build, or `reused_check` when the checked
    /// component was taken instead.
    mount_parse_ms: f64,
    mount_build_ms: f64,
    reused_check: bool,
    /// The last `facet_state()` read: `state_json` + serialisation.
    state_ms_last: f64,
    state_bytes_last: usize,
}

/// `performance.now()`, or 0 where there is no window.
fn now_ms() -> f64 {
    web_sys::window().and_then(|w| w.performance()).map_or(0.0, |p| p.now())
}

/// Parse `data_json` and build `component` from it, timing both halves into `perf`.
fn parse_and_build(component: &str, data_json: &str) -> (Result<Box<dyn Facet>, String>, f64, f64) {
    let t0 = now_ms();
    let data = match serde_json::from_str::<serde_json::Value>(data_json) {
        Ok(v) => v,
        Err(e) => return (Err(format!("malformed data payload for `{component}`: {e}")), now_ms() - t0, 0.0),
    };
    let t1 = now_ms();
    let built = Registry::with_builtins().build_with(component, &data).map_err(|e| e.to_string());
    (built, t1 - t0, now_ms() - t1)
}

/// Take the component [`facet_check`] built, if it was built from exactly this
/// `component` + `data_json`; otherwise parse and build now. Records the timings.
fn checked_or_build(component: &str, data_json: &str) -> Result<Box<dyn Facet>, String> {
    let reused = CHECKED.with(|c| {
        let mut c = c.borrow_mut();
        match c.take() {
            Some((n, p, f)) if n == component && p == data_json => Some(f),
            other => {
                *c = other;
                None
            }
        }
    });
    if let Some(f) = reused {
        PERF.with(|p| {
            let mut p = p.borrow_mut();
            p.reused_check = true;
            p.mount_parse_ms = 0.0;
            p.mount_build_ms = 0.0;
        });
        return Ok(f);
    }
    let (built, parse, build) = parse_and_build(component, data_json);
    PERF.with(|p| {
        let mut p = p.borrow_mut();
        p.reused_check = false;
        p.mount_parse_ms = parse;
        p.mount_build_ms = build;
    });
    built
}

// ── discovery + pre-flight (no canvas) ──────────────────────────────────────────

/// The component manifest as JSON: `[{ name, description, group, title, data_driven }]`.
#[wasm_bindgen]
#[must_use]
pub fn facet_manifest() -> String {
    Registry::with_builtins().manifest_json().to_string()
}

/// How many components are wrapped.
#[wasm_bindgen]
#[must_use]
pub fn facet_count() -> usize {
    Registry::with_builtins().len()
}

/// The names that accept caller data, as a JSON array string.
#[wasm_bindgen]
#[must_use]
pub fn facet_data_driven() -> String {
    serde_json::to_string(&Registry::with_builtins().data_driven_names())
        .unwrap_or_else(|_| "[]".into())
}

/// A component's **fixture** `state_json` (`"null"` if the name is unknown).
#[wasm_bindgen]
#[must_use]
pub fn facet_fixture_state(name: &str) -> String {
    Registry::with_builtins()
        .component_state_json(name)
        .map(|v| v.to_string())
        .unwrap_or_else(|| "null".to_string())
}

/// **Pre-flight**: `""` when `name` can render `data_json`, else the typed refusal
/// message. A host calls this before mounting so it can show the reason in place of a
/// canvas rather than mounting something that merely looks right.
#[wasm_bindgen]
#[must_use]
pub fn facet_check(name: &str, data_json: &str) -> String {
    let (built, parse, build) = parse_and_build(name, data_json);
    PERF.with(|p| {
        let mut p = p.borrow_mut();
        p.check_parse_ms = parse;
        p.check_build_ms = build;
    });
    match built {
        Ok(f) => {
            // Kept for the mount that follows (see `CHECKED`).
            CHECKED.with(|c| *c.borrow_mut() = Some((name.to_string(), data_json.to_string(), f)));
            String::new()
        }
        Err(e) => e,
    }
}

/// A component's `state_json` **built from caller data**, without a canvas — the
/// headless read a robot test asserts on. `"null"` on ANY refusal; no fixture is ever
/// substituted. Use [`facet_check`] for the reason.
#[wasm_bindgen]
#[must_use]
pub fn facet_state_with_data(name: &str, data_json: &str) -> String {
    let Ok(data) = serde_json::from_str::<serde_json::Value>(data_json) else {
        return "null".to_string();
    };
    Registry::with_builtins()
        .build_with(name, &data)
        .map(|f| f.state_json().to_string())
        .unwrap_or_else(|_| "null".to_string())
}

// ── the canvas mount ────────────────────────────────────────────────────────────

/// The eframe app hosting ONE wrapped component. Its `ui` is the component's own —
/// the whole interactive surface — plus the per-frame state republish.
struct MountApp {
    name: String,
    /// Shared with [`LIVE`], so [`facet_state`] can read the component between frames.
    facet: std::rc::Rc<std::cell::RefCell<Box<dyn Facet>>>,
}

impl eframe::App for MountApp {
    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
        if let Some(render_state) = frame.wgpu_render_state() {
            facett_flow::gpu::evict_idle(render_state, ui.ctx().cumulative_frame_nr());
        }
        let t0 = now_ms();
        // A live update swaps the component IN PLACE: same runner, same canvas, same
        // camera — no second eframe on the element, no teardown per frame of data.
        if let Some((name, facet)) = PENDING.with(|p| p.borrow_mut().take()) {
            self.name = name;
            *self.facet.borrow_mut() = facet;
        }
        // `facet.ui(ui)` — wrapped in the navgraph spine when the host asked for dev
        // mode (`facet_dev`), and exactly `facet.ui(ui)` otherwise. State is read on
        // demand through `LIVE` (`facet_state`), never serialised per frame.
        crate::devmode::draw(ui, &self.name, &mut **self.facet.borrow_mut());
        set(&MOUNTED, &self.name);
        let dt = now_ms() - t0;
        PERF.with(|p| {
            let mut p = p.borrow_mut();
            p.frames += 1;
            p.ui_ms_last = dt;
            p.ui_ms_total += dt;
            p.ui_ms_max = p.ui_ms_max.max(dt);
        });
    }
}

/// **Dev mode for this page** (navgraph numbers on the canvas). The loader calls it
/// before the first mount when the host passes `dev`; `nav` is the overlay mode
/// (`labels` / `next` / `all`). Returns whether dev mode is on. `Ctrl+Shift+G` cycles
/// the overlay and `Ctrl+G <number> Enter` focuses a node, as in every facett app.
#[wasm_bindgen]
pub fn facet_dev(nav: &str) -> bool {
    facett_core::navgraph::web::prevent_browser_ctrl_g();
    crate::devmode::request(nav)
}

/// The mounted canvas's navigation graph as JSON (`null` before the first frame or
/// outside dev mode) — what a robot asserts the numbers on.
#[wasm_bindgen]
#[must_use]
pub fn facet_navgraph() -> String {
    CTX.with(|c| c.borrow().as_ref().map(|ctx| crate::devmode::graph_json(ctx).to_string()))
        .unwrap_or_else(|| "null".to_string())
}

/// Mount component `component`'s **fixture** onto `<canvas id=canvas_id>` (the
/// zero-config showcase path).
#[wasm_bindgen]
pub fn facet_mount(canvas_id: &str, component: &str) {
    mount_inner(canvas_id, component, "");
}

/// Mount component `component` onto the canvas **with caller data**.
///
/// An empty `data_json` takes the fixture path. Otherwise the payload must render:
/// a malformed document, an unknown component, a fixture-only component or a payload
/// with nothing in it mounts NOTHING and records the reason in [`facet_error`].
#[wasm_bindgen]
pub fn facet_mount_with_data(canvas_id: &str, component: &str, data_json: &str) {
    mount_inner(canvas_id, component, data_json);
}

/// **Replace the mounted component's data** without remounting the canvas.
///
/// The live-feed path (row 6): a host streaming snapshots — knut's `knutpipe web`
/// sends one `PipelineModel` per Spark run event — calls this per frame of data. The
/// new component is built through the same registry seam as a mount and swapped into
/// the RUNNING eframe app on its next frame, so the renderer, the canvas and the
/// user's camera survive. Calling `facet_mount_with_data` per snapshot instead would
/// start a second `WebRunner` on the same element every time.
///
/// Returns `""` on success, else the typed refusal (also recorded in
/// [`facet_error`]); on a refusal the canvas keeps showing the previous data. There
/// must already be a mounted canvas for the swap to land — a host calls
/// `facet_mount_with_data` for the first frame and this for the rest.
#[wasm_bindgen]
#[must_use]
pub fn facet_update_data(component: &str, data_json: &str) -> String {
    match checked_or_build(component, data_json) {
        Ok(f) => {
            set(&LAST_ERROR, "");
            PENDING.with(|p| *p.borrow_mut() = Some((component.to_string(), f)));
            CTX.with(|c| {
                if let Some(ctx) = c.borrow().as_ref() {
                    ctx.request_repaint();
                }
            });
            String::new()
        }
        Err(msg) => {
            log::error!("facet-wrapped: {msg}");
            set(&LAST_ERROR, &msg);
            msg
        }
    }
}

/// **Put a renderer on every GPU lane this bundle SELECTS** — the second half of the
/// GPU path, and the half this crate was missing.
///
/// # The defect this closes (measured on oden's RTX 4090, 2026-09-06)
///
/// The bundle mounted `osm3d` inside a Streamlit iframe and reported, off the running
/// page: `facet_canvas_api: "webgpu"` · `gpu_lane_selected: true` ·
/// `backend: "wgpu-vello"` · **`lane_flag: "cpu"`**. Not `pending` — so a frame really
/// had painted, and it had painted on the CPU painter, **identically on the 4090 and on
/// SwiftShader**, which is the signature of something that is not a property of the
/// adapter at all.
///
/// It was a missing call, right here. Selecting a lane and installing its renderer are
/// two different acts:
///
/// * `with_gpu(true)` (`crate::map::osm3d`) swaps the backend. That is what
///   `gpu_lane_selected` and `backend` report, and it is answerable before any frame.
/// * `install_renderer` puts a `Map3dRenderer` into the `RenderState`'s
///   `callback_resources`. **Without it the paint callback simply no-ops** and
///   `facett_map3d::gpu::renderer_installed()` stays `false`, so
///   `WgpuVelloBackend::paint` reports `used_gpu = false`
///   (`facett-map3d/src/backend.rs`, the `let installed = …` line), `last_used_gpu`
///   becomes `Some(false)`, and `lane_flag()` answers a perfectly honest `Cpu`.
///
/// `crate::map::osm3d`'s own comment predicted this in the same breath as selecting the
/// lane — *"if the host has not installed a `Map3dRenderer` into its `RenderState`, the
/// callback no-ops and the CPU path still draws"*. **facet-wrapped is that host**, and
/// until this function existed the crate contained zero occurrences of
/// `install_renderer` in `src/` or `tests/`.
///
/// # Why it is a function and not two lines in the closure
///
/// The set of lanes to install is derived from ONE fact — which GPU features
/// `facet-wrapped/Cargo.toml` turns on — and that fact already has a guard reading it
/// out of cargo's own manifest parse (`xtask/tests/wasm_bundle_has_gpu_features.rs`).
/// Keeping the installs in one named place means the manifest edge and the install have
/// a single seam between them instead of being restated inside a `WebRunner` callback
/// (LAW 5). `tests/gpu_lanes_are_installed.rs` holds the two sides together.
///
/// # What is installed, and how observable each one is — said plainly
///
/// * **`facett-map3d/wgpu` → [`facett_map3d::gpu::install_renderer`]. PROVEN.** It is
///   the one lane with a public runtime readout (`lane_flag`/`used_gpu` in
///   `state_json`), so the browser can be asked whether this line took effect, and it
///   was asked before and after.
/// * **`facett-graph3d/wgpu` and `facett-graphview/gpu` →
///   `graphcloud::cloud::install_cloud_renderer`.** Both graph surfaces route their node/edge
///   cloud through facett-core's ONE `graphcloud::cloud` entry point, and
///   `cloud_renderer_installed()` is *"what unlocks `Graph3D`'s instanced fast path"* —
///   so this was the same missing install. **NOT PROVEN from the browser**, and that is
///   a limit worth stating rather than glossing: `facett-graph3d` publishes no lane in
///   its `state_json` and has no public `use_gpu` getter (`crate::graph::graph3d`
///   records the same gap), so nothing outside that crate can read the selection back.
///   This installs it because the defect is identical and the call is correct; the
///   readout that would let a test *assert* it is graph3d's own row, not this one.
///
/// `facett-map`'s 2-D GPU lane is deliberately NOT installed: the manifest takes
/// `facett-map` on default features, so `facett_map::gpu` is not in this binary at all.
/// Installing a lane the bundle does not select is how the reverse lie gets built.
///
/// * **`facett-graphnav/gpu` → [`facett_graphview::gpu_layout::install_host_device`].**
///   Not a renderer — a LOAN. The force layout is a compute job, and in a browser it
///   cannot make its own device (`pollster::block_on` on the main thread is a
///   deadlock), so the host lends the one eframe already created. Without this line
///   the selector finds no device and every large graph converges on wasm32's single
///   CPU thread. **Observable:** graphnav's `state_json().canvas.layout_lane` reads
///   `"gpu-job"` when the lane ran, so this install — unlike the cloud one above — can
///   be asserted from the browser.
#[cfg(target_arch = "wasm32")]
fn install_gpu_lanes(render_state: &eframe::egui_wgpu::RenderState) {
    facett_flow::gpu::install_renderer(render_state);
    facett_map3d::gpu::install_renderer(render_state);
    // The 2-D map lane (`facett-map/wgpu` + `facett-geomap/map-wgpu`), used by `osm2d`
    // and `map`. Without it their GPU paint callback no-ops and the CPU painter draws.
    facett_map::gpu::install_renderer(render_state);
    facett_core::render::gpu::graphcloud::cloud::install_cloud_renderer(render_state);
    facett_graphview::gpu_layout::install_host_device(&render_state.device, &render_state.queue);
}

fn mount_inner(canvas_id: &str, component: &str, data_json: &str) {
    console_error_panic_hook::set_once(); #[allow(unexpected_cfgs)] #[cfg(feature = "group-only")] if let Some(msg) = crate::groups::wrong_bundle(component) { log::error!("facet-wrapped: {msg}"); set(&LAST_ERROR, &msg); return; }
    let registry = Registry::with_builtins();

    let facet: Box<dyn Facet> = if data_json.trim().is_empty() {
        // Showcase path: an unknown name falls back to the first entry so a bare load
        // still shows something live. No caller data is at stake here.
        let name = if registry.get(component).is_some() {
            component.to_string()
        } else {
            registry
                .names()
                .first()
                .copied()
                .unwrap_or("table")
                .to_string()
        };
        let Some(f) = registry.build(&name) else {
            log::error!("facet-wrapped: empty registry");
            return;
        };
        set(&LAST_ERROR, "");
        let again = name.clone();
        return start(canvas_id, name, f, Box::new(move || Registry::with_builtins().build(&again)));
    } else {
        // Live path: the caller's data or an honest refusal — never a fixture.
        match checked_or_build(component, data_json) {
            Ok(f) => {
                set(&LAST_ERROR, "");
                f
            }
            Err(msg) => {
                log::error!("facet-wrapped: {msg}");
                set(&LAST_ERROR, &msg);
                return;
            }
        }
    };
    let (c, d) = (component.to_string(), data_json.to_string());
    start(canvas_id, component.to_string(), facet, Box::new(move || checked_or_build(&c, &d).ok()));
}

/// The backend this build renders **through** — a constant, and deliberately still one.
///
/// It answers "what kind of surface is this", and the honest reading is that it can be
/// trusted for exactly that: the port off WebGL is real, and a driver reading `webgpu`
/// back out of the running wasm knows no GL path was compiled in.
///
/// **It is not evidence about the renderer, and this crate has twice been caught
/// reading it as though it were.** `Cargo.toml`'s map paragraph records row 3, where
/// the GPU lanes were compiled OUT of the bundle while this function and
/// `facet_canvas_api` both answered `webgpu` truthfully; and a canvas on
/// `--use-angle=swiftshader` answers `webgpu` here while a CPU rasteriser paints every
/// pixel. [`facet_adapter`] is the one that can tell those apart, by naming the device.
#[wasm_bindgen]
#[must_use]
pub fn facet_backend() -> String {
    "webgpu".to_string()
}

/// **The physical device the running canvas actually opened** — the JSON
/// `facett_core::render::selected_adapter_json()` publishes, which is the SAME shape
/// every native facett surface reports. `"null"` before any canvas has mounted.
///
/// This is the answer [`facet_backend`] cannot give. `--use-angle=swiftshader` hands
/// Chrome a real WebGPU adapter that is a CPU rasteriser: `facet_backend()` says
/// `webgpu`, the loader's `facet_canvas_api` probe says `webgpu`, and
/// `tests/gpu_lane_is_observable.rs` proves the wgpu lane is wired — all three true,
/// all three true of software too. The fields here are not: `software` is a boolean,
/// `kind` is `cpu` / `integrated` / `discrete`, and `name` is the device a person
/// recognises (`AMD Radeon 890M Graphics (RADV GFX1150)`).
///
/// A driver should assert on `kind` or `software`, never on the presence of an adapter.
#[wasm_bindgen]
#[must_use]
pub fn facet_adapter() -> String {
    crate::gpu::adapter_json()
}

/// Why WebGPU is unavailable, or `None` when `navigator.gpu` is present.
///
/// Returns the workspace's TYPED [`GpuUnavailable`] rather than a bespoke string, so
/// this 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
/// served from `file://` or plain `http:` on a non-loopback host has no
/// `navigator.gpu` at all — which is indistinguishable, at this level, from a browser
/// too old to have it. The extra `isSecureContext` line below exists to tell those
/// two apart, because their remedies are completely different.
/// The two synchronous facts that decide whether a page may ask for an adapter at all:
/// `navigator.gpu` present, and `isSecureContext`.
fn browser_preflight() -> Option<crate::adapter_retry::Preflight> {
    let window = web_sys::window()?;
    let has_gpu = js_sys::Reflect::get(window.navigator().as_ref(), &JsValue::from_str("gpu"))
        .map(|v| !v.is_undefined() && !v.is_null())
        .unwrap_or(false);
    let secure = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("isSecureContext"))
        .ok()
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    crate::adapter_retry::preflight(has_gpu, secure)
}

fn webgpu_unavailable() -> Option<facett_core::render::GpuUnavailable> {
    use facett_core::render::{GpuUnavailable, gpu::preferred_backends};

    let window = web_sys::window()?;
    let navigator = window.navigator();
    let has_gpu = js_sys::Reflect::get(navigator.as_ref(), &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(), &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 localhost)",
            preferred_backends()
        )
    };
    Some(GpuUnavailable::NothingEnumerated { requested })
}

/// Boot eframe's `WebRunner` on the canvas with `facet` (async; returns immediately).
///
/// Every failure path writes [`LAST_ERROR`] as well as logging. On the WebGL backend
/// a failed start merely left a blank canvas; on WebGPU "no adapter" is a realistic
/// outcome, and a blank canvas would strand a headless driver waiting on
/// [`facet_mounted`] until its timeout with nothing to report.
fn start(canvas_id: &str, name: String, facet: Box<dyn Facet>, rebuild: Rebuild) {
    let canvas_id = canvas_id.to_string();
    wasm_bindgen_futures::spawn_local(async move {
        // Pre-flight BEFORE touching the canvas: a typed reason beats a blank frame.
        // navigator.gpu missing or an insecure page cannot be fixed by asking again, so it
        // fails at once, in plain words (`adapter_retry::preflight`); only a real null
        // adapter with navigator.gpu present is retried below.
        crate::adapter_retry::record_reason("");
        crate::adapter_retry::record_attempts(0);
        if let Some(p) = browser_preflight() {
            let msg = crate::adapter_retry::preflight_message(p);
            log::error!("facet-wrapped: {msg}");
            set(&LAST_ERROR, msg);
            crate::adapter_retry::record_reason(msg);
            if let Some(why) = webgpu_unavailable() {
                facett_core::render::record_gpu_unavailable(why);
            }
            return;
        }
        if let Some(why) = webgpu_unavailable() {
            log::error!("facet-wrapped: {why}");
            // Record it in the shared slot too, so `gpu_unavailable_json()` reports
            // this canvas's failure exactly like any other facett surface's.
            set(&LAST_ERROR, &why.to_string());
            facett_core::render::record_gpu_unavailable(why);
            return;
        }
        // A SOFTWARE ADAPTER IS NOT A GPU (owner, 2026-10-02): SwiftShader answers
        // requestAdapter() on a Chrome that cannot reach the card, and the lanes installed
        // on it panicked in the first resident upload. Refused before eframe or any lane
        // exists, with facett-core's predicate (the demo and the probes use the same one).
        // A null adapter falls through to the bounded retry below.
        if let Some(what) = browser_software_adapter().await {
            let msg = crate::adapter_retry::software_adapter_message(&what);
            log::error!("facet-wrapped: {msg}");
            set(&LAST_ERROR, &msg);
            crate::adapter_retry::record_reason(&msg);
            return;
        }
        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
            log::error!("facet-wrapped: no document");
            set(&LAST_ERROR, "no document");
            return;
        };
        let Some(canvas) = document
            .get_element_by_id(&canvas_id)
            .and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
        else {
            let msg = format!("missing <canvas id=\"{canvas_id}\">");
            log::error!("facet-wrapped: {msg}");
            set(&LAST_ERROR, &msg);
            return;
        };
        // BOUNDED RETRY on "no adapter" (`crate::adapter_retry`): the first mount in a
        // fresh Chrome can be refused an adapter while the GPU process comes up. The
        // facet is consumed by an attempt, so a retry rebuilds it from the same inputs.
        let mut first = Some(facet);
        let mut attempt = 0;
        loop {
            attempt += 1;
            crate::adapter_retry::record_attempts(attempt);
            let Some(facet) = first.take().or_else(|| rebuild()) else {
                set(&LAST_ERROR, "the component could not be rebuilt for a retry");
                return;
            };
            let name = name.clone();
            // The renderer configuration is the workspace policy's, not this crate's:
            // `facett_wgpu_options()` sets the backend mask to `BROWSER_WEBGPU`, so wgpu
            // cannot silently pick a WebGL2 device even if that backend were compiled in.
            let web_options = eframe::WebOptions {
                wgpu_options: facett_core::render::gpu::facett_wgpu_options(),
                ..Default::default()
            };
            let started = eframe::WebRunner::new()
                .start(
                    canvas.clone(),
                    web_options,
                    Box::new(move |cc| {
                        // NAME the device before anything paints. `--use-angle=swiftshader`
                        // yields a working WebGPU adapter that is a CPU rasteriser, and
                        // every other observable this crate has answers `webgpu` on it —
                        // so the one fact that separates the 890M from SwiftShader is the
                        // adapter's own `AdapterInfo`, and this is the only place it is
                        // reachable. Read off eframe's REAL `RenderState`, never inferred.
                        if let Some(rs) = cc.wgpu_render_state.as_ref() {
                            crate::gpu::record_adapter(
                                facett_core::render::gpu::adapter_wgpu::facts_of(
                                    &rs.adapter.get_info(),
                                ),
                            );
                            // AND PUT SOMETHING ON THE LANES THIS BUNDLE SELECTS. Naming
                            // the adapter used to be the only thing done with this
                            // `RenderState`, and that is the whole of the defect below.
                            install_gpu_lanes(rs);
                        }
                        LookTheme::from_os(cc.egui_ctx.os()).apply(&cc.egui_ctx);
                        // Kept so `facet_update_data` can wake this canvas for the frame
                        // that swaps new data in.
                        CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
                        let facet = std::rc::Rc::new(std::cell::RefCell::new(facet));
                        LIVE.with(|l| *l.borrow_mut() = Some(facet.clone()));
                        Ok(Box::new(MountApp { name, facet }))
                    }),
                )
                .await;
            match started {
                Ok(()) => {
                    name_the_device_from_the_browser().await;
                    return;
                }
                // Requesting the adapter/device happens inside `start`, so this is where
                // "WebGPU exists but gave us no usable device" surfaces.
                Err(e) => match crate::adapter_retry::after_failure(attempt, &format!("{e:?}")) {
                    crate::adapter_retry::Next::RetryAfter(ms) => {
                        log::warn!("facet-wrapped: no WebGPU adapter on attempt {attempt}; retrying in {ms} ms");
                        sleep_ms(ms).await;
                    }
                    crate::adapter_retry::Next::Fail(msg) => {
                        log::error!("facet-wrapped: {msg}");
                        set(&LAST_ERROR, &msg);
                        crate::adapter_retry::record_reason(&msg);
                        return;
                    }
                },
            }
        }
    });
}

/// Rebuilds a mount's component for an adapter retry (the first build was consumed).
type Rebuild = Box<dyn Fn() -> Option<Box<dyn Facet>>>;

/// `setTimeout` as a future (the retry backoff).
async fn sleep_ms(ms: u32) {
    let p = js_sys::Promise::new(&mut |resolve, _| {
        if let Some(w) = web_sys::window() {
            let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms as i32);
        }
    });
    let _ = wasm_bindgen_futures::JsFuture::from(p).await;
}

/// Ask the BROWSER what device it opened, when wgpu could not say.
///
/// # Why this exists — measured on the 890M, in a real window, 2026-09-06
///
/// The `record_adapter` call above reads `rs.adapter.get_info()`, which is the right
/// source on every native backend. On `BrowserWebGpu` it comes back **masked**: on
/// genuine hardware, painting a real frame, with the vulkan flags, `facet_adapter()`
/// answered `"name":""`, `"vendor":"0x0000"`, `"driver":""`, `"kind":"other"`. So the
/// loader printed `GPU:  (other, BrowserWebGpu)` — an empty name on a Radeon.
///
/// **That is not cosmetic.** `software` — the field
/// `the_canvas_names_its_adapter.rs` tells drivers to assert on — is decided by
/// `is_software_rasteriser`, which looks for `swiftshader` / `llvmpipe` / `lavapipe`
/// **in the name and driver**. Against two empty strings it can only ever answer
/// `false`. **The SwiftShader trap that file exists to close was, in practice, still
/// open: the discriminator could not have fired.**
///
/// The browser has the identity and hands it over — in the same window, in the same
/// arm, `(await navigator.gpu.requestAdapter()).info` gave `vendor: "amd"`,
/// `architecture: "rdna-3"` — so this asks for it and re-records ONLY when wgpu left
/// the name empty. A backend that reports a real driver string keeps it: the browser's
/// pair is coarser, and replacing a good name with a coarse one is a regression
/// wearing a fix's clothes.
///
/// Everything here is reflective (`js_sys::Reflect`) for the reason `webgpu_unavailable`
/// already gives: web-sys's WebGPU bindings sit behind `--cfg=web_sys_unstable_apis`,
/// and only these four string properties are needed. **Every step is fallible and every
/// failure is silent on purpose** — this is an enrichment of a record that already
/// exists, so a browser that refuses leaves the honest masked reading in place rather
/// than replacing it with an error.
async fn name_the_device_from_the_browser() {
    if !crate::gpu::recorded_name_is_empty() {
        return;
    }
    let Some(info) = browser_adapter_info().await else {
        return;
    };
    let get = |k: &str| {
        js_sys::Reflect::get(&info, &JsValue::from_str(k))
            .ok()
            .and_then(|v| v.as_string())
            .unwrap_or_default()
    };
    let facts = crate::gpu::facts_from_browser_info(
        &get("vendor"),
        &get("architecture"),
        &get("device"),
        &get("description"),
        "BrowserWebGpu",
    );
    // Empty in, empty out: if the browser masked everything too, re-recording would
    // only churn the record without adding a fact.
    if facts.name.trim().is_empty() {
        return;
    }
    crate::gpu::record_adapter(facts);
}

/// `(await navigator.gpu.requestAdapter()).info`, or `None` at the first step that
/// does not answer.
///
/// This is a SECOND `requestAdapter` and not the one the canvas is running on. That is
/// deliberate and it is the honest limit of this enrichment: there is no supported way
/// to reach the underlying `GPUAdapter` through wgpu's handle, and a browser hands out
/// one device per set of flags, so the identity is the same in every arm that matters.
/// It is asked once, after mount, off the paint path.
async fn browser_adapter_info() -> Option<js_sys::Object> {
    let adapter = browser_adapter().await?;
    let info = js_sys::Reflect::get(&adapter, &JsValue::from_str("info")).ok()?;
    info.dyn_into().ok()
}

/// `Some(what)` when the browser's adapter is a CPU rasteriser — its `info` strings and
/// `isFallbackAdapter` (on `info` in current Chrome, on the adapter in older ones)
/// through `facett_core::adapter_retry::software_adapter`. `None` for a hardware
/// adapter, a masked one, or no adapter (the retry handles that).
async fn browser_software_adapter() -> Option<String> {
    let adapter = browser_adapter().await?;
    let info = js_sys::Reflect::get(&adapter, &JsValue::from_str("info")).unwrap_or(JsValue::UNDEFINED);
    let s = |k: &str| js_sys::Reflect::get(&info, &JsValue::from_str(k)).ok().and_then(|v| v.as_string()).unwrap_or_default();
    let flag = |o: &JsValue| js_sys::Reflect::get(o, &JsValue::from_str("isFallbackAdapter")).ok().and_then(|v| v.as_bool());
    let fallback = flag(&info).or_else(|| flag(&adapter)).unwrap_or(false);
    crate::adapter_retry::software_adapter(&s("vendor"), &s("architecture"), &s("device"), &s("description"), fallback)
}

/// `await navigator.gpu.requestAdapter()`, or `None` at the first step that does not answer.
async fn browser_adapter() -> Option<JsValue> {
    let navigator = web_sys::window()?.navigator();
    let gpu = js_sys::Reflect::get(navigator.as_ref(), &JsValue::from_str("gpu")).ok()?;
    if gpu.is_undefined() || gpu.is_null() {
        return None;
    }
    let req = js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")).ok()?;
    let req: js_sys::Function = req.dyn_into().ok()?;
    let promise = req.call0(&gpu).ok()?;
    let promise: js_sys::Promise = promise.dyn_into().ok()?;
    let adapter = wasm_bindgen_futures::JsFuture::from(promise).await.ok()?;
    if adapter.is_undefined() || adapter.is_null() {
        return None;
    }
    Some(adapter)
}

/// The live mounted component's `state_json`, built on this call. `"null"` before
/// the first frame. This is what a headless drive reads back.
#[wasm_bindgen]
#[must_use]
pub fn facet_state() -> String {
    // "null" until the first frame has drawn, exactly as before.
    if get(&MOUNTED).is_empty() {
        return "null".to_string();
    }
    let t0 = now_ms();
    let state = LIVE.with(|l| {
        let l = l.borrow();
        let facet = l.as_ref()?.try_borrow().ok()?;
        let mut v = facet.state_json();
        stamp_lanes(&mut v);
        serde_json::to_string(&v).ok()
    });
    let s = state.unwrap_or_else(|| "null".to_string());
    PERF.with(|p| {
        let mut p = p.borrow_mut();
        p.state_ms_last = now_ms() - t0;
        p.state_bytes_last = s.len();
    });
    s
}

/// Add the bundle's own lane facts to a component's state, never overwriting a key
/// the component already publishes:
///
/// * `gpu` — `"discrete"`, `"integrated"`, … when the canvas opened a hardware
///   adapter, `"none"` otherwise, with `gpu_reason` saying why. A CPU rasteriser
///   (SwiftShader, llvmpipe) is `"none"`: the GPU lanes need a GPU, and a software
///   adapter is a CPU drawing pixels.
/// * `threads` / `threads_reason` — this bundle is single-threaded, so `1` and
///   `"no-bundle"` (no shared-memory bundle is built; see `.nornir/wasm-threads-plan.md`).
fn stamp_lanes(v: &mut serde_json::Value) {
    let Some(o) = v.as_object_mut() else { return };
    let adapter: serde_json::Value = serde_json::from_str(&crate::gpu::adapter_json()).unwrap_or_default();
    let (gpu, why) = if adapter.is_null() {
        let r = crate::adapter_retry::reason();
        ("none".to_string(), if r.is_empty() { "no adapter was opened".to_string() } else { r })
    } else if crate::gpu::is_software() {
        (
            "none".to_string(),
            format!("software rasteriser: {}", adapter.get("name").and_then(|n| n.as_str()).unwrap_or("")),
        )
    } else {
        (adapter.get("kind").and_then(|k| k.as_str()).unwrap_or("other").to_string(), String::new())
    };
    o.entry("gpu").or_insert_with(|| serde_json::json!(gpu));
    o.entry("gpu_reason").or_insert_with(|| serde_json::json!(why));
    // How many adapter requests the mount made (1 = first time; see `adapter_retry`).
    o.entry("adapter_attempts").or_insert_with(|| serde_json::json!(crate::adapter_retry::attempts()));
    // A GPU resource the device refused (the lane drew nothing and said why, no panic).
    if let Some(e) = facett_core::render::lane::gpu_error() {
        o.entry("gpu_error").or_insert_with(|| serde_json::json!(e));
    }
    o.entry("threads").or_insert_with(|| serde_json::json!(1));
    o.entry("threads_reason").or_insert_with(|| serde_json::json!("no-bundle"));
}

/// Where the time went: frame count and per-frame `ui` time, the last check/mount's
/// parse and build, and the last `facet_state()` read — as JSON.
#[wasm_bindgen]
#[must_use]
pub fn facet_perf() -> String {
    PERF.with(|p| serde_json::to_string(&*p.borrow()).unwrap_or_else(|_| "null".into()))
}

/// The name of the component currently mounted (empty before any mount).
#[wasm_bindgen]
#[must_use]
pub fn facet_mounted() -> String {
    get(&MOUNTED)
}

/// The last mount **refusal**, or `""` when the last mount succeeded. A host shows
/// this instead of a canvas, so "we cannot render your data" is always visible rather
/// than being papered over with demo data.
#[wasm_bindgen]
#[must_use]
pub fn facet_error() -> String {
    get(&LAST_ERROR)
}