Skip to main content

mobiler_web/
lib.rs

1//! `mobiler-web` — Mobiler's web shell.
2//!
3//! Renders **any** Mobiler app's `Widget` tree to the DOM (Leptos / WASM), driving
4//! the Rust core via crux's `Core` and fulfilling capabilities (HTTP) with the
5//! browser's `fetch`. The web twin of the generic Android/SwiftUI shells: write
6//! your app once as a `MobilerApp`, then
7//!
8//! ```ignore
9//! fn main() { mobiler_web::run::<my_app::App>(); }
10//! ```
11//!
12//! renders it on the web — fully styled, no CSS required: the shell ships its own
13//! theme (`mobiler.css`) and injects it on mount, and `Scaffold.dark_mode` flips
14//! the whole theme. Your crate only supplies a minimal `index.html` with the Trunk
15//! entry point; an app may add its own stylesheet to override any widget class.
16
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::rc::Rc;
20use std::sync::Arc;
21
22use crux_core::{App, Core, Request};
23use leptos::prelude::*;
24use mobiler_core::{
25    A11yRole, Action, BoxAlign, ButtonStyle, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
26    ChartSeries, ChartStyle, ChartTick, Corner, Density, Effect, FieldKind, FontFamily, HttpHeader, HttpOutcome, Icon,
27    ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, PluginStreamCall, ProjectColor,
28    Rgb, Spacing, TextStyle, Theme, Tone, TransferEvent, Widget,
29};
30use wasm_bindgen_futures::spawn_local;
31
32/// The shell's own stylesheet — the web twin of the look the Android/SwiftUI shells
33/// decide in code. Shipped with the crate and injected on mount, so `run::<App>()`
34/// renders a fully styled, themeable app with no CSS required from the consuming
35/// app (it can still override any class). Uses CSS variables so `Scaffold.dark_mode`
36/// flips the whole theme by toggling one class.
37const STYLE: &str = include_str!("mobiler.css");
38
39/// Cloneable handle for sending an `Action` into the core. Leptos 0.7 view closures
40/// require `Send`, so this is `Arc` + `Send + Sync` (the crux `Core` is both).
41type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
42
43/// What a Mobiler app must be to render on the web: a crux `App` speaking the fixed
44/// ABI (`Action` in, `Widget` out, `Effect` for capabilities). `MobilerShell<_>`
45/// satisfies this automatically.
46pub trait WebApp:
47    App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
48where
49    Self::Model: Default + Send + Sync,
50{
51}
52impl<T> WebApp for T
53where
54    T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
55    T::Model: Default + Send + Sync,
56{
57}
58
59/// Mount a Mobiler app into the document body. Call from your wasm `main`.
60pub fn run<A: WebApp>()
61where
62    A::Model: Default + Send + Sync,
63{
64    console_error_panic_hook::set_once();
65    inject_default_style();
66    inject_hls_support();
67    inject_maplibre_support();
68    leptos::mount::mount_to_body(shell::<A>);
69}
70
71/// hls.js bootstrap for HLS (`.m3u8`) playback in browsers without native HLS
72/// (Chrome/Firefox — Safari/iOS play HLS natively). A `<video>` whose source is an
73/// `.m3u8` is rendered with `data-hls-src` and no `src`; this self-contained script
74/// watches the DOM (a `MutationObserver`, so it also catches elements re-rendered on
75/// each `update`) and, for each such `<video>`, either sets `src` directly (native
76/// HLS, e.g. Safari) or lazily loads hls.js from a CDN and attaches it. If the CDN
77/// fails it falls back to a plain `src`. Inert until an `.m3u8` `Video` appears, so
78/// MP4/Bunny content (and non-video apps) pay nothing. Bunny content keeps using its
79/// own player via `WebView`; this is for raw non-Bunny `.m3u8` on Chrome/Firefox.
80fn inject_hls_support() {
81    const BOOTSTRAP: &str = r#"(function(){
82  function ensureHls(cb){
83    if(window.Hls){return cb();}
84    if(window.__mobilerHlsLoading){(window.__mobilerHlsCbs=window.__mobilerHlsCbs||[]).push(cb);return;}
85    window.__mobilerHlsLoading=true;window.__mobilerHlsCbs=[cb];
86    var s=document.createElement('script');
87    s.src='https://cdn.jsdelivr.net/npm/hls.js@1';
88    var flush=function(){var cbs=window.__mobilerHlsCbs||[];window.__mobilerHlsCbs=[];cbs.forEach(function(f){f();});};
89    s.onload=flush;s.onerror=flush;
90    document.head.appendChild(s);
91  }
92  function attach(v){
93    if(v.__mobilerHlsDone){return;}v.__mobilerHlsDone=true;
94    var url=v.getAttribute('data-hls-src');if(!url){return;}
95    if(v.canPlayType('application/vnd.apple.mpegurl')){v.src=url;return;}
96    ensureHls(function(){
97      if(window.Hls&&window.Hls.isSupported()){var h=new window.Hls();h.loadSource(url);h.attachMedia(v);v.__mobilerHls=h;}
98      else{v.src=url;}
99    });
100  }
101  function scan(root){if(root&&root.querySelectorAll){root.querySelectorAll('video[data-hls-src]').forEach(attach);}}
102  new MutationObserver(function(muts){muts.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches('video[data-hls-src]')){attach(n);}scan(n);}});});}).observe(document.documentElement,{childList:true,subtree:true});
103  scan(document);
104})();"#;
105    let document = leptos::prelude::document();
106    let Some(head) = document.head() else { return };
107    let Ok(script) = document.create_element("script") else { return };
108    script.set_text_content(Some(BOOTSTRAP));
109    let _ = head.append_child(&script);
110}
111
112/// MapLibre-GL bootstrap for [`Widget::Map`]. A self-contained script (mirrors `inject_hls_support`):
113/// lazily loads maplibre-gl (JS + CSS) from a CDN the first time a `.mobiler-map` div appears, then for
114/// each one inits a `maplibregl.Map` from its `data-*` attributes (center/zoom/style/markers/interactive)
115/// and wires taps. The Rust render arm re-creates the map div on every `update`, so a `MutationObserver`
116/// also REMOVES the map (`.remove()`) when its node is dropped — no leaked WebGL contexts. Map/marker
117/// taps are reported to the core by writing `"tap|lat,lng"` / `"marker|id"` into the hidden sibling
118/// `.mobiler-map-sink` input and firing its `input` event, which the render arm's `on:input` forwards as
119/// `Action::Input`. Inert (and the CDN is never fetched) until a `Map` widget appears.
120fn inject_maplibre_support() {
121    const BOOTSTRAP: &str = r#"(function(){
122  function ensureML(cb){
123    if(window.maplibregl){return cb();}
124    if(window.__mobilerMlLoading){(window.__mobilerMlCbs=window.__mobilerMlCbs||[]).push(cb);return;}
125    window.__mobilerMlLoading=true;window.__mobilerMlCbs=[cb];
126    var l=document.createElement('link');l.rel='stylesheet';l.href='https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css';document.head.appendChild(l);
127    var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js';
128    var flush=function(){var cbs=window.__mobilerMlCbs||[];window.__mobilerMlCbs=[];cbs.forEach(function(f){f();});};
129    s.onload=flush;s.onerror=flush;document.head.appendChild(s);
130  }
131  function emit(el,payload){
132    var sink=el.parentElement&&el.parentElement.querySelector('.mobiler-map-sink');
133    if(sink){sink.value=payload;sink.dispatchEvent(new Event('input',{bubbles:true}));}
134  }
135  function init(el){
136    if(el.__mobilerMap){return;}el.__mobilerMap=true;
137    ensureML(function(){
138      try{
139        var c=(el.getAttribute('data-center')||'0,0').split(',');
140        var center=[parseFloat(c[1])||0,parseFloat(c[0])||0];
141        var zoom=parseFloat(el.getAttribute('data-zoom'))||2;
142        var style=el.getAttribute('data-style')||'https://tiles.openfreemap.org/styles/liberty';
143        var interactive=el.getAttribute('data-interactive')!=='false';
144        var map=new maplibregl.Map({container:el,style:style,center:center,zoom:zoom,interactive:interactive});
145        el.__mobilerMapInstance=map;
146        map.on('click',function(e){emit(el,'tap|'+e.lngLat.lat.toFixed(6)+','+e.lngLat.lng.toFixed(6));});
147        var markers=[];try{markers=JSON.parse(el.getAttribute('data-markers')||'[]');}catch(_){}
148        markers.forEach(function(mk){
149          var m=new maplibregl.Marker().setLngLat([mk.lng,mk.lat]);
150          if(mk.title){m.setPopup(new maplibregl.Popup({offset:24}).setText(mk.title));}
151          m.addTo(map);
152          m.getElement().addEventListener('click',function(ev){ev.stopPropagation();emit(el,'marker|'+mk.id);});
153        });
154      }catch(_){}
155    });
156  }
157  function scan(root){if(root&&root.querySelectorAll){root.querySelectorAll('.mobiler-map[data-map]').forEach(init);}}
158  new MutationObserver(function(muts){muts.forEach(function(m){
159    m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches('.mobiler-map[data-map]')){init(n);}scan(n);}});
160    m.removedNodes.forEach(function(n){if(n.nodeType===1){
161      if(n.__mobilerMapInstance){try{n.__mobilerMapInstance.remove();}catch(_){}}
162      if(n.querySelectorAll){n.querySelectorAll('.mobiler-map').forEach(function(x){if(x.__mobilerMapInstance){try{x.__mobilerMapInstance.remove();}catch(_){}}});}
163    }});
164  });}).observe(document.documentElement,{childList:true,subtree:true});
165  scan(document);
166})();"#;
167    let document = leptos::prelude::document();
168    let Some(head) = document.head() else { return };
169    let Ok(script) = document.create_element("script") else { return };
170    script.set_text_content(Some(BOOTSTRAP));
171    let _ = head.append_child(&script);
172}
173
174/// Inject the shell's default stylesheet at the **front** of `<head>` so it's the
175/// lowest-precedence baseline: an app that ships its own CSS (later in the document)
176/// overrides any of these classes, while an app with no CSS still gets a full theme.
177fn inject_default_style() {
178    let document = leptos::prelude::document();
179    let Some(head) = document.head() else { return };
180    let Ok(style) = document.create_element("style") else { return };
181    let _ = style.set_attribute("data-mobiler", "shell");
182    style.set_text_content(Some(STYLE));
183    let _ = head.insert_before(&style, head.first_child().as_ref());
184}
185
186fn shell<A: WebApp>() -> impl IntoView
187where
188    A::Model: Default + Send + Sync,
189{
190    let core = Arc::new(Core::<A>::new());
191    let (view, set_view) = signal(core.view());
192
193    let send: Dispatch = {
194        let core = core.clone();
195        Arc::new(move |action: Action| {
196            let effects = core.process_event(action);
197            drive(&core, set_view, effects);
198        })
199    };
200
201    // Restore persisted state (localStorage), then fire Start — mirrors the native
202    // shells (which restore before Start so the app sees its saved Model on launch).
203    let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
204    if !saved.is_empty() {
205        send(Action::Restore { data: saved });
206    }
207    send(Action::Start);
208
209    let send_for_view = send.clone();
210    view! {
211        <div class="app">
212            {move || render(&view.get(), &send_for_view)}
213        </div>
214    }
215}
216
217/// Process effects: re-read the view on Render; fulfil HTTP via fetch and resolve.
218fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
219where
220    A::Model: Default + Send + Sync,
221{
222    for effect in effects {
223        match effect {
224            Effect::Render(_) => set_view.set(core.view()),
225            Effect::PluginNotify(notify) => perform_notify(&notify.operation),
226            Effect::Plugin(mut request) => {
227                let core = core.clone();
228                spawn_local(async move {
229                    let response = perform(&request.operation).await;
230                    if let Ok(next) = core.resolve(&mut request, response) {
231                        drive(&core, set_view, next);
232                    }
233                });
234            }
235            // Long-lived subscription: start a native source that resolves the same
236            // request repeatedly (one event per `core.resolve`). See `start_stream`.
237            Effect::PluginStream(request) => start_stream(core, set_view, request),
238        }
239    }
240}
241
242/// Start a streaming subscription ([`Effect::PluginStream`]): begin a native source
243/// that resolves `request` **repeatedly** (a [`PluginResponse`] per event), each
244/// resolution re-entering the core. The source handle is parked in a per-key
245/// registry so [`unsubscribe`](mobiler_core::Cx::unsubscribe) can stop it.
246///
247/// Web sources: `ticker`/`start` (a `setInterval` emitting an incrementing counter
248/// every `input` ms — the deterministic demonstrator) and `websocket`/`stream`
249/// (a `WebSocket`, a frame per `onmessage`).
250fn start_stream<A: WebApp>(
251    core: &Arc<Core<A>>,
252    set_view: WriteSignal<Widget>,
253    request: Request<PluginStreamCall>,
254) where
255    A::Model: Default + Send + Sync,
256{
257    use wasm_bindgen::{closure::Closure, JsCast};
258
259    let call = request.operation.clone();
260
261    // Each resolution of a `resolves_many_times` request yields the next stream item;
262    // share the request across event closures via Rc<RefCell<_>>.
263    let request = Rc::new(RefCell::new(request));
264    let core = core.clone();
265    let emit = move |resp: PluginResponse| {
266        if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
267            drive(&core, set_view, next);
268        }
269    };
270
271    let handle = match (call.plugin.as_str(), call.op.as_str()) {
272        // Built-in deterministic demonstrator: emit an incrementing counter every
273        // `input` ms. Dropping the Interval (on unsubscribe) stops it.
274        ("ticker", "start") => {
275            let ms: u32 = call.input.parse().unwrap_or(1000);
276            let count = std::cell::Cell::new(0u32);
277            let interval = gloo_timers::callback::Interval::new(ms, move || {
278                count.set(count.get() + 1);
279                emit(PluginResponse::text(true, count.get().to_string()));
280            });
281            StreamHandle::Ticker { _interval: interval }
282        }
283        ("websocket", "stream") => {
284            let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
285            let onmessage = {
286                let emit = emit.clone();
287                Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
288                    emit(PluginResponse::text(true, e.data().as_string().unwrap_or_default()));
289                })
290            };
291            let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
292                emit(PluginResponse::text(false, "closed"));
293            });
294            ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
295            ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
296            StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
297        }
298        // Built-in `system` source: deep-link URLs + app lifecycle. On the web a "deep link" is the
299        // current URL (delivered on subscribe + on `popstate`) and "lifecycle" maps to page
300        // visibility (`visibilitychange`). Listeners are dropped (removed) on unsubscribe.
301        ("system", "events") => {
302            let win = web_sys::window().expect("window");
303            let doc = win.document().expect("document");
304            // Initial: the current URL as a deeplink + current visibility as lifecycle.
305            if let Ok(href) = win.location().href() {
306                emit(PluginResponse::text(true, system_deeplink(&href)));
307            }
308            emit(PluginResponse::text(true, system_lifecycle(&doc)));
309            let onpop = {
310                let (emit, win) = (emit.clone(), win.clone());
311                Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
312                    if let Ok(href) = win.location().href() {
313                        emit(PluginResponse::text(true, system_deeplink(&href)));
314                    }
315                })
316            };
317            let onvis = {
318                let (emit, doc) = (emit.clone(), doc.clone());
319                Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
320                    emit(PluginResponse::text(true, system_lifecycle(&doc)));
321                })
322            };
323            let _ = win.add_event_listener_with_callback("popstate", onpop.as_ref().unchecked_ref());
324            let _ = doc.add_event_listener_with_callback("visibilitychange", onvis.as_ref().unchecked_ref());
325            StreamHandle::System(SystemStream { win, doc, _onpop: onpop, _onvis: onvis })
326        }
327        // Streaming file transfer (`cx.upload` / `cx.download`, Release B). See
328        // `start_web_upload` / `start_web_download` for the WEB ASYMMETRY: upload uses
329        // XHR (the only web API with upload-progress events), download uses fetch +
330        // ReadableStream (progress) and hands the app back a `blob:` handle.
331        ("transfer", op @ ("upload" | "download")) => {
332            let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
333            let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("").to_string();
334            let headers: Vec<(String, String)> = v
335                .get("headers")
336                .and_then(|x| x.as_array())
337                .map(|hs| {
338                    hs.iter()
339                        .filter_map(|h| Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string())))
340                        .collect()
341                })
342                .unwrap_or_default();
343
344            if op == "upload" {
345                let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("PUT").to_string();
346                let source = v.get("source").and_then(|x| x.as_str()).unwrap_or("").to_string();
347                start_web_upload(url, method, headers, source, emit.clone())
348            } else {
349                start_web_download(url, headers, emit.clone())
350            }
351        }
352        _ => return, // unknown / native-only source — ignore on web
353    };
354
355    STREAMS.with(|m| {
356        m.borrow_mut().insert(call.key.clone(), handle);
357    });
358}
359
360/// Monotonic milliseconds, for the ~10/sec progress throttle (`performance.now()`).
361fn js_now() -> f64 {
362    web_sys::window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
363}
364
365/// Parse the CRLF-separated block from `XmlHttpRequest::get_all_response_headers` into
366/// `HttpHeader`s. Each line is `name: value`; a value never contains CRLF (XHR spec), so
367/// splitting on `\r\n` then on the first `:` is sufficient. Blank lines are skipped.
368fn parse_header_block(raw: &str) -> Vec<HttpHeader> {
369    raw.split("\r\n")
370        .filter_map(|line| {
371            let (name, value) = line.split_once(':')?;
372            let name = name.trim();
373            if name.is_empty() {
374                return None;
375            }
376            Some(HttpHeader { name: name.to_string(), value: value.trim().to_string() })
377        })
378        .collect()
379}
380
381/// Start a web upload via `XMLHttpRequest`.
382///
383/// DELIBERATE WEB ASYMMETRY (see `start_web_download` for the other half): upload uses
384/// XHR because it is the *only* web API that reports upload progress
385/// (`xhr.upload().onprogress`) — `fetch()` has no upload-progress signal at all. Do not
386/// "unify" this with fetch; there is no fetch-based way to get upload progress in a
387/// browser today.
388fn start_web_upload(
389    url: String,
390    method: String,
391    headers: Vec<(String, String)>,
392    source: String,
393    emit: impl Fn(PluginResponse) + Clone + 'static,
394) -> StreamHandle {
395    use wasm_bindgen::{closure::Closure, JsCast};
396    let xhr = web_sys::XmlHttpRequest::new().expect("xhr");
397    let _ = xhr.open_with_async(&method, &url, true);
398    for (n, val) in &headers {
399        let _ = xhr.set_request_header(n, val);
400    }
401
402    // ~10/sec progress throttling, purely on elapsed time. (Gating on `loaded < total`
403    // as well would be a no-op when `!length_computable`, since `total()` is then 0 and
404    // `loaded() < 0` is always false.) The terminal Done is emitted by the
405    // separate onload/onerror/onabort closures below, unthrottled, so completion is always
406    // seen regardless of this gate.
407    let last = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
408    let on_prog = {
409        let (emit, last) = (emit.clone(), last.clone());
410        Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |e: web_sys::ProgressEvent| {
411            let now = js_now();
412            if now - last.get() < 100.0 {
413                return;
414            }
415            last.set(now);
416            let total = if e.length_computable() { Some(e.total() as u64) } else { None };
417            emit(transfer_response(&TransferEvent::Progress { transferred: e.loaded() as u64, total }));
418        })
419    };
420    if let Ok(upload) = xhr.upload() {
421        upload.set_onprogress(Some(on_prog.as_ref().unchecked_ref()));
422    }
423
424    // Terminal event: a response (even non-2xx) is `Done { Response }`; only a failure
425    // to obtain a response at all is `Done { TransportError }`.
426    let on_done = {
427        let (emit, xhr_c) = (emit.clone(), xhr.clone());
428        Closure::<dyn FnMut()>::new(move || {
429            let status = xhr_c.status().unwrap_or(0);
430            let outcome = if status == 0 {
431                HttpOutcome::TransportError { message: "upload failed".into() }
432            } else {
433                // Carry the response headers, like the download path and both native shells
434                // do — an upload caller may need ETag / Location. `getAllResponseHeaders`
435                // returns a CRLF-separated block (and, cross-origin, only the
436                // CORS-exposed headers — a browser limit the web download path shares; the
437                // native shells read the full header set).
438                let headers = xhr_c
439                    .get_all_response_headers()
440                    .ok()
441                    .map(|raw| parse_header_block(&raw))
442                    .unwrap_or_default();
443                HttpOutcome::Response { status, headers, body: vec![] }
444            };
445            emit(transfer_response(&TransferEvent::Done { outcome, handle: None }));
446        })
447    };
448    xhr.set_onload(Some(on_done.as_ref().unchecked_ref()));
449    let on_err = {
450        let emit = emit.clone();
451        Closure::<dyn FnMut()>::new(move || {
452            emit(transfer_response(&TransferEvent::Done {
453                outcome: HttpOutcome::TransportError { message: "upload error".into() },
454                handle: None,
455            }));
456        })
457    };
458    xhr.set_onerror(Some(on_err.as_ref().unchecked_ref()));
459    let on_abort = {
460        let emit = emit.clone();
461        Closure::<dyn FnMut()>::new(move || {
462            emit(transfer_response(&TransferEvent::Done {
463                outcome: HttpOutcome::TransportError { message: "upload aborted".into() },
464                handle: None,
465            }));
466        })
467    };
468    xhr.set_onabort(Some(on_abort.as_ref().unchecked_ref()));
469
470    // The upload `source` is itself a `blob:` URL (e.g. produced by `photo`/`camera` or
471    // `files`), so fetch it back into a `Blob` before sending — same shape a native
472    // shell would read a file handle. A missing/unreadable source sends no body.
473    //
474    // Cancel race (see `TransferHandle::drop`): `open_with_async` above has already run,
475    // but `send`/`send_with_opt_blob` is deferred behind the `fetch_blob` await. Per the
476    // XHR spec, `abort()` before the send-flag is set (i.e. before `send` is called) is a
477    // no-op, so if `cx.unsubscribe` fires in this window, `xhr.abort()` alone would not
478    // stop the request from going out. `cancelled` is the second half of that guarantee:
479    // it's checked right before `send`, after the await, so a drop that lands during the
480    // fetch is still honored.
481    let xhr_send = xhr.clone();
482    let cancelled = std::rc::Rc::new(std::cell::Cell::new(false));
483    let cancelled_send = cancelled.clone();
484    wasm_bindgen_futures::spawn_local(async move {
485        let blob = fetch_blob(&source).await;
486        if cancelled_send.get() {
487            return;
488        }
489        if let Some(blob) = blob {
490            let _ = xhr_send.send_with_opt_blob(Some(&blob));
491        } else {
492            let _ = xhr_send.send();
493        }
494    });
495
496    StreamHandle::Transfer(TransferHandle {
497        xhr: Some(xhr),
498        abort: None,
499        cancelled: Some(cancelled),
500        _on_prog: Some(on_prog),
501        _on_done: Some(on_done),
502        _on_err: Some(on_err),
503        _on_abort: Some(on_abort),
504    })
505}
506
507/// Fetch a `blob:` (or any) URL back into a `Blob`, for handing to
508/// `XmlHttpRequest::send_with_opt_blob`. `None` on any failure (network error, not a
509/// Blob-shaped response, …) — the caller falls back to sending no body.
510async fn fetch_blob(url: &str) -> Option<web_sys::Blob> {
511    use wasm_bindgen::JsCast;
512    let win = web_sys::window()?;
513    let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_str(url)).await.ok()?;
514    let resp: web_sys::Response = resp_value.dyn_into().ok()?;
515    let blob_promise = resp.blob().ok()?;
516    let blob_value = wasm_bindgen_futures::JsFuture::from(blob_promise).await.ok()?;
517    blob_value.dyn_into().ok()
518}
519
520/// Start a web download via `fetch` + a `ReadableStream` reader.
521///
522/// DELIBERATE WEB ASYMMETRY (see `start_web_upload` for the other half): download uses
523/// `fetch`'s streaming response body to report progress as chunks arrive, then hands
524/// the app back a `blob:` handle for the assembled bytes — the same handle shape
525/// `take_image`/`photo.pick` returns via `Url::create_object_url_with_blob`. (XHR could
526/// also do a download, but fetch + ReadableStream is the standard/ergonomic way to get
527/// mid-transfer download progress on the web.)
528fn start_web_download(
529    url: String,
530    headers: Vec<(String, String)>,
531    emit: impl Fn(PluginResponse) + Clone + 'static,
532) -> StreamHandle {
533    let ctrl = web_sys::AbortController::new().expect("abortcontroller");
534    let signal = ctrl.signal();
535    let emit2 = emit.clone();
536    wasm_bindgen_futures::spawn_local(async move {
537        match fetch_stream(&url, &headers, &signal).await {
538            Ok((status, resp_headers, total, mut reader)) => {
539                let mut got: u64 = 0;
540                let mut chunks: Vec<u8> = Vec::new();
541                let mut last = js_now();
542                loop {
543                    match reader.next().await {
544                        Ok(Some(chunk)) => {
545                            got += chunk.len() as u64;
546                            chunks.extend_from_slice(&chunk);
547                            let now = js_now();
548                            // ~10/sec progress throttling (see `start_web_upload`).
549                            if now - last >= 100.0 {
550                                last = now;
551                                emit2(transfer_response(&TransferEvent::Progress { transferred: got, total }));
552                            }
553                        }
554                        Ok(None) => break, // stream finished
555                        Err(msg) => {
556                            emit2(transfer_response(&TransferEvent::Done {
557                                outcome: HttpOutcome::TransportError { message: msg },
558                                handle: None,
559                            }));
560                            return;
561                        }
562                    }
563                }
564                let handle = make_blob_url(&chunks);
565                let outcome = HttpOutcome::Response { status, headers: resp_headers, body: vec![] };
566                emit2(transfer_response(&TransferEvent::Done { outcome, handle: Some(handle) }));
567            }
568            Err(msg) => emit2(transfer_response(&TransferEvent::Done {
569                outcome: HttpOutcome::TransportError { message: msg },
570                handle: None,
571            })),
572        }
573    });
574    StreamHandle::Transfer(TransferHandle {
575        xhr: None,
576        abort: Some(ctrl),
577        cancelled: None,
578        _on_prog: None,
579        _on_done: None,
580        _on_err: None,
581        _on_abort: None,
582    })
583}
584
585/// Begin a GET (with the given headers) via `fetch` under `signal` and return the
586/// response's status, headers, `Content-Length` (if present) and a chunk [`Reader`]
587/// over its body stream.
588async fn fetch_stream(
589    url: &str,
590    headers: &[(String, String)],
591    signal: &web_sys::AbortSignal,
592) -> Result<(u16, Vec<HttpHeader>, Option<u64>, Reader), String> {
593    use wasm_bindgen::JsCast;
594    let win = web_sys::window().ok_or_else(|| "no window".to_string())?;
595    let js_headers = web_sys::Headers::new().map_err(|e| js_err(&e))?;
596    for (n, v) in headers {
597        js_headers.append(n, v).map_err(|e| js_err(&e))?;
598    }
599    let init = web_sys::RequestInit::new();
600    init.set_method("GET");
601    init.set_headers_headers(&js_headers);
602    init.set_signal(Some(signal));
603    let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|e| js_err(&e))?;
604
605    let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_request(&request))
606        .await
607        .map_err(|e| js_err(&e))?;
608    let resp: web_sys::Response = resp_value.dyn_into().map_err(|_| "fetch: not a Response".to_string())?;
609    let status = resp.status();
610    let resp_headers = response_headers(&resp.headers());
611    let total = resp_headers
612        .iter()
613        .find(|h| h.name.eq_ignore_ascii_case("content-length"))
614        .and_then(|h| h.value.parse().ok());
615
616    let Some(stream) = resp.body() else {
617        // No body (e.g. 204/304, or a HEAD-like response) — an empty reader is correct:
618        // the caller's loop immediately sees "finished" and moves straight to Done.
619        return Ok((status, resp_headers, total, Reader::empty()));
620    };
621    let reader = web_sys::ReadableStreamDefaultReader::new(&stream).map_err(|e| js_err(&e))?;
622    Ok((status, resp_headers, total, Reader::new(reader)))
623}
624
625/// A `web_sys::Headers` iterable (Fetch's `Headers` implements `Symbol.iterator` over
626/// `[name, value]` pairs) collected into our wire [`HttpHeader`] shape.
627fn response_headers(headers: &web_sys::Headers) -> Vec<HttpHeader> {
628    use wasm_bindgen::JsCast;
629    let mut out = Vec::new();
630    if let Ok(Some(iter)) = js_sys::try_iter(headers) {
631        for entry in iter.flatten() {
632            let arr: js_sys::Array = entry.unchecked_into();
633            let name = arr.get(0).as_string().unwrap_or_default();
634            let value = arr.get(1).as_string().unwrap_or_default();
635            out.push(HttpHeader { name, value });
636        }
637    }
638    out
639}
640
641/// Best-effort stringification of a `JsValue` error (e.g. a `DOMException`) for
642/// `TransferEvent::Done { outcome: HttpOutcome::TransportError { message } }`.
643fn js_err(e: &wasm_bindgen::JsValue) -> String {
644    use wasm_bindgen::JsCast;
645    e.as_string()
646        .or_else(|| e.dyn_ref::<js_sys::Error>().map(|err| String::from(err.message())))
647        .unwrap_or_else(|| "transfer error".to_string())
648}
649
650/// A minimal async chunk reader over a `ReadableStreamDefaultReader`. `next()` resolves
651/// to `Ok(Some(bytes))` per chunk, `Ok(None)` when the stream is done, or `Err(message)`
652/// if the underlying `read()` rejects (e.g. the fetch was aborted mid-stream).
653struct Reader(Option<web_sys::ReadableStreamDefaultReader>);
654impl Reader {
655    fn new(reader: web_sys::ReadableStreamDefaultReader) -> Self {
656        Self(Some(reader))
657    }
658    /// A reader over no stream at all (e.g. a bodiless response) — always "done".
659    fn empty() -> Self {
660        Self(None)
661    }
662    async fn next(&mut self) -> Result<Option<Vec<u8>>, String> {
663        use wasm_bindgen::JsCast;
664        let Some(reader) = &self.0 else { return Ok(None) };
665        let result = wasm_bindgen_futures::JsFuture::from(reader.read()).await.map_err(|e| js_err(&e))?;
666        let result: web_sys::ReadableStreamReadResult = result.unchecked_into();
667        if result.get_done().unwrap_or(true) {
668            return Ok(None);
669        }
670        let value = result.get_value();
671        let bytes = js_sys::Uint8Array::new(&value).to_vec();
672        Ok(Some(bytes))
673    }
674}
675
676/// Assemble bytes into a `Blob` and return an object URL — the download's `handle`. The
677/// same shape [`take_image`]'s `Url::create_object_url_with_blob` returns for a picked
678/// photo, so an app can render/save a downloaded file the same way.
679fn make_blob_url(bytes: &[u8]) -> String {
680    let array = js_sys::Uint8Array::from(bytes);
681    let parts = js_sys::Array::new();
682    parts.push(&array);
683    web_sys::Blob::new_with_u8_array_sequence(&parts)
684        .ok()
685        .and_then(|blob| web_sys::Url::create_object_url_with_blob(&blob).ok())
686        .unwrap_or_default()
687}
688
689/// A `system` deeplink event payload (the push-style tagged JSON the app demuxes by `type`).
690fn system_deeplink(url: &str) -> String {
691    format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
692}
693/// A `system` lifecycle event payload — page visibility maps to active/background.
694fn system_lifecycle(doc: &web_sys::Document) -> String {
695    let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
696    format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
697}
698
699/// An open streaming source, parked by subscription key for teardown. Dropping the
700/// entry stops the source (the `Interval` cancels on drop; the `WebSocket` is closed
701/// explicitly in the `unsubscribe` handler and its closures drop here).
702enum StreamHandle {
703    /// A `ticker` interval — held only so dropping it (on unsubscribe) cancels it.
704    Ticker { _interval: gloo_timers::callback::Interval },
705    Ws(WsStream),
706    /// The built-in `system` source — holds its JS listeners alive; `Drop` removes them on
707    /// unsubscribe (the handle is dropped when removed from `STREAMS`). Never pattern-matched.
708    #[allow(dead_code)]
709    System(SystemStream),
710    /// An in-flight transfer — held so dropping it (on unsubscribe) aborts the XHR /
711    /// cancels the fetch reader. Never pattern-matched.
712    #[allow(dead_code)]
713    Transfer(TransferHandle),
714}
715
716/// Holds a web transfer so unsubscribe can abort it. For upload we keep the
717/// `XmlHttpRequest` (call `.abort()` on drop via the Drop impl); for download we keep an
718/// `AbortController` whose `.abort()` cancels the fetch + reader.
719struct TransferHandle {
720    xhr: Option<web_sys::XmlHttpRequest>,
721    abort: Option<web_sys::AbortController>,
722    // Upload-cancel race guard (see the comment at `start_web_upload`'s `spawn_local`):
723    // `xhr.abort()` before `send()` has been called is a spec no-op, so this flag is the
724    // half that actually stops a not-yet-sent upload. `None` for download, which has no
725    // such window (its `AbortController` is wired into the fetch before any async work).
726    cancelled: Option<std::rc::Rc<std::cell::Cell<bool>>>,
727    // Typed closure fields (not `Closure::into_js_value`, which leaks permanently — see
728    // `WsStream`/`SystemStream` above for the same pattern): held here so they free when
729    // the handle drops, on unsubscribe or transfer completion. Download wires no XHR
730    // event closures, so its fields are `None`.
731    _on_prog: Option<wasm_bindgen::closure::Closure<dyn FnMut(web_sys::ProgressEvent)>>,
732    _on_done: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
733    _on_err: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
734    _on_abort: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
735}
736impl Drop for TransferHandle {
737    fn drop(&mut self) {
738        if let Some(c) = &self.cancelled {
739            c.set(true);
740        }
741        if let Some(x) = &self.xhr {
742            let _ = x.abort();
743        }
744        if let Some(a) = &self.abort {
745            a.abort();
746        }
747    }
748}
749
750/// Bincode a `TransferEvent` into a stream `PluginResponse` (mirrors Release A's `http` encode).
751fn transfer_response(ev: &TransferEvent) -> PluginResponse {
752    PluginResponse {
753        ok: matches!(ev, TransferEvent::Done { outcome, .. } if outcome.is_success()),
754        output: ev.encode(),
755    }
756}
757
758/// The `system` subscription's event listeners — removed from the DOM when dropped (unsubscribe).
759struct SystemStream {
760    win: web_sys::Window,
761    doc: web_sys::Document,
762    _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
763    _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
764}
765impl Drop for SystemStream {
766    fn drop(&mut self) {
767        use wasm_bindgen::JsCast;
768        let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
769        let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
770    }
771}
772
773/// An open web `WebSocket` subscription — holds its JS closures so they stay alive.
774struct WsStream {
775    ws: web_sys::WebSocket,
776    _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
777    _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
778}
779
780/// Fulfil a request/response capability. `http` via `fetch`; `device` via the
781/// browser's user-agent string (the web analogue of a device model).
782async fn perform(call: &PluginCall) -> PluginResponse {
783    if call.plugin == "device" {
784        let nav = web_sys::window().map(|w| w.navigator());
785        let output = if call.op == "locale" {
786            // The browser's preferred language as a BCP-47 tag (e.g. "de-CH").
787            nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
788        } else {
789            nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
790        };
791        return PluginResponse::text(true, output);
792    }
793    if call.plugin == "photo" && call.op == "pick" {
794        return take_image(false).await;
795    }
796    if call.plugin == "camera" && call.op == "capture" {
797        return take_image(true).await;
798    }
799    if call.plugin == "datetime" {
800        return match call.op.as_str() {
801            "date" => take_datetime("date").await,
802            "time" => take_datetime("time").await,
803            other => PluginResponse::text(false, format!("unknown datetime op '{other}'")),
804        };
805    }
806    if call.plugin == "dialog" && call.op == "confirm" {
807        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
808        let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
809        let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
810        let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
811        let ok = web_sys::window()
812            .and_then(|w| w.confirm_with_message(&prompt).ok())
813            .unwrap_or(false);
814        return PluginResponse::text(ok, if ok { "ok" } else { "cancel" });
815    }
816    if call.plugin != "http" {
817        return PluginResponse::text(false, format!("plugin '{}' not available", call.plugin));
818    }
819    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
820    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
821    let body = v.get("body").and_then(serde_json::Value::as_str);
822    let req_headers: Vec<(String, String)> = v
823        .get("headers")
824        .and_then(serde_json::Value::as_array)
825        .map(|hs| {
826            hs.iter()
827                .filter_map(|h| {
828                    Some((
829                        h.get("name")?.as_str()?.to_string(),
830                        h.get("value")?.as_str()?.to_string(),
831                    ))
832                })
833                .collect()
834        })
835        .unwrap_or_default();
836
837    use gloo_net::http::{Method, Request};
838
839    // Exhaustive: an unknown verb is an error, never a silent GET. The previous
840    // `_ => Request::get(url)` fallthrough turned every PUT into a GET.
841    let builder = match call.op.as_str() {
842        "GET" => Request::get(url),
843        "POST" => Request::post(url),
844        "PUT" => Request::put(url),
845        "PATCH" => Request::patch(url),
846        "DELETE" => Request::delete(url),
847        "HEAD" => Request::get(url).method(Method::HEAD),
848        "OPTIONS" => Request::get(url).method(Method::OPTIONS),
849        other => return http_transport_error(format!("unsupported HTTP method '{other}'")),
850    };
851
852    // Only default Content-Type when the caller did not set one.
853    let caller_set_content_type =
854        req_headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("content-type"));
855
856    // `RequestBuilder::header` maps to `web_sys::Headers::set`, which REPLACES
857    // any existing value for that name — unlike iOS's `addValue` and Android's
858    // `addHeader`, which both APPEND. Build a `gloo_net::http::Headers` and
859    // `append` into it instead, so repeated names (Set-Cookie, Accept) survive
860    // on web the same way they do on the native shells.
861    let gloo_headers = gloo_net::http::Headers::new();
862    for (name, value) in &req_headers {
863        gloo_headers.append(name, value);
864    }
865    if body.is_some() && !caller_set_content_type {
866        gloo_headers.append("Content-Type", "application/json");
867    }
868    let builder = builder.headers(gloo_headers);
869
870    let request = match body {
871        Some(b) => builder.body(b),
872        None => builder.build(),
873    };
874    let request = match request {
875        Ok(r) => r,
876        Err(e) => return http_transport_error(e.to_string()),
877    };
878
879    match request.send().await {
880        Ok(resp) => {
881            let status = resp.status();
882            let headers = resp
883                .headers()
884                .entries()
885                .map(|(name, value)| HttpHeader { name, value })
886                .collect();
887            match resp.binary().await {
888                Ok(bytes) => {
889                    let outcome = HttpOutcome::Response { status, headers, body: bytes };
890                    PluginResponse { ok: (200..300).contains(&status), output: outcome.encode() }
891                }
892                // A body-read failure (truncated/aborted stream) is a transport
893                // failure, not a successful empty response — match native shells.
894                Err(e) => http_transport_error(e.to_string()),
895            }
896        }
897        Err(e) => http_transport_error(e.to_string()),
898    }
899}
900
901/// A failure where no HTTP response was obtained. `ok` is false and there is no status.
902fn http_transport_error(message: String) -> PluginResponse {
903    PluginResponse { ok: false, output: HttpOutcome::TransportError { message }.encode() }
904}
905
906/// Pick or capture an image via a hidden `<input type=file accept=image/*>`, clicked
907/// to open the browser's file dialog — or, with `capture`, to hint the device camera on
908/// supporting mobile browsers (desktop falls back to the file dialog). Awaits the
909/// `change` event and returns a `blob:` object URL the `<img>` renderer loads. No
910/// permission needed (the picker/camera prompt is the browser's). Backs both the
911/// `photo`/`pick` and `camera`/`capture` capabilities.
912async fn take_image(capture: bool) -> PluginResponse {
913    use wasm_bindgen::{closure::Closure, JsCast};
914    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
915        return PluginResponse::text(false, "no document");
916    };
917    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
918        return PluginResponse::text(false, "no input element");
919    };
920    input.set_type("file");
921    input.set_accept("image/*");
922    if capture {
923        // Hints the environment-facing camera on mobile browsers that support it.
924        let _ = input.set_attribute("capture", "environment");
925    }
926
927    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
928    let tx = std::cell::RefCell::new(Some(tx));
929    let input_for_cb = input.clone();
930    let on_change = Closure::wrap(Box::new(move || {
931        let url = input_for_cb
932            .files()
933            .and_then(|files| files.get(0))
934            .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
935        if let Some(tx) = tx.borrow_mut().take() {
936            let _ = tx.send(url);
937        }
938    }) as Box<dyn FnMut()>);
939    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
940    input.click();
941    on_change.forget(); // keep the handler alive until `change` fires
942
943    match rx.await {
944        Ok(Some(url)) => PluginResponse::text(true, url),
945        _ => PluginResponse::text(false, "cancelled"),
946    }
947}
948
949/// Pick a date (`kind = "date"`) or time (`kind = "time"`) via a hidden native
950/// `<input>`, opening the browser's picker with `showPicker()`. Returns the value
951/// (`YYYY-MM-DD` for date, 24-hour `HH:MM` for time); `ok=false` on cancel/dismiss.
952/// Backs the `datetime` capability (`cx.pick_date` / `cx.pick_time`).
953async fn take_datetime(kind: &str) -> PluginResponse {
954    use wasm_bindgen::{closure::Closure, JsCast};
955    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
956        return PluginResponse::text(false, "no document");
957    };
958    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
959        return PluginResponse::text(false, "no input element");
960    };
961    input.set_type(kind); // "date" or "time"
962    // showPicker() needs a connected element; keep it in the DOM but out of sight.
963    let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
964    if let Some(body) = doc.body() {
965        let _ = body.append_child(&input);
966    }
967
968    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
969    let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
970    let input_for_change = input.clone();
971    let tx_change = tx.clone();
972    let on_change = Closure::wrap(Box::new(move || {
973        let v = input_for_change.value();
974        if let Some(tx) = tx_change.borrow_mut().take() {
975            let _ = tx.send(if v.is_empty() { None } else { Some(v) });
976        }
977    }) as Box<dyn FnMut()>);
978    let tx_cancel = tx.clone();
979    let on_cancel = Closure::wrap(Box::new(move || {
980        if let Some(tx) = tx_cancel.borrow_mut().take() {
981            let _ = tx.send(None);
982        }
983    }) as Box<dyn FnMut()>);
984    let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
985    let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
986    if input.show_picker().is_err() {
987        input.click(); // older browsers: focus the field so the user can type a value
988    }
989    on_change.forget(); // keep the handlers alive until an event fires
990    on_cancel.forget();
991
992    let result = rx.await;
993    input.remove();
994    match result {
995        Ok(Some(v)) => PluginResponse::text(true, v),
996        _ => PluginResponse::text(false, "cancelled"),
997    }
998}
999
1000const STORAGE_KEY: &str = "mobiler.state";
1001
1002/// `window.localStorage`, if available.
1003fn local_storage() -> Option<web_sys::Storage> {
1004    web_sys::window()?.local_storage().ok().flatten()
1005}
1006
1007/// Fulfil a fire-and-forget capability in the browser — the web twin of the native
1008/// shells' notify handlers (storage/clipboard/share/browser). None block; an unknown
1009/// capability is a graceful no-op.
1010fn perform_notify(notify: &PluginNotify) {
1011    let win = match web_sys::window() {
1012        Some(w) => w,
1013        None => return,
1014    };
1015    match (notify.plugin.as_str(), notify.op.as_str()) {
1016        // Persist the state blob (paired with cx.save + restore-on-startup above).
1017        ("storage", "save") => {
1018            if let Some(s) = local_storage() {
1019                let _ = s.set_item(STORAGE_KEY, &notify.input);
1020            }
1021        }
1022        // Copy to the clipboard (write_text returns a Promise we let run).
1023        ("clipboard", "copy") => {
1024            let _ = win.navigator().clipboard().write_text(&notify.input);
1025        }
1026        // Open a URL in a new tab.
1027        ("browser", "open") => {
1028            let _ = win.open_with_url_and_target(&notify.input, "_blank");
1029        }
1030        // No reliable cross-browser share sheet (navigator.share is mobile-only and
1031        // gesture-gated), so degrade to copying — a sane universal fallback.
1032        ("share", _) => {
1033            let _ = win.navigator().clipboard().write_text(&notify.input);
1034        }
1035        // Tear down a streaming subscription: close the WebSocket parked under this
1036        // key (input = the subscription key) and drop its closures. Paired with
1037        // cx.unsubscribe; the matching source was opened in `start_stream`.
1038        ("stream", "unsubscribe") => {
1039            // Removing the entry drops the source (a `ticker` Interval cancels on
1040            // drop); for a WebSocket we also close it explicitly.
1041            if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(&notify.input)) {
1042                let _ = ws.ws.close();
1043            }
1044        }
1045        // Transient toast: a styled div appended to <body>, auto-removed after a beat.
1046        ("toast", _) => show_toast(&notify.input),
1047        // Haptic tap. navigator.vibrate is unsupported on iOS Safari (a graceful no-op).
1048        ("haptics", style) => {
1049            let ms = match style {
1050                "light" => 15,
1051                "heavy" => 50,
1052                _ => 30, // medium / unknown
1053            };
1054            let _ = win.navigator().vibrate_with_duration(ms);
1055        }
1056        _ => {} // unknown capability: ignore
1057    }
1058}
1059
1060/// Append a transient toast to `<body>` (styled by `.toast` in mobiler.css) and
1061/// remove it after ~2.6 s — the web twin of the native toast/snackbar.
1062fn show_toast(text: &str) {
1063    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
1064    let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
1065    el.set_class_name("toast");
1066    el.set_text_content(Some(text));
1067    let _ = body.append_child(&el);
1068    gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
1069}
1070
1071// ---------------- Widget → DOM ----------------
1072
1073/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
1074/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
1075/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
1076/// the concrete look lives in `mobiler.css`.
1077fn render(widget: &Widget, send: &Dispatch) -> AnyView {
1078    match widget {
1079        // ---- content ----
1080        Widget::Text { content, style } => {
1081            let (class, content) = (text_class(*style), content.clone());
1082            view! { <p class=class>{content}</p> }.into_any()
1083        }
1084        Widget::Image { source, shape, ratio } => {
1085            let (class, source) = (image_class(*shape, *ratio), source.clone());
1086            view! { <img class=class src=source /> }.into_any()
1087        }
1088        Widget::Badge { label, tone } => {
1089            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
1090            view! { <span class=class>{label}</span> }.into_any()
1091        }
1092        Widget::ColorDot { color } => {
1093            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
1094        }
1095        Widget::Avatar { source, status } => {
1096            let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
1097            view! {
1098                <span class="avatar">
1099                    <img class="avatar-img" src=source.clone() />
1100                    {dot}
1101                </span>
1102            }
1103            .into_any()
1104        }
1105        Widget::PdfView { url } => {
1106            // Browsers render PDFs natively in an iframe (remote URL or local blob/file URL).
1107            view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
1108        }
1109        Widget::WebView { url } => {
1110            // General embedded web content (incl. hosted player embeds like Bunny.net). `allow`
1111            // permits autoplay / fullscreen / PiP / encrypted-media so hosted players work.
1112            view! {
1113                <iframe
1114                    class="webview"
1115                    src=url.clone()
1116                    title="Web"
1117                    allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
1118                    allowfullscreen=true
1119                ></iframe>
1120            }.into_any()
1121        }
1122        // Interactive map (MapLibre-GL, no key). The div carries the config as data-* attrs;
1123        // `inject_maplibre_support` inits the map + reports taps by firing `input` on the hidden sink,
1124        // which this `on:input` forwards as Action::Input { "{id}.tap" | "{id}.marker", Text(...) }.
1125        Widget::Map { id, center_lat, center_lng, zoom, markers, style_url, interactive } => {
1126            let send = send.clone();
1127            let id = id.clone();
1128            let center = format!("{center_lat},{center_lng}");
1129            let markers_json = serde_json::to_string(markers).unwrap_or_else(|_| "[]".to_string());
1130            let style = style_url.clone().unwrap_or_default();
1131            view! {
1132                <div class="mobiler-map-wrap">
1133                    <div
1134                        class="mobiler-map"
1135                        data-map="1"
1136                        data-center=center
1137                        data-zoom=zoom.to_string()
1138                        data-style=style
1139                        data-markers=markers_json
1140                        data-interactive=interactive.to_string()
1141                    ></div>
1142                    <input
1143                        class="mobiler-map-sink"
1144                        type="text"
1145                        tabindex="-1"
1146                        aria-hidden="true"
1147                        on:input=move |ev| {
1148                            let raw = event_target_value(&ev);
1149                            if let Some((suffix, value)) = raw.split_once('|') {
1150                                send(Action::Input {
1151                                    id: format!("{id}.{suffix}"),
1152                                    value: InputValue::Text(value.to_string()),
1153                                });
1154                            }
1155                        }
1156                    />
1157                </div>
1158            }.into_any()
1159        }
1160        Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
1161            // Web = a native-controls `<video>`. App-driven play/pause + seek + position/state events
1162            // are iOS/Android only: the web shell rebuilds the whole tree on each `update`, which would
1163            // reset the element ~every tick — so we don't pump those here (poster/captions/rate/volume
1164            // ARE declarative attributes, so they're safe). `muted && playing` → autoplay. MP4 plays
1165            // everywhere; HLS (.m3u8) plays natively on Safari and, on Chrome/Firefox, via the hls.js
1166            // bootstrap (`inject_hls_support`). A non-empty `urls` is a playlist (best-effort: starts at
1167            // `start_index`, advances on `ended` within this element's lifetime — no index pump back).
1168            use wasm_bindgen::JsCast;
1169            let (send, ended) = (send.clone(), on_ended.clone());
1170            let autoplay = *playing && *muted;
1171            let playlist = urls.clone();
1172            let start_index = (*start_index).max(0) as usize;
1173            let effective = if playlist.is_empty() { url.clone() }
1174                else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
1175            let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
1176            let src = (!is_hls).then(|| effective.clone());
1177            let hls_src = is_hls.then(|| effective.clone());
1178            let poster_attr = poster.clone();
1179            let start_at = *start_at_ms;
1180            let rate = *rate as f64;
1181            let volume = (*volume as f64).clamp(0.0, 1.0);
1182            let tracks: Vec<_> = captions.iter().map(|c| view! {
1183                <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
1184            }).collect();
1185            let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
1186            view! {
1187                <video
1188                    class="video"
1189                    src=src
1190                    data-hls-src=hls_src
1191                    poster=poster_attr
1192                    controls=*controls
1193                    autoplay=autoplay
1194                    prop:loop=*looping
1195                    prop:playbackRate=rate
1196                    prop:volume=volume
1197                    muted=*muted
1198                    playsinline=true
1199                    on:loadedmetadata=move |ev| {
1200                        if start_at >= 0 {
1201                            if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1202                                v.set_current_time(start_at as f64 / 1000.0);
1203                            }
1204                        }
1205                    }
1206                    on:ended=move |ev| {
1207                        let nxt = next_idx.get() + 1;
1208                        if !playlist.is_empty() && nxt < playlist.len() {
1209                            next_idx.set(nxt);
1210                            if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1211                                v.set_src(&playlist[nxt]);
1212                                let _ = v.play();
1213                            }
1214                        } else if let Some(t) = ended.clone() {
1215                            send(Action::Fired { token: t });
1216                        }
1217                    }
1218                >{tracks}</video>
1219            }.into_any()
1220        }
1221        Widget::Rating { value, max, on_rate } => {
1222            let value = *value;
1223            let stars: Vec<AnyView> = (1..=*max)
1224                .map(|i| {
1225                    let threshold = u32::from(i) * 10;
1226                    // filled / half / empty by tenths.
1227                    let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
1228                    match on_rate {
1229                        Some(tokens) => {
1230                            let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
1231                            view! {
1232                                <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
1233                                    {glyph}
1234                                </button>
1235                            }
1236                            .into_any()
1237                        }
1238                        None => view! { <span class="star">{glyph}</span> }.into_any(),
1239                    }
1240                })
1241                .collect();
1242            view! { <span class="rating">{stars}</span> }.into_any()
1243        }
1244        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
1245        Widget::Progress { value } => match value {
1246            Some(v) => {
1247                let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
1248                view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
1249            }
1250            None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
1251        },
1252        Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
1253        Widget::Chart { series, labels, style, axis, legend } => {
1254            chart_view(series, labels, *style, *axis, *legend)
1255        }
1256        Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
1257            region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
1258        }
1259        Widget::Calendar { year, month, first_weekday, selected, on_day } => {
1260            const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
1261                "July", "August", "September", "October", "November", "December"];
1262            let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
1263            let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
1264            let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
1265            let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
1266            let selected = *selected;
1267            let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
1268                let day = (i + 1) as u8;
1269                let token = token.clone();
1270                let send = send.clone();
1271                let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
1272                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
1273            }).collect();
1274            view! {
1275                <div class="calendar">
1276                    <div class="cal-title">{head_label}</div>
1277                    <div class="cal-grid">{heads}{blanks}{days}</div>
1278                </div>
1279            }.into_any()
1280        }
1281        Widget::SwipeAction { child, actions } => {
1282            // Web has no swipe gesture — render the actions inline as a trailing button row.
1283            let acts: Vec<_> = actions.iter().map(|a| {
1284                let token = a.on_tap.clone();
1285                let send = send.clone();
1286                let cls = format!("swipe-act {}", tone_class(a.tone));
1287                let label = a.label.clone();
1288                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
1289            }).collect();
1290            view! {
1291                <div class="swipe-row">
1292                    <div class="swipe-content">{render(child, send)}</div>
1293                    <div class="swipe-actions">{acts}</div>
1294                </div>
1295            }.into_any()
1296        }
1297        Widget::Spacer { size } => {
1298            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
1299        }
1300
1301        // ---- layout ----
1302        Widget::Row { children } => {
1303            let kids = render_all(children, send);
1304            view! { <div class="row">{kids}</div> }.into_any()
1305        }
1306        Widget::Column { children } => {
1307            let kids = render_all(children, send);
1308            view! { <div class="col">{kids}</div> }.into_any()
1309        }
1310        Widget::Card { child, style, on_press, on_long_press } => {
1311            let class = format!("card {}", card_class(*style));
1312            let body = render(child, send);
1313            match (on_press, on_long_press) {
1314                // Plain, non-interactive card.
1315                (None, None) => view! { <div class=class>{body}</div> }.into_any(),
1316                // Tappable and/or long-pressable — render a button with the relevant handlers.
1317                (tap, long) => {
1318                    let send = send.clone();
1319                    // Web has no native long-press; shim it with a pointer-hold timer (~500 ms),
1320                    // cancelled on pointerup/leave/cancel. A `long_fired` flag suppresses the
1321                    // click that follows a successful hold so it doesn't also fire the tap.
1322                    let timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
1323                        Rc::new(RefCell::new(None));
1324                    let long_fired = Rc::new(RefCell::new(false));
1325
1326                    let on_pointerdown = {
1327                        let (send, long, timer, long_fired) =
1328                            (send.clone(), long.clone(), timer.clone(), long_fired.clone());
1329                        move |_: web_sys::PointerEvent| {
1330                            let Some(token) = long.clone() else { return };
1331                            *long_fired.borrow_mut() = false;
1332                            let (send, long_fired) = (send.clone(), long_fired.clone());
1333                            *timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
1334                                500,
1335                                move || {
1336                                    *long_fired.borrow_mut() = true;
1337                                    send(Action::Fired { token: token.clone() });
1338                                },
1339                            ));
1340                        }
1341                    };
1342                    let cancel = {
1343                        let timer = timer.clone();
1344                        // Dropping the `Timeout` cancels the pending fire.
1345                        move |_: web_sys::PointerEvent| { timer.borrow_mut().take(); }
1346                    };
1347                    let on_click = {
1348                        let (send, tap, long_fired) = (send.clone(), tap.clone(), long_fired.clone());
1349                        move |_| {
1350                            // Suppress the tap that trails a long-press.
1351                            if std::mem::take(&mut *long_fired.borrow_mut()) {
1352                                return;
1353                            }
1354                            if let Some(token) = tap.clone() {
1355                                send(Action::Fired { token });
1356                            }
1357                        }
1358                    };
1359                    view! {
1360                        <button
1361                            class=format!("{class} card-tappable")
1362                            on:pointerdown=on_pointerdown
1363                            on:pointerup=cancel.clone()
1364                            on:pointerleave=cancel.clone()
1365                            on:pointercancel=cancel
1366                            on:click=on_click
1367                        >
1368                            {body}
1369                        </button>
1370                    }
1371                    .into_any()
1372                }
1373            }
1374        }
1375        // Z-stack. With `scrim`, the first child is a background image, darkened
1376        // by an overlay, and the rest layer on top in light content — the DOM twin
1377        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
1378        Widget::Box { children, align, scrim } => {
1379            let acls = align_class(*align);
1380            if *scrim && children.len() > 1 {
1381                let bg = render(&children[0], send);
1382                let content = render_all(&children[1..], send);
1383                view! {
1384                    <div class=format!("box box-scrim {acls}")>
1385                        {bg}
1386                        <div class="scrim"></div>
1387                        <div class="box-content">{content}</div>
1388                    </div>
1389                }
1390                .into_any()
1391            } else {
1392                let kids = render_all(children, send);
1393                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
1394            }
1395        }
1396        Widget::Grid { children } => {
1397            let kids = render_all(children, send);
1398            view! { <div class="grid">{kids}</div> }.into_any()
1399        }
1400        Widget::Scroller { children } => {
1401            let kids = render_all(children, send);
1402            view! { <div class="scroller">{kids}</div> }.into_any()
1403        }
1404        // Two-pane master-detail. CSS does the adapting: wide (`@media min-width:768px`) shows both
1405        // panes side-by-side (back hidden); narrow shows one — primary by default, or detail (+ a
1406        // back chevron) when `data-detail` is set. `show_detail`/`on_back` only matter when narrow.
1407        Widget::Split { primary, detail, show_detail, on_back } => {
1408            let p = render(primary, send);
1409            let d = render(detail, send);
1410            let back_btn = on_back.clone().map(|t| {
1411                let send = send.clone();
1412                view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>"‹ Back"</button> }
1413            });
1414            view! {
1415                <div class="split" data-detail=show_detail.then_some("1")>
1416                    <div class="split-primary">{p}</div>
1417                    <div class="split-detail">{back_btn}{d}</div>
1418                </div>
1419            }.into_any()
1420        }
1421        // Accessibility wrapper: name the subtree for a screen reader (aria-label), give it a role,
1422        // and the hint via `title`. Best-effort web mapping of iOS traits / Android semantics.
1423        Widget::A11y { child, label, hint, role } => {
1424            let body = render(child, send);
1425            let role_attr = role.map(a11y_role_aria).unwrap_or("group");
1426            view! {
1427                <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
1428                    {body}
1429                </div>
1430            }.into_any()
1431        }
1432        // A long/paged feed. Web has no pull gesture or reliable infinite-scroll on a sub-container,
1433        // so (like Scaffold pull-to-refresh) the gestures degrade to controls: a top "↻ Refresh"
1434        // button (while `on_refresh`), and a bottom "Load more" button (while `has_more && !loading`)
1435        // / loading bar / "end" caption. iOS/Android do true pull + scroll-near-end detection.
1436        Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing } => {
1437            let kids = render_all(children, send);
1438            let refresh_btn = on_refresh.clone().map(|token| {
1439                let send = send.clone();
1440                view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻ Refresh"</button> }
1441            });
1442            let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1443            let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1444            let load_more_btn = (!*loading && *has_more)
1445                .then(|| on_load_more.clone())
1446                .flatten()
1447                .map(|token| {
1448                    let send = send.clone();
1449                    view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>"Load more"</button> }
1450                });
1451            let end_cap = (!*has_more && on_load_more.is_some()).then(|| view! { <div class="lazylist-end">"End of list"</div> });
1452            view! {
1453                <div class="lazylist">
1454                    {refresh_btn}
1455                    {refresh_bar}
1456                    {kids}
1457                    {loading_bar}
1458                    {load_more_btn}
1459                    {end_cap}
1460                </div>
1461            }.into_any()
1462        }
1463
1464        // ---- input / actions ----
1465        Widget::Button { label, style, on_press } => {
1466            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1467            let class = format!("btn {}", button_class(*style));
1468            view! {
1469                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1470                    {label}
1471                </button>
1472            }
1473            .into_any()
1474        }
1475        Widget::IconButton { icon, on_press } => {
1476            let (send, token) = (send.clone(), on_press.clone());
1477            let glyph = icon_glyph(*icon);
1478            view! {
1479                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
1480                    {glyph}
1481                </button>
1482            }
1483            .into_any()
1484        }
1485        Widget::Chip { label, selected, on_press } => {
1486            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1487            let class = if *selected { "chip selected" } else { "chip" };
1488            view! {
1489                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1490                    {label}
1491                </button>
1492            }
1493            .into_any()
1494        }
1495        Widget::TextField { id, placeholder, value, kind, error } => {
1496            let (send, id) = (send.clone(), id.clone());
1497            let (placeholder, value) = (placeholder.clone(), value.clone());
1498            let invalid = error.is_some();
1499            let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
1500            // (input type, inputmode) per FieldKind. Multiline renders a <textarea> below.
1501            let (itype, imode): (&str, &str) = match kind {
1502                FieldKind::Secure => ("password", ""),
1503                FieldKind::Email => ("email", "email"),
1504                FieldKind::Number => ("text", "numeric"),
1505                FieldKind::Decimal => ("text", "decimal"),
1506                FieldKind::Phone => ("tel", "tel"),
1507                FieldKind::Url => ("url", "url"),
1508                FieldKind::Text | FieldKind::Multiline => ("text", ""),
1509            };
1510            let field_class = if invalid { "field field-invalid" } else { "field" };
1511            let control = if matches!(kind, FieldKind::Multiline) {
1512                view! {
1513                    <textarea
1514                        class=field_class
1515                        rows="3"
1516                        placeholder=placeholder
1517                        prop:value=value
1518                        on:input=move |ev| send(Action::Input {
1519                            id: id.clone(),
1520                            value: InputValue::Text(event_target_value(&ev)),
1521                        })
1522                    ></textarea>
1523                }
1524                .into_any()
1525            } else {
1526                view! {
1527                    <input
1528                        class=field_class
1529                        r#type=itype
1530                        inputmode=imode
1531                        placeholder=placeholder
1532                        prop:value=value
1533                        on:input=move |ev| send(Action::Input {
1534                            id: id.clone(),
1535                            value: InputValue::Text(event_target_value(&ev)),
1536                        })
1537                    />
1538                }
1539                .into_any()
1540            };
1541            view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
1542        }
1543        Widget::SearchField { id, placeholder, value } => {
1544            let (send, id) = (send.clone(), id.clone());
1545            let (placeholder, value) = (placeholder.clone(), value.clone());
1546            view! {
1547                <div class="searchfield">
1548                    <span class="search-icon">{icon_glyph(Icon::Search)}</span>
1549                    <input
1550                        class="search-input"
1551                        placeholder=placeholder
1552                        prop:value=value
1553                        on:input=move |ev| send(Action::Input {
1554                            id: id.clone(),
1555                            value: InputValue::Text(event_target_value(&ev)),
1556                        })
1557                    />
1558                </div>
1559            }
1560            .into_any()
1561        }
1562        Widget::Segmented { segments } => {
1563            let segs: Vec<AnyView> = segments
1564                .iter()
1565                .map(|s| {
1566                    let (send, token) = (send.clone(), s.on_select.clone());
1567                    let class = if s.selected { "segment selected" } else { "segment" };
1568                    let label = s.label.clone();
1569                    view! {
1570                        <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1571                            {label}
1572                        </button>
1573                    }
1574                    .into_any()
1575                })
1576                .collect();
1577            view! { <div class="segmented">{segs}</div> }.into_any()
1578        }
1579        Widget::Toggle { id, label, value } => {
1580            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1581            view! {
1582                <label class="toggle">
1583                    {label}
1584                    <input
1585                        type="checkbox"
1586                        role="switch"
1587                        prop:checked=checked
1588                        on:change=move |ev| send(Action::Input {
1589                            id: id.clone(),
1590                            value: InputValue::Bool(event_target_checked(&ev)),
1591                        })
1592                    />
1593                </label>
1594            }
1595            .into_any()
1596        }
1597        Widget::Checkbox { id, label, value } => {
1598            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1599            view! {
1600                <label class="check">
1601                    <input
1602                        type="checkbox"
1603                        prop:checked=checked
1604                        on:change=move |ev| send(Action::Input {
1605                            id: id.clone(),
1606                            value: InputValue::Bool(event_target_checked(&ev)),
1607                        })
1608                    />
1609                    {label}
1610                </label>
1611            }
1612            .into_any()
1613        }
1614        Widget::Slider { id, value, max } => {
1615            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1616            view! {
1617                <input
1618                    class="slider"
1619                    type="range"
1620                    min="0"
1621                    max=max
1622                    prop:value=value
1623                    on:input=move |ev| send(Action::Input {
1624                        id: id.clone(),
1625                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1626                    })
1627                />
1628            }
1629            .into_any()
1630        }
1631        Widget::Stepper { value, on_decrement, on_increment } => {
1632            let send_dec = send.clone();
1633            let send_inc = send.clone();
1634            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1635            view! {
1636                <div class="stepper">
1637                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1638                    <span class="stepper-value">{*value}</span>
1639                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1640                </div>
1641            }
1642            .into_any()
1643        }
1644
1645        // ---- shell ----
1646        Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
1647            let back_btn = back.clone().map(|token| {
1648                let send = send.clone();
1649                view! {
1650                    <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
1651                        "‹"
1652                    </button>
1653                }
1654            });
1655            let tabbar = (!tabs.is_empty()).then(|| {
1656                let tabs: Vec<AnyView> = tabs
1657                    .iter()
1658                    .map(|tab| {
1659                        let (send, token) = (send.clone(), tab.on_select.clone());
1660                        let class = if tab.selected { "tab selected" } else { "tab" };
1661                        let label = tab.label.clone();
1662                        // Optional leading icon → glyph above the label (icon tab bar).
1663                        let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1664                        view! {
1665                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1666                                {icon}
1667                                <span class="tab-label">{label}</span>
1668                            </button>
1669                        }
1670                        .into_any()
1671                    })
1672                    .collect();
1673                view! { <div class="tabbar">{tabs}</div> }
1674            });
1675            // Floating action button — the raised primary action, anchored over the body.
1676            let fab_btn = fab.clone().map(|f| {
1677                let (send, token) = (send.clone(), f.on_press.clone());
1678                view! {
1679                    <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
1680                        {icon_glyph(f.icon)}
1681                    </button>
1682                }
1683            });
1684            // Modal bottom sheet — a scrim (tap to dismiss) + a panel rising from the bottom.
1685            let sheet_overlay = sheet.as_ref().map(|s| {
1686                let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
1687                let (title, child) = (s.title.clone(), render(&s.child, send));
1688                view! {
1689                    <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
1690                    <div class="sheet">
1691                        <div class="sheet-handle"></div>
1692                        <div class="sheet-title">{title}</div>
1693                        {child}
1694                    </div>
1695                }
1696            });
1697            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
1698            // the web twin of the native shells' `preferredColorScheme`/Material theme.
1699            let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
1700            // Pull-to-refresh — web has no pull gesture, so expose a top-bar refresh button +
1701            // an indeterminate bar at the top of the body while `refreshing`.
1702            let refresh_btn = on_refresh.clone().map(|token| {
1703                let send = send.clone();
1704                view! {
1705                    <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
1706                }
1707            });
1708            let refresh_bar = refreshing.then(|| {
1709                view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
1710            });
1711            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
1712            // An app `Theme` overrides the CSS variables inline (brand color, corner, density,
1713            // font) — the web twin of the native shells' brand/tint + shape + spacing + font.
1714            let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
1715            let (title, body) = (title.clone(), render(body, send));
1716            view! {
1717                <div class=class style=theme_style>
1718                    <div class="topbar">
1719                        {back_btn}
1720                        <span class="title">{title}</span>
1721                        {refresh_btn}
1722                    </div>
1723                    <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
1724                    {fab_btn}
1725                    {tabbar}
1726                    {sheet_overlay}
1727                </div>
1728            }
1729            .into_any()
1730        }
1731    }
1732}
1733
1734/// Render a slice of children as sibling views.
1735fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
1736    children.iter().map(|c| render(c, send)).collect()
1737}
1738
1739thread_local! {
1740    /// (previous route key, previous depth, alternating toggle). The render is a
1741    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
1742    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
1743    /// the native shells keying their body on `route`.
1744    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
1745
1746    /// Open streaming subscriptions keyed by subscription key (wasm is single-
1747    /// threaded). Each [`Effect::PluginStream`] parks its source here so
1748    /// `cx.unsubscribe(key)` can stop it; dropping the entry stops the source.
1749    static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
1750}
1751
1752/// Render an app [`Theme`] as inline CSS custom properties on the scaffold root — the web
1753/// twin of the native brand/tint + shape + spacing + font. Overrides `mobiler.css`'s defaults
1754/// (its rules read these via `var(--…)`); dark mode still works (it only swaps the colors the
1755/// seed doesn't pin).
1756fn theme_css(t: &Theme) -> String {
1757    let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
1758    let radius = match t.corner {
1759        Corner::None => "0px",
1760        Corner::Small => "8px",
1761        Corner::Medium => "14px",
1762        Corner::Large => "22px",
1763    };
1764    let (gap, pad) = match t.density {
1765        Density::Compact => ("8px", "10px"),
1766        Density::Comfortable => ("12px", "14px"),
1767    };
1768    let font = match t.font {
1769        FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
1770        FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
1771        FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
1772        FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
1773    };
1774    // Secondary brand color (for the CardStyle::Brand gradient); falls back to the seed.
1775    let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
1776    format!(
1777        "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
1778         --accent2:rgb({ar},{ag},{ab});\
1779         --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
1780         --gap:{gap};--pad:{pad};--font:{font};"
1781    )
1782}
1783
1784/// Pick the Scaffold body's transition class for this render. Returns `""` for a
1785/// same-route data update (re-render in place, no transition). On a route change it
1786/// returns a directional class — slide-in from the right when `depth` grew (push),
1787/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
1788/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
1789/// Leptos reuses the same DOM node.
1790fn nav_class(route: &str, depth: u32) -> &'static str {
1791    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
1792        if route == prev_route {
1793            return "";
1794        }
1795        let dir = if depth > *prev_depth {
1796            ["nav-push-a", "nav-push-b"]
1797        } else if depth < *prev_depth {
1798            ["nav-pop-a", "nav-pop-b"]
1799        } else {
1800            ["nav-fade-a", "nav-fade-b"]
1801        };
1802        *toggle = !*toggle;
1803        *prev_route = route.to_string();
1804        *prev_depth = depth;
1805        dir[usize::from(*toggle)]
1806    })
1807}
1808
1809// ---- style intent → CSS class / glyph (the only place that names the look) ----
1810
1811fn text_class(s: TextStyle) -> &'static str {
1812    match s {
1813        TextStyle::Title => "t-title",
1814        TextStyle::Subtitle => "t-subtitle",
1815        TextStyle::Caption => "t-caption",
1816        TextStyle::Emphasis => "t-emphasis",
1817        TextStyle::Body => "t-body",
1818    }
1819}
1820
1821fn button_class(s: ButtonStyle) -> &'static str {
1822    match s {
1823        ButtonStyle::Filled => "btn-filled",
1824        ButtonStyle::Outlined => "btn-outlined",
1825        ButtonStyle::Text => "btn-text",
1826    }
1827}
1828
1829fn card_class(s: CardStyle) -> &'static str {
1830    match s {
1831        CardStyle::Elevated => "card-elevated",
1832        CardStyle::Outlined => "card-outlined",
1833        CardStyle::Filled => "card-filled",
1834        CardStyle::Brand => "card-brand",
1835    }
1836}
1837
1838fn a11y_role_aria(role: A11yRole) -> &'static str {
1839    match role {
1840        A11yRole::Button => "button",
1841        A11yRole::Link => "link",
1842        A11yRole::Image => "img",
1843        A11yRole::Header => "heading",
1844        A11yRole::Adjustable => "slider",
1845    }
1846}
1847
1848fn tone_class(t: Tone) -> &'static str {
1849    match t {
1850        Tone::Neutral => "tone-neutral",
1851        Tone::Success => "tone-success",
1852        Tone::Warning => "tone-warning",
1853        Tone::Danger => "tone-danger",
1854        Tone::Info => "tone-info",
1855    }
1856}
1857
1858fn spacer_class(s: Spacing) -> &'static str {
1859    match s {
1860        Spacing::Xs => "sp-xs",
1861        Spacing::Sm => "sp-sm",
1862        Spacing::Md => "sp-md",
1863        Spacing::Lg => "sp-lg",
1864        Spacing::Xl => "sp-xl",
1865    }
1866}
1867
1868fn icon_glyph(i: Icon) -> &'static str {
1869    match i {
1870        Icon::Delete => "🗑",
1871        Icon::Add => "+",
1872        Icon::Edit => "✏️",
1873        Icon::Close => "✕",
1874        Icon::Settings => "⚙",
1875        Icon::Check => "✓",
1876        Icon::Star => "★",
1877        Icon::Info => "ℹ",
1878        Icon::Home => "⌂",
1879        Icon::Search => "🔍",
1880        Icon::Menu => "☰",
1881        Icon::Filter => "⚟",
1882        Icon::Back => "‹",
1883        Icon::Forward => "›",
1884        Icon::Down => "⌄",
1885        Icon::Bell => "🔔",
1886        Icon::Cart => "🛒",
1887        Icon::Share => "↗",
1888        Icon::Heart => "♡",
1889        Icon::HeartFilled => "♥",
1890        Icon::Person => "👤",
1891        Icon::People => "👥",
1892        Icon::Phone => "📞",
1893        Icon::Mail => "✉",
1894        Icon::Calendar => "📅",
1895        Icon::Clock => "🕑",
1896        Icon::MapPin => "📍",
1897        Icon::Camera => "📷",
1898        Icon::Photo => "🖼",
1899        Icon::Play => "▶",
1900        Icon::Scissors => "✂",
1901    }
1902}
1903
1904fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
1905    let shape = match shape {
1906        ImageShape::Square => "img-square",
1907        ImageShape::Rounded => "img-rounded",
1908        ImageShape::Circle => "img-circle",
1909    };
1910    let ratio = match ratio {
1911        ImageRatio::Wide => "ratio-wide",
1912        ImageRatio::Square => "ratio-square",
1913        ImageRatio::Tall => "ratio-tall",
1914    };
1915    format!("img {shape} {ratio}")
1916}
1917
1918fn dot_class(c: ProjectColor) -> &'static str {
1919    match c {
1920        ProjectColor::Indigo => "dot-indigo",
1921        ProjectColor::Teal => "dot-teal",
1922        ProjectColor::Coral => "dot-coral",
1923        ProjectColor::Amber => "dot-amber",
1924        ProjectColor::Lime => "dot-lime",
1925        ProjectColor::Pink => "dot-pink",
1926    }
1927}
1928
1929fn align_class(a: BoxAlign) -> &'static str {
1930    match a {
1931        BoxAlign::TopStart => "align-top-start",
1932        BoxAlign::TopEnd => "align-top-end",
1933        BoxAlign::Center => "align-center",
1934        BoxAlign::BottomStart => "align-bottom-start",
1935        BoxAlign::BottomCenter => "align-bottom-center",
1936        BoxAlign::BottomEnd => "align-bottom-end",
1937    }
1938}
1939
1940// ------------------------------- charts -------------------------------
1941
1942/// Distinct fallback colors for series 1.. (series 0 with no override rides the theme accent).
1943const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
1944
1945fn hex(c: Rgb) -> String {
1946    format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
1947}
1948
1949/// Color for series `i`: explicit override → theme accent (i==0) → palette.
1950fn chart_color(i: usize, s: &ChartSeries) -> String {
1951    match s.color {
1952        Some(c) => hex(c),
1953        None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
1954        None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
1955    }
1956}
1957
1958/// A series' single magnitude for circular charts (sum of its values).
1959fn chart_mag(s: &ChartSeries) -> f32 {
1960    s.values.iter().copied().sum()
1961}
1962
1963/// Point on a circle: `ang` in radians, 0 = top (12 o'clock), increasing clockwise.
1964fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
1965    (cx + r * ang.sin(), cy - r * ang.cos())
1966}
1967
1968/// An open arc path (for ring/donut/gauge strokes).
1969fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1970    let (x0, y0) = polar(cx, cy, r, a0);
1971    let (x1, y1) = polar(cx, cy, r, a1);
1972    let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1973    format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
1974}
1975
1976/// A filled wedge from the center (for pie/donut slices).
1977fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1978    let (x0, y0) = polar(cx, cy, r, a0);
1979    let (x1, y1) = polar(cx, cy, r, a1);
1980    let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1981    format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
1982}
1983
1984fn fmt_tick(v: f32) -> String {
1985    if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
1986}
1987
1988fn is_cartesian(style: ChartStyle) -> bool {
1989    matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
1990}
1991
1992/// The y-axis denominator for a cartesian chart.
1993fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
1994    match style {
1995        ChartStyle::StackedBar => (0..nslots)
1996            .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
1997            .fold(0.0, f32::max)
1998            .max(1e-6),
1999        ChartStyle::StackedBar100 => 1.0,
2000        _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
2001    }
2002}
2003
2004fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
2005    // plot area: y in [2, 48] of the 0..50 viewBox
2006    let mut nodes: Vec<AnyView> = Vec::new();
2007    if axis {
2008        for k in 0..=4 {
2009            let y = 2.0 + k as f32 * (46.0 / 4.0);
2010            nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
2011        }
2012    }
2013    match style {
2014        ChartStyle::Line => {
2015            for (i, s) in series.iter().enumerate() {
2016                let n = s.values.len().max(1);
2017                let pts = s.values.iter().enumerate().map(|(j, v)| {
2018                    let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
2019                    let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
2020                    format!("{x:.2},{y:.2}")
2021                }).collect::<Vec<_>>().join(" ");
2022                let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
2023                nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
2024            }
2025        }
2026        ChartStyle::Bar => {
2027            let sw = 100.0 / nslots as f32;
2028            let ns = series.len().max(1);
2029            for (i, s) in series.iter().enumerate() {
2030                let st = format!("fill:{}", chart_color(i, s));
2031                for (j, v) in s.values.iter().enumerate() {
2032                    let h = (v / max).clamp(0.0, 1.0) * 46.0;
2033                    let bw = sw * 0.8 / ns as f32;
2034                    let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
2035                    let y = 48.0 - h;
2036                    nodes.push(view! { <rect x=format!("{x:.2}") y=format!("{y:.2}") width=format!("{bw:.2}") height=format!("{h:.2}") style=st.clone()></rect> }.into_any());
2037                }
2038            }
2039        }
2040        ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
2041            let sw = 100.0 / nslots as f32;
2042            for j in 0..nslots {
2043                let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
2044                let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
2045                let mut acc = 0.0_f32;
2046                for (i, s) in series.iter().enumerate() {
2047                    let v = *s.values.get(j).unwrap_or(&0.0);
2048                    let h = (v / denom).clamp(0.0, 1.0) * 46.0;
2049                    let x = j as f32 * sw + sw * 0.15;
2050                    let bw = sw * 0.7;
2051                    let y = 48.0 - acc - h;
2052                    let st = format!("fill:{}", chart_color(i, s));
2053                    nodes.push(view! { <rect x=format!("{x:.2}") y=format!("{y:.2}") width=format!("{bw:.2}") height=format!("{h:.2}") style=st></rect> }.into_any());
2054                    acc += h;
2055                }
2056            }
2057        }
2058        _ => {}
2059    }
2060    view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
2061}
2062
2063fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
2064    use std::f32::consts::PI;
2065    let mut nodes: Vec<AnyView> = Vec::new();
2066    match style {
2067        ChartStyle::Pie | ChartStyle::Donut => {
2068            let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
2069            let mut a = 0.0_f32;
2070            for (i, s) in series.iter().enumerate() {
2071                let frac = chart_mag(s) / total;
2072                let st = format!("fill:{}", chart_color(i, s));
2073                if frac >= 0.999 {
2074                    nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
2075                } else if frac > 0.0 {
2076                    let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
2077                    nodes.push(view! { <path d=d style=st></path> }.into_any());
2078                }
2079                a += frac * 2.0 * PI;
2080            }
2081            if matches!(style, ChartStyle::Donut) {
2082                nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
2083            }
2084        }
2085        ChartStyle::Rings => {
2086            let n = series.len().max(1);
2087            for (i, s) in series.iter().enumerate() {
2088                let r = 45.0 - i as f32 * (34.0 / n as f32);
2089                let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2090                let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2091                nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style="fill:none;stroke:var(--border, #e6e6e6);stroke-width:6"></circle> }.into_any());
2092                let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
2093                if prog >= 0.999 {
2094                    nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
2095                } else if prog > 0.0 {
2096                    let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
2097                    nodes.push(view! { <path d=d style=st></path> }.into_any());
2098                }
2099            }
2100        }
2101        ChartStyle::Gauge => {
2102            let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
2103            let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2104            let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2105            let a0 = -0.75 * PI; // 270° sweep, gap at the bottom
2106            let a1 = 0.75 * PI;
2107            nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a1) style="fill:none;stroke:var(--border, #e6e6e6);stroke-width:8;stroke-linecap:round"></path> }.into_any());
2108            if prog > 0.0 {
2109                let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
2110                nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
2111            }
2112            let pct = format!("{}%", (prog * 100.0).round() as i64);
2113            nodes.push(view! { <text x="50" y="56" style="fill:var(--fg, #222);font-size:20px;font-weight:700;text-anchor:middle">{pct}</text> }.into_any());
2114        }
2115        _ => {}
2116    }
2117    view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
2118}
2119
2120fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
2121    let cartesian = is_cartesian(style);
2122    let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
2123    let max = cartesian_max(series, style, nslots);
2124
2125    let plot = if cartesian {
2126        let svg = cartesian_svg(series, style, axis, max, nslots);
2127        let yaxis = if axis {
2128            let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
2129                .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
2130                .collect();
2131            Some(view! { <div class="chart-yaxis">{ticks}</div> })
2132        } else {
2133            None
2134        };
2135        view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
2136    } else {
2137        circular_svg(series, style).into_any()
2138    };
2139
2140    let label_row = if cartesian && !labels.is_empty() {
2141        let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
2142        Some(view! { <div class="chart-labels">{items}</div> })
2143    } else {
2144        None
2145    };
2146
2147    let legend_row = if legend {
2148        let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
2149            let sw = format!("background:{}", chart_color(i, s));
2150            let name = s.name.clone();
2151            view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2152        }).collect();
2153        Some(view! { <div class="chart-legend">{items}</div> })
2154    } else {
2155        None
2156    };
2157
2158    view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
2159}
2160
2161// --------------------------- region chart ---------------------------
2162
2163/// Palette as RGB (parallel to `CHART_PALETTE`) so region charts can compute label contrast.
2164const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
2165    [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
2166
2167/// The resolved fill RGB for region `i` (explicit override → palette).
2168fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
2169    match r.color {
2170        Some(c) => (c.r, c.g, c.b),
2171        None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
2172    }
2173}
2174
2175/// Black or white label text, whichever reads on the given fill (perceived luminance).
2176fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
2177    let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
2178    if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
2179}
2180
2181fn region_color(i: usize, r: &ChartRegion) -> String {
2182    let (r8, g8, b8) = region_rgb(i, r);
2183    format!("#{r8:02x}{g8:02x}{b8:02x}")
2184}
2185
2186// A variable-width stacked-region / coverage-gap chart: absolute-positioned region rectangles in
2187// the [0,x_max]×[0,y_max] plane, horizontal ref lines + chips, an irregular x-axis, an optional
2188// right-side bracket, and a legend. The web twin of the Compose/SwiftUI RegionChart renderers.
2189fn region_chart_view(
2190    regions: &[ChartRegion],
2191    ticks: &[ChartTick],
2192    x_max: f32,
2193    y_max: f32,
2194    ref_lines: &[ChartRefLine],
2195    bracket: &Option<ChartBracket>,
2196    legend: &[ChartLegendItem],
2197) -> AnyView {
2198    let xm = x_max.max(1e-6);
2199    let ym = y_max.max(1e-6);
2200
2201    let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
2202        let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
2203        let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
2204        let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
2205        let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
2206        let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
2207        let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
2208        let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
2209        let label = r.label.clone();
2210        view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
2211    }).collect();
2212
2213    // The reference lines span the full plot width; their value chips sit in the right margin
2214    // (outside the plot), like the original — so the line clearly runs to the plot's edge.
2215    let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
2216        let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2217        let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
2218        view! { <div class=cls style=style></div> }
2219    }).collect();
2220    let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
2221        let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2222        let label = rl.label.clone();
2223        view! { <div class="rchart-chip" style=style>{label}</div> }
2224    }).collect();
2225
2226    let bracket_div = bracket.as_ref().map(|b| {
2227        let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
2228        let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
2229        let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
2230        let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
2231        view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
2232    });
2233
2234    let yticks: Vec<_> = (0..=4).rev().map(|k| {
2235        let v = ym * k as f32 / 4.0;
2236        view! { <span class="chart-tick">{fmt_tick(v)}</span> }
2237    }).collect();
2238
2239    let xticks: Vec<_> = ticks.iter().map(|t| {
2240        let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2241        let label = t.label.clone();
2242        view! { <span class="rchart-xtick" style=style>{label}</span> }
2243    }).collect();
2244
2245    // Axis tick marks (notches on the L-shaped axis): horizontal on the y-axis at each value,
2246    // vertical on the x-axis at each irregular break — drawn over the bands at the plot edges.
2247    let ytick_marks: Vec<_> = (0..=4).map(|k| {
2248        let style = format!("bottom:{:.3}%", k as f32 * 25.0);
2249        view! { <div class="rchart-ytick" style=style></div> }
2250    }).collect();
2251    let xtick_marks: Vec<_> = ticks.iter().map(|t| {
2252        let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2253        view! { <div class="rchart-xtickmark" style=style></div> }
2254    }).collect();
2255
2256    let legend_row = if legend.is_empty() {
2257        None
2258    } else {
2259        let items: Vec<_> = legend.iter().map(|l| {
2260            let sw = format!("background:{}", hex(l.color));
2261            let name = l.label.clone();
2262            view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2263        }).collect();
2264        Some(view! { <div class="chart-legend">{items}</div> })
2265    };
2266
2267    view! {
2268        <div class="rchart">
2269            <div class="rchart-row">
2270                <div class="rchart-yaxis">{yticks}</div>
2271                <div class="rchart-plotwrap">
2272                    <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
2273                    {chip_divs}{bracket_div}
2274                </div>
2275            </div>
2276            <div class="rchart-xaxis">{xticks}</div>
2277            {legend_row}
2278        </div>
2279    }.into_any()
2280}