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, ChartStyle, Corner, Density, Effect, FontFamily, Icon,
24    ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, ProjectColor,
25    Spacing, TextStyle, Theme, Tone, Widget,
26};
27use wasm_bindgen_futures::spawn_local;
28
29/// The shell's own stylesheet — the web twin of the look the Android/SwiftUI shells
30/// decide in code. Shipped with the crate and injected on mount, so `run::<App>()`
31/// renders a fully styled, themeable app with no CSS required from the consuming
32/// app (it can still override any class). Uses CSS variables so `Scaffold.dark_mode`
33/// flips the whole theme by toggling one class.
34const STYLE: &str = include_str!("mobiler.css");
35
36/// Cloneable handle for sending an `Action` into the core. Leptos 0.7 view closures
37/// require `Send`, so this is `Arc` + `Send + Sync` (the crux `Core` is both).
38type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
39
40/// What a Mobiler app must be to render on the web: a crux `App` speaking the fixed
41/// ABI (`Action` in, `Widget` out, `Effect` for capabilities). `MobilerShell<_>`
42/// satisfies this automatically.
43pub trait WebApp:
44    App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
45where
46    Self::Model: Default + Send + Sync,
47{
48}
49impl<T> WebApp for T
50where
51    T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
52    T::Model: Default + Send + Sync,
53{
54}
55
56/// Mount a Mobiler app into the document body. Call from your wasm `main`.
57pub fn run<A: WebApp>()
58where
59    A::Model: Default + Send + Sync,
60{
61    console_error_panic_hook::set_once();
62    inject_default_style();
63    leptos::mount::mount_to_body(shell::<A>);
64}
65
66/// Inject the shell's default stylesheet at the **front** of `<head>` so it's the
67/// lowest-precedence baseline: an app that ships its own CSS (later in the document)
68/// overrides any of these classes, while an app with no CSS still gets a full theme.
69fn inject_default_style() {
70    let document = leptos::prelude::document();
71    let Some(head) = document.head() else { return };
72    let Ok(style) = document.create_element("style") else { return };
73    let _ = style.set_attribute("data-mobiler", "shell");
74    style.set_text_content(Some(STYLE));
75    let _ = head.insert_before(&style, head.first_child().as_ref());
76}
77
78fn shell<A: WebApp>() -> impl IntoView
79where
80    A::Model: Default + Send + Sync,
81{
82    let core = Arc::new(Core::<A>::new());
83    let (view, set_view) = signal(core.view());
84
85    let send: Dispatch = {
86        let core = core.clone();
87        Arc::new(move |action: Action| {
88            let effects = core.process_event(action);
89            drive(&core, set_view, effects);
90        })
91    };
92
93    // Restore persisted state (localStorage), then fire Start — mirrors the native
94    // shells (which restore before Start so the app sees its saved Model on launch).
95    let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
96    if !saved.is_empty() {
97        send(Action::Restore { data: saved });
98    }
99    send(Action::Start);
100
101    let send_for_view = send.clone();
102    view! {
103        <div class="app">
104            {move || render(&view.get(), &send_for_view)}
105        </div>
106    }
107}
108
109/// Process effects: re-read the view on Render; fulfil HTTP via fetch and resolve.
110fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
111where
112    A::Model: Default + Send + Sync,
113{
114    for effect in effects {
115        match effect {
116            Effect::Render(_) => set_view.set(core.view()),
117            Effect::PluginNotify(notify) => perform_notify(&notify.operation),
118            Effect::Plugin(mut request) => {
119                let core = core.clone();
120                spawn_local(async move {
121                    let response = perform(&request.operation).await;
122                    if let Ok(next) = core.resolve(&mut request, response) {
123                        drive(&core, set_view, next);
124                    }
125                });
126            }
127        }
128    }
129}
130
131/// Fulfil a request/response capability. `http` via `fetch`; `device` via the
132/// browser's user-agent string (the web analogue of a device model).
133async fn perform(call: &PluginCall) -> PluginResponse {
134    if call.plugin == "device" {
135        let ua = web_sys::window()
136            .and_then(|w| w.navigator().user_agent().ok())
137            .unwrap_or_default();
138        return PluginResponse { ok: true, output: ua };
139    }
140    if call.plugin == "photo" && call.op == "pick" {
141        return take_image(false).await;
142    }
143    if call.plugin == "camera" && call.op == "capture" {
144        return take_image(true).await;
145    }
146    if call.plugin == "datetime" {
147        return match call.op.as_str() {
148            "date" => take_datetime("date").await,
149            "time" => take_datetime("time").await,
150            other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
151        };
152    }
153    if call.plugin == "dialog" && call.op == "confirm" {
154        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
155        let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
156        let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
157        let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
158        let ok = web_sys::window()
159            .and_then(|w| w.confirm_with_message(&prompt).ok())
160            .unwrap_or(false);
161        return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
162    }
163    if call.plugin != "http" {
164        return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
165    }
166    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
167    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
168    let body = v.get("body").and_then(serde_json::Value::as_str);
169
170    use gloo_net::http::Request;
171    let builder = match call.op.as_str() {
172        "POST" => Request::post(url),
173        "PATCH" => Request::patch(url),
174        "DELETE" => Request::delete(url),
175        _ => Request::get(url),
176    };
177    let request = match body {
178        Some(b) => builder.header("Content-Type", "application/json").body(b),
179        None => builder.build(),
180    };
181    let request = match request {
182        Ok(r) => r,
183        Err(e) => return PluginResponse { ok: false, output: e.to_string() },
184    };
185    match request.send().await {
186        Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
187        Err(e) => PluginResponse { ok: false, output: e.to_string() },
188    }
189}
190
191/// Pick or capture an image via a hidden `<input type=file accept=image/*>`, clicked
192/// to open the browser's file dialog — or, with `capture`, to hint the device camera on
193/// supporting mobile browsers (desktop falls back to the file dialog). Awaits the
194/// `change` event and returns a `blob:` object URL the `<img>` renderer loads. No
195/// permission needed (the picker/camera prompt is the browser's). Backs both the
196/// `photo`/`pick` and `camera`/`capture` capabilities.
197async fn take_image(capture: bool) -> PluginResponse {
198    use wasm_bindgen::{closure::Closure, JsCast};
199    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
200        return PluginResponse { ok: false, output: "no document".into() };
201    };
202    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
203        return PluginResponse { ok: false, output: "no input element".into() };
204    };
205    input.set_type("file");
206    input.set_accept("image/*");
207    if capture {
208        // Hints the environment-facing camera on mobile browsers that support it.
209        let _ = input.set_attribute("capture", "environment");
210    }
211
212    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
213    let tx = std::cell::RefCell::new(Some(tx));
214    let input_for_cb = input.clone();
215    let on_change = Closure::wrap(Box::new(move || {
216        let url = input_for_cb
217            .files()
218            .and_then(|files| files.get(0))
219            .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
220        if let Some(tx) = tx.borrow_mut().take() {
221            let _ = tx.send(url);
222        }
223    }) as Box<dyn FnMut()>);
224    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
225    input.click();
226    on_change.forget(); // keep the handler alive until `change` fires
227
228    match rx.await {
229        Ok(Some(url)) => PluginResponse { ok: true, output: url },
230        _ => PluginResponse { ok: false, output: "cancelled".into() },
231    }
232}
233
234/// Pick a date (`kind = "date"`) or time (`kind = "time"`) via a hidden native
235/// `<input>`, opening the browser's picker with `showPicker()`. Returns the value
236/// (`YYYY-MM-DD` for date, 24-hour `HH:MM` for time); `ok=false` on cancel/dismiss.
237/// Backs the `datetime` capability (`cx.pick_date` / `cx.pick_time`).
238async fn take_datetime(kind: &str) -> PluginResponse {
239    use wasm_bindgen::{closure::Closure, JsCast};
240    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
241        return PluginResponse { ok: false, output: "no document".into() };
242    };
243    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
244        return PluginResponse { ok: false, output: "no input element".into() };
245    };
246    input.set_type(kind); // "date" or "time"
247    // showPicker() needs a connected element; keep it in the DOM but out of sight.
248    let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
249    if let Some(body) = doc.body() {
250        let _ = body.append_child(&input);
251    }
252
253    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
254    let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
255    let input_for_change = input.clone();
256    let tx_change = tx.clone();
257    let on_change = Closure::wrap(Box::new(move || {
258        let v = input_for_change.value();
259        if let Some(tx) = tx_change.borrow_mut().take() {
260            let _ = tx.send(if v.is_empty() { None } else { Some(v) });
261        }
262    }) as Box<dyn FnMut()>);
263    let tx_cancel = tx.clone();
264    let on_cancel = Closure::wrap(Box::new(move || {
265        if let Some(tx) = tx_cancel.borrow_mut().take() {
266            let _ = tx.send(None);
267        }
268    }) as Box<dyn FnMut()>);
269    let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
270    let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
271    if input.show_picker().is_err() {
272        input.click(); // older browsers: focus the field so the user can type a value
273    }
274    on_change.forget(); // keep the handlers alive until an event fires
275    on_cancel.forget();
276
277    let result = rx.await;
278    input.remove();
279    match result {
280        Ok(Some(v)) => PluginResponse { ok: true, output: v },
281        _ => PluginResponse { ok: false, output: "cancelled".into() },
282    }
283}
284
285const STORAGE_KEY: &str = "mobiler.state";
286
287/// `window.localStorage`, if available.
288fn local_storage() -> Option<web_sys::Storage> {
289    web_sys::window()?.local_storage().ok().flatten()
290}
291
292/// Fulfil a fire-and-forget capability in the browser — the web twin of the native
293/// shells' notify handlers (storage/clipboard/share/browser). None block; an unknown
294/// capability is a graceful no-op.
295fn perform_notify(notify: &PluginNotify) {
296    let win = match web_sys::window() {
297        Some(w) => w,
298        None => return,
299    };
300    match (notify.plugin.as_str(), notify.op.as_str()) {
301        // Persist the state blob (paired with cx.save + restore-on-startup above).
302        ("storage", "save") => {
303            if let Some(s) = local_storage() {
304                let _ = s.set_item(STORAGE_KEY, &notify.input);
305            }
306        }
307        // Copy to the clipboard (write_text returns a Promise we let run).
308        ("clipboard", "copy") => {
309            let _ = win.navigator().clipboard().write_text(&notify.input);
310        }
311        // Open a URL in a new tab.
312        ("browser", "open") => {
313            let _ = win.open_with_url_and_target(&notify.input, "_blank");
314        }
315        // No reliable cross-browser share sheet (navigator.share is mobile-only and
316        // gesture-gated), so degrade to copying — a sane universal fallback.
317        ("share", _) => {
318            let _ = win.navigator().clipboard().write_text(&notify.input);
319        }
320        // Transient toast: a styled div appended to <body>, auto-removed after a beat.
321        ("toast", _) => show_toast(&notify.input),
322        // Haptic tap. navigator.vibrate is unsupported on iOS Safari (a graceful no-op).
323        ("haptics", style) => {
324            let ms = match style {
325                "light" => 15,
326                "heavy" => 50,
327                _ => 30, // medium / unknown
328            };
329            let _ = win.navigator().vibrate_with_duration(ms);
330        }
331        _ => {} // unknown capability: ignore
332    }
333}
334
335/// Append a transient toast to `<body>` (styled by `.toast` in mobiler.css) and
336/// remove it after ~2.6 s — the web twin of the native toast/snackbar.
337fn show_toast(text: &str) {
338    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
339    let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
340    el.set_class_name("toast");
341    el.set_text_content(Some(text));
342    let _ = body.append_child(&el);
343    gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
344}
345
346// ---------------- Widget → DOM ----------------
347
348/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
349/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
350/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
351/// the concrete look lives in `mobiler.css`.
352fn render(widget: &Widget, send: &Dispatch) -> AnyView {
353    match widget {
354        // ---- content ----
355        Widget::Text { content, style } => {
356            let (class, content) = (text_class(*style), content.clone());
357            view! { <p class=class>{content}</p> }.into_any()
358        }
359        Widget::Image { source, shape, ratio } => {
360            let (class, source) = (image_class(*shape, *ratio), source.clone());
361            view! { <img class=class src=source /> }.into_any()
362        }
363        Widget::Badge { label, tone } => {
364            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
365            view! { <span class=class>{label}</span> }.into_any()
366        }
367        Widget::ColorDot { color } => {
368            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
369        }
370        Widget::Avatar { source, status } => {
371            let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
372            view! {
373                <span class="avatar">
374                    <img class="avatar-img" src=source.clone() />
375                    {dot}
376                </span>
377            }
378            .into_any()
379        }
380        Widget::Rating { value, max, on_rate } => {
381            let value = *value;
382            let stars: Vec<AnyView> = (1..=*max)
383                .map(|i| {
384                    let threshold = u32::from(i) * 10;
385                    // filled / half / empty by tenths.
386                    let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
387                    match on_rate {
388                        Some(tokens) => {
389                            let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
390                            view! {
391                                <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
392                                    {glyph}
393                                </button>
394                            }
395                            .into_any()
396                        }
397                        None => view! { <span class="star">{glyph}</span> }.into_any(),
398                    }
399                })
400                .collect();
401            view! { <span class="rating">{stars}</span> }.into_any()
402        }
403        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
404        Widget::Progress { value } => match value {
405            Some(v) => {
406                let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
407                view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
408            }
409            None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
410        },
411        Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
412        Widget::Chart { values, labels, style } => {
413            let max = values.iter().copied().fold(0.0_f32, f32::max).max(1e-6);
414            let n = values.len().max(1);
415            let label_row = if labels.is_empty() {
416                None
417            } else {
418                let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
419                Some(view! { <div class="chart-labels">{items}</div> })
420            };
421            let svg = match style {
422                ChartStyle::Bar => {
423                    let bw = 100.0 / n as f32;
424                    let bars: Vec<_> = values.iter().enumerate().map(|(i, v)| {
425                        let h = (v / max).clamp(0.0, 1.0) * 48.0;
426                        let x = i as f32 * bw + bw * 0.15;
427                        let w = bw * 0.7;
428                        let y = 50.0 - h;
429                        view! { <rect x=format!("{x}") y=format!("{y}") width=format!("{w}") height=format!("{h}") class="chart-bar"></rect> }
430                    }).collect();
431                    view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{bars}</svg> }.into_any()
432                }
433                ChartStyle::Line => {
434                    let pts = values.iter().enumerate().map(|(i, v)| {
435                        let x = if n == 1 { 50.0 } else { i as f32 * (100.0 / (n as f32 - 1.0)) };
436                        let y = 50.0 - (v / max).clamp(0.0, 1.0) * 48.0;
437                        format!("{x},{y}")
438                    }).collect::<Vec<_>>().join(" ");
439                    view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg"><polyline points=pts class="chart-line"></polyline></svg> }.into_any()
440                }
441            };
442            view! { <div class="chart">{svg}{label_row}</div> }.into_any()
443        }
444        Widget::Calendar { year, month, first_weekday, selected, on_day } => {
445            const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
446                "July", "August", "September", "October", "November", "December"];
447            let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
448            let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
449            let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
450            let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
451            let selected = *selected;
452            let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
453                let day = (i + 1) as u8;
454                let token = token.clone();
455                let send = send.clone();
456                let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
457                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
458            }).collect();
459            view! {
460                <div class="calendar">
461                    <div class="cal-title">{head_label}</div>
462                    <div class="cal-grid">{heads}{blanks}{days}</div>
463                </div>
464            }.into_any()
465        }
466        Widget::SwipeAction { child, actions } => {
467            // Web has no swipe gesture — render the actions inline as a trailing button row.
468            let acts: Vec<_> = actions.iter().map(|a| {
469                let token = a.on_tap.clone();
470                let send = send.clone();
471                let cls = format!("swipe-act {}", tone_class(a.tone));
472                let label = a.label.clone();
473                view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
474            }).collect();
475            view! {
476                <div class="swipe-row">
477                    <div class="swipe-content">{render(child, send)}</div>
478                    <div class="swipe-actions">{acts}</div>
479                </div>
480            }.into_any()
481        }
482        Widget::Spacer { size } => {
483            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
484        }
485
486        // ---- layout ----
487        Widget::Row { children } => {
488            let kids = render_all(children, send);
489            view! { <div class="row">{kids}</div> }.into_any()
490        }
491        Widget::Column { children } => {
492            let kids = render_all(children, send);
493            view! { <div class="col">{kids}</div> }.into_any()
494        }
495        Widget::Card { child, style, on_press } => {
496            let class = format!("card {}", card_class(*style));
497            let body = render(child, send);
498            match on_press {
499                Some(token) => {
500                    let (send, token) = (send.clone(), token.clone());
501                    view! {
502                        <button
503                            class=format!("{class} card-tappable")
504                            on:click=move |_| send(Action::Fired { token: token.clone() })
505                        >
506                            {body}
507                        </button>
508                    }
509                    .into_any()
510                }
511                None => view! { <div class=class>{body}</div> }.into_any(),
512            }
513        }
514        // Z-stack. With `scrim`, the first child is a background image, darkened
515        // by an overlay, and the rest layer on top in light content — the DOM twin
516        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
517        Widget::Box { children, align, scrim } => {
518            let acls = align_class(*align);
519            if *scrim && children.len() > 1 {
520                let bg = render(&children[0], send);
521                let content = render_all(&children[1..], send);
522                view! {
523                    <div class=format!("box box-scrim {acls}")>
524                        {bg}
525                        <div class="scrim"></div>
526                        <div class="box-content">{content}</div>
527                    </div>
528                }
529                .into_any()
530            } else {
531                let kids = render_all(children, send);
532                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
533            }
534        }
535        Widget::Grid { children } => {
536            let kids = render_all(children, send);
537            view! { <div class="grid">{kids}</div> }.into_any()
538        }
539        Widget::Scroller { children } => {
540            let kids = render_all(children, send);
541            view! { <div class="scroller">{kids}</div> }.into_any()
542        }
543
544        // ---- input / actions ----
545        Widget::Button { label, style, on_press } => {
546            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
547            let class = format!("btn {}", button_class(*style));
548            view! {
549                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
550                    {label}
551                </button>
552            }
553            .into_any()
554        }
555        Widget::IconButton { icon, on_press } => {
556            let (send, token) = (send.clone(), on_press.clone());
557            let glyph = icon_glyph(*icon);
558            view! {
559                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
560                    {glyph}
561                </button>
562            }
563            .into_any()
564        }
565        Widget::Chip { label, selected, on_press } => {
566            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
567            let class = if *selected { "chip selected" } else { "chip" };
568            view! {
569                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
570                    {label}
571                </button>
572            }
573            .into_any()
574        }
575        Widget::TextField { id, placeholder, value } => {
576            let (send, id) = (send.clone(), id.clone());
577            let (placeholder, value) = (placeholder.clone(), value.clone());
578            view! {
579                <input
580                    class="field"
581                    placeholder=placeholder
582                    prop:value=value
583                    on:input=move |ev| send(Action::Input {
584                        id: id.clone(),
585                        value: InputValue::Text(event_target_value(&ev)),
586                    })
587                />
588            }
589            .into_any()
590        }
591        Widget::SearchField { id, placeholder, value } => {
592            let (send, id) = (send.clone(), id.clone());
593            let (placeholder, value) = (placeholder.clone(), value.clone());
594            view! {
595                <div class="searchfield">
596                    <span class="search-icon">{icon_glyph(Icon::Search)}</span>
597                    <input
598                        class="search-input"
599                        placeholder=placeholder
600                        prop:value=value
601                        on:input=move |ev| send(Action::Input {
602                            id: id.clone(),
603                            value: InputValue::Text(event_target_value(&ev)),
604                        })
605                    />
606                </div>
607            }
608            .into_any()
609        }
610        Widget::Segmented { segments } => {
611            let segs: Vec<AnyView> = segments
612                .iter()
613                .map(|s| {
614                    let (send, token) = (send.clone(), s.on_select.clone());
615                    let class = if s.selected { "segment selected" } else { "segment" };
616                    let label = s.label.clone();
617                    view! {
618                        <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
619                            {label}
620                        </button>
621                    }
622                    .into_any()
623                })
624                .collect();
625            view! { <div class="segmented">{segs}</div> }.into_any()
626        }
627        Widget::Toggle { id, label, value } => {
628            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
629            view! {
630                <label class="toggle">
631                    {label}
632                    <input
633                        type="checkbox"
634                        role="switch"
635                        prop:checked=checked
636                        on:change=move |ev| send(Action::Input {
637                            id: id.clone(),
638                            value: InputValue::Bool(event_target_checked(&ev)),
639                        })
640                    />
641                </label>
642            }
643            .into_any()
644        }
645        Widget::Checkbox { id, label, value } => {
646            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
647            view! {
648                <label class="check">
649                    <input
650                        type="checkbox"
651                        prop:checked=checked
652                        on:change=move |ev| send(Action::Input {
653                            id: id.clone(),
654                            value: InputValue::Bool(event_target_checked(&ev)),
655                        })
656                    />
657                    {label}
658                </label>
659            }
660            .into_any()
661        }
662        Widget::Slider { id, value, max } => {
663            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
664            view! {
665                <input
666                    class="slider"
667                    type="range"
668                    min="0"
669                    max=max
670                    prop:value=value
671                    on:input=move |ev| send(Action::Input {
672                        id: id.clone(),
673                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
674                    })
675                />
676            }
677            .into_any()
678        }
679        Widget::Stepper { value, on_decrement, on_increment } => {
680            let send_dec = send.clone();
681            let send_inc = send.clone();
682            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
683            view! {
684                <div class="stepper">
685                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
686                    <span class="stepper-value">{*value}</span>
687                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
688                </div>
689            }
690            .into_any()
691        }
692
693        // ---- shell ----
694        Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
695            let back_btn = back.clone().map(|token| {
696                let send = send.clone();
697                view! {
698                    <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
699                        "‹"
700                    </button>
701                }
702            });
703            let tabbar = (!tabs.is_empty()).then(|| {
704                let tabs: Vec<AnyView> = tabs
705                    .iter()
706                    .map(|tab| {
707                        let (send, token) = (send.clone(), tab.on_select.clone());
708                        let class = if tab.selected { "tab selected" } else { "tab" };
709                        let label = tab.label.clone();
710                        // Optional leading icon → glyph above the label (icon tab bar).
711                        let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
712                        view! {
713                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
714                                {icon}
715                                <span class="tab-label">{label}</span>
716                            </button>
717                        }
718                        .into_any()
719                    })
720                    .collect();
721                view! { <div class="tabbar">{tabs}</div> }
722            });
723            // Floating action button — the raised primary action, anchored over the body.
724            let fab_btn = fab.clone().map(|f| {
725                let (send, token) = (send.clone(), f.on_press.clone());
726                view! {
727                    <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
728                        {icon_glyph(f.icon)}
729                    </button>
730                }
731            });
732            // Modal bottom sheet — a scrim (tap to dismiss) + a panel rising from the bottom.
733            let sheet_overlay = sheet.as_ref().map(|s| {
734                let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
735                let (title, child) = (s.title.clone(), render(&s.child, send));
736                view! {
737                    <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
738                    <div class="sheet">
739                        <div class="sheet-handle"></div>
740                        <div class="sheet-title">{title}</div>
741                        {child}
742                    </div>
743                }
744            });
745            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
746            // the web twin of the native shells' `preferredColorScheme`/Material theme.
747            let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
748            // Pull-to-refresh — web has no pull gesture, so expose a top-bar refresh button +
749            // an indeterminate bar at the top of the body while `refreshing`.
750            let refresh_btn = on_refresh.clone().map(|token| {
751                let send = send.clone();
752                view! {
753                    <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
754                }
755            });
756            let refresh_bar = refreshing.then(|| {
757                view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
758            });
759            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
760            // An app `Theme` overrides the CSS variables inline (brand color, corner, density,
761            // font) — the web twin of the native shells' brand/tint + shape + spacing + font.
762            let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
763            let (title, body) = (title.clone(), render(body, send));
764            view! {
765                <div class=class style=theme_style>
766                    <div class="topbar">
767                        {back_btn}
768                        <span class="title">{title}</span>
769                        {refresh_btn}
770                    </div>
771                    <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
772                    {fab_btn}
773                    {tabbar}
774                    {sheet_overlay}
775                </div>
776            }
777            .into_any()
778        }
779    }
780}
781
782/// Render a slice of children as sibling views.
783fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
784    children.iter().map(|c| render(c, send)).collect()
785}
786
787thread_local! {
788    /// (previous route key, previous depth, alternating toggle). The render is a
789    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
790    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
791    /// the native shells keying their body on `route`.
792    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
793}
794
795/// Render an app [`Theme`] as inline CSS custom properties on the scaffold root — the web
796/// twin of the native brand/tint + shape + spacing + font. Overrides `mobiler.css`'s defaults
797/// (its rules read these via `var(--…)`); dark mode still works (it only swaps the colors the
798/// seed doesn't pin).
799fn theme_css(t: &Theme) -> String {
800    let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
801    let radius = match t.corner {
802        Corner::None => "0px",
803        Corner::Small => "8px",
804        Corner::Medium => "14px",
805        Corner::Large => "22px",
806    };
807    let (gap, pad) = match t.density {
808        Density::Compact => ("8px", "10px"),
809        Density::Comfortable => ("12px", "14px"),
810    };
811    let font = match t.font {
812        FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
813        FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
814        FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
815        FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
816    };
817    // Secondary brand color (for the CardStyle::Brand gradient); falls back to the seed.
818    let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
819    format!(
820        "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
821         --accent2:rgb({ar},{ag},{ab});\
822         --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
823         --gap:{gap};--pad:{pad};--font:{font};"
824    )
825}
826
827/// Pick the Scaffold body's transition class for this render. Returns `""` for a
828/// same-route data update (re-render in place, no transition). On a route change it
829/// returns a directional class — slide-in from the right when `depth` grew (push),
830/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
831/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
832/// Leptos reuses the same DOM node.
833fn nav_class(route: &str, depth: u32) -> &'static str {
834    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
835        if route == prev_route {
836            return "";
837        }
838        let dir = if depth > *prev_depth {
839            ["nav-push-a", "nav-push-b"]
840        } else if depth < *prev_depth {
841            ["nav-pop-a", "nav-pop-b"]
842        } else {
843            ["nav-fade-a", "nav-fade-b"]
844        };
845        *toggle = !*toggle;
846        *prev_route = route.to_string();
847        *prev_depth = depth;
848        dir[usize::from(*toggle)]
849    })
850}
851
852// ---- style intent → CSS class / glyph (the only place that names the look) ----
853
854fn text_class(s: TextStyle) -> &'static str {
855    match s {
856        TextStyle::Title => "t-title",
857        TextStyle::Subtitle => "t-subtitle",
858        TextStyle::Caption => "t-caption",
859        TextStyle::Emphasis => "t-emphasis",
860        TextStyle::Body => "t-body",
861    }
862}
863
864fn button_class(s: ButtonStyle) -> &'static str {
865    match s {
866        ButtonStyle::Filled => "btn-filled",
867        ButtonStyle::Outlined => "btn-outlined",
868        ButtonStyle::Text => "btn-text",
869    }
870}
871
872fn card_class(s: CardStyle) -> &'static str {
873    match s {
874        CardStyle::Elevated => "card-elevated",
875        CardStyle::Outlined => "card-outlined",
876        CardStyle::Filled => "card-filled",
877        CardStyle::Brand => "card-brand",
878    }
879}
880
881fn tone_class(t: Tone) -> &'static str {
882    match t {
883        Tone::Neutral => "tone-neutral",
884        Tone::Success => "tone-success",
885        Tone::Warning => "tone-warning",
886        Tone::Danger => "tone-danger",
887        Tone::Info => "tone-info",
888    }
889}
890
891fn spacer_class(s: Spacing) -> &'static str {
892    match s {
893        Spacing::Xs => "sp-xs",
894        Spacing::Sm => "sp-sm",
895        Spacing::Md => "sp-md",
896        Spacing::Lg => "sp-lg",
897        Spacing::Xl => "sp-xl",
898    }
899}
900
901fn icon_glyph(i: Icon) -> &'static str {
902    match i {
903        Icon::Delete => "🗑",
904        Icon::Add => "+",
905        Icon::Edit => "✏️",
906        Icon::Close => "✕",
907        Icon::Settings => "⚙",
908        Icon::Check => "✓",
909        Icon::Star => "★",
910        Icon::Info => "ℹ",
911        Icon::Home => "⌂",
912        Icon::Search => "🔍",
913        Icon::Menu => "☰",
914        Icon::Filter => "⚟",
915        Icon::Back => "‹",
916        Icon::Forward => "›",
917        Icon::Down => "⌄",
918        Icon::Bell => "🔔",
919        Icon::Cart => "🛒",
920        Icon::Share => "↗",
921        Icon::Heart => "♡",
922        Icon::HeartFilled => "♥",
923        Icon::Person => "👤",
924        Icon::People => "👥",
925        Icon::Phone => "📞",
926        Icon::Mail => "✉",
927        Icon::Calendar => "📅",
928        Icon::Clock => "🕑",
929        Icon::MapPin => "📍",
930        Icon::Camera => "📷",
931        Icon::Photo => "🖼",
932        Icon::Play => "▶",
933        Icon::Scissors => "✂",
934    }
935}
936
937fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
938    let shape = match shape {
939        ImageShape::Square => "img-square",
940        ImageShape::Rounded => "img-rounded",
941        ImageShape::Circle => "img-circle",
942    };
943    let ratio = match ratio {
944        ImageRatio::Wide => "ratio-wide",
945        ImageRatio::Square => "ratio-square",
946        ImageRatio::Tall => "ratio-tall",
947    };
948    format!("img {shape} {ratio}")
949}
950
951fn dot_class(c: ProjectColor) -> &'static str {
952    match c {
953        ProjectColor::Indigo => "dot-indigo",
954        ProjectColor::Teal => "dot-teal",
955        ProjectColor::Coral => "dot-coral",
956        ProjectColor::Amber => "dot-amber",
957        ProjectColor::Lime => "dot-lime",
958        ProjectColor::Pink => "dot-pink",
959    }
960}
961
962fn align_class(a: BoxAlign) -> &'static str {
963    match a {
964        BoxAlign::TopStart => "align-top-start",
965        BoxAlign::TopEnd => "align-top-end",
966        BoxAlign::Center => "align-center",
967        BoxAlign::BottomStart => "align-bottom-start",
968        BoxAlign::BottomCenter => "align-bottom-center",
969        BoxAlign::BottomEnd => "align-bottom-end",
970    }
971}