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, Icon,
27    ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, PluginStreamCall, ProjectColor,
28    Rgb, Spacing, TextStyle, Theme, Tone, 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    leptos::mount::mount_to_body(shell::<A>);
68}
69
70/// hls.js bootstrap for HLS (`.m3u8`) playback in browsers without native HLS
71/// (Chrome/Firefox — Safari/iOS play HLS natively). A `<video>` whose source is an
72/// `.m3u8` is rendered with `data-hls-src` and no `src`; this self-contained script
73/// watches the DOM (a `MutationObserver`, so it also catches elements re-rendered on
74/// each `update`) and, for each such `<video>`, either sets `src` directly (native
75/// HLS, e.g. Safari) or lazily loads hls.js from a CDN and attaches it. If the CDN
76/// fails it falls back to a plain `src`. Inert until an `.m3u8` `Video` appears, so
77/// MP4/Bunny content (and non-video apps) pay nothing. Bunny content keeps using its
78/// own player via `WebView`; this is for raw non-Bunny `.m3u8` on Chrome/Firefox.
79fn inject_hls_support() {
80    const BOOTSTRAP: &str = r#"(function(){
81  function ensureHls(cb){
82    if(window.Hls){return cb();}
83    if(window.__mobilerHlsLoading){(window.__mobilerHlsCbs=window.__mobilerHlsCbs||[]).push(cb);return;}
84    window.__mobilerHlsLoading=true;window.__mobilerHlsCbs=[cb];
85    var s=document.createElement('script');
86    s.src='https://cdn.jsdelivr.net/npm/hls.js@1';
87    var flush=function(){var cbs=window.__mobilerHlsCbs||[];window.__mobilerHlsCbs=[];cbs.forEach(function(f){f();});};
88    s.onload=flush;s.onerror=flush;
89    document.head.appendChild(s);
90  }
91  function attach(v){
92    if(v.__mobilerHlsDone){return;}v.__mobilerHlsDone=true;
93    var url=v.getAttribute('data-hls-src');if(!url){return;}
94    if(v.canPlayType('application/vnd.apple.mpegurl')){v.src=url;return;}
95    ensureHls(function(){
96      if(window.Hls&&window.Hls.isSupported()){var h=new window.Hls();h.loadSource(url);h.attachMedia(v);v.__mobilerHls=h;}
97      else{v.src=url;}
98    });
99  }
100  function scan(root){if(root&&root.querySelectorAll){root.querySelectorAll('video[data-hls-src]').forEach(attach);}}
101  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});
102  scan(document);
103})();"#;
104    let document = leptos::prelude::document();
105    let Some(head) = document.head() else { return };
106    let Ok(script) = document.create_element("script") else { return };
107    script.set_text_content(Some(BOOTSTRAP));
108    let _ = head.append_child(&script);
109}
110
111/// Inject the shell's default stylesheet at the **front** of `<head>` so it's the
112/// lowest-precedence baseline: an app that ships its own CSS (later in the document)
113/// overrides any of these classes, while an app with no CSS still gets a full theme.
114fn inject_default_style() {
115    let document = leptos::prelude::document();
116    let Some(head) = document.head() else { return };
117    let Ok(style) = document.create_element("style") else { return };
118    let _ = style.set_attribute("data-mobiler", "shell");
119    style.set_text_content(Some(STYLE));
120    let _ = head.insert_before(&style, head.first_child().as_ref());
121}
122
123fn shell<A: WebApp>() -> impl IntoView
124where
125    A::Model: Default + Send + Sync,
126{
127    let core = Arc::new(Core::<A>::new());
128    let (view, set_view) = signal(core.view());
129
130    let send: Dispatch = {
131        let core = core.clone();
132        Arc::new(move |action: Action| {
133            let effects = core.process_event(action);
134            drive(&core, set_view, effects);
135        })
136    };
137
138    // Restore persisted state (localStorage), then fire Start — mirrors the native
139    // shells (which restore before Start so the app sees its saved Model on launch).
140    let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
141    if !saved.is_empty() {
142        send(Action::Restore { data: saved });
143    }
144    send(Action::Start);
145
146    let send_for_view = send.clone();
147    view! {
148        <div class="app">
149            {move || render(&view.get(), &send_for_view)}
150        </div>
151    }
152}
153
154/// Process effects: re-read the view on Render; fulfil HTTP via fetch and resolve.
155fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
156where
157    A::Model: Default + Send + Sync,
158{
159    for effect in effects {
160        match effect {
161            Effect::Render(_) => set_view.set(core.view()),
162            Effect::PluginNotify(notify) => perform_notify(&notify.operation),
163            Effect::Plugin(mut request) => {
164                let core = core.clone();
165                spawn_local(async move {
166                    let response = perform(&request.operation).await;
167                    if let Ok(next) = core.resolve(&mut request, response) {
168                        drive(&core, set_view, next);
169                    }
170                });
171            }
172            // Long-lived subscription: start a native source that resolves the same
173            // request repeatedly (one event per `core.resolve`). See `start_stream`.
174            Effect::PluginStream(request) => start_stream(core, set_view, request),
175        }
176    }
177}
178
179/// Start a streaming subscription ([`Effect::PluginStream`]): begin a native source
180/// that resolves `request` **repeatedly** (a [`PluginResponse`] per event), each
181/// resolution re-entering the core. The source handle is parked in a per-key
182/// registry so [`unsubscribe`](mobiler_core::Cx::unsubscribe) can stop it.
183///
184/// Web sources: `ticker`/`start` (a `setInterval` emitting an incrementing counter
185/// every `input` ms — the deterministic demonstrator) and `websocket`/`stream`
186/// (a `WebSocket`, a frame per `onmessage`).
187fn start_stream<A: WebApp>(
188    core: &Arc<Core<A>>,
189    set_view: WriteSignal<Widget>,
190    request: Request<PluginStreamCall>,
191) where
192    A::Model: Default + Send + Sync,
193{
194    use wasm_bindgen::{closure::Closure, JsCast};
195
196    let call = request.operation.clone();
197
198    // Each resolution of a `resolves_many_times` request yields the next stream item;
199    // share the request across event closures via Rc<RefCell<_>>.
200    let request = Rc::new(RefCell::new(request));
201    let core = core.clone();
202    let emit = move |resp: PluginResponse| {
203        if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
204            drive(&core, set_view, next);
205        }
206    };
207
208    let handle = match (call.plugin.as_str(), call.op.as_str()) {
209        // Built-in deterministic demonstrator: emit an incrementing counter every
210        // `input` ms. Dropping the Interval (on unsubscribe) stops it.
211        ("ticker", "start") => {
212            let ms: u32 = call.input.parse().unwrap_or(1000);
213            let count = std::cell::Cell::new(0u32);
214            let interval = gloo_timers::callback::Interval::new(ms, move || {
215                count.set(count.get() + 1);
216                emit(PluginResponse { ok: true, output: count.get().to_string() });
217            });
218            StreamHandle::Ticker { _interval: interval }
219        }
220        ("websocket", "stream") => {
221            let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
222            let onmessage = {
223                let emit = emit.clone();
224                Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
225                    emit(PluginResponse { ok: true, output: e.data().as_string().unwrap_or_default() });
226                })
227            };
228            let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
229                emit(PluginResponse { ok: false, output: "closed".into() });
230            });
231            ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
232            ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
233            StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
234        }
235        // Built-in `system` source: deep-link URLs + app lifecycle. On the web a "deep link" is the
236        // current URL (delivered on subscribe + on `popstate`) and "lifecycle" maps to page
237        // visibility (`visibilitychange`). Listeners are dropped (removed) on unsubscribe.
238        ("system", "events") => {
239            let win = web_sys::window().expect("window");
240            let doc = win.document().expect("document");
241            // Initial: the current URL as a deeplink + current visibility as lifecycle.
242            if let Ok(href) = win.location().href() {
243                emit(PluginResponse { ok: true, output: system_deeplink(&href) });
244            }
245            emit(PluginResponse { ok: true, output: system_lifecycle(&doc) });
246            let onpop = {
247                let (emit, win) = (emit.clone(), win.clone());
248                Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
249                    if let Ok(href) = win.location().href() {
250                        emit(PluginResponse { ok: true, output: system_deeplink(&href) });
251                    }
252                })
253            };
254            let onvis = {
255                let (emit, doc) = (emit.clone(), doc.clone());
256                Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
257                    emit(PluginResponse { ok: true, output: system_lifecycle(&doc) });
258                })
259            };
260            let _ = win.add_event_listener_with_callback("popstate", onpop.as_ref().unchecked_ref());
261            let _ = doc.add_event_listener_with_callback("visibilitychange", onvis.as_ref().unchecked_ref());
262            StreamHandle::System(SystemStream { win, doc, _onpop: onpop, _onvis: onvis })
263        }
264        _ => return, // unknown / native-only source — ignore on web
265    };
266
267    STREAMS.with(|m| {
268        m.borrow_mut().insert(call.key.clone(), handle);
269    });
270}
271
272/// A `system` deeplink event payload (the push-style tagged JSON the app demuxes by `type`).
273fn system_deeplink(url: &str) -> String {
274    format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
275}
276/// A `system` lifecycle event payload — page visibility maps to active/background.
277fn system_lifecycle(doc: &web_sys::Document) -> String {
278    let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
279    format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
280}
281
282/// An open streaming source, parked by subscription key for teardown. Dropping the
283/// entry stops the source (the `Interval` cancels on drop; the `WebSocket` is closed
284/// explicitly in the `unsubscribe` handler and its closures drop here).
285enum StreamHandle {
286    /// A `ticker` interval — held only so dropping it (on unsubscribe) cancels it.
287    Ticker { _interval: gloo_timers::callback::Interval },
288    Ws(WsStream),
289    /// The built-in `system` source — holds its JS listeners alive; `Drop` removes them on
290    /// unsubscribe (the handle is dropped when removed from `STREAMS`). Never pattern-matched.
291    #[allow(dead_code)]
292    System(SystemStream),
293}
294
295/// The `system` subscription's event listeners — removed from the DOM when dropped (unsubscribe).
296struct SystemStream {
297    win: web_sys::Window,
298    doc: web_sys::Document,
299    _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
300    _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
301}
302impl Drop for SystemStream {
303    fn drop(&mut self) {
304        use wasm_bindgen::JsCast;
305        let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
306        let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
307    }
308}
309
310/// An open web `WebSocket` subscription — holds its JS closures so they stay alive.
311struct WsStream {
312    ws: web_sys::WebSocket,
313    _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
314    _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
315}
316
317/// Fulfil a request/response capability. `http` via `fetch`; `device` via the
318/// browser's user-agent string (the web analogue of a device model).
319async fn perform(call: &PluginCall) -> PluginResponse {
320    if call.plugin == "device" {
321        let nav = web_sys::window().map(|w| w.navigator());
322        let output = if call.op == "locale" {
323            // The browser's preferred language as a BCP-47 tag (e.g. "de-CH").
324            nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
325        } else {
326            nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
327        };
328        return PluginResponse { ok: true, output };
329    }
330    if call.plugin == "photo" && call.op == "pick" {
331        return take_image(false).await;
332    }
333    if call.plugin == "camera" && call.op == "capture" {
334        return take_image(true).await;
335    }
336    if call.plugin == "datetime" {
337        return match call.op.as_str() {
338            "date" => take_datetime("date").await,
339            "time" => take_datetime("time").await,
340            other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
341        };
342    }
343    if call.plugin == "dialog" && call.op == "confirm" {
344        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
345        let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
346        let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
347        let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
348        let ok = web_sys::window()
349            .and_then(|w| w.confirm_with_message(&prompt).ok())
350            .unwrap_or(false);
351        return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
352    }
353    if call.plugin != "http" {
354        return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
355    }
356    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
357    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
358    let body = v.get("body").and_then(serde_json::Value::as_str);
359
360    use gloo_net::http::Request;
361    let builder = match call.op.as_str() {
362        "POST" => Request::post(url),
363        "PATCH" => Request::patch(url),
364        "DELETE" => Request::delete(url),
365        _ => Request::get(url),
366    };
367    let request = match body {
368        Some(b) => builder.header("Content-Type", "application/json").body(b),
369        None => builder.build(),
370    };
371    let request = match request {
372        Ok(r) => r,
373        Err(e) => return PluginResponse { ok: false, output: e.to_string() },
374    };
375    match request.send().await {
376        Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
377        Err(e) => PluginResponse { ok: false, output: e.to_string() },
378    }
379}
380
381/// Pick or capture an image via a hidden `<input type=file accept=image/*>`, clicked
382/// to open the browser's file dialog — or, with `capture`, to hint the device camera on
383/// supporting mobile browsers (desktop falls back to the file dialog). Awaits the
384/// `change` event and returns a `blob:` object URL the `<img>` renderer loads. No
385/// permission needed (the picker/camera prompt is the browser's). Backs both the
386/// `photo`/`pick` and `camera`/`capture` capabilities.
387async fn take_image(capture: bool) -> PluginResponse {
388    use wasm_bindgen::{closure::Closure, JsCast};
389    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
390        return PluginResponse { ok: false, output: "no document".into() };
391    };
392    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
393        return PluginResponse { ok: false, output: "no input element".into() };
394    };
395    input.set_type("file");
396    input.set_accept("image/*");
397    if capture {
398        // Hints the environment-facing camera on mobile browsers that support it.
399        let _ = input.set_attribute("capture", "environment");
400    }
401
402    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
403    let tx = std::cell::RefCell::new(Some(tx));
404    let input_for_cb = input.clone();
405    let on_change = Closure::wrap(Box::new(move || {
406        let url = input_for_cb
407            .files()
408            .and_then(|files| files.get(0))
409            .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
410        if let Some(tx) = tx.borrow_mut().take() {
411            let _ = tx.send(url);
412        }
413    }) as Box<dyn FnMut()>);
414    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
415    input.click();
416    on_change.forget(); // keep the handler alive until `change` fires
417
418    match rx.await {
419        Ok(Some(url)) => PluginResponse { ok: true, output: url },
420        _ => PluginResponse { ok: false, output: "cancelled".into() },
421    }
422}
423
424/// Pick a date (`kind = "date"`) or time (`kind = "time"`) via a hidden native
425/// `<input>`, opening the browser's picker with `showPicker()`. Returns the value
426/// (`YYYY-MM-DD` for date, 24-hour `HH:MM` for time); `ok=false` on cancel/dismiss.
427/// Backs the `datetime` capability (`cx.pick_date` / `cx.pick_time`).
428async fn take_datetime(kind: &str) -> PluginResponse {
429    use wasm_bindgen::{closure::Closure, JsCast};
430    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
431        return PluginResponse { ok: false, output: "no document".into() };
432    };
433    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
434        return PluginResponse { ok: false, output: "no input element".into() };
435    };
436    input.set_type(kind); // "date" or "time"
437    // showPicker() needs a connected element; keep it in the DOM but out of sight.
438    let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
439    if let Some(body) = doc.body() {
440        let _ = body.append_child(&input);
441    }
442
443    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
444    let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
445    let input_for_change = input.clone();
446    let tx_change = tx.clone();
447    let on_change = Closure::wrap(Box::new(move || {
448        let v = input_for_change.value();
449        if let Some(tx) = tx_change.borrow_mut().take() {
450            let _ = tx.send(if v.is_empty() { None } else { Some(v) });
451        }
452    }) as Box<dyn FnMut()>);
453    let tx_cancel = tx.clone();
454    let on_cancel = Closure::wrap(Box::new(move || {
455        if let Some(tx) = tx_cancel.borrow_mut().take() {
456            let _ = tx.send(None);
457        }
458    }) as Box<dyn FnMut()>);
459    let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
460    let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
461    if input.show_picker().is_err() {
462        input.click(); // older browsers: focus the field so the user can type a value
463    }
464    on_change.forget(); // keep the handlers alive until an event fires
465    on_cancel.forget();
466
467    let result = rx.await;
468    input.remove();
469    match result {
470        Ok(Some(v)) => PluginResponse { ok: true, output: v },
471        _ => PluginResponse { ok: false, output: "cancelled".into() },
472    }
473}
474
475const STORAGE_KEY: &str = "mobiler.state";
476
477/// `window.localStorage`, if available.
478fn local_storage() -> Option<web_sys::Storage> {
479    web_sys::window()?.local_storage().ok().flatten()
480}
481
482/// Fulfil a fire-and-forget capability in the browser — the web twin of the native
483/// shells' notify handlers (storage/clipboard/share/browser). None block; an unknown
484/// capability is a graceful no-op.
485fn perform_notify(notify: &PluginNotify) {
486    let win = match web_sys::window() {
487        Some(w) => w,
488        None => return,
489    };
490    match (notify.plugin.as_str(), notify.op.as_str()) {
491        // Persist the state blob (paired with cx.save + restore-on-startup above).
492        ("storage", "save") => {
493            if let Some(s) = local_storage() {
494                let _ = s.set_item(STORAGE_KEY, &notify.input);
495            }
496        }
497        // Copy to the clipboard (write_text returns a Promise we let run).
498        ("clipboard", "copy") => {
499            let _ = win.navigator().clipboard().write_text(&notify.input);
500        }
501        // Open a URL in a new tab.
502        ("browser", "open") => {
503            let _ = win.open_with_url_and_target(&notify.input, "_blank");
504        }
505        // No reliable cross-browser share sheet (navigator.share is mobile-only and
506        // gesture-gated), so degrade to copying — a sane universal fallback.
507        ("share", _) => {
508            let _ = win.navigator().clipboard().write_text(&notify.input);
509        }
510        // Tear down a streaming subscription: close the WebSocket parked under this
511        // key (input = the subscription key) and drop its closures. Paired with
512        // cx.unsubscribe; the matching source was opened in `start_stream`.
513        ("stream", "unsubscribe") => {
514            // Removing the entry drops the source (a `ticker` Interval cancels on
515            // drop); for a WebSocket we also close it explicitly.
516            if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(&notify.input)) {
517                let _ = ws.ws.close();
518            }
519        }
520        // Transient toast: a styled div appended to <body>, auto-removed after a beat.
521        ("toast", _) => show_toast(&notify.input),
522        // Haptic tap. navigator.vibrate is unsupported on iOS Safari (a graceful no-op).
523        ("haptics", style) => {
524            let ms = match style {
525                "light" => 15,
526                "heavy" => 50,
527                _ => 30, // medium / unknown
528            };
529            let _ = win.navigator().vibrate_with_duration(ms);
530        }
531        _ => {} // unknown capability: ignore
532    }
533}
534
535/// Append a transient toast to `<body>` (styled by `.toast` in mobiler.css) and
536/// remove it after ~2.6 s — the web twin of the native toast/snackbar.
537fn show_toast(text: &str) {
538    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
539    let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
540    el.set_class_name("toast");
541    el.set_text_content(Some(text));
542    let _ = body.append_child(&el);
543    gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
544}
545
546// ---------------- Widget → DOM ----------------
547
548/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
549/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
550/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
551/// the concrete look lives in `mobiler.css`.
552fn render(widget: &Widget, send: &Dispatch) -> AnyView {
553    match widget {
554        // ---- content ----
555        Widget::Text { content, style } => {
556            let (class, content) = (text_class(*style), content.clone());
557            view! { <p class=class>{content}</p> }.into_any()
558        }
559        Widget::Image { source, shape, ratio } => {
560            let (class, source) = (image_class(*shape, *ratio), source.clone());
561            view! { <img class=class src=source /> }.into_any()
562        }
563        Widget::Badge { label, tone } => {
564            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
565            view! { <span class=class>{label}</span> }.into_any()
566        }
567        Widget::ColorDot { color } => {
568            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
569        }
570        Widget::Avatar { source, status } => {
571            let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
572            view! {
573                <span class="avatar">
574                    <img class="avatar-img" src=source.clone() />
575                    {dot}
576                </span>
577            }
578            .into_any()
579        }
580        Widget::PdfView { url } => {
581            // Browsers render PDFs natively in an iframe (remote URL or local blob/file URL).
582            view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
583        }
584        Widget::WebView { url } => {
585            // General embedded web content (incl. hosted player embeds like Bunny.net). `allow`
586            // permits autoplay / fullscreen / PiP / encrypted-media so hosted players work.
587            view! {
588                <iframe
589                    class="webview"
590                    src=url.clone()
591                    title="Web"
592                    allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
593                    allowfullscreen=true
594                ></iframe>
595            }.into_any()
596        }
597        Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
598            // Web = a native-controls `<video>`. App-driven play/pause + seek + position/state events
599            // are iOS/Android only: the web shell rebuilds the whole tree on each `update`, which would
600            // reset the element ~every tick — so we don't pump those here (poster/captions/rate/volume
601            // ARE declarative attributes, so they're safe). `muted && playing` → autoplay. MP4 plays
602            // everywhere; HLS (.m3u8) plays natively on Safari and, on Chrome/Firefox, via the hls.js
603            // bootstrap (`inject_hls_support`). A non-empty `urls` is a playlist (best-effort: starts at
604            // `start_index`, advances on `ended` within this element's lifetime — no index pump back).
605            use wasm_bindgen::JsCast;
606            let (send, ended) = (send.clone(), on_ended.clone());
607            let autoplay = *playing && *muted;
608            let playlist = urls.clone();
609            let start_index = (*start_index).max(0) as usize;
610            let effective = if playlist.is_empty() { url.clone() }
611                else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
612            let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
613            let src = (!is_hls).then(|| effective.clone());
614            let hls_src = is_hls.then(|| effective.clone());
615            let poster_attr = poster.clone();
616            let start_at = *start_at_ms;
617            let rate = *rate as f64;
618            let volume = (*volume as f64).clamp(0.0, 1.0);
619            let tracks: Vec<_> = captions.iter().map(|c| view! {
620                <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
621            }).collect();
622            let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
623            view! {
624                <video
625                    class="video"
626                    src=src
627                    data-hls-src=hls_src
628                    poster=poster_attr
629                    controls=*controls
630                    autoplay=autoplay
631                    prop:loop=*looping
632                    prop:playbackRate=rate
633                    prop:volume=volume
634                    muted=*muted
635                    playsinline=true
636                    on:loadedmetadata=move |ev| {
637                        if start_at >= 0 {
638                            if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
639                                v.set_current_time(start_at as f64 / 1000.0);
640                            }
641                        }
642                    }
643                    on:ended=move |ev| {
644                        let nxt = next_idx.get() + 1;
645                        if !playlist.is_empty() && nxt < playlist.len() {
646                            next_idx.set(nxt);
647                            if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
648                                v.set_src(&playlist[nxt]);
649                                let _ = v.play();
650                            }
651                        } else if let Some(t) = ended.clone() {
652                            send(Action::Fired { token: t });
653                        }
654                    }
655                >{tracks}</video>
656            }.into_any()
657        }
658        Widget::Rating { value, max, on_rate } => {
659            let value = *value;
660            let stars: Vec<AnyView> = (1..=*max)
661                .map(|i| {
662                    let threshold = u32::from(i) * 10;
663                    // filled / half / empty by tenths.
664                    let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
665                    match on_rate {
666                        Some(tokens) => {
667                            let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
668                            view! {
669                                <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
670                                    {glyph}
671                                </button>
672                            }
673                            .into_any()
674                        }
675                        None => view! { <span class="star">{glyph}</span> }.into_any(),
676                    }
677                })
678                .collect();
679            view! { <span class="rating">{stars}</span> }.into_any()
680        }
681        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
682        Widget::Progress { value } => match value {
683            Some(v) => {
684                let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
685                view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
686            }
687            None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
688        },
689        Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
690        Widget::Chart { series, labels, style, axis, legend } => {
691            chart_view(series, labels, *style, *axis, *legend)
692        }
693        Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
694            region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
695        }
696        Widget::Calendar { year, month, first_weekday, selected, on_day } => {
697            const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
698                "July", "August", "September", "October", "November", "December"];
699            let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
700            let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
701            let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
702            let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
703            let selected = *selected;
704            let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
705                let day = (i + 1) as u8;
706                let token = token.clone();
707                let send = send.clone();
708                let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
709                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
710            }).collect();
711            view! {
712                <div class="calendar">
713                    <div class="cal-title">{head_label}</div>
714                    <div class="cal-grid">{heads}{blanks}{days}</div>
715                </div>
716            }.into_any()
717        }
718        Widget::SwipeAction { child, actions } => {
719            // Web has no swipe gesture — render the actions inline as a trailing button row.
720            let acts: Vec<_> = actions.iter().map(|a| {
721                let token = a.on_tap.clone();
722                let send = send.clone();
723                let cls = format!("swipe-act {}", tone_class(a.tone));
724                let label = a.label.clone();
725                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
726            }).collect();
727            view! {
728                <div class="swipe-row">
729                    <div class="swipe-content">{render(child, send)}</div>
730                    <div class="swipe-actions">{acts}</div>
731                </div>
732            }.into_any()
733        }
734        Widget::Spacer { size } => {
735            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
736        }
737
738        // ---- layout ----
739        Widget::Row { children } => {
740            let kids = render_all(children, send);
741            view! { <div class="row">{kids}</div> }.into_any()
742        }
743        Widget::Column { children } => {
744            let kids = render_all(children, send);
745            view! { <div class="col">{kids}</div> }.into_any()
746        }
747        Widget::Card { child, style, on_press } => {
748            let class = format!("card {}", card_class(*style));
749            let body = render(child, send);
750            match on_press {
751                Some(token) => {
752                    let (send, token) = (send.clone(), token.clone());
753                    view! {
754                        <button
755                            class=format!("{class} card-tappable")
756                            on:click=move |_| send(Action::Fired { token: token.clone() })
757                        >
758                            {body}
759                        </button>
760                    }
761                    .into_any()
762                }
763                None => view! { <div class=class>{body}</div> }.into_any(),
764            }
765        }
766        // Z-stack. With `scrim`, the first child is a background image, darkened
767        // by an overlay, and the rest layer on top in light content — the DOM twin
768        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
769        Widget::Box { children, align, scrim } => {
770            let acls = align_class(*align);
771            if *scrim && children.len() > 1 {
772                let bg = render(&children[0], send);
773                let content = render_all(&children[1..], send);
774                view! {
775                    <div class=format!("box box-scrim {acls}")>
776                        {bg}
777                        <div class="scrim"></div>
778                        <div class="box-content">{content}</div>
779                    </div>
780                }
781                .into_any()
782            } else {
783                let kids = render_all(children, send);
784                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
785            }
786        }
787        Widget::Grid { children } => {
788            let kids = render_all(children, send);
789            view! { <div class="grid">{kids}</div> }.into_any()
790        }
791        Widget::Scroller { children } => {
792            let kids = render_all(children, send);
793            view! { <div class="scroller">{kids}</div> }.into_any()
794        }
795        // Two-pane master-detail. CSS does the adapting: wide (`@media min-width:768px`) shows both
796        // panes side-by-side (back hidden); narrow shows one — primary by default, or detail (+ a
797        // back chevron) when `data-detail` is set. `show_detail`/`on_back` only matter when narrow.
798        Widget::Split { primary, detail, show_detail, on_back } => {
799            let p = render(primary, send);
800            let d = render(detail, send);
801            let back_btn = on_back.clone().map(|t| {
802                let send = send.clone();
803                view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>"‹ Back"</button> }
804            });
805            view! {
806                <div class="split" data-detail=show_detail.then_some("1")>
807                    <div class="split-primary">{p}</div>
808                    <div class="split-detail">{back_btn}{d}</div>
809                </div>
810            }.into_any()
811        }
812        // Accessibility wrapper: name the subtree for a screen reader (aria-label), give it a role,
813        // and the hint via `title`. Best-effort web mapping of iOS traits / Android semantics.
814        Widget::A11y { child, label, hint, role } => {
815            let body = render(child, send);
816            let role_attr = role.map(a11y_role_aria).unwrap_or("group");
817            view! {
818                <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
819                    {body}
820                </div>
821            }.into_any()
822        }
823        // A long/paged feed. Web has no pull gesture or reliable infinite-scroll on a sub-container,
824        // so (like Scaffold pull-to-refresh) the gestures degrade to controls: a top "↻ Refresh"
825        // button (while `on_refresh`), and a bottom "Load more" button (while `has_more && !loading`)
826        // / loading bar / "end" caption. iOS/Android do true pull + scroll-near-end detection.
827        Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing } => {
828            let kids = render_all(children, send);
829            let refresh_btn = on_refresh.clone().map(|token| {
830                let send = send.clone();
831                view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻ Refresh"</button> }
832            });
833            let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
834            let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
835            let load_more_btn = (!*loading && *has_more)
836                .then(|| on_load_more.clone())
837                .flatten()
838                .map(|token| {
839                    let send = send.clone();
840                    view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>"Load more"</button> }
841                });
842            let end_cap = (!*has_more && on_load_more.is_some()).then(|| view! { <div class="lazylist-end">"End of list"</div> });
843            view! {
844                <div class="lazylist">
845                    {refresh_btn}
846                    {refresh_bar}
847                    {kids}
848                    {loading_bar}
849                    {load_more_btn}
850                    {end_cap}
851                </div>
852            }.into_any()
853        }
854
855        // ---- input / actions ----
856        Widget::Button { label, style, on_press } => {
857            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
858            let class = format!("btn {}", button_class(*style));
859            view! {
860                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
861                    {label}
862                </button>
863            }
864            .into_any()
865        }
866        Widget::IconButton { icon, on_press } => {
867            let (send, token) = (send.clone(), on_press.clone());
868            let glyph = icon_glyph(*icon);
869            view! {
870                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
871                    {glyph}
872                </button>
873            }
874            .into_any()
875        }
876        Widget::Chip { label, selected, on_press } => {
877            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
878            let class = if *selected { "chip selected" } else { "chip" };
879            view! {
880                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
881                    {label}
882                </button>
883            }
884            .into_any()
885        }
886        Widget::TextField { id, placeholder, value, kind, error } => {
887            let (send, id) = (send.clone(), id.clone());
888            let (placeholder, value) = (placeholder.clone(), value.clone());
889            let invalid = error.is_some();
890            let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
891            // (input type, inputmode) per FieldKind. Multiline renders a <textarea> below.
892            let (itype, imode): (&str, &str) = match kind {
893                FieldKind::Secure => ("password", ""),
894                FieldKind::Email => ("email", "email"),
895                FieldKind::Number => ("text", "numeric"),
896                FieldKind::Decimal => ("text", "decimal"),
897                FieldKind::Phone => ("tel", "tel"),
898                FieldKind::Url => ("url", "url"),
899                FieldKind::Text | FieldKind::Multiline => ("text", ""),
900            };
901            let field_class = if invalid { "field field-invalid" } else { "field" };
902            let control = if matches!(kind, FieldKind::Multiline) {
903                view! {
904                    <textarea
905                        class=field_class
906                        rows="3"
907                        placeholder=placeholder
908                        prop:value=value
909                        on:input=move |ev| send(Action::Input {
910                            id: id.clone(),
911                            value: InputValue::Text(event_target_value(&ev)),
912                        })
913                    ></textarea>
914                }
915                .into_any()
916            } else {
917                view! {
918                    <input
919                        class=field_class
920                        r#type=itype
921                        inputmode=imode
922                        placeholder=placeholder
923                        prop:value=value
924                        on:input=move |ev| send(Action::Input {
925                            id: id.clone(),
926                            value: InputValue::Text(event_target_value(&ev)),
927                        })
928                    />
929                }
930                .into_any()
931            };
932            view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
933        }
934        Widget::SearchField { id, placeholder, value } => {
935            let (send, id) = (send.clone(), id.clone());
936            let (placeholder, value) = (placeholder.clone(), value.clone());
937            view! {
938                <div class="searchfield">
939                    <span class="search-icon">{icon_glyph(Icon::Search)}</span>
940                    <input
941                        class="search-input"
942                        placeholder=placeholder
943                        prop:value=value
944                        on:input=move |ev| send(Action::Input {
945                            id: id.clone(),
946                            value: InputValue::Text(event_target_value(&ev)),
947                        })
948                    />
949                </div>
950            }
951            .into_any()
952        }
953        Widget::Segmented { segments } => {
954            let segs: Vec<AnyView> = segments
955                .iter()
956                .map(|s| {
957                    let (send, token) = (send.clone(), s.on_select.clone());
958                    let class = if s.selected { "segment selected" } else { "segment" };
959                    let label = s.label.clone();
960                    view! {
961                        <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
962                            {label}
963                        </button>
964                    }
965                    .into_any()
966                })
967                .collect();
968            view! { <div class="segmented">{segs}</div> }.into_any()
969        }
970        Widget::Toggle { id, label, value } => {
971            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
972            view! {
973                <label class="toggle">
974                    {label}
975                    <input
976                        type="checkbox"
977                        role="switch"
978                        prop:checked=checked
979                        on:change=move |ev| send(Action::Input {
980                            id: id.clone(),
981                            value: InputValue::Bool(event_target_checked(&ev)),
982                        })
983                    />
984                </label>
985            }
986            .into_any()
987        }
988        Widget::Checkbox { id, label, value } => {
989            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
990            view! {
991                <label class="check">
992                    <input
993                        type="checkbox"
994                        prop:checked=checked
995                        on:change=move |ev| send(Action::Input {
996                            id: id.clone(),
997                            value: InputValue::Bool(event_target_checked(&ev)),
998                        })
999                    />
1000                    {label}
1001                </label>
1002            }
1003            .into_any()
1004        }
1005        Widget::Slider { id, value, max } => {
1006            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1007            view! {
1008                <input
1009                    class="slider"
1010                    type="range"
1011                    min="0"
1012                    max=max
1013                    prop:value=value
1014                    on:input=move |ev| send(Action::Input {
1015                        id: id.clone(),
1016                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1017                    })
1018                />
1019            }
1020            .into_any()
1021        }
1022        Widget::Stepper { value, on_decrement, on_increment } => {
1023            let send_dec = send.clone();
1024            let send_inc = send.clone();
1025            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1026            view! {
1027                <div class="stepper">
1028                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1029                    <span class="stepper-value">{*value}</span>
1030                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1031                </div>
1032            }
1033            .into_any()
1034        }
1035
1036        // ---- shell ----
1037        Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
1038            let back_btn = back.clone().map(|token| {
1039                let send = send.clone();
1040                view! {
1041                    <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
1042                        "‹"
1043                    </button>
1044                }
1045            });
1046            let tabbar = (!tabs.is_empty()).then(|| {
1047                let tabs: Vec<AnyView> = tabs
1048                    .iter()
1049                    .map(|tab| {
1050                        let (send, token) = (send.clone(), tab.on_select.clone());
1051                        let class = if tab.selected { "tab selected" } else { "tab" };
1052                        let label = tab.label.clone();
1053                        // Optional leading icon → glyph above the label (icon tab bar).
1054                        let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1055                        view! {
1056                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1057                                {icon}
1058                                <span class="tab-label">{label}</span>
1059                            </button>
1060                        }
1061                        .into_any()
1062                    })
1063                    .collect();
1064                view! { <div class="tabbar">{tabs}</div> }
1065            });
1066            // Floating action button — the raised primary action, anchored over the body.
1067            let fab_btn = fab.clone().map(|f| {
1068                let (send, token) = (send.clone(), f.on_press.clone());
1069                view! {
1070                    <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
1071                        {icon_glyph(f.icon)}
1072                    </button>
1073                }
1074            });
1075            // Modal bottom sheet — a scrim (tap to dismiss) + a panel rising from the bottom.
1076            let sheet_overlay = sheet.as_ref().map(|s| {
1077                let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
1078                let (title, child) = (s.title.clone(), render(&s.child, send));
1079                view! {
1080                    <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
1081                    <div class="sheet">
1082                        <div class="sheet-handle"></div>
1083                        <div class="sheet-title">{title}</div>
1084                        {child}
1085                    </div>
1086                }
1087            });
1088            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
1089            // the web twin of the native shells' `preferredColorScheme`/Material theme.
1090            let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
1091            // Pull-to-refresh — web has no pull gesture, so expose a top-bar refresh button +
1092            // an indeterminate bar at the top of the body while `refreshing`.
1093            let refresh_btn = on_refresh.clone().map(|token| {
1094                let send = send.clone();
1095                view! {
1096                    <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
1097                }
1098            });
1099            let refresh_bar = refreshing.then(|| {
1100                view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
1101            });
1102            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
1103            // An app `Theme` overrides the CSS variables inline (brand color, corner, density,
1104            // font) — the web twin of the native shells' brand/tint + shape + spacing + font.
1105            let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
1106            let (title, body) = (title.clone(), render(body, send));
1107            view! {
1108                <div class=class style=theme_style>
1109                    <div class="topbar">
1110                        {back_btn}
1111                        <span class="title">{title}</span>
1112                        {refresh_btn}
1113                    </div>
1114                    <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
1115                    {fab_btn}
1116                    {tabbar}
1117                    {sheet_overlay}
1118                </div>
1119            }
1120            .into_any()
1121        }
1122    }
1123}
1124
1125/// Render a slice of children as sibling views.
1126fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
1127    children.iter().map(|c| render(c, send)).collect()
1128}
1129
1130thread_local! {
1131    /// (previous route key, previous depth, alternating toggle). The render is a
1132    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
1133    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
1134    /// the native shells keying their body on `route`.
1135    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
1136
1137    /// Open streaming subscriptions keyed by subscription key (wasm is single-
1138    /// threaded). Each [`Effect::PluginStream`] parks its source here so
1139    /// `cx.unsubscribe(key)` can stop it; dropping the entry stops the source.
1140    static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
1141}
1142
1143/// Render an app [`Theme`] as inline CSS custom properties on the scaffold root — the web
1144/// twin of the native brand/tint + shape + spacing + font. Overrides `mobiler.css`'s defaults
1145/// (its rules read these via `var(--…)`); dark mode still works (it only swaps the colors the
1146/// seed doesn't pin).
1147fn theme_css(t: &Theme) -> String {
1148    let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
1149    let radius = match t.corner {
1150        Corner::None => "0px",
1151        Corner::Small => "8px",
1152        Corner::Medium => "14px",
1153        Corner::Large => "22px",
1154    };
1155    let (gap, pad) = match t.density {
1156        Density::Compact => ("8px", "10px"),
1157        Density::Comfortable => ("12px", "14px"),
1158    };
1159    let font = match t.font {
1160        FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
1161        FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
1162        FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
1163        FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
1164    };
1165    // Secondary brand color (for the CardStyle::Brand gradient); falls back to the seed.
1166    let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
1167    format!(
1168        "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
1169         --accent2:rgb({ar},{ag},{ab});\
1170         --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
1171         --gap:{gap};--pad:{pad};--font:{font};"
1172    )
1173}
1174
1175/// Pick the Scaffold body's transition class for this render. Returns `""` for a
1176/// same-route data update (re-render in place, no transition). On a route change it
1177/// returns a directional class — slide-in from the right when `depth` grew (push),
1178/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
1179/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
1180/// Leptos reuses the same DOM node.
1181fn nav_class(route: &str, depth: u32) -> &'static str {
1182    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
1183        if route == prev_route {
1184            return "";
1185        }
1186        let dir = if depth > *prev_depth {
1187            ["nav-push-a", "nav-push-b"]
1188        } else if depth < *prev_depth {
1189            ["nav-pop-a", "nav-pop-b"]
1190        } else {
1191            ["nav-fade-a", "nav-fade-b"]
1192        };
1193        *toggle = !*toggle;
1194        *prev_route = route.to_string();
1195        *prev_depth = depth;
1196        dir[usize::from(*toggle)]
1197    })
1198}
1199
1200// ---- style intent → CSS class / glyph (the only place that names the look) ----
1201
1202fn text_class(s: TextStyle) -> &'static str {
1203    match s {
1204        TextStyle::Title => "t-title",
1205        TextStyle::Subtitle => "t-subtitle",
1206        TextStyle::Caption => "t-caption",
1207        TextStyle::Emphasis => "t-emphasis",
1208        TextStyle::Body => "t-body",
1209    }
1210}
1211
1212fn button_class(s: ButtonStyle) -> &'static str {
1213    match s {
1214        ButtonStyle::Filled => "btn-filled",
1215        ButtonStyle::Outlined => "btn-outlined",
1216        ButtonStyle::Text => "btn-text",
1217    }
1218}
1219
1220fn card_class(s: CardStyle) -> &'static str {
1221    match s {
1222        CardStyle::Elevated => "card-elevated",
1223        CardStyle::Outlined => "card-outlined",
1224        CardStyle::Filled => "card-filled",
1225        CardStyle::Brand => "card-brand",
1226    }
1227}
1228
1229fn a11y_role_aria(role: A11yRole) -> &'static str {
1230    match role {
1231        A11yRole::Button => "button",
1232        A11yRole::Link => "link",
1233        A11yRole::Image => "img",
1234        A11yRole::Header => "heading",
1235        A11yRole::Adjustable => "slider",
1236    }
1237}
1238
1239fn tone_class(t: Tone) -> &'static str {
1240    match t {
1241        Tone::Neutral => "tone-neutral",
1242        Tone::Success => "tone-success",
1243        Tone::Warning => "tone-warning",
1244        Tone::Danger => "tone-danger",
1245        Tone::Info => "tone-info",
1246    }
1247}
1248
1249fn spacer_class(s: Spacing) -> &'static str {
1250    match s {
1251        Spacing::Xs => "sp-xs",
1252        Spacing::Sm => "sp-sm",
1253        Spacing::Md => "sp-md",
1254        Spacing::Lg => "sp-lg",
1255        Spacing::Xl => "sp-xl",
1256    }
1257}
1258
1259fn icon_glyph(i: Icon) -> &'static str {
1260    match i {
1261        Icon::Delete => "🗑",
1262        Icon::Add => "+",
1263        Icon::Edit => "✏️",
1264        Icon::Close => "✕",
1265        Icon::Settings => "⚙",
1266        Icon::Check => "✓",
1267        Icon::Star => "★",
1268        Icon::Info => "ℹ",
1269        Icon::Home => "⌂",
1270        Icon::Search => "🔍",
1271        Icon::Menu => "☰",
1272        Icon::Filter => "⚟",
1273        Icon::Back => "‹",
1274        Icon::Forward => "›",
1275        Icon::Down => "⌄",
1276        Icon::Bell => "🔔",
1277        Icon::Cart => "🛒",
1278        Icon::Share => "↗",
1279        Icon::Heart => "♡",
1280        Icon::HeartFilled => "♥",
1281        Icon::Person => "👤",
1282        Icon::People => "👥",
1283        Icon::Phone => "📞",
1284        Icon::Mail => "✉",
1285        Icon::Calendar => "📅",
1286        Icon::Clock => "🕑",
1287        Icon::MapPin => "📍",
1288        Icon::Camera => "📷",
1289        Icon::Photo => "🖼",
1290        Icon::Play => "▶",
1291        Icon::Scissors => "✂",
1292    }
1293}
1294
1295fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
1296    let shape = match shape {
1297        ImageShape::Square => "img-square",
1298        ImageShape::Rounded => "img-rounded",
1299        ImageShape::Circle => "img-circle",
1300    };
1301    let ratio = match ratio {
1302        ImageRatio::Wide => "ratio-wide",
1303        ImageRatio::Square => "ratio-square",
1304        ImageRatio::Tall => "ratio-tall",
1305    };
1306    format!("img {shape} {ratio}")
1307}
1308
1309fn dot_class(c: ProjectColor) -> &'static str {
1310    match c {
1311        ProjectColor::Indigo => "dot-indigo",
1312        ProjectColor::Teal => "dot-teal",
1313        ProjectColor::Coral => "dot-coral",
1314        ProjectColor::Amber => "dot-amber",
1315        ProjectColor::Lime => "dot-lime",
1316        ProjectColor::Pink => "dot-pink",
1317    }
1318}
1319
1320fn align_class(a: BoxAlign) -> &'static str {
1321    match a {
1322        BoxAlign::TopStart => "align-top-start",
1323        BoxAlign::TopEnd => "align-top-end",
1324        BoxAlign::Center => "align-center",
1325        BoxAlign::BottomStart => "align-bottom-start",
1326        BoxAlign::BottomCenter => "align-bottom-center",
1327        BoxAlign::BottomEnd => "align-bottom-end",
1328    }
1329}
1330
1331// ------------------------------- charts -------------------------------
1332
1333/// Distinct fallback colors for series 1.. (series 0 with no override rides the theme accent).
1334const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
1335
1336fn hex(c: Rgb) -> String {
1337    format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
1338}
1339
1340/// Color for series `i`: explicit override → theme accent (i==0) → palette.
1341fn chart_color(i: usize, s: &ChartSeries) -> String {
1342    match s.color {
1343        Some(c) => hex(c),
1344        None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
1345        None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
1346    }
1347}
1348
1349/// A series' single magnitude for circular charts (sum of its values).
1350fn chart_mag(s: &ChartSeries) -> f32 {
1351    s.values.iter().copied().sum()
1352}
1353
1354/// Point on a circle: `ang` in radians, 0 = top (12 o'clock), increasing clockwise.
1355fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
1356    (cx + r * ang.sin(), cy - r * ang.cos())
1357}
1358
1359/// An open arc path (for ring/donut/gauge strokes).
1360fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1361    let (x0, y0) = polar(cx, cy, r, a0);
1362    let (x1, y1) = polar(cx, cy, r, a1);
1363    let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1364    format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
1365}
1366
1367/// A filled wedge from the center (for pie/donut slices).
1368fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1369    let (x0, y0) = polar(cx, cy, r, a0);
1370    let (x1, y1) = polar(cx, cy, r, a1);
1371    let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1372    format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
1373}
1374
1375fn fmt_tick(v: f32) -> String {
1376    if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
1377}
1378
1379fn is_cartesian(style: ChartStyle) -> bool {
1380    matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
1381}
1382
1383/// The y-axis denominator for a cartesian chart.
1384fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
1385    match style {
1386        ChartStyle::StackedBar => (0..nslots)
1387            .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
1388            .fold(0.0, f32::max)
1389            .max(1e-6),
1390        ChartStyle::StackedBar100 => 1.0,
1391        _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
1392    }
1393}
1394
1395fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
1396    // plot area: y in [2, 48] of the 0..50 viewBox
1397    let mut nodes: Vec<AnyView> = Vec::new();
1398    if axis {
1399        for k in 0..=4 {
1400            let y = 2.0 + k as f32 * (46.0 / 4.0);
1401            nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
1402        }
1403    }
1404    match style {
1405        ChartStyle::Line => {
1406            for (i, s) in series.iter().enumerate() {
1407                let n = s.values.len().max(1);
1408                let pts = s.values.iter().enumerate().map(|(j, v)| {
1409                    let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
1410                    let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
1411                    format!("{x:.2},{y:.2}")
1412                }).collect::<Vec<_>>().join(" ");
1413                let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
1414                nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
1415            }
1416        }
1417        ChartStyle::Bar => {
1418            let sw = 100.0 / nslots as f32;
1419            let ns = series.len().max(1);
1420            for (i, s) in series.iter().enumerate() {
1421                let st = format!("fill:{}", chart_color(i, s));
1422                for (j, v) in s.values.iter().enumerate() {
1423                    let h = (v / max).clamp(0.0, 1.0) * 46.0;
1424                    let bw = sw * 0.8 / ns as f32;
1425                    let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
1426                    let y = 48.0 - h;
1427                    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());
1428                }
1429            }
1430        }
1431        ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
1432            let sw = 100.0 / nslots as f32;
1433            for j in 0..nslots {
1434                let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
1435                let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
1436                let mut acc = 0.0_f32;
1437                for (i, s) in series.iter().enumerate() {
1438                    let v = *s.values.get(j).unwrap_or(&0.0);
1439                    let h = (v / denom).clamp(0.0, 1.0) * 46.0;
1440                    let x = j as f32 * sw + sw * 0.15;
1441                    let bw = sw * 0.7;
1442                    let y = 48.0 - acc - h;
1443                    let st = format!("fill:{}", chart_color(i, s));
1444                    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());
1445                    acc += h;
1446                }
1447            }
1448        }
1449        _ => {}
1450    }
1451    view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
1452}
1453
1454fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
1455    use std::f32::consts::PI;
1456    let mut nodes: Vec<AnyView> = Vec::new();
1457    match style {
1458        ChartStyle::Pie | ChartStyle::Donut => {
1459            let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
1460            let mut a = 0.0_f32;
1461            for (i, s) in series.iter().enumerate() {
1462                let frac = chart_mag(s) / total;
1463                let st = format!("fill:{}", chart_color(i, s));
1464                if frac >= 0.999 {
1465                    nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
1466                } else if frac > 0.0 {
1467                    let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
1468                    nodes.push(view! { <path d=d style=st></path> }.into_any());
1469                }
1470                a += frac * 2.0 * PI;
1471            }
1472            if matches!(style, ChartStyle::Donut) {
1473                nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
1474            }
1475        }
1476        ChartStyle::Rings => {
1477            let n = series.len().max(1);
1478            for (i, s) in series.iter().enumerate() {
1479                let r = 45.0 - i as f32 * (34.0 / n as f32);
1480                let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1481                let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1482                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());
1483                let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
1484                if prog >= 0.999 {
1485                    nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
1486                } else if prog > 0.0 {
1487                    let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
1488                    nodes.push(view! { <path d=d style=st></path> }.into_any());
1489                }
1490            }
1491        }
1492        ChartStyle::Gauge => {
1493            let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
1494            let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1495            let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1496            let a0 = -0.75 * PI; // 270° sweep, gap at the bottom
1497            let a1 = 0.75 * PI;
1498            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());
1499            if prog > 0.0 {
1500                let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
1501                nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
1502            }
1503            let pct = format!("{}%", (prog * 100.0).round() as i64);
1504            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());
1505        }
1506        _ => {}
1507    }
1508    view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
1509}
1510
1511fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
1512    let cartesian = is_cartesian(style);
1513    let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
1514    let max = cartesian_max(series, style, nslots);
1515
1516    let plot = if cartesian {
1517        let svg = cartesian_svg(series, style, axis, max, nslots);
1518        let yaxis = if axis {
1519            let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
1520                .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
1521                .collect();
1522            Some(view! { <div class="chart-yaxis">{ticks}</div> })
1523        } else {
1524            None
1525        };
1526        view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
1527    } else {
1528        circular_svg(series, style).into_any()
1529    };
1530
1531    let label_row = if cartesian && !labels.is_empty() {
1532        let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
1533        Some(view! { <div class="chart-labels">{items}</div> })
1534    } else {
1535        None
1536    };
1537
1538    let legend_row = if legend {
1539        let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
1540            let sw = format!("background:{}", chart_color(i, s));
1541            let name = s.name.clone();
1542            view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1543        }).collect();
1544        Some(view! { <div class="chart-legend">{items}</div> })
1545    } else {
1546        None
1547    };
1548
1549    view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
1550}
1551
1552// --------------------------- region chart ---------------------------
1553
1554/// Palette as RGB (parallel to `CHART_PALETTE`) so region charts can compute label contrast.
1555const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
1556    [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
1557
1558/// The resolved fill RGB for region `i` (explicit override → palette).
1559fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
1560    match r.color {
1561        Some(c) => (c.r, c.g, c.b),
1562        None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
1563    }
1564}
1565
1566/// Black or white label text, whichever reads on the given fill (perceived luminance).
1567fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
1568    let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1569    if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
1570}
1571
1572fn region_color(i: usize, r: &ChartRegion) -> String {
1573    let (r8, g8, b8) = region_rgb(i, r);
1574    format!("#{r8:02x}{g8:02x}{b8:02x}")
1575}
1576
1577// A variable-width stacked-region / coverage-gap chart: absolute-positioned region rectangles in
1578// the [0,x_max]×[0,y_max] plane, horizontal ref lines + chips, an irregular x-axis, an optional
1579// right-side bracket, and a legend. The web twin of the Compose/SwiftUI RegionChart renderers.
1580fn region_chart_view(
1581    regions: &[ChartRegion],
1582    ticks: &[ChartTick],
1583    x_max: f32,
1584    y_max: f32,
1585    ref_lines: &[ChartRefLine],
1586    bracket: &Option<ChartBracket>,
1587    legend: &[ChartLegendItem],
1588) -> AnyView {
1589    let xm = x_max.max(1e-6);
1590    let ym = y_max.max(1e-6);
1591
1592    let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
1593        let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
1594        let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
1595        let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
1596        let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
1597        let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
1598        let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
1599        let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
1600        let label = r.label.clone();
1601        view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
1602    }).collect();
1603
1604    // The reference lines span the full plot width; their value chips sit in the right margin
1605    // (outside the plot), like the original — so the line clearly runs to the plot's edge.
1606    let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
1607        let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1608        let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
1609        view! { <div class=cls style=style></div> }
1610    }).collect();
1611    let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
1612        let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1613        let label = rl.label.clone();
1614        view! { <div class="rchart-chip" style=style>{label}</div> }
1615    }).collect();
1616
1617    let bracket_div = bracket.as_ref().map(|b| {
1618        let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
1619        let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
1620        let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
1621        let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
1622        view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
1623    });
1624
1625    let yticks: Vec<_> = (0..=4).rev().map(|k| {
1626        let v = ym * k as f32 / 4.0;
1627        view! { <span class="chart-tick">{fmt_tick(v)}</span> }
1628    }).collect();
1629
1630    let xticks: Vec<_> = ticks.iter().map(|t| {
1631        let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1632        let label = t.label.clone();
1633        view! { <span class="rchart-xtick" style=style>{label}</span> }
1634    }).collect();
1635
1636    // Axis tick marks (notches on the L-shaped axis): horizontal on the y-axis at each value,
1637    // vertical on the x-axis at each irregular break — drawn over the bands at the plot edges.
1638    let ytick_marks: Vec<_> = (0..=4).map(|k| {
1639        let style = format!("bottom:{:.3}%", k as f32 * 25.0);
1640        view! { <div class="rchart-ytick" style=style></div> }
1641    }).collect();
1642    let xtick_marks: Vec<_> = ticks.iter().map(|t| {
1643        let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1644        view! { <div class="rchart-xtickmark" style=style></div> }
1645    }).collect();
1646
1647    let legend_row = if legend.is_empty() {
1648        None
1649    } else {
1650        let items: Vec<_> = legend.iter().map(|l| {
1651            let sw = format!("background:{}", hex(l.color));
1652            let name = l.label.clone();
1653            view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1654        }).collect();
1655        Some(view! { <div class="chart-legend">{items}</div> })
1656    };
1657
1658    view! {
1659        <div class="rchart">
1660            <div class="rchart-row">
1661                <div class="rchart-yaxis">{yticks}</div>
1662                <div class="rchart-plotwrap">
1663                    <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
1664                    {chip_divs}{bracket_div}
1665                </div>
1666            </div>
1667            <div class="rchart-xaxis">{xticks}</div>
1668            {legend_row}
1669        </div>
1670    }.into_any()
1671}