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, Corner, Density, Effect, FontFamily, Icon, ImageRatio,
24    ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, ProjectColor, Spacing,
25    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 == "dialog" && call.op == "confirm" {
147        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
148        let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
149        let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
150        let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
151        let ok = web_sys::window()
152            .and_then(|w| w.confirm_with_message(&prompt).ok())
153            .unwrap_or(false);
154        return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
155    }
156    if call.plugin != "http" {
157        return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
158    }
159    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
160    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
161    let body = v.get("body").and_then(serde_json::Value::as_str);
162
163    use gloo_net::http::Request;
164    let builder = match call.op.as_str() {
165        "POST" => Request::post(url),
166        "PATCH" => Request::patch(url),
167        "DELETE" => Request::delete(url),
168        _ => Request::get(url),
169    };
170    let request = match body {
171        Some(b) => builder.header("Content-Type", "application/json").body(b),
172        None => builder.build(),
173    };
174    let request = match request {
175        Ok(r) => r,
176        Err(e) => return PluginResponse { ok: false, output: e.to_string() },
177    };
178    match request.send().await {
179        Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
180        Err(e) => PluginResponse { ok: false, output: e.to_string() },
181    }
182}
183
184/// Pick or capture an image via a hidden `<input type=file accept=image/*>`, clicked
185/// to open the browser's file dialog — or, with `capture`, to hint the device camera on
186/// supporting mobile browsers (desktop falls back to the file dialog). Awaits the
187/// `change` event and returns a `blob:` object URL the `<img>` renderer loads. No
188/// permission needed (the picker/camera prompt is the browser's). Backs both the
189/// `photo`/`pick` and `camera`/`capture` capabilities.
190async fn take_image(capture: bool) -> PluginResponse {
191    use wasm_bindgen::{closure::Closure, JsCast};
192    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
193        return PluginResponse { ok: false, output: "no document".into() };
194    };
195    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
196        return PluginResponse { ok: false, output: "no input element".into() };
197    };
198    input.set_type("file");
199    input.set_accept("image/*");
200    if capture {
201        // Hints the environment-facing camera on mobile browsers that support it.
202        let _ = input.set_attribute("capture", "environment");
203    }
204
205    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
206    let tx = std::cell::RefCell::new(Some(tx));
207    let input_for_cb = input.clone();
208    let on_change = Closure::wrap(Box::new(move || {
209        let url = input_for_cb
210            .files()
211            .and_then(|files| files.get(0))
212            .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
213        if let Some(tx) = tx.borrow_mut().take() {
214            let _ = tx.send(url);
215        }
216    }) as Box<dyn FnMut()>);
217    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
218    input.click();
219    on_change.forget(); // keep the handler alive until `change` fires
220
221    match rx.await {
222        Ok(Some(url)) => PluginResponse { ok: true, output: url },
223        _ => PluginResponse { ok: false, output: "cancelled".into() },
224    }
225}
226
227const STORAGE_KEY: &str = "mobiler.state";
228
229/// `window.localStorage`, if available.
230fn local_storage() -> Option<web_sys::Storage> {
231    web_sys::window()?.local_storage().ok().flatten()
232}
233
234/// Fulfil a fire-and-forget capability in the browser — the web twin of the native
235/// shells' notify handlers (storage/clipboard/share/browser). None block; an unknown
236/// capability is a graceful no-op.
237fn perform_notify(notify: &PluginNotify) {
238    let win = match web_sys::window() {
239        Some(w) => w,
240        None => return,
241    };
242    match (notify.plugin.as_str(), notify.op.as_str()) {
243        // Persist the state blob (paired with cx.save + restore-on-startup above).
244        ("storage", "save") => {
245            if let Some(s) = local_storage() {
246                let _ = s.set_item(STORAGE_KEY, &notify.input);
247            }
248        }
249        // Copy to the clipboard (write_text returns a Promise we let run).
250        ("clipboard", "copy") => {
251            let _ = win.navigator().clipboard().write_text(&notify.input);
252        }
253        // Open a URL in a new tab.
254        ("browser", "open") => {
255            let _ = win.open_with_url_and_target(&notify.input, "_blank");
256        }
257        // No reliable cross-browser share sheet (navigator.share is mobile-only and
258        // gesture-gated), so degrade to copying — a sane universal fallback.
259        ("share", _) => {
260            let _ = win.navigator().clipboard().write_text(&notify.input);
261        }
262        // Transient toast: a styled div appended to <body>, auto-removed after a beat.
263        ("toast", _) => show_toast(&notify.input),
264        // Haptic tap. navigator.vibrate is unsupported on iOS Safari (a graceful no-op).
265        ("haptics", style) => {
266            let ms = match style {
267                "light" => 15,
268                "heavy" => 50,
269                _ => 30, // medium / unknown
270            };
271            let _ = win.navigator().vibrate_with_duration(ms);
272        }
273        _ => {} // unknown capability: ignore
274    }
275}
276
277/// Append a transient toast to `<body>` (styled by `.toast` in mobiler.css) and
278/// remove it after ~2.6 s — the web twin of the native toast/snackbar.
279fn show_toast(text: &str) {
280    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
281    let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
282    el.set_class_name("toast");
283    el.set_text_content(Some(text));
284    let _ = body.append_child(&el);
285    gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
286}
287
288// ---------------- Widget → DOM ----------------
289
290/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
291/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
292/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
293/// the concrete look lives in `mobiler.css`.
294fn render(widget: &Widget, send: &Dispatch) -> AnyView {
295    match widget {
296        // ---- content ----
297        Widget::Text { content, style } => {
298            let (class, content) = (text_class(*style), content.clone());
299            view! { <p class=class>{content}</p> }.into_any()
300        }
301        Widget::Image { source, shape, ratio } => {
302            let (class, source) = (image_class(*shape, *ratio), source.clone());
303            view! { <img class=class src=source /> }.into_any()
304        }
305        Widget::Badge { label, tone } => {
306            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
307            view! { <span class=class>{label}</span> }.into_any()
308        }
309        Widget::ColorDot { color } => {
310            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
311        }
312        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
313        Widget::Spacer { size } => {
314            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
315        }
316
317        // ---- layout ----
318        Widget::Row { children } => {
319            let kids = render_all(children, send);
320            view! { <div class="row">{kids}</div> }.into_any()
321        }
322        Widget::Column { children } => {
323            let kids = render_all(children, send);
324            view! { <div class="col">{kids}</div> }.into_any()
325        }
326        Widget::Card { child, style, on_press } => {
327            let class = format!("card {}", card_class(*style));
328            let body = render(child, send);
329            match on_press {
330                Some(token) => {
331                    let (send, token) = (send.clone(), token.clone());
332                    view! {
333                        <button
334                            class=format!("{class} card-tappable")
335                            on:click=move |_| send(Action::Fired { token: token.clone() })
336                        >
337                            {body}
338                        </button>
339                    }
340                    .into_any()
341                }
342                None => view! { <div class=class>{body}</div> }.into_any(),
343            }
344        }
345        // Z-stack. With `scrim`, the first child is a background image, darkened
346        // by an overlay, and the rest layer on top in light content — the DOM twin
347        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
348        Widget::Box { children, align, scrim } => {
349            let acls = align_class(*align);
350            if *scrim && children.len() > 1 {
351                let bg = render(&children[0], send);
352                let content = render_all(&children[1..], send);
353                view! {
354                    <div class=format!("box box-scrim {acls}")>
355                        {bg}
356                        <div class="scrim"></div>
357                        <div class="box-content">{content}</div>
358                    </div>
359                }
360                .into_any()
361            } else {
362                let kids = render_all(children, send);
363                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
364            }
365        }
366        Widget::Grid { children } => {
367            let kids = render_all(children, send);
368            view! { <div class="grid">{kids}</div> }.into_any()
369        }
370
371        // ---- input / actions ----
372        Widget::Button { label, style, on_press } => {
373            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
374            let class = format!("btn {}", button_class(*style));
375            view! {
376                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
377                    {label}
378                </button>
379            }
380            .into_any()
381        }
382        Widget::IconButton { icon, on_press } => {
383            let (send, token) = (send.clone(), on_press.clone());
384            let glyph = icon_glyph(*icon);
385            view! {
386                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
387                    {glyph}
388                </button>
389            }
390            .into_any()
391        }
392        Widget::Chip { label, selected, on_press } => {
393            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
394            let class = if *selected { "chip selected" } else { "chip" };
395            view! {
396                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
397                    {label}
398                </button>
399            }
400            .into_any()
401        }
402        Widget::TextField { id, placeholder, value } => {
403            let (send, id) = (send.clone(), id.clone());
404            let (placeholder, value) = (placeholder.clone(), value.clone());
405            view! {
406                <input
407                    class="field"
408                    placeholder=placeholder
409                    prop:value=value
410                    on:input=move |ev| send(Action::Input {
411                        id: id.clone(),
412                        value: InputValue::Text(event_target_value(&ev)),
413                    })
414                />
415            }
416            .into_any()
417        }
418        Widget::Toggle { id, label, value } => {
419            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
420            view! {
421                <label class="toggle">
422                    {label}
423                    <input
424                        type="checkbox"
425                        role="switch"
426                        prop:checked=checked
427                        on:change=move |ev| send(Action::Input {
428                            id: id.clone(),
429                            value: InputValue::Bool(event_target_checked(&ev)),
430                        })
431                    />
432                </label>
433            }
434            .into_any()
435        }
436        Widget::Checkbox { id, label, value } => {
437            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
438            view! {
439                <label class="check">
440                    <input
441                        type="checkbox"
442                        prop:checked=checked
443                        on:change=move |ev| send(Action::Input {
444                            id: id.clone(),
445                            value: InputValue::Bool(event_target_checked(&ev)),
446                        })
447                    />
448                    {label}
449                </label>
450            }
451            .into_any()
452        }
453        Widget::Slider { id, value, max } => {
454            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
455            view! {
456                <input
457                    class="slider"
458                    type="range"
459                    min="0"
460                    max=max
461                    prop:value=value
462                    on:input=move |ev| send(Action::Input {
463                        id: id.clone(),
464                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
465                    })
466                />
467            }
468            .into_any()
469        }
470        Widget::Stepper { value, on_decrement, on_increment } => {
471            let send_dec = send.clone();
472            let send_inc = send.clone();
473            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
474            view! {
475                <div class="stepper">
476                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
477                    <span class="stepper-value">{*value}</span>
478                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
479                </div>
480            }
481            .into_any()
482        }
483
484        // ---- shell ----
485        Widget::Scaffold { title, body, tabs, back, dark_mode, theme, route, depth } => {
486            let back_btn = back.clone().map(|token| {
487                let send = send.clone();
488                view! {
489                    <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
490                        "‹"
491                    </button>
492                }
493            });
494            let tabbar = (!tabs.is_empty()).then(|| {
495                let tabs: Vec<AnyView> = tabs
496                    .iter()
497                    .map(|tab| {
498                        let (send, token) = (send.clone(), tab.on_select.clone());
499                        let class = if tab.selected { "tab selected" } else { "tab" };
500                        let label = tab.label.clone();
501                        view! {
502                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
503                                {label}
504                            </button>
505                        }
506                        .into_any()
507                    })
508                    .collect();
509                view! { <div class="tabbar">{tabs}</div> }
510            });
511            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
512            // the web twin of the native shells' `preferredColorScheme`/Material theme.
513            let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
514            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
515            // An app `Theme` overrides the CSS variables inline (brand color, corner, density,
516            // font) — the web twin of the native shells' brand/tint + shape + spacing + font.
517            let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
518            let (title, body) = (title.clone(), render(body, send));
519            view! {
520                <div class=class style=theme_style>
521                    <div class="topbar">
522                        {back_btn}
523                        <span class="title">{title}</span>
524                    </div>
525                    <div class=body_class data-route=route.clone()>{body}</div>
526                    {tabbar}
527                </div>
528            }
529            .into_any()
530        }
531    }
532}
533
534/// Render a slice of children as sibling views.
535fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
536    children.iter().map(|c| render(c, send)).collect()
537}
538
539thread_local! {
540    /// (previous route key, previous depth, alternating toggle). The render is a
541    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
542    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
543    /// the native shells keying their body on `route`.
544    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
545}
546
547/// Render an app [`Theme`] as inline CSS custom properties on the scaffold root — the web
548/// twin of the native brand/tint + shape + spacing + font. Overrides `mobiler.css`'s defaults
549/// (its rules read these via `var(--…)`); dark mode still works (it only swaps the colors the
550/// seed doesn't pin).
551fn theme_css(t: &Theme) -> String {
552    let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
553    let radius = match t.corner {
554        Corner::None => "0px",
555        Corner::Small => "8px",
556        Corner::Medium => "14px",
557        Corner::Large => "22px",
558    };
559    let (gap, pad) = match t.density {
560        Density::Compact => ("8px", "10px"),
561        Density::Comfortable => ("12px", "14px"),
562    };
563    let font = match t.font {
564        FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
565        FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
566        FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
567        FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
568    };
569    format!(
570        "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
571         --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
572         --gap:{gap};--pad:{pad};--font:{font};"
573    )
574}
575
576/// Pick the Scaffold body's transition class for this render. Returns `""` for a
577/// same-route data update (re-render in place, no transition). On a route change it
578/// returns a directional class — slide-in from the right when `depth` grew (push),
579/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
580/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
581/// Leptos reuses the same DOM node.
582fn nav_class(route: &str, depth: u32) -> &'static str {
583    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
584        if route == prev_route {
585            return "";
586        }
587        let dir = if depth > *prev_depth {
588            ["nav-push-a", "nav-push-b"]
589        } else if depth < *prev_depth {
590            ["nav-pop-a", "nav-pop-b"]
591        } else {
592            ["nav-fade-a", "nav-fade-b"]
593        };
594        *toggle = !*toggle;
595        *prev_route = route.to_string();
596        *prev_depth = depth;
597        dir[usize::from(*toggle)]
598    })
599}
600
601// ---- style intent → CSS class / glyph (the only place that names the look) ----
602
603fn text_class(s: TextStyle) -> &'static str {
604    match s {
605        TextStyle::Title => "t-title",
606        TextStyle::Subtitle => "t-subtitle",
607        TextStyle::Caption => "t-caption",
608        TextStyle::Emphasis => "t-emphasis",
609        TextStyle::Body => "t-body",
610    }
611}
612
613fn button_class(s: ButtonStyle) -> &'static str {
614    match s {
615        ButtonStyle::Filled => "btn-filled",
616        ButtonStyle::Outlined => "btn-outlined",
617        ButtonStyle::Text => "btn-text",
618    }
619}
620
621fn card_class(s: CardStyle) -> &'static str {
622    match s {
623        CardStyle::Elevated => "card-elevated",
624        CardStyle::Outlined => "card-outlined",
625        CardStyle::Filled => "card-filled",
626    }
627}
628
629fn tone_class(t: Tone) -> &'static str {
630    match t {
631        Tone::Neutral => "tone-neutral",
632        Tone::Success => "tone-success",
633        Tone::Warning => "tone-warning",
634        Tone::Danger => "tone-danger",
635        Tone::Info => "tone-info",
636    }
637}
638
639fn spacer_class(s: Spacing) -> &'static str {
640    match s {
641        Spacing::Xs => "sp-xs",
642        Spacing::Sm => "sp-sm",
643        Spacing::Md => "sp-md",
644        Spacing::Lg => "sp-lg",
645        Spacing::Xl => "sp-xl",
646    }
647}
648
649fn icon_glyph(i: Icon) -> &'static str {
650    match i {
651        Icon::Delete => "🗑",
652        Icon::Add => "+",
653        Icon::Edit => "✏️",
654        Icon::Close => "✕",
655        Icon::Settings => "⚙",
656        Icon::Check => "✓",
657        Icon::Star => "★",
658    }
659}
660
661fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
662    let shape = match shape {
663        ImageShape::Square => "img-square",
664        ImageShape::Rounded => "img-rounded",
665        ImageShape::Circle => "img-circle",
666    };
667    let ratio = match ratio {
668        ImageRatio::Wide => "ratio-wide",
669        ImageRatio::Square => "ratio-square",
670        ImageRatio::Tall => "ratio-tall",
671    };
672    format!("img {shape} {ratio}")
673}
674
675fn dot_class(c: ProjectColor) -> &'static str {
676    match c {
677        ProjectColor::Indigo => "dot-indigo",
678        ProjectColor::Teal => "dot-teal",
679        ProjectColor::Coral => "dot-coral",
680        ProjectColor::Amber => "dot-amber",
681        ProjectColor::Lime => "dot-lime",
682        ProjectColor::Pink => "dot-pink",
683    }
684}
685
686fn align_class(a: BoxAlign) -> &'static str {
687    match a {
688        BoxAlign::TopStart => "align-top-start",
689        BoxAlign::TopEnd => "align-top-end",
690        BoxAlign::Center => "align-center",
691        BoxAlign::BottomStart => "align-bottom-start",
692        BoxAlign::BottomCenter => "align-bottom-center",
693        BoxAlign::BottomEnd => "align-bottom-end",
694    }
695}