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, Effect, Icon, ImageRatio, ImageShape, InputValue,
24    PluginCall, PluginNotify, PluginResponse, ProjectColor, Spacing, TextStyle, Tone, Widget,
25};
26use wasm_bindgen_futures::spawn_local;
27
28/// The shell's own stylesheet — the web twin of the look the Android/SwiftUI shells
29/// decide in code. Shipped with the crate and injected on mount, so `run::<App>()`
30/// renders a fully styled, themeable app with no CSS required from the consuming
31/// app (it can still override any class). Uses CSS variables so `Scaffold.dark_mode`
32/// flips the whole theme by toggling one class.
33const STYLE: &str = include_str!("mobiler.css");
34
35/// Cloneable handle for sending an `Action` into the core. Leptos 0.7 view closures
36/// require `Send`, so this is `Arc` + `Send + Sync` (the crux `Core` is both).
37type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
38
39/// What a Mobiler app must be to render on the web: a crux `App` speaking the fixed
40/// ABI (`Action` in, `Widget` out, `Effect` for capabilities). `MobilerShell<_>`
41/// satisfies this automatically.
42pub trait WebApp:
43    App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
44where
45    Self::Model: Default + Send + Sync,
46{
47}
48impl<T> WebApp for T
49where
50    T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
51    T::Model: Default + Send + Sync,
52{
53}
54
55/// Mount a Mobiler app into the document body. Call from your wasm `main`.
56pub fn run<A: WebApp>()
57where
58    A::Model: Default + Send + Sync,
59{
60    console_error_panic_hook::set_once();
61    inject_default_style();
62    leptos::mount::mount_to_body(shell::<A>);
63}
64
65/// Inject the shell's default stylesheet at the **front** of `<head>` so it's the
66/// lowest-precedence baseline: an app that ships its own CSS (later in the document)
67/// overrides any of these classes, while an app with no CSS still gets a full theme.
68fn inject_default_style() {
69    let document = leptos::prelude::document();
70    let Some(head) = document.head() else { return };
71    let Ok(style) = document.create_element("style") else { return };
72    let _ = style.set_attribute("data-mobiler", "shell");
73    style.set_text_content(Some(STYLE));
74    let _ = head.insert_before(&style, head.first_child().as_ref());
75}
76
77fn shell<A: WebApp>() -> impl IntoView
78where
79    A::Model: Default + Send + Sync,
80{
81    let core = Arc::new(Core::<A>::new());
82    let (view, set_view) = signal(core.view());
83
84    let send: Dispatch = {
85        let core = core.clone();
86        Arc::new(move |action: Action| {
87            let effects = core.process_event(action);
88            drive(&core, set_view, effects);
89        })
90    };
91
92    // Restore persisted state (localStorage), then fire Start — mirrors the native
93    // shells (which restore before Start so the app sees its saved Model on launch).
94    let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
95    if !saved.is_empty() {
96        send(Action::Restore { data: saved });
97    }
98    send(Action::Start);
99
100    let send_for_view = send.clone();
101    view! {
102        <div class="app">
103            {move || render(&view.get(), &send_for_view)}
104        </div>
105    }
106}
107
108/// Process effects: re-read the view on Render; fulfil HTTP via fetch and resolve.
109fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
110where
111    A::Model: Default + Send + Sync,
112{
113    for effect in effects {
114        match effect {
115            Effect::Render(_) => set_view.set(core.view()),
116            Effect::PluginNotify(notify) => perform_notify(&notify.operation),
117            Effect::Plugin(mut request) => {
118                let core = core.clone();
119                spawn_local(async move {
120                    let response = perform(&request.operation).await;
121                    if let Ok(next) = core.resolve(&mut request, response) {
122                        drive(&core, set_view, next);
123                    }
124                });
125            }
126        }
127    }
128}
129
130/// Fulfil a request/response capability. `http` via `fetch`; `device` via the
131/// browser's user-agent string (the web analogue of a device model).
132async fn perform(call: &PluginCall) -> PluginResponse {
133    if call.plugin == "device" {
134        let ua = web_sys::window()
135            .and_then(|w| w.navigator().user_agent().ok())
136            .unwrap_or_default();
137        return PluginResponse { ok: true, output: ua };
138    }
139    if call.plugin == "photo" && call.op == "pick" {
140        return take_image(false).await;
141    }
142    if call.plugin == "camera" && call.op == "capture" {
143        return take_image(true).await;
144    }
145    if call.plugin == "dialog" && call.op == "confirm" {
146        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
147        let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
148        let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
149        let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
150        let ok = web_sys::window()
151            .and_then(|w| w.confirm_with_message(&prompt).ok())
152            .unwrap_or(false);
153        return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
154    }
155    if call.plugin != "http" {
156        return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
157    }
158    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
159    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
160    let body = v.get("body").and_then(serde_json::Value::as_str);
161
162    use gloo_net::http::Request;
163    let builder = match call.op.as_str() {
164        "POST" => Request::post(url),
165        "PATCH" => Request::patch(url),
166        "DELETE" => Request::delete(url),
167        _ => Request::get(url),
168    };
169    let request = match body {
170        Some(b) => builder.header("Content-Type", "application/json").body(b),
171        None => builder.build(),
172    };
173    let request = match request {
174        Ok(r) => r,
175        Err(e) => return PluginResponse { ok: false, output: e.to_string() },
176    };
177    match request.send().await {
178        Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
179        Err(e) => PluginResponse { ok: false, output: e.to_string() },
180    }
181}
182
183/// Pick or capture an image via a hidden `<input type=file accept=image/*>`, clicked
184/// to open the browser's file dialog — or, with `capture`, to hint the device camera on
185/// supporting mobile browsers (desktop falls back to the file dialog). Awaits the
186/// `change` event and returns a `blob:` object URL the `<img>` renderer loads. No
187/// permission needed (the picker/camera prompt is the browser's). Backs both the
188/// `photo`/`pick` and `camera`/`capture` capabilities.
189async fn take_image(capture: bool) -> PluginResponse {
190    use wasm_bindgen::{closure::Closure, JsCast};
191    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
192        return PluginResponse { ok: false, output: "no document".into() };
193    };
194    let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
195        return PluginResponse { ok: false, output: "no input element".into() };
196    };
197    input.set_type("file");
198    input.set_accept("image/*");
199    if capture {
200        // Hints the environment-facing camera on mobile browsers that support it.
201        let _ = input.set_attribute("capture", "environment");
202    }
203
204    let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
205    let tx = std::cell::RefCell::new(Some(tx));
206    let input_for_cb = input.clone();
207    let on_change = Closure::wrap(Box::new(move || {
208        let url = input_for_cb
209            .files()
210            .and_then(|files| files.get(0))
211            .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
212        if let Some(tx) = tx.borrow_mut().take() {
213            let _ = tx.send(url);
214        }
215    }) as Box<dyn FnMut()>);
216    input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
217    input.click();
218    on_change.forget(); // keep the handler alive until `change` fires
219
220    match rx.await {
221        Ok(Some(url)) => PluginResponse { ok: true, output: url },
222        _ => PluginResponse { ok: false, output: "cancelled".into() },
223    }
224}
225
226const STORAGE_KEY: &str = "mobiler.state";
227
228/// `window.localStorage`, if available.
229fn local_storage() -> Option<web_sys::Storage> {
230    web_sys::window()?.local_storage().ok().flatten()
231}
232
233/// Fulfil a fire-and-forget capability in the browser — the web twin of the native
234/// shells' notify handlers (storage/clipboard/share/browser). None block; an unknown
235/// capability is a graceful no-op.
236fn perform_notify(notify: &PluginNotify) {
237    let win = match web_sys::window() {
238        Some(w) => w,
239        None => return,
240    };
241    match (notify.plugin.as_str(), notify.op.as_str()) {
242        // Persist the state blob (paired with cx.save + restore-on-startup above).
243        ("storage", "save") => {
244            if let Some(s) = local_storage() {
245                let _ = s.set_item(STORAGE_KEY, &notify.input);
246            }
247        }
248        // Copy to the clipboard (write_text returns a Promise we let run).
249        ("clipboard", "copy") => {
250            let _ = win.navigator().clipboard().write_text(&notify.input);
251        }
252        // Open a URL in a new tab.
253        ("browser", "open") => {
254            let _ = win.open_with_url_and_target(&notify.input, "_blank");
255        }
256        // No reliable cross-browser share sheet (navigator.share is mobile-only and
257        // gesture-gated), so degrade to copying — a sane universal fallback.
258        ("share", _) => {
259            let _ = win.navigator().clipboard().write_text(&notify.input);
260        }
261        // Transient toast: a styled div appended to <body>, auto-removed after a beat.
262        ("toast", _) => show_toast(&notify.input),
263        // Haptic tap. navigator.vibrate is unsupported on iOS Safari (a graceful no-op).
264        ("haptics", style) => {
265            let ms = match style {
266                "light" => 15,
267                "heavy" => 50,
268                _ => 30, // medium / unknown
269            };
270            let _ = win.navigator().vibrate_with_duration(ms);
271        }
272        _ => {} // unknown capability: ignore
273    }
274}
275
276/// Append a transient toast to `<body>` (styled by `.toast` in mobiler.css) and
277/// remove it after ~2.6 s — the web twin of the native toast/snackbar.
278fn show_toast(text: &str) {
279    let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
280    let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
281    el.set_class_name("toast");
282    el.set_text_content(Some(text));
283    let _ = body.append_child(&el);
284    gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
285}
286
287// ---------------- Widget → DOM ----------------
288
289/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
290/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
291/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
292/// the concrete look lives in `mobiler.css`.
293fn render(widget: &Widget, send: &Dispatch) -> AnyView {
294    match widget {
295        // ---- content ----
296        Widget::Text { content, style } => {
297            let (class, content) = (text_class(*style), content.clone());
298            view! { <p class=class>{content}</p> }.into_any()
299        }
300        Widget::Image { source, shape, ratio } => {
301            let (class, source) = (image_class(*shape, *ratio), source.clone());
302            view! { <img class=class src=source /> }.into_any()
303        }
304        Widget::Badge { label, tone } => {
305            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
306            view! { <span class=class>{label}</span> }.into_any()
307        }
308        Widget::ColorDot { color } => {
309            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
310        }
311        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
312        Widget::Spacer { size } => {
313            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
314        }
315
316        // ---- layout ----
317        Widget::Row { children } => {
318            let kids = render_all(children, send);
319            view! { <div class="row">{kids}</div> }.into_any()
320        }
321        Widget::Column { children } => {
322            let kids = render_all(children, send);
323            view! { <div class="col">{kids}</div> }.into_any()
324        }
325        Widget::Card { child, style, on_press } => {
326            let class = format!("card {}", card_class(*style));
327            let body = render(child, send);
328            match on_press {
329                Some(token) => {
330                    let (send, token) = (send.clone(), token.clone());
331                    view! {
332                        <button
333                            class=format!("{class} card-tappable")
334                            on:click=move |_| send(Action::Fired { token: token.clone() })
335                        >
336                            {body}
337                        </button>
338                    }
339                    .into_any()
340                }
341                None => view! { <div class=class>{body}</div> }.into_any(),
342            }
343        }
344        // Z-stack. With `scrim`, the first child is a background image, darkened
345        // by an overlay, and the rest layer on top in light content — the DOM twin
346        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
347        Widget::Box { children, align, scrim } => {
348            let acls = align_class(*align);
349            if *scrim && children.len() > 1 {
350                let bg = render(&children[0], send);
351                let content = render_all(&children[1..], send);
352                view! {
353                    <div class=format!("box box-scrim {acls}")>
354                        {bg}
355                        <div class="scrim"></div>
356                        <div class="box-content">{content}</div>
357                    </div>
358                }
359                .into_any()
360            } else {
361                let kids = render_all(children, send);
362                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
363            }
364        }
365        Widget::Grid { children } => {
366            let kids = render_all(children, send);
367            view! { <div class="grid">{kids}</div> }.into_any()
368        }
369
370        // ---- input / actions ----
371        Widget::Button { label, style, on_press } => {
372            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
373            let class = format!("btn {}", button_class(*style));
374            view! {
375                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
376                    {label}
377                </button>
378            }
379            .into_any()
380        }
381        Widget::IconButton { icon, on_press } => {
382            let (send, token) = (send.clone(), on_press.clone());
383            let glyph = icon_glyph(*icon);
384            view! {
385                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
386                    {glyph}
387                </button>
388            }
389            .into_any()
390        }
391        Widget::Chip { label, selected, on_press } => {
392            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
393            let class = if *selected { "chip selected" } else { "chip" };
394            view! {
395                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
396                    {label}
397                </button>
398            }
399            .into_any()
400        }
401        Widget::TextField { id, placeholder, value } => {
402            let (send, id) = (send.clone(), id.clone());
403            let (placeholder, value) = (placeholder.clone(), value.clone());
404            view! {
405                <input
406                    class="field"
407                    placeholder=placeholder
408                    prop:value=value
409                    on:input=move |ev| send(Action::Input {
410                        id: id.clone(),
411                        value: InputValue::Text(event_target_value(&ev)),
412                    })
413                />
414            }
415            .into_any()
416        }
417        Widget::Toggle { id, label, value } => {
418            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
419            view! {
420                <label class="toggle">
421                    {label}
422                    <input
423                        type="checkbox"
424                        role="switch"
425                        prop:checked=checked
426                        on:change=move |ev| send(Action::Input {
427                            id: id.clone(),
428                            value: InputValue::Bool(event_target_checked(&ev)),
429                        })
430                    />
431                </label>
432            }
433            .into_any()
434        }
435        Widget::Checkbox { id, label, value } => {
436            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
437            view! {
438                <label class="check">
439                    <input
440                        type="checkbox"
441                        prop:checked=checked
442                        on:change=move |ev| send(Action::Input {
443                            id: id.clone(),
444                            value: InputValue::Bool(event_target_checked(&ev)),
445                        })
446                    />
447                    {label}
448                </label>
449            }
450            .into_any()
451        }
452        Widget::Slider { id, value, max } => {
453            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
454            view! {
455                <input
456                    class="slider"
457                    type="range"
458                    min="0"
459                    max=max
460                    prop:value=value
461                    on:input=move |ev| send(Action::Input {
462                        id: id.clone(),
463                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
464                    })
465                />
466            }
467            .into_any()
468        }
469        Widget::Stepper { value, on_decrement, on_increment } => {
470            let send_dec = send.clone();
471            let send_inc = send.clone();
472            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
473            view! {
474                <div class="stepper">
475                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
476                    <span class="stepper-value">{*value}</span>
477                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
478                </div>
479            }
480            .into_any()
481        }
482
483        // ---- shell ----
484        Widget::Scaffold { title, body, tabs, back, dark_mode, route, depth } => {
485            let back_btn = back.clone().map(|token| {
486                let send = send.clone();
487                view! {
488                    <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
489                        "‹"
490                    </button>
491                }
492            });
493            let tabbar = (!tabs.is_empty()).then(|| {
494                let tabs: Vec<AnyView> = tabs
495                    .iter()
496                    .map(|tab| {
497                        let (send, token) = (send.clone(), tab.on_select.clone());
498                        let class = if tab.selected { "tab selected" } else { "tab" };
499                        let label = tab.label.clone();
500                        view! {
501                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
502                                {label}
503                            </button>
504                        }
505                        .into_any()
506                    })
507                    .collect();
508                view! { <div class="tabbar">{tabs}</div> }
509            });
510            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
511            // the web twin of the native shells' `preferredColorScheme`/Material theme.
512            let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
513            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
514            let (title, body) = (title.clone(), render(body, send));
515            view! {
516                <div class=class>
517                    <div class="topbar">
518                        {back_btn}
519                        <span class="title">{title}</span>
520                    </div>
521                    <div class=body_class data-route=route.clone()>{body}</div>
522                    {tabbar}
523                </div>
524            }
525            .into_any()
526        }
527    }
528}
529
530/// Render a slice of children as sibling views.
531fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
532    children.iter().map(|c| render(c, send)).collect()
533}
534
535thread_local! {
536    /// (previous route key, previous depth, alternating toggle). The render is a
537    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
538    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
539    /// the native shells keying their body on `route`.
540    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
541}
542
543/// Pick the Scaffold body's transition class for this render. Returns `""` for a
544/// same-route data update (re-render in place, no transition). On a route change it
545/// returns a directional class — slide-in from the right when `depth` grew (push),
546/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
547/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
548/// Leptos reuses the same DOM node.
549fn nav_class(route: &str, depth: u32) -> &'static str {
550    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
551        if route == prev_route {
552            return "";
553        }
554        let dir = if depth > *prev_depth {
555            ["nav-push-a", "nav-push-b"]
556        } else if depth < *prev_depth {
557            ["nav-pop-a", "nav-pop-b"]
558        } else {
559            ["nav-fade-a", "nav-fade-b"]
560        };
561        *toggle = !*toggle;
562        *prev_route = route.to_string();
563        *prev_depth = depth;
564        dir[usize::from(*toggle)]
565    })
566}
567
568// ---- style intent → CSS class / glyph (the only place that names the look) ----
569
570fn text_class(s: TextStyle) -> &'static str {
571    match s {
572        TextStyle::Title => "t-title",
573        TextStyle::Subtitle => "t-subtitle",
574        TextStyle::Caption => "t-caption",
575        TextStyle::Emphasis => "t-emphasis",
576        TextStyle::Body => "t-body",
577    }
578}
579
580fn button_class(s: ButtonStyle) -> &'static str {
581    match s {
582        ButtonStyle::Filled => "btn-filled",
583        ButtonStyle::Outlined => "btn-outlined",
584        ButtonStyle::Text => "btn-text",
585    }
586}
587
588fn card_class(s: CardStyle) -> &'static str {
589    match s {
590        CardStyle::Elevated => "card-elevated",
591        CardStyle::Outlined => "card-outlined",
592        CardStyle::Filled => "card-filled",
593    }
594}
595
596fn tone_class(t: Tone) -> &'static str {
597    match t {
598        Tone::Neutral => "tone-neutral",
599        Tone::Success => "tone-success",
600        Tone::Warning => "tone-warning",
601        Tone::Danger => "tone-danger",
602        Tone::Info => "tone-info",
603    }
604}
605
606fn spacer_class(s: Spacing) -> &'static str {
607    match s {
608        Spacing::Xs => "sp-xs",
609        Spacing::Sm => "sp-sm",
610        Spacing::Md => "sp-md",
611        Spacing::Lg => "sp-lg",
612        Spacing::Xl => "sp-xl",
613    }
614}
615
616fn icon_glyph(i: Icon) -> &'static str {
617    match i {
618        Icon::Delete => "🗑",
619        Icon::Add => "+",
620        Icon::Edit => "✏️",
621        Icon::Close => "✕",
622        Icon::Settings => "⚙",
623        Icon::Check => "✓",
624        Icon::Star => "★",
625    }
626}
627
628fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
629    let shape = match shape {
630        ImageShape::Square => "img-square",
631        ImageShape::Rounded => "img-rounded",
632        ImageShape::Circle => "img-circle",
633    };
634    let ratio = match ratio {
635        ImageRatio::Wide => "ratio-wide",
636        ImageRatio::Square => "ratio-square",
637        ImageRatio::Tall => "ratio-tall",
638    };
639    format!("img {shape} {ratio}")
640}
641
642fn dot_class(c: ProjectColor) -> &'static str {
643    match c {
644        ProjectColor::Indigo => "dot-indigo",
645        ProjectColor::Teal => "dot-teal",
646        ProjectColor::Coral => "dot-coral",
647        ProjectColor::Amber => "dot-amber",
648        ProjectColor::Lime => "dot-lime",
649        ProjectColor::Pink => "dot-pink",
650    }
651}
652
653fn align_class(a: BoxAlign) -> &'static str {
654    match a {
655        BoxAlign::TopStart => "align-top-start",
656        BoxAlign::TopEnd => "align-top-end",
657        BoxAlign::Center => "align-center",
658        BoxAlign::BottomStart => "align-bottom-start",
659        BoxAlign::BottomCenter => "align-bottom-center",
660        BoxAlign::BottomEnd => "align-bottom-end",
661    }
662}