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, ShellLabels, 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 || {
213                let widget = view.get();
214                // Stash labels from the root Scaffold only. A Scaffold nested inside a sheet,
215                // body or Split (rendered by the Scaffold arm below) must not overwrite the
216                // root's labels — only the root scaffold owns the shell chrome. Code that never
217                // sees the view (the confirm modal) reads this stash.
218                ACTIVE_LABELS.with(|l| {
219                    *l.borrow_mut() = match &widget {
220                        Widget::Scaffold { labels, .. } => labels.clone(),
221                        _ => None,
222                    };
223                });
224                render(&widget, &send_for_view)
225            }}
226        </div>
227    }
228}
229
230/// Process effects: re-read the view on Render; fulfil HTTP via fetch and resolve.
231fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
232where
233    A::Model: Default + Send + Sync,
234{
235    for effect in effects {
236        match effect {
237            Effect::Render(_) => set_view.set(core.view()),
238            Effect::PluginNotify(notify) => perform_notify(&notify.operation),
239            Effect::Plugin(mut request) => {
240                let core = core.clone();
241                spawn_local(async move {
242                    let response = perform(&request.operation).await;
243                    if let Ok(next) = core.resolve(&mut request, response) {
244                        drive(&core, set_view, next);
245                    }
246                });
247            }
248            // Long-lived subscription: start a native source that resolves the same
249            // request repeatedly (one event per `core.resolve`). See `start_stream`.
250            Effect::PluginStream(request) => start_stream(core, set_view, request),
251        }
252    }
253}
254
255/// Start a streaming subscription ([`Effect::PluginStream`]): begin a native source
256/// that resolves `request` **repeatedly** (a [`PluginResponse`] per event), each
257/// resolution re-entering the core. The source handle is parked in a per-key
258/// registry so [`unsubscribe`](mobiler_core::Cx::unsubscribe) can stop it.
259///
260/// Web sources: `ticker`/`start` (a `setInterval` emitting an incrementing counter
261/// every `input` ms — the deterministic demonstrator) and `websocket`/`stream`
262/// (a `WebSocket`, a frame per `onmessage`).
263fn start_stream<A: WebApp>(
264    core: &Arc<Core<A>>,
265    set_view: WriteSignal<Widget>,
266    request: Request<PluginStreamCall>,
267) where
268    A::Model: Default + Send + Sync,
269{
270    use wasm_bindgen::{closure::Closure, JsCast};
271
272    let call = request.operation.clone();
273
274    // Each resolution of a `resolves_many_times` request yields the next stream item;
275    // share the request across event closures via Rc<RefCell<_>>.
276    let request = Rc::new(RefCell::new(request));
277    let core = core.clone();
278    let emit = move |resp: PluginResponse| {
279        if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
280            drive(&core, set_view, next);
281        }
282    };
283
284    let handle = match (call.plugin.as_str(), call.op.as_str()) {
285        // Built-in deterministic demonstrator: emit an incrementing counter every
286        // `input` ms. Dropping the Interval (on unsubscribe) stops it.
287        ("ticker", "start") => {
288            let ms: u32 = call.input.parse().unwrap_or(1000);
289            let count = std::cell::Cell::new(0u32);
290            let interval = gloo_timers::callback::Interval::new(ms, move || {
291                count.set(count.get() + 1);
292                emit(PluginResponse::text(true, count.get().to_string()));
293            });
294            StreamHandle::Ticker { _interval: interval }
295        }
296        ("websocket", "stream") => {
297            let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
298            let onmessage = {
299                let emit = emit.clone();
300                Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
301                    emit(PluginResponse::text(true, e.data().as_string().unwrap_or_default()));
302                })
303            };
304            let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
305                emit(PluginResponse::text(false, "closed"));
306            });
307            ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
308            ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
309            StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
310        }
311        // Built-in `system` source: deep-link URLs + app lifecycle. On the web a "deep link" is the
312        // current URL (delivered on subscribe + on `popstate`) and "lifecycle" maps to page
313        // visibility (`visibilitychange`). Listeners are dropped (removed) on unsubscribe.
314        ("system", "events") => {
315            let win = web_sys::window().expect("window");
316            let doc = win.document().expect("document");
317            // Initial: the current URL as a deeplink + current visibility as lifecycle.
318            if let Ok(href) = win.location().href() {
319                emit(PluginResponse::text(true, system_deeplink(&href)));
320            }
321            emit(PluginResponse::text(true, system_lifecycle(&doc)));
322            let onpop = {
323                let (emit, win) = (emit.clone(), win.clone());
324                Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
325                    if let Ok(href) = win.location().href() {
326                        emit(PluginResponse::text(true, system_deeplink(&href)));
327                    }
328                })
329            };
330            let onvis = {
331                let (emit, doc) = (emit.clone(), doc.clone());
332                Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
333                    emit(PluginResponse::text(true, system_lifecycle(&doc)));
334                })
335            };
336            let _ = win.add_event_listener_with_callback("popstate", onpop.as_ref().unchecked_ref());
337            let _ = doc.add_event_listener_with_callback("visibilitychange", onvis.as_ref().unchecked_ref());
338            StreamHandle::System(SystemStream { win, doc, _onpop: onpop, _onvis: onvis })
339        }
340        // Streaming file transfer (`cx.upload` / `cx.download`, Release B). See
341        // `start_web_upload` / `start_web_download` for the WEB ASYMMETRY: upload uses
342        // XHR (the only web API with upload-progress events), download uses fetch +
343        // ReadableStream (progress) and hands the app back a `blob:` handle.
344        ("transfer", op @ ("upload" | "download")) => {
345            let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
346            let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("").to_string();
347            let headers: Vec<(String, String)> = v
348                .get("headers")
349                .and_then(|x| x.as_array())
350                .map(|hs| {
351                    hs.iter()
352                        .filter_map(|h| Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string())))
353                        .collect()
354                })
355                .unwrap_or_default();
356
357            if op == "upload" {
358                let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("PUT").to_string();
359                let source = v.get("source").and_then(|x| x.as_str()).unwrap_or("").to_string();
360                let multipart = v.get("multipart").map(|m| WebMultipart {
361                    field: m.get("field").and_then(|x| x.as_str()).unwrap_or("").to_string(),
362                    filename: m.get("filename").and_then(|x| x.as_str()).map(|s| s.to_string()),
363                    fields: m
364                        .get("fields")
365                        .and_then(|x| x.as_array())
366                        .map(|fs| {
367                            fs.iter()
368                                .filter_map(|f| Some((f.get("name")?.as_str()?.to_string(), f.get("value")?.as_str()?.to_string())))
369                                .collect()
370                        })
371                        .unwrap_or_default(),
372                });
373                start_web_upload(url, method, headers, source, multipart, emit.clone())
374            } else {
375                start_web_download(url, headers, emit.clone())
376            }
377        }
378        _ => return, // unknown / native-only source — ignore on web
379    };
380
381    STREAMS.with(|m| {
382        m.borrow_mut().insert(call.key.clone(), handle);
383    });
384}
385
386/// Monotonic milliseconds, for the ~10/sec progress throttle (`performance.now()`).
387fn js_now() -> f64 {
388    web_sys::window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
389}
390
391/// Parsed `multipart` envelope config (Task 1's `cx.upload(...).multipart(...)`), threaded
392/// into `start_web_upload` the same way `headers`/`method`/`source` are. `file_content_type`
393/// is deliberately NOT carried here — it's a native-only override; on web the browser derives
394/// the file part's `Content-Type` from the `Blob` itself.
395struct WebMultipart {
396    /// The file part's field name.
397    field: String,
398    /// Override for the file part's `filename=`; `None` -> infer from the source handle.
399    filename: Option<String>,
400    /// Text fields, in order, emitted before the file part.
401    fields: Vec<(String, String)>,
402}
403
404/// Last `/`-segment of a handle, `?query`/`#fragment` stripped; empty -> "file".
405fn infer_filename(source: &str) -> String {
406    let s = source.split(['?', '#']).next().unwrap_or(source);
407    let name = s.rsplit('/').next().unwrap_or("");
408    if name.is_empty() {
409        "file".to_string()
410    } else {
411        name.to_string()
412    }
413}
414
415/// Parse the CRLF-separated block from `XmlHttpRequest::get_all_response_headers` into
416/// `HttpHeader`s. Each line is `name: value`; a value never contains CRLF (XHR spec), so
417/// splitting on `\r\n` then on the first `:` is sufficient. Blank lines are skipped.
418fn parse_header_block(raw: &str) -> Vec<HttpHeader> {
419    raw.split("\r\n")
420        .filter_map(|line| {
421            let (name, value) = line.split_once(':')?;
422            let name = name.trim();
423            if name.is_empty() {
424                return None;
425            }
426            Some(HttpHeader { name: name.to_string(), value: value.trim().to_string() })
427        })
428        .collect()
429}
430
431/// Start a web upload via `XMLHttpRequest`.
432///
433/// DELIBERATE WEB ASYMMETRY (see `start_web_download` for the other half): upload uses
434/// XHR because it is the *only* web API that reports upload progress
435/// (`xhr.upload().onprogress`) — `fetch()` has no upload-progress signal at all. Do not
436/// "unify" this with fetch; there is no fetch-based way to get upload progress in a
437/// browser today.
438fn start_web_upload(
439    url: String,
440    method: String,
441    headers: Vec<(String, String)>,
442    source: String,
443    multipart: Option<WebMultipart>,
444    emit: impl Fn(PluginResponse) + Clone + 'static,
445) -> StreamHandle {
446    use wasm_bindgen::{closure::Closure, JsCast};
447    let xhr = web_sys::XmlHttpRequest::new().expect("xhr");
448    let _ = xhr.open_with_async(&method, &url, true);
449    for (n, val) in &headers {
450        // In multipart mode, the browser sets `Content-Type: multipart/form-data;
451        // boundary=...` itself when sending a `FormData` body; a caller-supplied
452        // `Content-Type` header would clobber that boundary and break the request. Other
453        // headers (Authorization, etc.) still apply.
454        if multipart.is_some() && n.eq_ignore_ascii_case("content-type") {
455            continue;
456        }
457        let _ = xhr.set_request_header(n, val);
458    }
459
460    // ~10/sec progress throttling, purely on elapsed time. (Gating on `loaded < total`
461    // as well would be a no-op when `!length_computable`, since `total()` is then 0 and
462    // `loaded() < 0` is always false.) The terminal Done is emitted by the
463    // separate onload/onerror/onabort closures below, unthrottled, so completion is always
464    // seen regardless of this gate.
465    let last = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
466    let on_prog = {
467        let (emit, last) = (emit.clone(), last.clone());
468        Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |e: web_sys::ProgressEvent| {
469            let now = js_now();
470            if now - last.get() < 100.0 {
471                return;
472            }
473            last.set(now);
474            let total = if e.length_computable() { Some(e.total() as u64) } else { None };
475            emit(transfer_response(&TransferEvent::Progress { transferred: e.loaded() as u64, total }));
476        })
477    };
478    if let Ok(upload) = xhr.upload() {
479        upload.set_onprogress(Some(on_prog.as_ref().unchecked_ref()));
480    }
481
482    // Terminal event: a response (even non-2xx) is `Done { Response }`; only a failure
483    // to obtain a response at all is `Done { TransportError }`.
484    let on_done = {
485        let (emit, xhr_c) = (emit.clone(), xhr.clone());
486        Closure::<dyn FnMut()>::new(move || {
487            let status = xhr_c.status().unwrap_or(0);
488            let outcome = if status == 0 {
489                HttpOutcome::TransportError { message: "upload failed".into() }
490            } else {
491                // Carry the response headers, like the download path and both native shells
492                // do — an upload caller may need ETag / Location. `getAllResponseHeaders`
493                // returns a CRLF-separated block (and, cross-origin, only the
494                // CORS-exposed headers — a browser limit the web download path shares; the
495                // native shells read the full header set).
496                let headers = xhr_c
497                    .get_all_response_headers()
498                    .ok()
499                    .map(|raw| parse_header_block(&raw))
500                    .unwrap_or_default();
501                HttpOutcome::Response { status, headers, body: vec![] }
502            };
503            emit(transfer_response(&TransferEvent::Done { outcome, handle: None }));
504        })
505    };
506    xhr.set_onload(Some(on_done.as_ref().unchecked_ref()));
507    let on_err = {
508        let emit = emit.clone();
509        Closure::<dyn FnMut()>::new(move || {
510            emit(transfer_response(&TransferEvent::Done {
511                outcome: HttpOutcome::TransportError { message: "upload error".into() },
512                handle: None,
513            }));
514        })
515    };
516    xhr.set_onerror(Some(on_err.as_ref().unchecked_ref()));
517    let on_abort = {
518        let emit = emit.clone();
519        Closure::<dyn FnMut()>::new(move || {
520            emit(transfer_response(&TransferEvent::Done {
521                outcome: HttpOutcome::TransportError { message: "upload aborted".into() },
522                handle: None,
523            }));
524        })
525    };
526    xhr.set_onabort(Some(on_abort.as_ref().unchecked_ref()));
527
528    // The upload `source` is itself a `blob:` URL (e.g. produced by `photo`/`camera` or
529    // `files`), so fetch it back into a `Blob` before sending — same shape a native
530    // shell would read a file handle. A missing/unreadable source sends no body.
531    //
532    // Cancel race (see `TransferHandle::drop`): `open_with_async` above has already run,
533    // but `send`/`send_with_opt_blob` is deferred behind the `fetch_blob` await. Per the
534    // XHR spec, `abort()` before the send-flag is set (i.e. before `send` is called) is a
535    // no-op, so if `cx.unsubscribe` fires in this window, `xhr.abort()` alone would not
536    // stop the request from going out. `cancelled` is the second half of that guarantee:
537    // it's checked right before `send`, after the await, so a drop that lands during the
538    // fetch is still honored.
539    let xhr_send = xhr.clone();
540    let cancelled = std::rc::Rc::new(std::cell::Cell::new(false));
541    let cancelled_send = cancelled.clone();
542    wasm_bindgen_futures::spawn_local(async move {
543        let blob = fetch_blob(&source).await;
544        if cancelled_send.get() {
545            return;
546        }
547        match (blob, &multipart) {
548            (Some(blob), Some(mp)) => {
549                // Text fields first, file part LAST (multipart/form-data ordering).
550                let form = web_sys::FormData::new().expect("FormData");
551                for (name, value) in &mp.fields {
552                    let _ = form.append_with_str(name, value);
553                }
554                let filename = mp.filename.clone().unwrap_or_else(|| infer_filename(&source));
555                let _ = form.append_with_blob_and_filename(&mp.field, &blob, &filename);
556                let _ = xhr_send.send_with_opt_form_data(Some(&form));
557            }
558            (Some(blob), None) => {
559                let _ = xhr_send.send_with_opt_blob(Some(&blob));
560            }
561            (None, _) => {
562                let _ = xhr_send.send();
563            }
564        }
565    });
566
567    StreamHandle::Transfer(TransferHandle {
568        xhr: Some(xhr),
569        abort: None,
570        cancelled: Some(cancelled),
571        _on_prog: Some(on_prog),
572        _on_done: Some(on_done),
573        _on_err: Some(on_err),
574        _on_abort: Some(on_abort),
575    })
576}
577
578/// Fetch a `blob:` (or any) URL back into a `Blob`, for handing to
579/// `XmlHttpRequest::send_with_opt_blob`. `None` on any failure (network error, not a
580/// Blob-shaped response, …) — the caller falls back to sending no body.
581async fn fetch_blob(url: &str) -> Option<web_sys::Blob> {
582    use wasm_bindgen::JsCast;
583    let win = web_sys::window()?;
584    let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_str(url)).await.ok()?;
585    let resp: web_sys::Response = resp_value.dyn_into().ok()?;
586    let blob_promise = resp.blob().ok()?;
587    let blob_value = wasm_bindgen_futures::JsFuture::from(blob_promise).await.ok()?;
588    blob_value.dyn_into().ok()
589}
590
591/// Start a web download via `fetch` + a `ReadableStream` reader.
592///
593/// DELIBERATE WEB ASYMMETRY (see `start_web_upload` for the other half): download uses
594/// `fetch`'s streaming response body to report progress as chunks arrive, then hands
595/// the app back a `blob:` handle for the assembled bytes — the same handle shape
596/// `take_image`/`photo.pick` returns via `Url::create_object_url_with_blob`. (XHR could
597/// also do a download, but fetch + ReadableStream is the standard/ergonomic way to get
598/// mid-transfer download progress on the web.)
599fn start_web_download(
600    url: String,
601    headers: Vec<(String, String)>,
602    emit: impl Fn(PluginResponse) + Clone + 'static,
603) -> StreamHandle {
604    let ctrl = web_sys::AbortController::new().expect("abortcontroller");
605    let signal = ctrl.signal();
606    let emit2 = emit.clone();
607    wasm_bindgen_futures::spawn_local(async move {
608        match fetch_stream(&url, &headers, &signal).await {
609            Ok((status, resp_headers, total, mut reader)) => {
610                let mut got: u64 = 0;
611                let mut chunks: Vec<u8> = Vec::new();
612                let mut last = js_now();
613                loop {
614                    match reader.next().await {
615                        Ok(Some(chunk)) => {
616                            got += chunk.len() as u64;
617                            chunks.extend_from_slice(&chunk);
618                            let now = js_now();
619                            // ~10/sec progress throttling (see `start_web_upload`).
620                            if now - last >= 100.0 {
621                                last = now;
622                                emit2(transfer_response(&TransferEvent::Progress { transferred: got, total }));
623                            }
624                        }
625                        Ok(None) => break, // stream finished
626                        Err(msg) => {
627                            emit2(transfer_response(&TransferEvent::Done {
628                                outcome: HttpOutcome::TransportError { message: msg },
629                                handle: None,
630                            }));
631                            return;
632                        }
633                    }
634                }
635                let handle = make_blob_url(&chunks);
636                let outcome = HttpOutcome::Response { status, headers: resp_headers, body: vec![] };
637                emit2(transfer_response(&TransferEvent::Done { outcome, handle: Some(handle) }));
638            }
639            Err(msg) => emit2(transfer_response(&TransferEvent::Done {
640                outcome: HttpOutcome::TransportError { message: msg },
641                handle: None,
642            })),
643        }
644    });
645    StreamHandle::Transfer(TransferHandle {
646        xhr: None,
647        abort: Some(ctrl),
648        cancelled: None,
649        _on_prog: None,
650        _on_done: None,
651        _on_err: None,
652        _on_abort: None,
653    })
654}
655
656/// Begin a GET (with the given headers) via `fetch` under `signal` and return the
657/// response's status, headers, `Content-Length` (if present) and a chunk [`Reader`]
658/// over its body stream.
659async fn fetch_stream(
660    url: &str,
661    headers: &[(String, String)],
662    signal: &web_sys::AbortSignal,
663) -> Result<(u16, Vec<HttpHeader>, Option<u64>, Reader), String> {
664    use wasm_bindgen::JsCast;
665    let win = web_sys::window().ok_or_else(|| "no window".to_string())?;
666    let js_headers = web_sys::Headers::new().map_err(|e| js_err(&e))?;
667    for (n, v) in headers {
668        js_headers.append(n, v).map_err(|e| js_err(&e))?;
669    }
670    let init = web_sys::RequestInit::new();
671    init.set_method("GET");
672    init.set_headers_headers(&js_headers);
673    init.set_signal(Some(signal));
674    let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|e| js_err(&e))?;
675
676    let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_request(&request))
677        .await
678        .map_err(|e| js_err(&e))?;
679    let resp: web_sys::Response = resp_value.dyn_into().map_err(|_| "fetch: not a Response".to_string())?;
680    let status = resp.status();
681    let resp_headers = response_headers(&resp.headers());
682    let total = resp_headers
683        .iter()
684        .find(|h| h.name.eq_ignore_ascii_case("content-length"))
685        .and_then(|h| h.value.parse().ok());
686
687    let Some(stream) = resp.body() else {
688        // No body (e.g. 204/304, or a HEAD-like response) — an empty reader is correct:
689        // the caller's loop immediately sees "finished" and moves straight to Done.
690        return Ok((status, resp_headers, total, Reader::empty()));
691    };
692    let reader = web_sys::ReadableStreamDefaultReader::new(&stream).map_err(|e| js_err(&e))?;
693    Ok((status, resp_headers, total, Reader::new(reader)))
694}
695
696/// A `web_sys::Headers` iterable (Fetch's `Headers` implements `Symbol.iterator` over
697/// `[name, value]` pairs) collected into our wire [`HttpHeader`] shape.
698fn response_headers(headers: &web_sys::Headers) -> Vec<HttpHeader> {
699    use wasm_bindgen::JsCast;
700    let mut out = Vec::new();
701    if let Ok(Some(iter)) = js_sys::try_iter(headers) {
702        for entry in iter.flatten() {
703            let arr: js_sys::Array = entry.unchecked_into();
704            let name = arr.get(0).as_string().unwrap_or_default();
705            let value = arr.get(1).as_string().unwrap_or_default();
706            out.push(HttpHeader { name, value });
707        }
708    }
709    out
710}
711
712/// Best-effort stringification of a `JsValue` error (e.g. a `DOMException`) for
713/// `TransferEvent::Done { outcome: HttpOutcome::TransportError { message } }`.
714fn js_err(e: &wasm_bindgen::JsValue) -> String {
715    use wasm_bindgen::JsCast;
716    e.as_string()
717        .or_else(|| e.dyn_ref::<js_sys::Error>().map(|err| String::from(err.message())))
718        .unwrap_or_else(|| "transfer error".to_string())
719}
720
721/// A minimal async chunk reader over a `ReadableStreamDefaultReader`. `next()` resolves
722/// to `Ok(Some(bytes))` per chunk, `Ok(None)` when the stream is done, or `Err(message)`
723/// if the underlying `read()` rejects (e.g. the fetch was aborted mid-stream).
724struct Reader(Option<web_sys::ReadableStreamDefaultReader>);
725impl Reader {
726    fn new(reader: web_sys::ReadableStreamDefaultReader) -> Self {
727        Self(Some(reader))
728    }
729    /// A reader over no stream at all (e.g. a bodiless response) — always "done".
730    fn empty() -> Self {
731        Self(None)
732    }
733    async fn next(&mut self) -> Result<Option<Vec<u8>>, String> {
734        use wasm_bindgen::JsCast;
735        let Some(reader) = &self.0 else { return Ok(None) };
736        let result = wasm_bindgen_futures::JsFuture::from(reader.read()).await.map_err(|e| js_err(&e))?;
737        let result: web_sys::ReadableStreamReadResult = result.unchecked_into();
738        if result.get_done().unwrap_or(true) {
739            return Ok(None);
740        }
741        let value = result.get_value();
742        let bytes = js_sys::Uint8Array::new(&value).to_vec();
743        Ok(Some(bytes))
744    }
745}
746
747/// Assemble bytes into a `Blob` and return an object URL — the download's `handle`. The
748/// same shape [`take_image`]'s `Url::create_object_url_with_blob` returns for a picked
749/// photo, so an app can render/save a downloaded file the same way.
750fn make_blob_url(bytes: &[u8]) -> String {
751    let array = js_sys::Uint8Array::from(bytes);
752    let parts = js_sys::Array::new();
753    parts.push(&array);
754    web_sys::Blob::new_with_u8_array_sequence(&parts)
755        .ok()
756        .and_then(|blob| web_sys::Url::create_object_url_with_blob(&blob).ok())
757        .unwrap_or_default()
758}
759
760/// A `system` deeplink event payload (the push-style tagged JSON the app demuxes by `type`).
761fn system_deeplink(url: &str) -> String {
762    format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
763}
764/// A `system` lifecycle event payload — page visibility maps to active/background.
765fn system_lifecycle(doc: &web_sys::Document) -> String {
766    let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
767    format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
768}
769
770/// An open streaming source, parked by subscription key for teardown. Dropping the
771/// entry stops the source (the `Interval` cancels on drop; the `WebSocket` is closed
772/// explicitly in the `unsubscribe` handler and its closures drop here).
773enum StreamHandle {
774    /// A `ticker` interval — held only so dropping it (on unsubscribe) cancels it.
775    Ticker { _interval: gloo_timers::callback::Interval },
776    Ws(WsStream),
777    /// The built-in `system` source — holds its JS listeners alive; `Drop` removes them on
778    /// unsubscribe (the handle is dropped when removed from `STREAMS`). Never pattern-matched.
779    #[allow(dead_code)]
780    System(SystemStream),
781    /// An in-flight transfer — held so dropping it (on unsubscribe) aborts the XHR /
782    /// cancels the fetch reader. Never pattern-matched.
783    #[allow(dead_code)]
784    Transfer(TransferHandle),
785}
786
787/// Holds a web transfer so unsubscribe can abort it. For upload we keep the
788/// `XmlHttpRequest` (call `.abort()` on drop via the Drop impl); for download we keep an
789/// `AbortController` whose `.abort()` cancels the fetch + reader.
790struct TransferHandle {
791    xhr: Option<web_sys::XmlHttpRequest>,
792    abort: Option<web_sys::AbortController>,
793    // Upload-cancel race guard (see the comment at `start_web_upload`'s `spawn_local`):
794    // `xhr.abort()` before `send()` has been called is a spec no-op, so this flag is the
795    // half that actually stops a not-yet-sent upload. `None` for download, which has no
796    // such window (its `AbortController` is wired into the fetch before any async work).
797    cancelled: Option<std::rc::Rc<std::cell::Cell<bool>>>,
798    // Typed closure fields (not `Closure::into_js_value`, which leaks permanently — see
799    // `WsStream`/`SystemStream` above for the same pattern): held here so they free when
800    // the handle drops, on unsubscribe or transfer completion. Download wires no XHR
801    // event closures, so its fields are `None`.
802    _on_prog: Option<wasm_bindgen::closure::Closure<dyn FnMut(web_sys::ProgressEvent)>>,
803    _on_done: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
804    _on_err: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
805    _on_abort: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
806}
807impl Drop for TransferHandle {
808    fn drop(&mut self) {
809        if let Some(c) = &self.cancelled {
810            c.set(true);
811        }
812        if let Some(x) = &self.xhr {
813            let _ = x.abort();
814        }
815        if let Some(a) = &self.abort {
816            a.abort();
817        }
818    }
819}
820
821/// Bincode a `TransferEvent` into a stream `PluginResponse` (mirrors Release A's `http` encode).
822fn transfer_response(ev: &TransferEvent) -> PluginResponse {
823    PluginResponse {
824        ok: matches!(ev, TransferEvent::Done { outcome, .. } if outcome.is_success()),
825        output: ev.encode(),
826    }
827}
828
829/// The `system` subscription's event listeners — removed from the DOM when dropped (unsubscribe).
830struct SystemStream {
831    win: web_sys::Window,
832    doc: web_sys::Document,
833    _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
834    _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
835}
836impl Drop for SystemStream {
837    fn drop(&mut self) {
838        use wasm_bindgen::JsCast;
839        let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
840        let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
841    }
842}
843
844/// An open web `WebSocket` subscription — holds its JS closures so they stay alive.
845struct WsStream {
846    ws: web_sys::WebSocket,
847    _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
848    _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
849}
850
851/// Fulfil a request/response capability. `http` via `fetch`; `device` via the
852/// browser's user-agent string (the web analogue of a device model).
853async fn perform(call: &PluginCall) -> PluginResponse {
854    if call.plugin == "device" {
855        let nav = web_sys::window().map(|w| w.navigator());
856        let output = if call.op == "locale" {
857            // The browser's preferred language as a BCP-47 tag (e.g. "de-CH").
858            nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
859        } else {
860            nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
861        };
862        return PluginResponse::text(true, output);
863    }
864    if call.plugin == "photo" && call.op == "pick" {
865        return take_image(false).await;
866    }
867    if call.plugin == "camera" && call.op == "capture" {
868        return take_image(true).await;
869    }
870    if call.plugin == "datetime" {
871        return match call.op.as_str() {
872            "date" => take_datetime("date").await,
873            "time" => take_datetime("time").await,
874            other => PluginResponse::text(false, format!("unknown datetime op '{other}'")),
875        };
876    }
877    if call.plugin == "dialog" && call.op == "confirm" {
878        let ok = confirm_modal(ConfirmAsk::parse(&call.input)).await;
879        return PluginResponse::text(ok, if ok { "ok" } else { "cancel" });
880    }
881    if call.plugin != "http" {
882        return PluginResponse::text(false, format!("plugin '{}' not available", call.plugin));
883    }
884    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
885    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
886    let body = v.get("body").and_then(serde_json::Value::as_str);
887    let req_headers: Vec<(String, String)> = v
888        .get("headers")
889        .and_then(serde_json::Value::as_array)
890        .map(|hs| {
891            hs.iter()
892                .filter_map(|h| {
893                    Some((
894                        h.get("name")?.as_str()?.to_string(),
895                        h.get("value")?.as_str()?.to_string(),
896                    ))
897                })
898                .collect()
899        })
900        .unwrap_or_default();
901
902    use gloo_net::http::{Method, Request};
903
904    // Exhaustive: an unknown verb is an error, never a silent GET. The previous
905    // `_ => Request::get(url)` fallthrough turned every PUT into a GET.
906    let builder = match call.op.as_str() {
907        "GET" => Request::get(url),
908        "POST" => Request::post(url),
909        "PUT" => Request::put(url),
910        "PATCH" => Request::patch(url),
911        "DELETE" => Request::delete(url),
912        "HEAD" => Request::get(url).method(Method::HEAD),
913        "OPTIONS" => Request::get(url).method(Method::OPTIONS),
914        other => return http_transport_error(format!("unsupported HTTP method '{other}'")),
915    };
916
917    // Only default Content-Type when the caller did not set one.
918    let caller_set_content_type =
919        req_headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("content-type"));
920
921    // `RequestBuilder::header` maps to `web_sys::Headers::set`, which REPLACES
922    // any existing value for that name — unlike iOS's `addValue` and Android's
923    // `addHeader`, which both APPEND. Build a `gloo_net::http::Headers` and
924    // `append` into it instead, so repeated names (Set-Cookie, Accept) survive
925    // on web the same way they do on the native shells.
926    let gloo_headers = gloo_net::http::Headers::new();
927    for (name, value) in &req_headers {
928        gloo_headers.append(name, value);
929    }
930    if body.is_some() && !caller_set_content_type {
931        gloo_headers.append("Content-Type", "application/json");
932    }
933    let builder = builder.headers(gloo_headers);
934
935    let request = match body {
936        Some(b) => builder.body(b),
937        None => builder.build(),
938    };
939    let request = match request {
940        Ok(r) => r,
941        Err(e) => return http_transport_error(e.to_string()),
942    };
943
944    match request.send().await {
945        Ok(resp) => {
946            let status = resp.status();
947            let headers = resp
948                .headers()
949                .entries()
950                .map(|(name, value)| HttpHeader { name, value })
951                .collect();
952            match resp.binary().await {
953                Ok(bytes) => {
954                    let outcome = HttpOutcome::Response { status, headers, body: bytes };
955                    PluginResponse { ok: (200..300).contains(&status), output: outcome.encode() }
956                }
957                // A body-read failure (truncated/aborted stream) is a transport
958                // failure, not a successful empty response — match native shells.
959                Err(e) => http_transport_error(e.to_string()),
960            }
961        }
962        Err(e) => http_transport_error(e.to_string()),
963    }
964}
965
966/// A failure where no HTTP response was obtained. `ok` is false and there is no status.
967fn http_transport_error(message: String) -> PluginResponse {
968    PluginResponse { ok: false, output: HttpOutcome::TransportError { message }.encode() }
969}
970
971/// Pick or capture an image via a hidden `<input type=file accept=image/*>`, clicked
972/// to open the browser's file dialog — or, with `capture`, to hint the device camera on
973/// supporting mobile browsers (desktop falls back to the file dialog). Awaits the
974/// `change` event and returns a `blob:` object URL the `<img>` renderer loads. No
975/// permission needed (the picker/camera prompt is the browser's). Backs both the
976/// `photo`/`pick` and `camera`/`capture` capabilities.
977async fn take_image(capture: bool) -> PluginResponse {
978    use wasm_bindgen::{closure::Closure, JsCast};
979    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
980        return PluginResponse::text(false, "no document");
981    };
982    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
983        return PluginResponse::text(false, "no input element");
984    };
985    input.set_type("file");
986    input.set_accept("image/*");
987    if capture {
988        // Hints the environment-facing camera on mobile browsers that support it.
989        let _ = input.set_attribute("capture", "environment");
990    }
991
992    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
993    let tx = std::cell::RefCell::new(Some(tx));
994    let input_for_cb = input.clone();
995    let on_change = Closure::wrap(Box::new(move || {
996        let url = input_for_cb
997            .files()
998            .and_then(|files| files.get(0))
999            .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
1000        if let Some(tx) = tx.borrow_mut().take() {
1001            let _ = tx.send(url);
1002        }
1003    }) as Box<dyn FnMut()>);
1004    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
1005    input.click();
1006    on_change.forget(); // keep the handler alive until `change` fires
1007
1008    match rx.await {
1009        Ok(Some(url)) => PluginResponse::text(true, url),
1010        _ => PluginResponse::text(false, "cancelled"),
1011    }
1012}
1013
1014/// Pick a date (`kind = "date"`) or time (`kind = "time"`) via a hidden native
1015/// `<input>`, opening the browser's picker with `showPicker()`. Returns the value
1016/// (`YYYY-MM-DD` for date, 24-hour `HH:MM` for time); `ok=false` on cancel/dismiss.
1017/// Backs the `datetime` capability (`cx.pick_date` / `cx.pick_time`).
1018async fn take_datetime(kind: &str) -> PluginResponse {
1019    use wasm_bindgen::{closure::Closure, JsCast};
1020    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
1021        return PluginResponse::text(false, "no document");
1022    };
1023    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
1024        return PluginResponse::text(false, "no input element");
1025    };
1026    input.set_type(kind); // "date" or "time"
1027    // showPicker() needs a connected element; keep it in the DOM but out of sight.
1028    let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
1029    if let Some(body) = doc.body() {
1030        let _ = body.append_child(&input);
1031    }
1032
1033    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
1034    let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
1035    let input_for_change = input.clone();
1036    let tx_change = tx.clone();
1037    let on_change = Closure::wrap(Box::new(move || {
1038        let v = input_for_change.value();
1039        if let Some(tx) = tx_change.borrow_mut().take() {
1040            let _ = tx.send(if v.is_empty() { None } else { Some(v) });
1041        }
1042    }) as Box<dyn FnMut()>);
1043    let tx_cancel = tx.clone();
1044    let on_cancel = Closure::wrap(Box::new(move || {
1045        if let Some(tx) = tx_cancel.borrow_mut().take() {
1046            let _ = tx.send(None);
1047        }
1048    }) as Box<dyn FnMut()>);
1049    let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
1050    let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
1051    if input.show_picker().is_err() {
1052        input.click(); // older browsers: focus the field so the user can type a value
1053    }
1054    on_change.forget(); // keep the handlers alive until an event fires
1055    on_cancel.forget();
1056
1057    let result = rx.await;
1058    input.remove();
1059    match result {
1060        Ok(Some(v)) => PluginResponse::text(true, v),
1061        _ => PluginResponse::text(false, "cancelled"),
1062    }
1063}
1064
1065const STORAGE_KEY: &str = "mobiler.state";
1066
1067/// `window.localStorage`, if available.
1068fn local_storage() -> Option<web_sys::Storage> {
1069    web_sys::window()?.local_storage().ok().flatten()
1070}
1071
1072/// Fulfil a fire-and-forget capability in the browser — the web twin of the native
1073/// shells' notify handlers (storage/clipboard/share/browser). None block; an unknown
1074/// capability is a graceful no-op.
1075fn perform_notify(notify: &PluginNotify) {
1076    let win = match web_sys::window() {
1077        Some(w) => w,
1078        None => return,
1079    };
1080    match (notify.plugin.as_str(), notify.op.as_str()) {
1081        // Persist the state blob (paired with cx.save + restore-on-startup above).
1082        ("storage", "save") => {
1083            if let Some(s) = local_storage() {
1084                let _ = s.set_item(STORAGE_KEY, &notify.input);
1085            }
1086        }
1087        // Copy to the clipboard (write_text returns a Promise we let run).
1088        ("clipboard", "copy") => {
1089            let _ = win.navigator().clipboard().write_text(&notify.input);
1090        }
1091        // Open a URL in a new tab.
1092        ("browser", "open") => {
1093            let _ = win.open_with_url_and_target(&notify.input, "_blank");
1094        }
1095        // No reliable cross-browser share sheet (navigator.share is mobile-only and
1096        // gesture-gated), so degrade to copying — a sane universal fallback.
1097        ("share", _) => {
1098            let _ = win.navigator().clipboard().write_text(&notify.input);
1099        }
1100        // Tear down a streaming subscription: close the WebSocket parked under this
1101        // key (input = the subscription key) and drop its closures. Paired with
1102        // cx.unsubscribe; the matching source was opened in `start_stream`.
1103        ("stream", "unsubscribe") => {
1104            // Removing the entry drops the source (a `ticker` Interval cancels on
1105            // drop); for a WebSocket we also close it explicitly.
1106            if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(&notify.input)) {
1107                let _ = ws.ws.close();
1108            }
1109        }
1110        // Transient toast: a styled div appended to <body>, auto-removed after a beat.
1111        ("toast", _) => show_toast(&notify.input),
1112        // Haptic tap. navigator.vibrate is unsupported on iOS Safari (a graceful no-op).
1113        ("haptics", style) => {
1114            let ms = match style {
1115                "light" => 15,
1116                "heavy" => 50,
1117                _ => 30, // medium / unknown
1118            };
1119            let _ = win.navigator().vibrate_with_duration(ms);
1120        }
1121        _ => {} // unknown capability: ignore
1122    }
1123}
1124
1125/// Append a transient toast to `<body>` (styled by `.toast` in mobiler.css) and
1126/// remove it after ~2.6 s — the web twin of the native toast/snackbar.
1127fn show_toast(text: &str) {
1128    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
1129    let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
1130    el.set_class_name("toast");
1131    el.set_text_content(Some(text));
1132    let _ = body.append_child(&el);
1133    gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
1134}
1135
1136/// What the `dialog`/`confirm` request asks for (see `mobiler_core::Confirm`); missing fields keep
1137/// the defaults, so a plain `cx.confirm` shows OK / Cancel.
1138struct ConfirmAsk {
1139    title: String,
1140    message: String,
1141    confirm_label: String,
1142    cancel_label: String,
1143    destructive: bool,
1144}
1145
1146impl ConfirmAsk {
1147    fn parse(input: &str) -> Self {
1148        let v: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::Value::Null);
1149        let s = |k: &str, d: &str| v.get(k).and_then(serde_json::Value::as_str).filter(|s| !s.is_empty()).unwrap_or(d).to_string();
1150        // The scaffold's ShellLabels supply the default when the call itself gives no label; a
1151        // per-call non-empty label still wins (that's what `s` above already does).
1152        let ok_default = shell_label(|l| l.ok.clone(), "OK");
1153        let cancel_default = shell_label(|l| l.cancel.clone(), "Cancel");
1154        Self {
1155            title: s("title", ""),
1156            message: s("message", ""),
1157            confirm_label: s("confirm_label", &ok_default),
1158            cancel_label: s("cancel_label", &cancel_default),
1159            destructive: v.get("destructive").and_then(serde_json::Value::as_bool).unwrap_or(false),
1160        }
1161    }
1162}
1163
1164/// Per-call counter so the title/message get unique DOM ids for `aria-labelledby` /
1165/// `aria-describedby` to point at, even if a dialog somehow opens while another is still closing.
1166static CONFIRM_DIALOG_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1167
1168/// The open confirm modal: its answer sender, whether a newer confirm replaced it, and where focus
1169/// should return when the (last) dialog closes. One confirm at a time — a new one answers the
1170/// open one `false` and takes over its focus-return target.
1171struct OpenConfirm {
1172    tx: std::rc::Rc<std::cell::RefCell<Option<futures_channel::oneshot::Sender<bool>>>>,
1173    superseded: std::rc::Rc<std::cell::Cell<bool>>,
1174    restore_to: Option<web_sys::Element>,
1175}
1176thread_local! {
1177    static OPEN_CONFIRM: std::cell::RefCell<Option<OpenConfirm>> = const { std::cell::RefCell::new(None) };
1178}
1179
1180/// The web confirm dialog: a modal card on `<body>` that resolves `true` on the confirm button and
1181/// `false` on the cancel button, Escape, or a press-and-release on the backdrop itself (a drag
1182/// that starts on the card and releases over the backdrop does not count). Enter activates the
1183/// focused button (focus starts on cancel for a destructive dialog, else on confirm); whatever had
1184/// focus before the dialog opened gets it back afterwards. Labelled for assistive tech via
1185/// `aria-labelledby`/`aria-describedby` when a title/message is present. Replaces
1186/// `window.confirm`, which can't be relabelled, styled or made accessible this way. Only one
1187/// confirm is ever open: a new call answers the open one `false` (superseding it, so its teardown
1188/// won't steal focus) and reuses its focus-return target, so focus ultimately returns to wherever
1189/// it was before the *first* dialog in the chain opened. The Tab key is trapped inside the card.
1190async fn confirm_modal(ask: ConfirmAsk) -> bool {
1191    use wasm_bindgen::{closure::Closure, JsCast};
1192    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return false };
1193    let Some(body) = doc.body() else { return false };
1194    let el = |tag: &str, class: &str| -> Option<web_sys::HtmlElement> {
1195        let e = doc.create_element(tag).ok()?.dyn_into::<web_sys::HtmlElement>().ok()?;
1196        e.set_class_name(class);
1197        Some(e)
1198    };
1199    let confirm_class = if ask.destructive { "btn btn-filled btn-danger" } else { "btn btn-filled" };
1200    let (Some(scrim), Some(card), Some(actions), Some(cancel), Some(confirm)) = (
1201        el("div", "confirm-scrim"),
1202        el("div", "confirm-card"),
1203        el("div", "confirm-actions"),
1204        el("button", "btn btn-text"),
1205        el("button", confirm_class),
1206    ) else {
1207        return false;
1208    };
1209
1210    // Inherit the scaffold's theme: brand vars (inline style) + dark / Large-density classes.
1211    if let Some(scaffold) = doc.query_selector(".scaffold").ok().flatten() {
1212        if let Some(style) = scaffold.get_attribute("style") {
1213            let _ = scrim.set_attribute("style", &style);
1214        }
1215        let classes = scaffold.class_list();
1216        for c in ["theme-dark", "density-large"] {
1217            if classes.contains(c) {
1218                let _ = scrim.class_list().add_1(c);
1219            }
1220        }
1221    }
1222
1223    let _ = card.set_attribute("role", "alertdialog");
1224    let _ = card.set_attribute("aria-modal", "true");
1225    let call_id = CONFIRM_DIALOG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1226    if !ask.title.is_empty() {
1227        if let Some(title) = el("div", "confirm-title") {
1228            let title_id = format!("mobiler-confirm-title-{call_id}");
1229            title.set_text_content(Some(&ask.title));
1230            let _ = title.set_attribute("id", &title_id);
1231            let _ = card.append_child(&title);
1232            let _ = card.set_attribute("aria-labelledby", &title_id);
1233        }
1234    }
1235    // No message element at all when there's nothing to say — an empty <p> would still get read
1236    // out by a screen reader and wired up as the description.
1237    if !ask.message.is_empty() {
1238        if let Some(message) = el("p", "confirm-message") {
1239            let msg_id = format!("mobiler-confirm-msg-{call_id}");
1240            message.set_text_content(Some(&ask.message));
1241            let _ = message.set_attribute("id", &msg_id);
1242            let _ = card.append_child(&message);
1243            let _ = card.set_attribute("aria-describedby", &msg_id);
1244        }
1245    }
1246    cancel.set_text_content(Some(&ask.cancel_label));
1247    confirm.set_text_content(Some(&ask.confirm_label));
1248    let _ = actions.append_child(&cancel);
1249    let _ = actions.append_child(&confirm);
1250    let _ = card.append_child(&actions);
1251    let _ = scrim.append_child(&card);
1252
1253    // Supersede any confirm that's still open, now that this one's elements exist and are about
1254    // to mount: it resolves `false`, its teardown won't restore focus (see below), and this
1255    // dialog inherits its focus-return target — so the target always traces back to wherever
1256    // focus was before the first dialog in the chain opened. Doing this only here (not before
1257    // element creation) means an early return above leaves the old dialog untouched.
1258    let previously_focused = OPEN_CONFIRM.with(|c| c.borrow_mut().take()).map(|old| {
1259        old.superseded.set(true);
1260        if let Some(tx) = old.tx.borrow_mut().take() {
1261            let _ = tx.send(false);
1262        }
1263        old.restore_to
1264    }).unwrap_or_else(|| doc.active_element());
1265
1266    let _ = body.append_child(&scrim);
1267    let _ = if ask.destructive { cancel.focus() } else { confirm.focus() };
1268
1269    let (tx, rx) = futures_channel::oneshot::channel::<bool>();
1270    let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
1271    let superseded = std::rc::Rc::new(std::cell::Cell::new(false));
1272    OPEN_CONFIRM.with(|c| {
1273        *c.borrow_mut() = Some(OpenConfirm { tx: tx.clone(), superseded: superseded.clone(), restore_to: previously_focused.clone() });
1274    });
1275    let answer = move |tx: &std::rc::Rc<std::cell::RefCell<Option<futures_channel::oneshot::Sender<bool>>>>, ok: bool| {
1276        if let Some(tx) = tx.borrow_mut().take() {
1277            let _ = tx.send(ok);
1278        }
1279    };
1280    let (t1, t2, t3, t4) = (tx.clone(), tx.clone(), tx.clone(), tx.clone());
1281    let on_cancel = Closure::wrap(Box::new(move || answer(&t1, false)) as Box<dyn FnMut()>);
1282    let on_confirm = Closure::wrap(Box::new(move || answer(&t2, true)) as Box<dyn FnMut()>);
1283
1284    // A drag that starts inside the card and releases over the backdrop (e.g. overshooting while
1285    // selecting the message text) must not read as a backdrop dismiss. `pointerdown` records
1286    // whether the press itself landed on the scrim; `click` (which fires on release, and bubbles
1287    // from whatever was under the pointer) only cancels when both the press and the click target
1288    // were the scrim itself, never a bubbled child.
1289    let press_started_on_scrim = std::rc::Rc::new(std::cell::Cell::new(false));
1290    let scrim_node: web_sys::EventTarget = scrim.clone().into();
1291    let press_flag = press_started_on_scrim.clone();
1292    let pointerdown_target = scrim_node.clone();
1293    let on_pointerdown = Closure::wrap(Box::new(move |e: web_sys::Event| {
1294        press_flag.set(e.target().as_ref() == Some(&pointerdown_target));
1295    }) as Box<dyn FnMut(web_sys::Event)>);
1296    let on_backdrop = Closure::wrap(Box::new(move |e: web_sys::Event| {
1297        if press_started_on_scrim.get() && e.target().as_ref() == Some(&scrim_node) {
1298            answer(&t3, false);
1299        }
1300    }) as Box<dyn FnMut(web_sys::Event)>);
1301    // Trap Tab inside the card: Shift+Tab off the first focusable (or from outside the card)
1302    // wraps to the last; Tab off the last (or from outside) wraps to the first.
1303    let (doc_for_key, card_for_key, cancel_for_key, confirm_for_key) = (doc.clone(), card.clone(), cancel.clone(), confirm.clone());
1304    let on_key = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
1305        if e.key() == "Escape" {
1306            answer(&t4, false);
1307        } else if e.key() == "Tab" {
1308            let first: web_sys::Element = cancel_for_key.clone().into();
1309            let last: web_sys::Element = confirm_for_key.clone().into();
1310            let active = doc_for_key.active_element();
1311            let inside = active.as_ref().is_some_and(|a| card_for_key.contains(a.dyn_ref::<web_sys::Node>()));
1312            let (at_first, at_last) = (active.as_ref() == Some(&first), active.as_ref() == Some(&last));
1313            if e.shift_key() {
1314                if at_first || !inside { e.prevent_default(); let _ = confirm_for_key.focus(); }
1315            } else if at_last || !inside {
1316                e.prevent_default();
1317                let _ = cancel_for_key.focus();
1318            }
1319        }
1320    }) as Box<dyn FnMut(web_sys::KeyboardEvent)>);
1321    cancel.set_onclick(Some(on_cancel.as_ref().unchecked_ref()));
1322    confirm.set_onclick(Some(on_confirm.as_ref().unchecked_ref()));
1323    let _ = scrim.add_event_listener_with_callback("pointerdown", on_pointerdown.as_ref().unchecked_ref());
1324    let _ = scrim.add_event_listener_with_callback("click", on_backdrop.as_ref().unchecked_ref());
1325    let _ = doc.add_event_listener_with_callback("keydown", on_key.as_ref().unchecked_ref());
1326
1327    let ok = rx.await.unwrap_or(false);
1328    // A real click sends on `tx` and `confirm_modal` resumes through a microtask while the click
1329    // is still bubbling toward the scrim — the spec runs a microtask checkpoint between listeners
1330    // during a user-initiated dispatch. A listener removed during dispatch is not invoked, so
1331    // detach every one of them (and null out the onclick handlers) before their closures drop
1332    // below; otherwise a click still in flight calls into an already-dropped closure and
1333    // wasm-bindgen throws "closure invoked recursively or after being dropped".
1334    let _ = doc.remove_event_listener_with_callback("keydown", on_key.as_ref().unchecked_ref());
1335    let _ = scrim.remove_event_listener_with_callback("click", on_backdrop.as_ref().unchecked_ref());
1336    let _ = scrim.remove_event_listener_with_callback("pointerdown", on_pointerdown.as_ref().unchecked_ref());
1337    cancel.set_onclick(None);
1338    confirm.set_onclick(None);
1339    scrim.remove();
1340    // Only clear OPEN_CONFIRM if it's still ours — a newer confirm may already have superseded us
1341    // and installed its own entry, which this teardown must not touch.
1342    OPEN_CONFIRM.with(|c| {
1343        let mut open = c.borrow_mut();
1344        if open.as_ref().is_some_and(|o| std::rc::Rc::ptr_eq(&o.tx, &tx)) {
1345            *open = None;
1346        }
1347    });
1348    // A superseded dialog's teardown must not steal focus back from whatever the newer dialog (or
1349    // its own teardown) is doing with it.
1350    if !superseded.get() {
1351        if let Some(focus_target) = previously_focused.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) {
1352            let _ = focus_target.focus();
1353        }
1354    }
1355    ok
1356}
1357
1358// ---------------- Widget → DOM ----------------
1359
1360/// The scaffold body's fill list, per the shared rule: the body itself, or the first direct child
1361/// of the body's column, that is a `LazyList { fill: true }`. `None` ⇒ the body scrolls as a page.
1362fn body_fill_index(body: &Widget) -> Option<Option<usize>> {
1363    match body {
1364        Widget::LazyList { fill: true, .. } => Some(None),
1365        Widget::Column { children } => {
1366            children.iter().position(|c| matches!(c, Widget::LazyList { fill: true, .. })).map(Some)
1367        }
1368        _ => None,
1369    }
1370}
1371
1372/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
1373/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
1374/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
1375/// the concrete look lives in `mobiler.css`.
1376fn render(widget: &Widget, send: &Dispatch) -> AnyView {
1377    match widget {
1378        // ---- content ----
1379        Widget::Text { content, style } => {
1380            let (class, content) = (text_class(*style), content.clone());
1381            view! { <p class=class>{content}</p> }.into_any()
1382        }
1383        Widget::Image { source, shape, ratio } => {
1384            let (class, source) = (image_class(*shape, *ratio), source.clone());
1385            view! { <img class=class src=source /> }.into_any()
1386        }
1387        Widget::Badge { label, tone } => {
1388            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
1389            view! { <span class=class>{label}</span> }.into_any()
1390        }
1391        Widget::ColorDot { color } => {
1392            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
1393        }
1394        Widget::Avatar { source, status } => {
1395            let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
1396            view! {
1397                <span class="avatar">
1398                    <img class="avatar-img" src=source.clone() />
1399                    {dot}
1400                </span>
1401            }
1402            .into_any()
1403        }
1404        Widget::PdfView { url } => {
1405            // Browsers render PDFs natively in an iframe (remote URL or local blob/file URL).
1406            let pdf_title = shell_label(|l| l.pdf_title.clone(), "PDF");
1407            view! { <iframe class="pdfview" src=url.clone() title=pdf_title></iframe> }.into_any()
1408        }
1409        Widget::WebView { url } => {
1410            // General embedded web content (incl. hosted player embeds like Bunny.net). `allow`
1411            // permits autoplay / fullscreen / PiP / encrypted-media so hosted players work.
1412            let web_title = shell_label(|l| l.web_title.clone(), "Web");
1413            view! {
1414                <iframe
1415                    class="webview"
1416                    src=url.clone()
1417                    title=web_title
1418                    allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
1419                    allowfullscreen=true
1420                ></iframe>
1421            }.into_any()
1422        }
1423        // Interactive map (MapLibre-GL, no key). The div carries the config as data-* attrs;
1424        // `inject_maplibre_support` inits the map + reports taps by firing `input` on the hidden sink,
1425        // which this `on:input` forwards as Action::Input { "{id}.tap" | "{id}.marker", Text(...) }.
1426        Widget::Map { id, center_lat, center_lng, zoom, markers, style_url, interactive } => {
1427            let send = send.clone();
1428            let id = id.clone();
1429            let center = format!("{center_lat},{center_lng}");
1430            let markers_json = serde_json::to_string(markers).unwrap_or_else(|_| "[]".to_string());
1431            let style = style_url.clone().unwrap_or_default();
1432            view! {
1433                <div class="mobiler-map-wrap">
1434                    <div
1435                        class="mobiler-map"
1436                        data-map="1"
1437                        data-center=center
1438                        data-zoom=zoom.to_string()
1439                        data-style=style
1440                        data-markers=markers_json
1441                        data-interactive=interactive.to_string()
1442                    ></div>
1443                    <input
1444                        class="mobiler-map-sink"
1445                        type="text"
1446                        tabindex="-1"
1447                        aria-hidden="true"
1448                        on:input=move |ev| {
1449                            let raw = event_target_value(&ev);
1450                            if let Some((suffix, value)) = raw.split_once('|') {
1451                                send(Action::Input {
1452                                    id: format!("{id}.{suffix}"),
1453                                    value: InputValue::Text(value.to_string()),
1454                                });
1455                            }
1456                        }
1457                    />
1458                </div>
1459            }.into_any()
1460        }
1461        Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
1462            // Web = a native-controls `<video>`. App-driven play/pause + seek + position/state events
1463            // are iOS/Android only: the web shell rebuilds the whole tree on each `update`, which would
1464            // reset the element ~every tick — so we don't pump those here (poster/captions/rate/volume
1465            // ARE declarative attributes, so they're safe). `muted && playing` → autoplay. MP4 plays
1466            // everywhere; HLS (.m3u8) plays natively on Safari and, on Chrome/Firefox, via the hls.js
1467            // bootstrap (`inject_hls_support`). A non-empty `urls` is a playlist (best-effort: starts at
1468            // `start_index`, advances on `ended` within this element's lifetime — no index pump back).
1469            use wasm_bindgen::JsCast;
1470            let (send, ended) = (send.clone(), on_ended.clone());
1471            let autoplay = *playing && *muted;
1472            let playlist = urls.clone();
1473            let start_index = (*start_index).max(0) as usize;
1474            let effective = if playlist.is_empty() { url.clone() }
1475                else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
1476            let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
1477            let src = (!is_hls).then(|| effective.clone());
1478            let hls_src = is_hls.then(|| effective.clone());
1479            let poster_attr = poster.clone();
1480            let start_at = *start_at_ms;
1481            let rate = *rate as f64;
1482            let volume = (*volume as f64).clamp(0.0, 1.0);
1483            let tracks: Vec<_> = captions.iter().map(|c| view! {
1484                <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
1485            }).collect();
1486            let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
1487            view! {
1488                <video
1489                    class="video"
1490                    src=src
1491                    data-hls-src=hls_src
1492                    poster=poster_attr
1493                    controls=*controls
1494                    autoplay=autoplay
1495                    prop:loop=*looping
1496                    prop:playbackRate=rate
1497                    prop:volume=volume
1498                    muted=*muted
1499                    playsinline=true
1500                    on:loadedmetadata=move |ev| {
1501                        if start_at >= 0 {
1502                            if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1503                                v.set_current_time(start_at as f64 / 1000.0);
1504                            }
1505                        }
1506                    }
1507                    on:ended=move |ev| {
1508                        let nxt = next_idx.get() + 1;
1509                        if !playlist.is_empty() && nxt < playlist.len() {
1510                            next_idx.set(nxt);
1511                            if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1512                                v.set_src(&playlist[nxt]);
1513                                let _ = v.play();
1514                            }
1515                        } else if let Some(t) = ended.clone() {
1516                            send(Action::Fired { token: t });
1517                        }
1518                    }
1519                >{tracks}</video>
1520            }.into_any()
1521        }
1522        Widget::Rating { value, max, on_rate } => {
1523            let value = *value;
1524            let stars: Vec<AnyView> = (1..=*max)
1525                .map(|i| {
1526                    let threshold = u32::from(i) * 10;
1527                    // filled / half / empty by tenths.
1528                    let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
1529                    match on_rate {
1530                        Some(tokens) => {
1531                            let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
1532                            view! {
1533                                <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
1534                                    {glyph}
1535                                </button>
1536                            }
1537                            .into_any()
1538                        }
1539                        None => view! { <span class="star">{glyph}</span> }.into_any(),
1540                    }
1541                })
1542                .collect();
1543            view! { <span class="rating">{stars}</span> }.into_any()
1544        }
1545        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
1546        Widget::Progress { value } => match value {
1547            Some(v) => {
1548                let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
1549                view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
1550            }
1551            None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
1552        },
1553        Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
1554        Widget::Chart { series, labels, style, axis, legend } => {
1555            chart_view(series, labels, *style, *axis, *legend)
1556        }
1557        Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
1558            region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
1559        }
1560        Widget::Calendar { title, weekday_labels, leading_blanks, selected, on_day, markers, .. } => {
1561            let heads: Vec<_> = weekday_labels.iter().map(|w| view! { <div class="cal-head">{w.clone()}</div> }).collect();
1562            let blanks: Vec<_> = (0..*leading_blanks).map(|_| view! { <div class="cal-blank"></div> }).collect();
1563            let selected = *selected;
1564            let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
1565                let day = (i + 1) as u8;
1566                let token = token.clone();
1567                let send = send.clone();
1568                let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
1569                // 0–3 busy-dots under the number; nothing at all for level 0 / no markers.
1570                let level = markers.get(i).copied().unwrap_or(0).min(3);
1571                let dots = (level > 0).then(|| {
1572                    let d: Vec<_> = (0..level).map(|_| view! { <span class="cal-dot"></span> }).collect();
1573                    view! { <span class="cal-dots">{d}</span> }
1574                });
1575                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}{dots}</button> }
1576            }).collect();
1577            view! {
1578                <div class="calendar">
1579                    <div class="cal-title">{title.clone()}</div>
1580                    <div class="cal-grid">{heads}{blanks}{days}</div>
1581                </div>
1582            }.into_any()
1583        }
1584        Widget::SwipeAction { child, actions } => {
1585            // Web has no swipe gesture — render the actions inline as a trailing button row.
1586            let acts: Vec<_> = actions.iter().map(|a| {
1587                let token = a.on_tap.clone();
1588                let send = send.clone();
1589                let cls = format!("swipe-act {}", tone_class(a.tone));
1590                let label = a.label.clone();
1591                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
1592            }).collect();
1593            view! {
1594                <div class="swipe-row">
1595                    <div class="swipe-content">{render(child, send)}</div>
1596                    <div class="swipe-actions">{acts}</div>
1597                </div>
1598            }.into_any()
1599        }
1600        Widget::Spacer { size } => {
1601            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
1602        }
1603
1604        // ---- layout ----
1605        Widget::Row { children } => {
1606            let kids = render_all(children, send);
1607            view! { <div class="row">{kids}</div> }.into_any()
1608        }
1609        Widget::Column { children } => {
1610            let kids = render_all(children, send);
1611            view! { <div class="col">{kids}</div> }.into_any()
1612        }
1613        Widget::Card { child, style, on_press, on_long_press } => {
1614            let class = format!("card {}", card_class(*style));
1615            let body = render(child, send);
1616            match (on_press, on_long_press) {
1617                // Plain, non-interactive card.
1618                (None, None) => view! { <div class=class>{body}</div> }.into_any(),
1619                // Tappable and/or long-pressable — render a button with the relevant handlers.
1620                (tap, long) => {
1621                    let send = send.clone();
1622                    // Web has no native long-press; shim it with a pointer-hold timer (~500 ms),
1623                    // cancelled on pointerup/leave/cancel. A `long_fired` flag suppresses the
1624                    // click that follows a successful hold so it doesn't also fire the tap.
1625                    let timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
1626                        Rc::new(RefCell::new(None));
1627                    let long_fired = Rc::new(RefCell::new(false));
1628
1629                    let on_pointerdown = {
1630                        let (send, long, timer, long_fired) =
1631                            (send.clone(), long.clone(), timer.clone(), long_fired.clone());
1632                        move |_: web_sys::PointerEvent| {
1633                            let Some(token) = long.clone() else { return };
1634                            *long_fired.borrow_mut() = false;
1635                            let (send, long_fired) = (send.clone(), long_fired.clone());
1636                            *timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
1637                                500,
1638                                move || {
1639                                    *long_fired.borrow_mut() = true;
1640                                    send(Action::Fired { token: token.clone() });
1641                                },
1642                            ));
1643                        }
1644                    };
1645                    let cancel = {
1646                        let timer = timer.clone();
1647                        // Dropping the `Timeout` cancels the pending fire.
1648                        move |_: web_sys::PointerEvent| { timer.borrow_mut().take(); }
1649                    };
1650                    let on_click = {
1651                        let (send, tap, long_fired) = (send.clone(), tap.clone(), long_fired.clone());
1652                        move |_| {
1653                            // Suppress the tap that trails a long-press.
1654                            if std::mem::take(&mut *long_fired.borrow_mut()) {
1655                                return;
1656                            }
1657                            if let Some(token) = tap.clone() {
1658                                send(Action::Fired { token });
1659                            }
1660                        }
1661                    };
1662                    view! {
1663                        <button
1664                            class=format!("{class} card-tappable")
1665                            on:pointerdown=on_pointerdown
1666                            on:pointerup=cancel.clone()
1667                            on:pointerleave=cancel.clone()
1668                            on:pointercancel=cancel
1669                            on:click=on_click
1670                        >
1671                            {body}
1672                        </button>
1673                    }
1674                    .into_any()
1675                }
1676            }
1677        }
1678        // Z-stack. With `scrim`, the first child is a background image, darkened
1679        // by an overlay, and the rest layer on top in light content — the DOM twin
1680        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
1681        Widget::Box { children, align, scrim } => {
1682            let acls = align_class(*align);
1683            if *scrim && children.len() > 1 {
1684                let bg = render(&children[0], send);
1685                let content = render_all(&children[1..], send);
1686                view! {
1687                    <div class=format!("box box-scrim {acls}")>
1688                        {bg}
1689                        <div class="scrim"></div>
1690                        <div class="box-content">{content}</div>
1691                    </div>
1692                }
1693                .into_any()
1694            } else {
1695                let kids = render_all(children, send);
1696                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
1697            }
1698        }
1699        Widget::Grid { children } => {
1700            let kids = render_all(children, send);
1701            view! { <div class="grid">{kids}</div> }.into_any()
1702        }
1703        Widget::Scroller { children, edge_fade } => {
1704            let kids = render_all(children, send);
1705            if *edge_fade {
1706                view! { <div class="scroller scroller-fade">{kids}<div class="scroller-end"></div></div> }.into_any()
1707            } else {
1708                view! { <div class="scroller">{kids}</div> }.into_any()
1709            }
1710        }
1711        // Two-pane master-detail. CSS does the adapting: wide (`@media min-width:768px`) shows both
1712        // panes side-by-side (back hidden); narrow shows one — primary by default, or detail (+ a
1713        // back chevron) when `data-detail` is set. `show_detail`/`on_back` only matter when narrow.
1714        Widget::Split { primary, detail, show_detail, on_back } => {
1715            let p = render(primary, send);
1716            let d = render(detail, send);
1717            let back_text = format!("‹ {}", shell_label(|l| l.back.clone(), "Back"));
1718            let back_btn = on_back.clone().map(|t| {
1719                let send = send.clone();
1720                view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>{back_text}</button> }
1721            });
1722            view! {
1723                <div class="split" data-detail=show_detail.then_some("1")>
1724                    <div class="split-primary">{p}</div>
1725                    <div class="split-detail">{back_btn}{d}</div>
1726                </div>
1727            }.into_any()
1728        }
1729        // Accessibility wrapper: name the subtree for a screen reader (aria-label), give it a role,
1730        // and the hint via `title`. Best-effort web mapping of iOS traits / Android semantics.
1731        Widget::A11y { child, label, hint, role } => {
1732            let body = render(child, send);
1733            let role_attr = role.map(a11y_role_aria).unwrap_or("group");
1734            view! {
1735                <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
1736                    {body}
1737                </div>
1738            }.into_any()
1739        }
1740        // A long/paged feed. Web has no pull gesture or reliable infinite-scroll on a sub-container,
1741        // so (like Scaffold pull-to-refresh) the gestures degrade to controls: a top "↻ Refresh"
1742        // button (while `on_refresh`), and a bottom "Load more" button (while `has_more && !loading`)
1743        // / loading bar / the app's end caption (if set). iOS/Android do true pull + scroll-near-end
1744        // detection.
1745        Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing, end_label, fill } => {
1746            let kids = render_all(children, send);
1747            // Only the one `LazyList` the Scaffold arm marked by address gets `lazylist-fill` —
1748            // another `fill: true` list elsewhere (nested, or a second one) keeps the 60vh cap.
1749            let is_fill_target =
1750                *fill && FILL_TARGET.with(|t| t.borrow().is_some_and(|p| std::ptr::eq(p, widget)));
1751            let list_class = if is_fill_target { "lazylist lazylist-fill" } else { "lazylist" };
1752            let refresh_text = format!("↻ {}", shell_label(|l| l.refresh.clone(), "Refresh"));
1753            let refresh_btn = on_refresh.clone().map(|token| {
1754                let send = send.clone();
1755                view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>{refresh_text}</button> }
1756            });
1757            let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1758            let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1759            let load_more_text = shell_label(|l| l.load_more.clone(), "Load more");
1760            let load_more_btn = (!*loading && *has_more)
1761                .then(|| on_load_more.clone())
1762                .flatten()
1763                .map(|token| {
1764                    let send = send.clone();
1765                    view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>{load_more_text}</button> }
1766                });
1767            // The app's own end text (e.g. "Kraj liste"); nothing when it didn't set one.
1768            let end_cap = (!*has_more && on_load_more.is_some())
1769                .then(|| end_label.clone())
1770                .flatten()
1771                .map(|label| view! { <div class="lazylist-end">{label}</div> });
1772            view! {
1773                <div class=list_class>
1774                    {refresh_btn}
1775                    {refresh_bar}
1776                    {kids}
1777                    {loading_bar}
1778                    {load_more_btn}
1779                    {end_cap}
1780                </div>
1781            }.into_any()
1782        }
1783
1784        // ---- input / actions ----
1785        Widget::Button { label, style, on_press, tone, icon, wide } => {
1786            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1787            // Neutral + not wide keeps the exact original class string.
1788            let mut class = format!("btn {}", button_class(*style));
1789            if *tone != Tone::Neutral {
1790                class.push(' ');
1791                class.push_str(button_tone_class(*tone));
1792            }
1793            if *wide {
1794                class.push_str(" btn-wide");
1795            }
1796            let glyph = icon.map(|i| view! { <span class="btn-icon">{icon_glyph(i)}</span> });
1797            view! {
1798                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1799                    {glyph}
1800                    {label}
1801                </button>
1802            }
1803            .into_any()
1804        }
1805        Widget::IconButton { icon, on_press } => {
1806            let (send, token) = (send.clone(), on_press.clone());
1807            let glyph = icon_glyph(*icon);
1808            view! {
1809                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
1810                    {glyph}
1811                </button>
1812            }
1813            .into_any()
1814        }
1815        Widget::Chip { label, selected, on_press } => {
1816            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1817            let class = if *selected { "chip selected" } else { "chip" };
1818            view! {
1819                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1820                    {label}
1821                </button>
1822            }
1823            .into_any()
1824        }
1825        Widget::TextField { id, placeholder, value, kind, error } => {
1826            let (send, id) = (send.clone(), id.clone());
1827            let (placeholder, value) = (placeholder.clone(), value.clone());
1828            let invalid = error.is_some();
1829            let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
1830            // (input type, inputmode) per FieldKind. Multiline renders a <textarea> below.
1831            let (itype, imode): (&str, &str) = match kind {
1832                FieldKind::Secure => ("password", ""),
1833                FieldKind::Email => ("email", "email"),
1834                FieldKind::Number => ("text", "numeric"),
1835                FieldKind::Decimal => ("text", "decimal"),
1836                FieldKind::Phone => ("tel", "tel"),
1837                FieldKind::Url => ("url", "url"),
1838                FieldKind::Text | FieldKind::Multiline => ("text", ""),
1839            };
1840            let field_class = if invalid { "field field-invalid" } else { "field" };
1841            let control = if matches!(kind, FieldKind::Multiline) {
1842                view! {
1843                    <textarea
1844                        class=field_class
1845                        rows="3"
1846                        placeholder=placeholder
1847                        prop:value=value
1848                        on:input=move |ev| send(Action::Input {
1849                            id: id.clone(),
1850                            value: InputValue::Text(event_target_value(&ev)),
1851                        })
1852                    ></textarea>
1853                }
1854                .into_any()
1855            } else {
1856                view! {
1857                    <input
1858                        class=field_class
1859                        r#type=itype
1860                        inputmode=imode
1861                        placeholder=placeholder
1862                        prop:value=value
1863                        on:input=move |ev| send(Action::Input {
1864                            id: id.clone(),
1865                            value: InputValue::Text(event_target_value(&ev)),
1866                        })
1867                    />
1868                }
1869                .into_any()
1870            };
1871            view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
1872        }
1873        Widget::SearchField { id, placeholder, value } => {
1874            let (send, id) = (send.clone(), id.clone());
1875            let (placeholder, value) = (placeholder.clone(), value.clone());
1876            view! {
1877                <div class="searchfield">
1878                    <span class="search-icon">{icon_glyph(Icon::Search)}</span>
1879                    <input
1880                        class="search-input"
1881                        placeholder=placeholder
1882                        prop:value=value
1883                        on:input=move |ev| send(Action::Input {
1884                            id: id.clone(),
1885                            value: InputValue::Text(event_target_value(&ev)),
1886                        })
1887                    />
1888                </div>
1889            }
1890            .into_any()
1891        }
1892        Widget::Segmented { segments } => {
1893            let segs: Vec<AnyView> = segments
1894                .iter()
1895                .map(|s| {
1896                    let (send, token) = (send.clone(), s.on_select.clone());
1897                    let class = if s.selected { "segment selected" } else { "segment" };
1898                    let label = s.label.clone();
1899                    view! {
1900                        <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1901                            {label}
1902                        </button>
1903                    }
1904                    .into_any()
1905                })
1906                .collect();
1907            view! { <div class="segmented">{segs}</div> }.into_any()
1908        }
1909        Widget::Toggle { id, label, value } => {
1910            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1911            view! {
1912                <label class="toggle">
1913                    {label}
1914                    <input
1915                        type="checkbox"
1916                        role="switch"
1917                        prop:checked=checked
1918                        on:change=move |ev| send(Action::Input {
1919                            id: id.clone(),
1920                            value: InputValue::Bool(event_target_checked(&ev)),
1921                        })
1922                    />
1923                </label>
1924            }
1925            .into_any()
1926        }
1927        Widget::Checkbox { id, label, value } => {
1928            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1929            view! {
1930                <label class="check">
1931                    <input
1932                        type="checkbox"
1933                        prop:checked=checked
1934                        on:change=move |ev| send(Action::Input {
1935                            id: id.clone(),
1936                            value: InputValue::Bool(event_target_checked(&ev)),
1937                        })
1938                    />
1939                    {label}
1940                </label>
1941            }
1942            .into_any()
1943        }
1944        Widget::Slider { id, value, max } => {
1945            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1946            view! {
1947                <input
1948                    class="slider"
1949                    type="range"
1950                    min="0"
1951                    max=max
1952                    prop:value=value
1953                    on:input=move |ev| send(Action::Input {
1954                        id: id.clone(),
1955                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1956                    })
1957                />
1958            }
1959            .into_any()
1960        }
1961        Widget::Stepper { value, on_decrement, on_increment } => {
1962            let send_dec = send.clone();
1963            let send_inc = send.clone();
1964            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1965            view! {
1966                <div class="stepper">
1967                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1968                    <span class="stepper-value">{*value}</span>
1969                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1970                </div>
1971            }
1972            .into_any()
1973        }
1974
1975        // ---- shell ----
1976        Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth, labels: _ } => {
1977            // ACTIVE_LABELS is stashed once at the root render closure (from the root widget's
1978            // own `labels`), not here — a Scaffold nested in a sheet, body or Split must not
1979            // overwrite the root's labels. This arm's own aria-label reads below still see the
1980            // root labels, since the stash is set before render() is called.
1981            let back_aria = shell_label(|l| l.back.clone(), "Back");
1982            let back_btn = back.clone().map(|token| {
1983                let send = send.clone();
1984                view! {
1985                    <button class="back" aria-label=back_aria on:click=move |_| send(Action::Fired { token: token.clone() })>
1986                        "‹"
1987                    </button>
1988                }
1989            });
1990            let tabbar = (!tabs.is_empty()).then(|| {
1991                let tabs: Vec<AnyView> = tabs
1992                    .iter()
1993                    .map(|tab| {
1994                        let (send, token) = (send.clone(), tab.on_select.clone());
1995                        let class = if tab.selected { "tab selected" } else { "tab" };
1996                        let label = tab.label.clone();
1997                        // Optional leading icon → glyph above the label (icon tab bar).
1998                        let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1999                        view! {
2000                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
2001                                {icon}
2002                                <span class="tab-label">{label}</span>
2003                            </button>
2004                        }
2005                        .into_any()
2006                    })
2007                    .collect();
2008                view! { <div class="tabbar">{tabs}</div> }
2009            });
2010            // Floating action button — the raised primary action, anchored over the body.
2011            let fab_btn = fab.clone().map(|f| {
2012                let (send, token) = (send.clone(), f.on_press.clone());
2013                view! {
2014                    <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
2015                        {icon_glyph(f.icon)}
2016                    </button>
2017                }
2018            });
2019            // Modal bottom sheet — a scrim (tap to dismiss) + a panel rising from the bottom.
2020            let sheet_overlay = sheet.as_ref().map(|s| {
2021                let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
2022                let (title, child) = (s.title.clone(), render(&s.child, send));
2023                view! {
2024                    <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
2025                    <div class="sheet">
2026                        <div class="sheet-handle"></div>
2027                        <div class="sheet-title">{title}</div>
2028                        {child}
2029                    </div>
2030                }
2031            });
2032            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
2033            // the web twin of the native shells' `preferredColorScheme`/Material theme.
2034            // `density-large` scopes the Density::Large control sizes in mobiler.css.
2035            let large = theme.as_ref().is_some_and(|t| t.density == Density::Large);
2036            // Fill mode: the body itself, or the first direct child of the body's column, is a
2037            // `LazyList { fill: true }` (`body_fill_index`). Stops page-scroll; that list takes
2038            // the rest of the height and scrolls itself.
2039            let fill_index = body_fill_index(body);
2040            let class = format!(
2041                "scaffold{}{}{}",
2042                if *dark_mode { " theme-dark" } else { "" },
2043                if large { " density-large" } else { "" },
2044                if fill_index.is_some() { " scaffold-fill" } else { "" },
2045            );
2046            // Pull-to-refresh — web has no pull gesture, so expose a top-bar refresh button +
2047            // an indeterminate bar at the top of the body while `refreshing`.
2048            let refresh_aria = shell_label(|l| l.refresh.clone(), "Refresh");
2049            let refresh_btn = on_refresh.clone().map(|token| {
2050                let send = send.clone();
2051                view! {
2052                    <button class="refresh-btn" aria-label=refresh_aria on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
2053                }
2054            });
2055            let refresh_bar = refreshing.then(|| {
2056                view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
2057            });
2058            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
2059            // An app `Theme` overrides the CSS variables inline (brand color, corner, density,
2060            // font) — the web twin of the native shells' brand/tint + shape + spacing + font.
2061            let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
2062            // Mark the fill target by address so the LazyList arm can pick out exactly that one,
2063            // even if other `fill: true` lists exist elsewhere. `render` is a plain function, so
2064            // the whole subtree below builds synchronously within this call — the marker is only
2065            // ever visible during it.
2066            let target = fill_index.map(|idx| match idx {
2067                None => &**body as *const Widget,
2068                Some(i) => match &**body {
2069                    Widget::Column { children } => &children[i] as *const Widget,
2070                    _ => unreachable!("body_fill_index only returns Some(Some(_)) for a Column body"),
2071                },
2072            });
2073            let prev = FILL_TARGET.with(|t| t.replace(target));
2074            let (title, body) = (title.clone(), render(body, send));
2075            FILL_TARGET.with(|t| *t.borrow_mut() = prev);
2076            view! {
2077                <div class=class style=theme_style>
2078                    <div class="topbar">
2079                        {back_btn}
2080                        <span class="title">{title}</span>
2081                        {refresh_btn}
2082                    </div>
2083                    <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
2084                    {fab_btn}
2085                    {tabbar}
2086                    {sheet_overlay}
2087                </div>
2088            }
2089            .into_any()
2090        }
2091    }
2092}
2093
2094/// Render a slice of children as sibling views.
2095fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
2096    children.iter().map(|c| render(c, send)).collect()
2097}
2098
2099thread_local! {
2100    /// (previous route key, previous depth, alternating toggle). The render is a
2101    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
2102    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
2103    /// the native shells keying their body on `route`.
2104    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
2105
2106    /// Open streaming subscriptions keyed by subscription key (wasm is single-
2107    /// threaded). Each [`Effect::PluginStream`] parks its source here so
2108    /// `cx.unsubscribe(key)` can stop it; dropping the entry stops the source.
2109    static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
2110
2111    /// The current scaffold's app-wide shell text, set once in the root render closure from the
2112    /// root widget and read by code that never sees the view (the confirm modal). `None` ⇒
2113    /// English defaults.
2114    static ACTIVE_LABELS: std::cell::RefCell<Option<ShellLabels>> = const { std::cell::RefCell::new(None) };
2115
2116    /// The address of the scaffold body's fill `LazyList` (see [`body_fill_index`]), set around the
2117    /// synchronous `render(body, ...)` call in the Scaffold arm and restored to its previous value
2118    /// right after — so a nested fill Scaffold doesn't wipe an outer marker. `render` is a plain
2119    /// function — the whole tree is built eagerly within that call — so the LazyList arm sees this
2120    /// set only while rendering the marked widget's subtree.
2121    static FILL_TARGET: std::cell::RefCell<Option<*const Widget>> = const { std::cell::RefCell::new(None) };
2122}
2123
2124/// The app's label for a piece of shell text, or `default`. An empty string (an app that set
2125/// the field to `""`) falls back to `default` too, same as the other shells.
2126fn shell_label(pick: impl Fn(&ShellLabels) -> Option<String>, default: &str) -> String {
2127    ACTIVE_LABELS.with(|l| {
2128        l.borrow()
2129            .as_ref()
2130            .and_then(pick)
2131            .filter(|s| !s.is_empty())
2132            .unwrap_or_else(|| default.to_string())
2133    })
2134}
2135
2136/// Render an app [`Theme`] as inline CSS custom properties on the scaffold root — the web
2137/// twin of the native brand/tint + shape + spacing + font. Overrides `mobiler.css`'s defaults
2138/// (its rules read these via `var(--…)`); dark mode still works (it only swaps the colors the
2139/// seed doesn't pin).
2140fn theme_css(t: &Theme) -> String {
2141    let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
2142    let radius = match t.corner {
2143        Corner::None => "0px",
2144        Corner::Small => "8px",
2145        Corner::Medium => "14px",
2146        Corner::Large => "22px",
2147    };
2148    let (gap, pad) = match t.density {
2149        Density::Compact => ("8px", "10px"),
2150        Density::Comfortable => ("12px", "14px"),
2151        Density::Large => ("16px", "18px"),
2152    };
2153    let font = match t.font {
2154        FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
2155        FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
2156        FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
2157        FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
2158    };
2159    // Secondary brand color (for the CardStyle::Brand gradient); falls back to the seed.
2160    let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
2161    format!(
2162        "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
2163         --accent2:rgb({ar},{ag},{ab});\
2164         --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
2165         --gap:{gap};--pad:{pad};--font:{font};"
2166    )
2167}
2168
2169/// Pick the Scaffold body's transition class for this render. Returns `""` for a
2170/// same-route data update (re-render in place, no transition). On a route change it
2171/// returns a directional class — slide-in from the right when `depth` grew (push),
2172/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
2173/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
2174/// Leptos reuses the same DOM node.
2175fn nav_class(route: &str, depth: u32) -> &'static str {
2176    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
2177        if route == prev_route {
2178            return "";
2179        }
2180        let dir = if depth > *prev_depth {
2181            ["nav-push-a", "nav-push-b"]
2182        } else if depth < *prev_depth {
2183            ["nav-pop-a", "nav-pop-b"]
2184        } else {
2185            ["nav-fade-a", "nav-fade-b"]
2186        };
2187        *toggle = !*toggle;
2188        *prev_route = route.to_string();
2189        *prev_depth = depth;
2190        dir[usize::from(*toggle)]
2191    })
2192}
2193
2194// ---- style intent → CSS class / glyph (the only place that names the look) ----
2195
2196fn text_class(s: TextStyle) -> &'static str {
2197    match s {
2198        TextStyle::Title => "t-title",
2199        TextStyle::Subtitle => "t-subtitle",
2200        TextStyle::Caption => "t-caption",
2201        TextStyle::Emphasis => "t-emphasis",
2202        TextStyle::Body => "t-body",
2203    }
2204}
2205
2206fn button_class(s: ButtonStyle) -> &'static str {
2207    match s {
2208        ButtonStyle::Filled => "btn-filled",
2209        ButtonStyle::Outlined => "btn-outlined",
2210        ButtonStyle::Text => "btn-text",
2211        ButtonStyle::Tonal => "btn-tonal",
2212    }
2213}
2214
2215fn button_tone_class(t: Tone) -> &'static str {
2216    match t {
2217        Tone::Neutral => "",
2218        Tone::Success => "btn-success",
2219        Tone::Warning => "btn-warning",
2220        Tone::Danger => "btn-danger",
2221        Tone::Info => "btn-info",
2222    }
2223}
2224
2225fn card_class(s: CardStyle) -> &'static str {
2226    match s {
2227        CardStyle::Elevated => "card-elevated",
2228        CardStyle::Outlined => "card-outlined",
2229        CardStyle::Filled => "card-filled",
2230        CardStyle::Brand => "card-brand",
2231    }
2232}
2233
2234fn a11y_role_aria(role: A11yRole) -> &'static str {
2235    match role {
2236        A11yRole::Button => "button",
2237        A11yRole::Link => "link",
2238        A11yRole::Image => "img",
2239        A11yRole::Header => "heading",
2240        A11yRole::Adjustable => "slider",
2241    }
2242}
2243
2244fn tone_class(t: Tone) -> &'static str {
2245    match t {
2246        Tone::Neutral => "tone-neutral",
2247        Tone::Success => "tone-success",
2248        Tone::Warning => "tone-warning",
2249        Tone::Danger => "tone-danger",
2250        Tone::Info => "tone-info",
2251    }
2252}
2253
2254fn spacer_class(s: Spacing) -> &'static str {
2255    match s {
2256        Spacing::Xs => "sp-xs",
2257        Spacing::Sm => "sp-sm",
2258        Spacing::Md => "sp-md",
2259        Spacing::Lg => "sp-lg",
2260        Spacing::Xl => "sp-xl",
2261    }
2262}
2263
2264fn icon_glyph(i: Icon) -> &'static str {
2265    match i {
2266        Icon::Delete => "🗑",
2267        Icon::Add => "+",
2268        Icon::Edit => "✏️",
2269        Icon::Close => "✕",
2270        Icon::Settings => "⚙",
2271        Icon::Check => "✓",
2272        Icon::Star => "★",
2273        Icon::Info => "ℹ",
2274        Icon::Home => "⌂",
2275        Icon::Search => "🔍",
2276        Icon::Menu => "☰",
2277        Icon::Filter => "⚟",
2278        Icon::Back => "‹",
2279        Icon::Forward => "›",
2280        Icon::Down => "⌄",
2281        Icon::Bell => "🔔",
2282        Icon::Cart => "🛒",
2283        Icon::Share => "↗",
2284        Icon::Heart => "♡",
2285        Icon::HeartFilled => "♥",
2286        Icon::Person => "👤",
2287        Icon::People => "👥",
2288        Icon::Phone => "📞",
2289        Icon::Mail => "✉",
2290        Icon::Calendar => "📅",
2291        Icon::Clock => "🕑",
2292        Icon::MapPin => "📍",
2293        Icon::Camera => "📷",
2294        Icon::Photo => "🖼",
2295        Icon::Play => "▶",
2296        Icon::Scissors => "✂",
2297    }
2298}
2299
2300fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
2301    let shape = match shape {
2302        ImageShape::Square => "img-square",
2303        ImageShape::Rounded => "img-rounded",
2304        ImageShape::Circle => "img-circle",
2305    };
2306    let ratio = match ratio {
2307        ImageRatio::Wide => "ratio-wide",
2308        ImageRatio::Square => "ratio-square",
2309        ImageRatio::Tall => "ratio-tall",
2310    };
2311    format!("img {shape} {ratio}")
2312}
2313
2314fn dot_class(c: ProjectColor) -> &'static str {
2315    match c {
2316        ProjectColor::Indigo => "dot-indigo",
2317        ProjectColor::Teal => "dot-teal",
2318        ProjectColor::Coral => "dot-coral",
2319        ProjectColor::Amber => "dot-amber",
2320        ProjectColor::Lime => "dot-lime",
2321        ProjectColor::Pink => "dot-pink",
2322    }
2323}
2324
2325fn align_class(a: BoxAlign) -> &'static str {
2326    match a {
2327        BoxAlign::TopStart => "align-top-start",
2328        BoxAlign::TopEnd => "align-top-end",
2329        BoxAlign::Center => "align-center",
2330        BoxAlign::BottomStart => "align-bottom-start",
2331        BoxAlign::BottomCenter => "align-bottom-center",
2332        BoxAlign::BottomEnd => "align-bottom-end",
2333    }
2334}
2335
2336// ------------------------------- charts -------------------------------
2337
2338/// Distinct fallback colors for series 1.. (series 0 with no override rides the theme accent).
2339const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
2340
2341fn hex(c: Rgb) -> String {
2342    format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
2343}
2344
2345/// Color for series `i`: explicit override → theme accent (i==0) → palette.
2346fn chart_color(i: usize, s: &ChartSeries) -> String {
2347    match s.color {
2348        Some(c) => hex(c),
2349        None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
2350        None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
2351    }
2352}
2353
2354/// A series' single magnitude for circular charts (sum of its values).
2355fn chart_mag(s: &ChartSeries) -> f32 {
2356    s.values.iter().copied().sum()
2357}
2358
2359/// Point on a circle: `ang` in radians, 0 = top (12 o'clock), increasing clockwise.
2360fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
2361    (cx + r * ang.sin(), cy - r * ang.cos())
2362}
2363
2364/// An open arc path (for ring/donut/gauge strokes).
2365fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
2366    let (x0, y0) = polar(cx, cy, r, a0);
2367    let (x1, y1) = polar(cx, cy, r, a1);
2368    let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
2369    format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
2370}
2371
2372/// A filled wedge from the center (for pie/donut slices).
2373fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
2374    let (x0, y0) = polar(cx, cy, r, a0);
2375    let (x1, y1) = polar(cx, cy, r, a1);
2376    let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
2377    format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
2378}
2379
2380fn fmt_tick(v: f32) -> String {
2381    if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
2382}
2383
2384fn is_cartesian(style: ChartStyle) -> bool {
2385    matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
2386}
2387
2388/// The y-axis denominator for a cartesian chart.
2389fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
2390    match style {
2391        ChartStyle::StackedBar => (0..nslots)
2392            .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
2393            .fold(0.0, f32::max)
2394            .max(1e-6),
2395        ChartStyle::StackedBar100 => 1.0,
2396        _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
2397    }
2398}
2399
2400fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
2401    // plot area: y in [2, 48] of the 0..50 viewBox
2402    let mut nodes: Vec<AnyView> = Vec::new();
2403    if axis {
2404        for k in 0..=4 {
2405            let y = 2.0 + k as f32 * (46.0 / 4.0);
2406            nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
2407        }
2408    }
2409    match style {
2410        ChartStyle::Line => {
2411            for (i, s) in series.iter().enumerate() {
2412                let n = s.values.len().max(1);
2413                let pts = s.values.iter().enumerate().map(|(j, v)| {
2414                    let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
2415                    let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
2416                    format!("{x:.2},{y:.2}")
2417                }).collect::<Vec<_>>().join(" ");
2418                let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
2419                nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
2420            }
2421        }
2422        ChartStyle::Bar => {
2423            let sw = 100.0 / nslots as f32;
2424            let ns = series.len().max(1);
2425            for (i, s) in series.iter().enumerate() {
2426                let st = format!("fill:{}", chart_color(i, s));
2427                for (j, v) in s.values.iter().enumerate() {
2428                    let h = (v / max).clamp(0.0, 1.0) * 46.0;
2429                    let bw = sw * 0.8 / ns as f32;
2430                    let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
2431                    let y = 48.0 - h;
2432                    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());
2433                }
2434            }
2435        }
2436        ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
2437            let sw = 100.0 / nslots as f32;
2438            for j in 0..nslots {
2439                let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
2440                let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
2441                let mut acc = 0.0_f32;
2442                for (i, s) in series.iter().enumerate() {
2443                    let v = *s.values.get(j).unwrap_or(&0.0);
2444                    let h = (v / denom).clamp(0.0, 1.0) * 46.0;
2445                    let x = j as f32 * sw + sw * 0.15;
2446                    let bw = sw * 0.7;
2447                    let y = 48.0 - acc - h;
2448                    let st = format!("fill:{}", chart_color(i, s));
2449                    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());
2450                    acc += h;
2451                }
2452            }
2453        }
2454        _ => {}
2455    }
2456    view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
2457}
2458
2459fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
2460    use std::f32::consts::PI;
2461    let mut nodes: Vec<AnyView> = Vec::new();
2462    match style {
2463        ChartStyle::Pie | ChartStyle::Donut => {
2464            let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
2465            let mut a = 0.0_f32;
2466            for (i, s) in series.iter().enumerate() {
2467                let frac = chart_mag(s) / total;
2468                let st = format!("fill:{}", chart_color(i, s));
2469                if frac >= 0.999 {
2470                    nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
2471                } else if frac > 0.0 {
2472                    let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
2473                    nodes.push(view! { <path d=d style=st></path> }.into_any());
2474                }
2475                a += frac * 2.0 * PI;
2476            }
2477            if matches!(style, ChartStyle::Donut) {
2478                nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
2479            }
2480        }
2481        ChartStyle::Rings => {
2482            let n = series.len().max(1);
2483            for (i, s) in series.iter().enumerate() {
2484                let r = 45.0 - i as f32 * (34.0 / n as f32);
2485                let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2486                let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2487                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());
2488                let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
2489                if prog >= 0.999 {
2490                    nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
2491                } else if prog > 0.0 {
2492                    let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
2493                    nodes.push(view! { <path d=d style=st></path> }.into_any());
2494                }
2495            }
2496        }
2497        ChartStyle::Gauge => {
2498            let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
2499            let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2500            let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2501            let a0 = -0.75 * PI; // 270° sweep, gap at the bottom
2502            let a1 = 0.75 * PI;
2503            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());
2504            if prog > 0.0 {
2505                let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
2506                nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
2507            }
2508            let pct = format!("{}%", (prog * 100.0).round() as i64);
2509            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());
2510        }
2511        _ => {}
2512    }
2513    view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
2514}
2515
2516fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
2517    let cartesian = is_cartesian(style);
2518    let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
2519    let max = cartesian_max(series, style, nslots);
2520
2521    let plot = if cartesian {
2522        let svg = cartesian_svg(series, style, axis, max, nslots);
2523        let yaxis = if axis {
2524            let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
2525                .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
2526                .collect();
2527            Some(view! { <div class="chart-yaxis">{ticks}</div> })
2528        } else {
2529            None
2530        };
2531        view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
2532    } else {
2533        circular_svg(series, style).into_any()
2534    };
2535
2536    let label_row = if cartesian && !labels.is_empty() {
2537        let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
2538        Some(view! { <div class="chart-labels">{items}</div> })
2539    } else {
2540        None
2541    };
2542
2543    let legend_row = if legend {
2544        let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
2545            let sw = format!("background:{}", chart_color(i, s));
2546            let name = s.name.clone();
2547            view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2548        }).collect();
2549        Some(view! { <div class="chart-legend">{items}</div> })
2550    } else {
2551        None
2552    };
2553
2554    view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
2555}
2556
2557// --------------------------- region chart ---------------------------
2558
2559/// Palette as RGB (parallel to `CHART_PALETTE`) so region charts can compute label contrast.
2560const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
2561    [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
2562
2563/// The resolved fill RGB for region `i` (explicit override → palette).
2564fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
2565    match r.color {
2566        Some(c) => (c.r, c.g, c.b),
2567        None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
2568    }
2569}
2570
2571/// Black or white label text, whichever reads on the given fill (perceived luminance).
2572fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
2573    let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
2574    if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
2575}
2576
2577fn region_color(i: usize, r: &ChartRegion) -> String {
2578    let (r8, g8, b8) = region_rgb(i, r);
2579    format!("#{r8:02x}{g8:02x}{b8:02x}")
2580}
2581
2582// A variable-width stacked-region / coverage-gap chart: absolute-positioned region rectangles in
2583// the [0,x_max]×[0,y_max] plane, horizontal ref lines + chips, an irregular x-axis, an optional
2584// right-side bracket, and a legend. The web twin of the Compose/SwiftUI RegionChart renderers.
2585fn region_chart_view(
2586    regions: &[ChartRegion],
2587    ticks: &[ChartTick],
2588    x_max: f32,
2589    y_max: f32,
2590    ref_lines: &[ChartRefLine],
2591    bracket: &Option<ChartBracket>,
2592    legend: &[ChartLegendItem],
2593) -> AnyView {
2594    let xm = x_max.max(1e-6);
2595    let ym = y_max.max(1e-6);
2596
2597    let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
2598        let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
2599        let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
2600        let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
2601        let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
2602        let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
2603        let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
2604        let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
2605        let label = r.label.clone();
2606        view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
2607    }).collect();
2608
2609    // The reference lines span the full plot width; their value chips sit in the right margin
2610    // (outside the plot), like the original — so the line clearly runs to the plot's edge.
2611    let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
2612        let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2613        let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
2614        view! { <div class=cls style=style></div> }
2615    }).collect();
2616    let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
2617        let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2618        let label = rl.label.clone();
2619        view! { <div class="rchart-chip" style=style>{label}</div> }
2620    }).collect();
2621
2622    let bracket_div = bracket.as_ref().map(|b| {
2623        let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
2624        let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
2625        let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
2626        let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
2627        view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
2628    });
2629
2630    let yticks: Vec<_> = (0..=4).rev().map(|k| {
2631        let v = ym * k as f32 / 4.0;
2632        view! { <span class="chart-tick">{fmt_tick(v)}</span> }
2633    }).collect();
2634
2635    let xticks: Vec<_> = ticks.iter().map(|t| {
2636        let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2637        let label = t.label.clone();
2638        view! { <span class="rchart-xtick" style=style>{label}</span> }
2639    }).collect();
2640
2641    // Axis tick marks (notches on the L-shaped axis): horizontal on the y-axis at each value,
2642    // vertical on the x-axis at each irregular break — drawn over the bands at the plot edges.
2643    let ytick_marks: Vec<_> = (0..=4).map(|k| {
2644        let style = format!("bottom:{:.3}%", k as f32 * 25.0);
2645        view! { <div class="rchart-ytick" style=style></div> }
2646    }).collect();
2647    let xtick_marks: Vec<_> = ticks.iter().map(|t| {
2648        let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2649        view! { <div class="rchart-xtickmark" style=style></div> }
2650    }).collect();
2651
2652    let legend_row = if legend.is_empty() {
2653        None
2654    } else {
2655        let items: Vec<_> = legend.iter().map(|l| {
2656            let sw = format!("background:{}", hex(l.color));
2657            let name = l.label.clone();
2658            view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2659        }).collect();
2660        Some(view! { <div class="chart-legend">{items}</div> })
2661    };
2662
2663    view! {
2664        <div class="rchart">
2665            <div class="rchart-row">
2666                <div class="rchart-yaxis">{yticks}</div>
2667                <div class="rchart-plotwrap">
2668                    <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
2669                    {chip_divs}{bracket_div}
2670                </div>
2671            </div>
2672            <div class="rchart-xaxis">{xticks}</div>
2673            {legend_row}
2674        </div>
2675    }.into_any()
2676}