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