Skip to main content

mobiler_ui/
lib.rs

1//! Mobiler's fixed UI wire ABI.
2//!
3//! These types are the **stable contract** between any Mobiler app's Rust core
4//! and the native shell. Because they never change per app, a single shell is
5//! built once and renders *any* Mobiler app — the shell only ever knows these
6//! types, never an app's domain events or widgets.
7//!
8//! - The core emits a [`Widget`] tree (the `ViewModel`).
9//! - The shell sends back an [`Action`] (the `Event`).
10//! - App domain events ride inside actions as opaque [`ActionToken`]s that the
11//!   shell round-trips without interpreting.
12//!
13//! Style is expressed as **intent tokens** (e.g. [`TextStyle`], [`Tone`]); the
14//! shell maps each to a concrete look (font, color, dp), so dark mode and theme
15//! come for free on the native side.
16
17use facet::Facet;
18use serde::{Deserialize, Serialize};
19
20/// An opaque, serialized app event (e.g. JSON of the app's domain action).
21pub type ActionToken = String;
22
23/// A value produced by an input widget at runtime.
24#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
25#[repr(C)]
26pub enum InputValue {
27    Text(String),
28    Bool(bool),
29    Int(i64),
30}
31
32/// What the shell sends back to the core. **Fixed across all apps.**
33#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
34#[repr(C)]
35pub enum Action {
36    /// An action widget (button/etc.) fired; `token` is the opaque app event.
37    Fired { token: ActionToken },
38    /// A value-carrying input changed; `id` names the widget.
39    Input { id: String, value: InputValue },
40    /// Persisted state handed back to the core on startup (empty string if none).
41    Restore { data: String },
42    /// Fired once on startup (after `Restore`) so the app can kick off initial
43    /// effects (e.g. fetching data).
44    Start,
45}
46
47// ---------------------------- style tokens ----------------------------
48
49#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
50#[repr(C)]
51pub enum TextStyle { Body, Title, Subtitle, Caption, Emphasis }
52
53#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
54#[repr(C)]
55pub enum ButtonStyle { Filled, Outlined, Text }
56
57#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
58#[repr(C)]
59pub enum CardStyle { Elevated, Outlined, Filled, Brand }
60
61/// Semantic status color (distinct from brand/identity color).
62#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
63#[repr(C)]
64pub enum Tone { Neutral, Success, Warning, Danger, Info }
65
66/// How a `Chart` draws its series.
67///
68/// **Cartesian** styles plot every series over the shared `labels` x-axis:
69/// `Bar`/`Line` (grouped bars / one polyline per series), `StackedBar` (series stack to a total
70/// per x-slot), `StackedBar100` (each x-slot fills to 100% — series as proportions).
71///
72/// **Circular** styles ignore the x-axis and the `axis` flag: `Pie`/`Donut` turn **each series**
73/// into one wedge sized by its magnitude (`Donut` leaves a center hole); `Rings` draws concentric
74/// progress arcs (Apple-Watch fitness style), one per series, swept by `sum(values) / goal`;
75/// `Gauge` draws a single arc for the first series' `value / goal` with the number in the center.
76#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
77#[repr(C)]
78pub enum ChartStyle { Bar, Line, StackedBar, StackedBar100, Pie, Donut, Rings, Gauge }
79
80/// One named data series in a [`Widget::Chart`]. Cartesian styles plot `values` across the chart's
81/// x-axis `labels`; circular styles (pie/donut/rings/gauge) collapse the series to a single
82/// magnitude (`values` summed). `color` overrides the auto-assigned palette slot; `goal` is the
83/// denominator for `Rings`/`Gauge` progress (ignored by the other styles).
84#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
85#[repr(C)]
86pub struct ChartSeries {
87    pub name: String,
88    pub values: Vec<f32>,
89    pub color: Option<Rgb>,
90    pub goal: Option<f32>,
91}
92
93impl ChartSeries {
94    /// A named series carrying `values`. Color falls back to the chart palette; no goal.
95    #[must_use]
96    pub fn new(name: impl Into<String>, values: Vec<f32>) -> Self {
97        Self { name: name.into(), values, color: None, goal: None }
98    }
99    /// Override the auto-assigned palette color for this series.
100    #[must_use]
101    pub fn with_color(mut self, color: Rgb) -> Self {
102        self.color = Some(color);
103        self
104    }
105    /// Set the denominator for `Rings`/`Gauge` progress (`sum(values) / goal`). Ignored by
106    /// cartesian and pie/donut styles.
107    #[must_use]
108    pub fn with_goal(mut self, goal: f32) -> Self {
109        self.goal = Some(goal);
110        self
111    }
112}
113
114// ----------------------------- region chart -----------------------------
115//
116// A [`Widget::RegionChart`] is a variable-width stacked-region ("Marimekko" / coverage-gap)
117// chart: arbitrary colored rectangles placed in a 2-D `[0, x_max] × [0, y_max]` plane, each with
118// an in-cell label, plus horizontal reference lines, an irregular x-axis, an optional right-side
119// bracket annotation, and a legend. The app computes the geometry; the shells map domain→pixels.
120
121/// One rectangle in a [`Widget::RegionChart`], spanning `[x0, x1]` horizontally and `[y0, y1]`
122/// vertically in the chart's domain. `label` is centered inside (empty = none); `vertical` rotates
123/// it 90° for narrow columns. `color` overrides the auto-assigned palette slot.
124#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
125#[repr(C)]
126pub struct ChartRegion {
127    pub x0: f32,
128    pub x1: f32,
129    pub y0: f32,
130    pub y1: f32,
131    pub color: Option<Rgb>,
132    pub label: String,
133    pub vertical: bool,
134}
135
136/// A horizontal reference line across a [`Widget::RegionChart`] at `value`, with a right-edge
137/// `label` chip. `dashed` draws it dashed (e.g. a "max insured" ceiling) vs solid (a target).
138#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
139#[repr(C)]
140pub struct ChartRefLine {
141    pub value: f32,
142    pub label: String,
143    pub dashed: bool,
144}
145
146/// A right-side bracket annotation spanning `[y0, y1]` with a `label` note (e.g. a ceiling band).
147#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
148#[repr(C)]
149pub struct ChartBracket {
150    pub y0: f32,
151    pub y1: f32,
152    pub label: String,
153    /// Show an ⓘ info marker above the label (e.g. a "Ceiling max …" note). `label` may contain
154    /// `\n` for multiple lines.
155    pub info: bool,
156}
157
158/// An x-axis tick on a [`Widget::RegionChart`] at domain position `at`, labelled `label`. Ticks
159/// are irregular (the app places them), so shells position them by fraction, not even spacing.
160#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
161#[repr(C)]
162pub struct ChartTick {
163    pub at: f32,
164    pub label: String,
165}
166
167/// One legend entry (swatch + name) for a [`Widget::RegionChart`].
168#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
169#[repr(C)]
170pub struct ChartLegendItem {
171    pub label: String,
172    pub color: Rgb,
173}
174
175impl ChartRegion {
176    /// A region spanning `[x0,x1] × [y0,y1]` with a centered `label` (palette color, horizontal).
177    #[must_use]
178    pub fn new(x0: f32, x1: f32, y0: f32, y1: f32, label: impl Into<String>) -> Self {
179        Self { x0, x1, y0, y1, color: None, label: label.into(), vertical: false }
180    }
181    /// Override the fill color.
182    #[must_use]
183    pub fn with_color(mut self, color: Rgb) -> Self {
184        self.color = Some(color);
185        self
186    }
187    /// Render the label rotated 90° (for tall, narrow regions).
188    #[must_use]
189    pub fn vertical(mut self) -> Self {
190        self.vertical = true;
191        self
192    }
193}
194
195impl ChartRefLine {
196    /// A solid target line at `value` with a right-edge chip.
197    #[must_use]
198    pub fn target(value: f32, label: impl Into<String>) -> Self {
199        Self { value, label: label.into(), dashed: false }
200    }
201    /// A dashed "max"/ceiling line at `value`.
202    #[must_use]
203    pub fn max(value: f32, label: impl Into<String>) -> Self {
204        Self { value, label: label.into(), dashed: true }
205    }
206}
207
208impl ChartTick {
209    #[must_use]
210    pub fn new(at: f32, label: impl Into<String>) -> Self {
211        Self { at, label: label.into() }
212    }
213}
214
215impl ChartLegendItem {
216    #[must_use]
217    pub fn new(label: impl Into<String>, color: Rgb) -> Self {
218        Self { label: label.into(), color }
219    }
220}
221
222impl ChartBracket {
223    #[must_use]
224    pub fn new(y0: f32, y1: f32, label: impl Into<String>) -> Self {
225        Self { y0, y1, label: label.into(), info: false }
226    }
227    /// Show an ⓘ info marker above the label.
228    #[must_use]
229    pub fn with_info(mut self) -> Self {
230        self.info = true;
231        self
232    }
233}
234
235#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
236#[repr(C)]
237pub enum Spacing { Xs, Sm, Md, Lg, Xl }
238
239/// A finite icon set (maps to Material icons / SF Symbols / web glyphs per shell).
240/// Grouped: editing, navigation/chrome, content, and domain icons.
241#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
242#[repr(C)]
243pub enum Icon {
244    // editing / status
245    Delete, Add, Edit, Close, Settings, Check, Star, Info,
246    // navigation / chrome
247    Home, Search, Menu, Filter, Back, Forward, Down, Bell, Cart, Share, Heart, HeartFilled,
248    // people / contact
249    Person, People, Phone, Mail, Calendar, Clock, MapPin,
250    // content / media
251    Camera, Photo, Play,
252    // domain
253    Scissors,
254}
255
256#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
257#[repr(C)]
258pub enum ImageShape { Square, Rounded, Circle }
259
260#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
261#[repr(C)]
262pub enum ImageRatio { Wide, Square, Tall }
263
264#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
265#[repr(C)]
266pub enum BoxAlign { TopStart, TopEnd, Center, BottomStart, BottomCenter, BottomEnd }
267
268/// Project-identity colors (distinct from semantic `Tone`). Concrete RGB decided
269/// in the render layer.
270#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
271#[repr(C)]
272pub enum ProjectColor { Indigo, Teal, Coral, Amber, Lime, Pink }
273
274// ------------------------------- theme -------------------------------
275
276/// A 24-bit RGB color. Used for a theme's brand/seed color — the one place an app
277/// supplies an arbitrary color (everything else is intent tokens).
278#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
279#[repr(C)]
280pub struct Rgb {
281    pub r: u8,
282    pub g: u8,
283    pub b: u8,
284}
285
286impl Rgb {
287    pub const fn new(r: u8, g: u8, b: u8) -> Self {
288        Self { r, g, b }
289    }
290}
291
292/// Global corner-radius scale. `Medium` ≈ the current (un-themed) look.
293#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
294#[repr(C)]
295pub enum Corner { None, Small, Medium, Large }
296
297/// Global spacing scale. `Comfortable` ≈ the current (un-themed) spacing.
298#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
299#[repr(C)]
300pub enum Density { Compact, Comfortable }
301
302/// A finite, cross-platform font family (maps to each platform's nearest system
303/// font design — no bundled font files). `System` ≈ the current look.
304#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
305#[repr(C)]
306pub enum FontFamily { System, Rounded, Serif, Monospace }
307
308/// App branding as data — the visual twin of `dark_mode`. Set on a [`Widget::Scaffold`]
309/// (`theme: None` = the framework defaults, i.e. no visual change). The shell maps these
310/// to its native theming: `seed` → the brand/primary color (Android M3 scheme / iOS tint /
311/// web `--primary`), plus a global corner, spacing, and font choice.
312#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
313#[repr(C)]
314pub struct Theme {
315    pub seed: Rgb,
316    /// Optional secondary brand color. `None` ⇒ derived from `seed`. Used for the
317    /// gradient on `CardStyle::Brand` (seed → accent) and as a secondary accent.
318    pub accent: Option<Rgb>,
319    pub corner: Corner,
320    pub density: Density,
321    pub font: FontFamily,
322}
323
324/// `Theme::default()` matches the framework's un-themed look as closely as a theme can
325/// (medium corners, comfortable spacing, system font) with a neutral indigo seed — so an
326/// app can override just the bits it cares about: `Theme { seed: brand, ..Default::default() }`.
327impl Default for Theme {
328    fn default() -> Self {
329        Theme {
330            seed: Rgb::new(0x5C, 0x6B, 0xC0), // indigo — matches the legacy default accent
331            accent: None,
332            corner: Corner::Medium,
333            density: Density::Comfortable,
334            font: FontFamily::System,
335        }
336    }
337}
338
339/// A bottom-navigation tab. `selected` marks the active one; tapping sends
340/// `on_select`. `icon` (optional) renders above the label for an icon tab bar.
341#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
342#[repr(C)]
343pub struct Tab {
344    pub label: String,
345    pub selected: bool,
346    pub on_select: ActionToken,
347    /// Optional leading icon (icon tab bar). `None` = label-only (the original look).
348    pub icon: Option<Icon>,
349}
350
351/// A floating action button anchored over the scaffold body (the raised primary action).
352#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
353#[repr(C)]
354pub struct Fab {
355    pub icon: Icon,
356    pub on_press: ActionToken,
357}
358
359/// One option in a [`Widget::Segmented`] control (mirrors [`Tab`]). `selected` marks the
360/// active segment; tapping sends `on_select`.
361#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
362#[repr(C)]
363pub struct Segment {
364    pub label: String,
365    pub selected: bool,
366    pub on_select: ActionToken,
367}
368
369/// A modal bottom sheet anchored over the scaffold body (a scrim behind, a panel rising from
370/// the bottom). Present (`Some`) ⇒ open; tapping the scrim/handle sends `on_dismiss`.
371#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
372#[repr(C)]
373pub struct Sheet {
374    pub title: String,
375    pub child: Box<Widget>,
376    pub on_dismiss: ActionToken,
377}
378
379/// One revealed action in a `SwipeAction` row (swipe to reveal, tap to fire).
380#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
381#[repr(C)]
382pub struct SwipeButton {
383    pub label: String,
384    pub tone: Tone,
385    pub on_tap: ActionToken,
386}
387
388// ------------------------------- widgets -------------------------------
389
390/// The app-agnostic widget tree the shell renders. **Fixed across all apps.**
391#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
392#[repr(C)]
393pub enum Widget {
394    // Content
395    Text { content: String, style: TextStyle },
396    Image { source: String, shape: ImageShape, ratio: ImageRatio },
397    Badge { label: String, tone: Tone },
398    /// A circular avatar image with an optional colored status dot.
399    Avatar { source: String, status: Option<Tone> },
400    /// A star rating. `value` is in tenths (e.g. `48` = 4.8 of `max` stars). When `on_rate`
401    /// is set (one token per star), the stars are tappable — star *i* fires `on_rate[i]`.
402    Rating { value: u32, max: u8, on_rate: Option<Vec<ActionToken>> },
403    /// Small non-interactive colored dot — a project/identity hint.
404    ColorDot { color: ProjectColor },
405    Divider,
406    /// Progress indicator: `value` 0.0–1.0 for a determinate bar, `None` for an indeterminate spinner.
407    Progress { value: Option<f32> },
408    /// Shimmer placeholder shown while content loads.
409    Skeleton,
410    /// A data chart drawing one or more named `series` in the given `style` (see [`ChartStyle`]).
411    /// `labels` (optional) annotate the x-axis for cartesian styles. `axis` shows y gridlines +
412    /// tick values (cartesian only); `legend` shows a series swatch+name row. Non-interactive.
413    Chart { series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool },
414    /// A variable-width stacked-region ("Marimekko" / coverage-gap) chart: `regions` are arbitrary
415    /// colored rectangles in the `[0, x_max] × [0, y_max]` plane (each with an in-cell label),
416    /// `ticks` annotate the irregular x-axis, `ref_lines` are horizontal target/max lines with
417    /// right-edge chips, `bracket` is an optional right-side range annotation, and `legend` names
418    /// the colors. The app supplies all geometry; shells map domain→pixels. Non-interactive.
419    RegionChart {
420        regions: Vec<ChartRegion>,
421        ticks: Vec<ChartTick>,
422        x_max: f32,
423        y_max: f32,
424        ref_lines: Vec<ChartRefLine>,
425        bracket: Option<ChartBracket>,
426        legend: Vec<ChartLegendItem>,
427    },
428    /// An inline month calendar. `first_weekday` is the weekday of day 1 (0=Sun..6=Sat) so the
429    /// shells render leading blanks without date math; `on_day[d-1]` fires when day `d` is tapped
430    /// (length = days in the month). `selected` highlights a day.
431    Calendar { year: u32, month: u8, first_weekday: u8, selected: Option<u8>, on_day: Vec<ActionToken> },
432    /// A list row that reveals trailing `actions` on horizontal swipe (each tappable). On web the
433    /// actions render inline as a trailing button row (no gesture).
434    SwipeAction { child: Box<Widget>, actions: Vec<SwipeButton> },
435    Spacer { size: Spacing },
436    // Layout
437    Row { children: Vec<Widget> },
438    Column { children: Vec<Widget> },
439    /// Card; tappable when `on_press` is set.
440    Card { child: Box<Widget>, style: CardStyle, on_press: Option<ActionToken> },
441    /// Z-stack: children layered back-to-front, positioned by `align`. With
442    /// `scrim`, the first child is a background image, darkened for legibility,
443    /// and the rest render on top in light content.
444    Box { children: Vec<Widget>, align: BoxAlign, scrim: bool },
445    /// Fixed 2-column grid; children flow left-to-right, top-to-bottom.
446    Grid { children: Vec<Widget> },
447    /// Horizontally scrolling row of children (a carousel / chip rail).
448    Scroller { children: Vec<Widget> },
449    // Input
450    Button { label: String, style: ButtonStyle, on_press: ActionToken },
451    IconButton { icon: Icon, on_press: ActionToken },
452    Chip { label: String, selected: bool, on_press: ActionToken },
453    TextField { id: String, placeholder: String, value: String },
454    /// A search input (leading magnifier, pill shape); emits `Input { id, Text }` like `TextField`.
455    SearchField { id: String, placeholder: String, value: String },
456    /// A single-choice segmented control — exclusive options in a pill (e.g. Men/Women/Kids).
457    Segmented { segments: Vec<Segment> },
458    Toggle { id: String, label: String, value: bool },
459    Checkbox { id: String, label: String, value: bool },
460    /// Continuous 0..=`max` slider; emits `Input { id, Int }`.
461    Slider { id: String, value: i32, max: i32 },
462    /// Numeric stepper with −/+ controls carrying their own events.
463    Stepper { value: i32, on_decrement: ActionToken, on_increment: ActionToken },
464    /// App shell: a top bar (`title` + optional `back`), a scrollable `body`,
465    /// and bottom-nav `tabs`. `dark_mode` is theme-as-data — the shell themes
466    /// the whole app from it.
467    ///
468    /// `route` + `depth` drive navigation: the shell animates the body when
469    /// `route` (the current screen's identity) changes — slide for push/pop
470    /// (direction from whether `depth` grew or shrank), crossfade for a lateral
471    /// move at the same depth — and wires the system back button to `back`.
472    Scaffold {
473        title: String,
474        body: Box<Widget>,
475        tabs: Vec<Tab>,
476        back: Option<ActionToken>,
477        dark_mode: bool,
478        /// App branding (brand color, corner, density, font). `None` = framework
479        /// defaults (no visual change) — theme-as-data, the visual twin of `dark_mode`.
480        theme: Option<Theme>,
481        /// Optional floating action button (raised primary action over the body).
482        fab: Option<Fab>,
483        /// Optional modal bottom sheet over the body (a scrim + a panel from the bottom).
484        sheet: Option<Sheet>,
485        /// Pull-to-refresh: when set, the body is pull-refreshable and fires this event on pull.
486        /// The app owns `refreshing` — set it true when the pull fires, clear it when the async
487        /// reload completes (the shell shows a spinner while it's true).
488        on_refresh: Option<ActionToken>,
489        refreshing: bool,
490        route: String,
491        depth: u32,
492    },
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use serde::Serialize;
499    use serde::de::DeserializeOwned;
500
501    // Round-trips the ABI without requiring `PartialEq` on the wire types:
502    // serialize → deserialize → re-serialize, and compare the two encodings.
503    fn round_trips<T: Serialize + DeserializeOwned>(value: &T) {
504        let a = serde_json::to_string(value).expect("serialize");
505        let back: T = serde_json::from_str(&a).expect("deserialize");
506        let b = serde_json::to_string(&back).expect("re-serialize");
507        assert_eq!(a, b);
508    }
509
510    #[test]
511    fn action_round_trips() {
512        round_trips(&Action::Start);
513        round_trips(&Action::Fired { token: "tok".to_string() });
514        round_trips(&Action::Input { id: "field".to_string(), value: InputValue::Bool(true) });
515        round_trips(&Action::Restore { data: "{}".to_string() });
516    }
517
518    #[test]
519    fn widget_round_trips() {
520        round_trips(&Widget::Text { content: "hi".to_string(), style: TextStyle::Title });
521        round_trips(&Widget::ColorDot { color: ProjectColor::Teal });
522        round_trips(&Widget::Chart {
523            series: vec![ChartSeries { name: "s".to_string(), values: vec![1.0, 2.5, 3.0], color: None, goal: None }],
524            labels: vec!["a".to_string()],
525            style: ChartStyle::Bar,
526            axis: true,
527            legend: false,
528        });
529        round_trips(&Widget::RegionChart {
530            regions: vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical(),
531                          ChartRegion::new(3.0, 21.0, 0.0, 80.0, "CHF 80'000").with_color(Rgb::new(0x8E, 0xC6, 0xBA))],
532            ticks: vec![ChartTick { at: 3.0, label: "3 Mt.".to_string() }, ChartTick { at: 65.0, label: "65 J.".to_string() }],
533            x_max: 65.0,
534            y_max: 80.0,
535            ref_lines: vec![ChartRefLine { value: 80.0, label: "CHF 80'000".to_string(), dashed: false }],
536            bracket: Some(ChartBracket { y0: 60.0, y1: 80.0, label: "Ceiling".to_string(), info: true }),
537            legend: vec![ChartLegendItem { label: "Gap".to_string(), color: Rgb::new(0x5A, 0x7D, 0x9A) }],
538        });
539        round_trips(&Widget::Calendar { year: 2026, month: 6, first_weekday: 1, selected: Some(15), on_day: vec!["d1".to_string(), "d2".to_string()] });
540        round_trips(&Widget::SwipeAction { child: Box::new(Widget::Divider), actions: vec![SwipeButton { label: "Del".to_string(), tone: Tone::Danger, on_tap: "t".to_string() }] });
541        // Un-themed scaffold (theme: None) — the default, must round-trip.
542        round_trips(&Widget::Scaffold {
543            title: "T".to_string(),
544            body: Box::new(Widget::Divider),
545            tabs: vec![Tab { label: "A".to_string(), selected: true, on_select: "t".to_string(), icon: Some(Icon::Home) }],
546            back: Some("b".to_string()),
547            dark_mode: true,
548            theme: None,
549            fab: None,
550            sheet: None,
551            on_refresh: None,
552            refreshing: false,
553            route: "r".to_string(),
554            depth: 2,
555        });
556        // Themed scaffold — all four theme knobs must round-trip.
557        round_trips(&Widget::Scaffold {
558            title: "T".to_string(),
559            body: Box::new(Widget::Divider),
560            tabs: vec![],
561            back: None,
562            dark_mode: false,
563            theme: Some(Theme {
564                seed: Rgb::new(0xC8, 0x5A, 0x3C),
565                accent: Some(Rgb::new(0xE0, 0x6A, 0x2C)),
566                corner: Corner::Large,
567                density: Density::Compact,
568                font: FontFamily::Rounded,
569            }),
570            fab: Some(Fab { icon: Icon::Calendar, on_press: "f".to_string() }),
571            sheet: Some(Sheet { title: "S".to_string(), child: Box::new(Widget::Divider), on_dismiss: "d".to_string() }),
572            on_refresh: Some("r".to_string()),
573            refreshing: true,
574            route: "r".to_string(),
575            depth: 1,
576        });
577    }
578}