1use 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
29const STYLE: &str = include_str!("mobiler.css");
35
36type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
39
40pub 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
56pub 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
66fn 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 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
109fn 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(¬ify.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
131async 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
191async 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 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(); match rx.await {
229 Ok(Some(url)) => PluginResponse { ok: true, output: url },
230 _ => PluginResponse { ok: false, output: "cancelled".into() },
231 }
232}
233
234async 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); 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(); }
274 on_change.forget(); 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
287fn local_storage() -> Option<web_sys::Storage> {
289 web_sys::window()?.local_storage().ok().flatten()
290}
291
292fn 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 ("storage", "save") => {
303 if let Some(s) = local_storage() {
304 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
305 }
306 }
307 ("clipboard", "copy") => {
309 let _ = win.navigator().clipboard().write_text(¬ify.input);
310 }
311 ("browser", "open") => {
313 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
314 }
315 ("share", _) => {
318 let _ = win.navigator().clipboard().write_text(¬ify.input);
319 }
320 ("toast", _) => show_toast(¬ify.input),
322 ("haptics", style) => {
324 let ms = match style {
325 "light" => 15,
326 "heavy" => 50,
327 _ => 30, };
329 let _ = win.navigator().vibrate_with_duration(ms);
330 }
331 _ => {} }
333}
334
335fn 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
346fn render(widget: &Widget, send: &Dispatch) -> AnyView {
353 match widget {
354 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 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::Spacer { size } => {
405 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
406 }
407
408 Widget::Row { children } => {
410 let kids = render_all(children, send);
411 view! { <div class="row">{kids}</div> }.into_any()
412 }
413 Widget::Column { children } => {
414 let kids = render_all(children, send);
415 view! { <div class="col">{kids}</div> }.into_any()
416 }
417 Widget::Card { child, style, on_press } => {
418 let class = format!("card {}", card_class(*style));
419 let body = render(child, send);
420 match on_press {
421 Some(token) => {
422 let (send, token) = (send.clone(), token.clone());
423 view! {
424 <button
425 class=format!("{class} card-tappable")
426 on:click=move |_| send(Action::Fired { token: token.clone() })
427 >
428 {body}
429 </button>
430 }
431 .into_any()
432 }
433 None => view! { <div class=class>{body}</div> }.into_any(),
434 }
435 }
436 Widget::Box { children, align, scrim } => {
440 let acls = align_class(*align);
441 if *scrim && children.len() > 1 {
442 let bg = render(&children[0], send);
443 let content = render_all(&children[1..], send);
444 view! {
445 <div class=format!("box box-scrim {acls}")>
446 {bg}
447 <div class="scrim"></div>
448 <div class="box-content">{content}</div>
449 </div>
450 }
451 .into_any()
452 } else {
453 let kids = render_all(children, send);
454 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
455 }
456 }
457 Widget::Grid { children } => {
458 let kids = render_all(children, send);
459 view! { <div class="grid">{kids}</div> }.into_any()
460 }
461 Widget::Scroller { children } => {
462 let kids = render_all(children, send);
463 view! { <div class="scroller">{kids}</div> }.into_any()
464 }
465
466 Widget::Button { label, style, on_press } => {
468 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
469 let class = format!("btn {}", button_class(*style));
470 view! {
471 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
472 {label}
473 </button>
474 }
475 .into_any()
476 }
477 Widget::IconButton { icon, on_press } => {
478 let (send, token) = (send.clone(), on_press.clone());
479 let glyph = icon_glyph(*icon);
480 view! {
481 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
482 {glyph}
483 </button>
484 }
485 .into_any()
486 }
487 Widget::Chip { label, selected, on_press } => {
488 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
489 let class = if *selected { "chip selected" } else { "chip" };
490 view! {
491 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
492 {label}
493 </button>
494 }
495 .into_any()
496 }
497 Widget::TextField { id, placeholder, value } => {
498 let (send, id) = (send.clone(), id.clone());
499 let (placeholder, value) = (placeholder.clone(), value.clone());
500 view! {
501 <input
502 class="field"
503 placeholder=placeholder
504 prop:value=value
505 on:input=move |ev| send(Action::Input {
506 id: id.clone(),
507 value: InputValue::Text(event_target_value(&ev)),
508 })
509 />
510 }
511 .into_any()
512 }
513 Widget::SearchField { id, placeholder, value } => {
514 let (send, id) = (send.clone(), id.clone());
515 let (placeholder, value) = (placeholder.clone(), value.clone());
516 view! {
517 <div class="searchfield">
518 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
519 <input
520 class="search-input"
521 placeholder=placeholder
522 prop:value=value
523 on:input=move |ev| send(Action::Input {
524 id: id.clone(),
525 value: InputValue::Text(event_target_value(&ev)),
526 })
527 />
528 </div>
529 }
530 .into_any()
531 }
532 Widget::Segmented { segments } => {
533 let segs: Vec<AnyView> = segments
534 .iter()
535 .map(|s| {
536 let (send, token) = (send.clone(), s.on_select.clone());
537 let class = if s.selected { "segment selected" } else { "segment" };
538 let label = s.label.clone();
539 view! {
540 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
541 {label}
542 </button>
543 }
544 .into_any()
545 })
546 .collect();
547 view! { <div class="segmented">{segs}</div> }.into_any()
548 }
549 Widget::Toggle { id, label, value } => {
550 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
551 view! {
552 <label class="toggle">
553 {label}
554 <input
555 type="checkbox"
556 role="switch"
557 prop:checked=checked
558 on:change=move |ev| send(Action::Input {
559 id: id.clone(),
560 value: InputValue::Bool(event_target_checked(&ev)),
561 })
562 />
563 </label>
564 }
565 .into_any()
566 }
567 Widget::Checkbox { id, label, value } => {
568 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
569 view! {
570 <label class="check">
571 <input
572 type="checkbox"
573 prop:checked=checked
574 on:change=move |ev| send(Action::Input {
575 id: id.clone(),
576 value: InputValue::Bool(event_target_checked(&ev)),
577 })
578 />
579 {label}
580 </label>
581 }
582 .into_any()
583 }
584 Widget::Slider { id, value, max } => {
585 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
586 view! {
587 <input
588 class="slider"
589 type="range"
590 min="0"
591 max=max
592 prop:value=value
593 on:input=move |ev| send(Action::Input {
594 id: id.clone(),
595 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
596 })
597 />
598 }
599 .into_any()
600 }
601 Widget::Stepper { value, on_decrement, on_increment } => {
602 let send_dec = send.clone();
603 let send_inc = send.clone();
604 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
605 view! {
606 <div class="stepper">
607 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
608 <span class="stepper-value">{*value}</span>
609 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
610 </div>
611 }
612 .into_any()
613 }
614
615 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, route, depth } => {
617 let back_btn = back.clone().map(|token| {
618 let send = send.clone();
619 view! {
620 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
621 "‹"
622 </button>
623 }
624 });
625 let tabbar = (!tabs.is_empty()).then(|| {
626 let tabs: Vec<AnyView> = tabs
627 .iter()
628 .map(|tab| {
629 let (send, token) = (send.clone(), tab.on_select.clone());
630 let class = if tab.selected { "tab selected" } else { "tab" };
631 let label = tab.label.clone();
632 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
634 view! {
635 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
636 {icon}
637 <span class="tab-label">{label}</span>
638 </button>
639 }
640 .into_any()
641 })
642 .collect();
643 view! { <div class="tabbar">{tabs}</div> }
644 });
645 let fab_btn = fab.clone().map(|f| {
647 let (send, token) = (send.clone(), f.on_press.clone());
648 view! {
649 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
650 {icon_glyph(f.icon)}
651 </button>
652 }
653 });
654 let sheet_overlay = sheet.as_ref().map(|s| {
656 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
657 let (title, child) = (s.title.clone(), render(&s.child, send));
658 view! {
659 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
660 <div class="sheet">
661 <div class="sheet-handle"></div>
662 <div class="sheet-title">{title}</div>
663 {child}
664 </div>
665 }
666 });
667 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
670 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
671 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
674 let (title, body) = (title.clone(), render(body, send));
675 view! {
676 <div class=class style=theme_style>
677 <div class="topbar">
678 {back_btn}
679 <span class="title">{title}</span>
680 </div>
681 <div class=body_class data-route=route.clone()>{body}</div>
682 {fab_btn}
683 {tabbar}
684 {sheet_overlay}
685 </div>
686 }
687 .into_any()
688 }
689 }
690}
691
692fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
694 children.iter().map(|c| render(c, send)).collect()
695}
696
697thread_local! {
698 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
703}
704
705fn theme_css(t: &Theme) -> String {
710 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
711 let radius = match t.corner {
712 Corner::None => "0px",
713 Corner::Small => "8px",
714 Corner::Medium => "14px",
715 Corner::Large => "22px",
716 };
717 let (gap, pad) = match t.density {
718 Density::Compact => ("8px", "10px"),
719 Density::Comfortable => ("12px", "14px"),
720 };
721 let font = match t.font {
722 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
723 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
724 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
725 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
726 };
727 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
729 format!(
730 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
731 --accent2:rgb({ar},{ag},{ab});\
732 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
733 --gap:{gap};--pad:{pad};--font:{font};"
734 )
735}
736
737fn nav_class(route: &str, depth: u32) -> &'static str {
744 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
745 if route == prev_route {
746 return "";
747 }
748 let dir = if depth > *prev_depth {
749 ["nav-push-a", "nav-push-b"]
750 } else if depth < *prev_depth {
751 ["nav-pop-a", "nav-pop-b"]
752 } else {
753 ["nav-fade-a", "nav-fade-b"]
754 };
755 *toggle = !*toggle;
756 *prev_route = route.to_string();
757 *prev_depth = depth;
758 dir[usize::from(*toggle)]
759 })
760}
761
762fn text_class(s: TextStyle) -> &'static str {
765 match s {
766 TextStyle::Title => "t-title",
767 TextStyle::Subtitle => "t-subtitle",
768 TextStyle::Caption => "t-caption",
769 TextStyle::Emphasis => "t-emphasis",
770 TextStyle::Body => "t-body",
771 }
772}
773
774fn button_class(s: ButtonStyle) -> &'static str {
775 match s {
776 ButtonStyle::Filled => "btn-filled",
777 ButtonStyle::Outlined => "btn-outlined",
778 ButtonStyle::Text => "btn-text",
779 }
780}
781
782fn card_class(s: CardStyle) -> &'static str {
783 match s {
784 CardStyle::Elevated => "card-elevated",
785 CardStyle::Outlined => "card-outlined",
786 CardStyle::Filled => "card-filled",
787 CardStyle::Brand => "card-brand",
788 }
789}
790
791fn tone_class(t: Tone) -> &'static str {
792 match t {
793 Tone::Neutral => "tone-neutral",
794 Tone::Success => "tone-success",
795 Tone::Warning => "tone-warning",
796 Tone::Danger => "tone-danger",
797 Tone::Info => "tone-info",
798 }
799}
800
801fn spacer_class(s: Spacing) -> &'static str {
802 match s {
803 Spacing::Xs => "sp-xs",
804 Spacing::Sm => "sp-sm",
805 Spacing::Md => "sp-md",
806 Spacing::Lg => "sp-lg",
807 Spacing::Xl => "sp-xl",
808 }
809}
810
811fn icon_glyph(i: Icon) -> &'static str {
812 match i {
813 Icon::Delete => "🗑",
814 Icon::Add => "+",
815 Icon::Edit => "✏️",
816 Icon::Close => "✕",
817 Icon::Settings => "⚙",
818 Icon::Check => "✓",
819 Icon::Star => "★",
820 Icon::Info => "ℹ",
821 Icon::Home => "⌂",
822 Icon::Search => "🔍",
823 Icon::Menu => "☰",
824 Icon::Filter => "⚟",
825 Icon::Back => "‹",
826 Icon::Forward => "›",
827 Icon::Down => "⌄",
828 Icon::Bell => "🔔",
829 Icon::Cart => "🛒",
830 Icon::Share => "↗",
831 Icon::Heart => "♡",
832 Icon::HeartFilled => "♥",
833 Icon::Person => "👤",
834 Icon::People => "👥",
835 Icon::Phone => "📞",
836 Icon::Mail => "✉",
837 Icon::Calendar => "📅",
838 Icon::Clock => "🕑",
839 Icon::MapPin => "📍",
840 Icon::Camera => "📷",
841 Icon::Photo => "🖼",
842 Icon::Play => "▶",
843 Icon::Scissors => "✂",
844 }
845}
846
847fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
848 let shape = match shape {
849 ImageShape::Square => "img-square",
850 ImageShape::Rounded => "img-rounded",
851 ImageShape::Circle => "img-circle",
852 };
853 let ratio = match ratio {
854 ImageRatio::Wide => "ratio-wide",
855 ImageRatio::Square => "ratio-square",
856 ImageRatio::Tall => "ratio-tall",
857 };
858 format!("img {shape} {ratio}")
859}
860
861fn dot_class(c: ProjectColor) -> &'static str {
862 match c {
863 ProjectColor::Indigo => "dot-indigo",
864 ProjectColor::Teal => "dot-teal",
865 ProjectColor::Coral => "dot-coral",
866 ProjectColor::Amber => "dot-amber",
867 ProjectColor::Lime => "dot-lime",
868 ProjectColor::Pink => "dot-pink",
869 }
870}
871
872fn align_class(a: BoxAlign) -> &'static str {
873 match a {
874 BoxAlign::TopStart => "align-top-start",
875 BoxAlign::TopEnd => "align-top-end",
876 BoxAlign::Center => "align-center",
877 BoxAlign::BottomStart => "align-bottom-start",
878 BoxAlign::BottomCenter => "align-bottom-center",
879 BoxAlign::BottomEnd => "align-bottom-end",
880 }
881}