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