mobiler-ui 0.20.0

Mobiler's fixed UI wire ABI — app-agnostic Widget tree + Action protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
//! Mobiler's fixed UI wire ABI.
//!
//! These types are the **stable contract** between any Mobiler app's Rust core
//! and the native shell. Because they never change per app, a single shell is
//! built once and renders *any* Mobiler app — the shell only ever knows these
//! types, never an app's domain events or widgets.
//!
//! - The core emits a [`Widget`] tree (the `ViewModel`).
//! - The shell sends back an [`Action`] (the `Event`).
//! - App domain events ride inside actions as opaque [`ActionToken`]s that the
//!   shell round-trips without interpreting.
//!
//! Style is expressed as **intent tokens** (e.g. [`TextStyle`], [`Tone`]); the
//! shell maps each to a concrete look (font, color, dp), so dark mode and theme
//! come for free on the native side.

use facet::Facet;
use serde::{Deserialize, Serialize};

/// An opaque, serialized app event (e.g. JSON of the app's domain action).
pub type ActionToken = String;

/// A value produced by an input widget at runtime.
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub enum InputValue {
    Text(String),
    Bool(bool),
    Int(i64),
}

/// What the shell sends back to the core. **Fixed across all apps.**
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub enum Action {
    /// An action widget (button/etc.) fired; `token` is the opaque app event.
    Fired { token: ActionToken },
    /// A value-carrying input changed; `id` names the widget.
    Input { id: String, value: InputValue },
    /// Persisted state handed back to the core on startup (empty string if none).
    Restore { data: String },
    /// Fired once on startup (after `Restore`) so the app can kick off initial
    /// effects (e.g. fetching data).
    Start,
}

// ---------------------------- style tokens ----------------------------

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum TextStyle { Body, Title, Subtitle, Caption, Emphasis }

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum ButtonStyle { Filled, Outlined, Text }

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum CardStyle { Elevated, Outlined, Filled, Brand }

/// What a [`Widget::TextField`] accepts — selects the on-screen keyboard,
/// secure (masked) entry, and single- vs multi-line layout in one axis.
///
/// `Text` is the plain default. `Secure` masks input (passwords). `Email`,
/// `Number` (integer), `Decimal`, `Phone`, and `Url` pick the matching native
/// keyboard / input mode without masking. `Multiline` is a growable multi-row
/// text area (plain keyboard).
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum FieldKind { Text, Secure, Email, Number, Decimal, Phone, Url, Multiline }

/// Semantic status color (distinct from brand/identity color).
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum Tone { Neutral, Success, Warning, Danger, Info }

/// How a `Chart` draws its series.
///
/// **Cartesian** styles plot every series over the shared `labels` x-axis:
/// `Bar`/`Line` (grouped bars / one polyline per series), `StackedBar` (series stack to a total
/// per x-slot), `StackedBar100` (each x-slot fills to 100% — series as proportions).
///
/// **Circular** styles ignore the x-axis and the `axis` flag: `Pie`/`Donut` turn **each series**
/// into one wedge sized by its magnitude (`Donut` leaves a center hole); `Rings` draws concentric
/// progress arcs (Apple-Watch fitness style), one per series, swept by `sum(values) / goal`;
/// `Gauge` draws a single arc for the first series' `value / goal` with the number in the center.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum ChartStyle { Bar, Line, StackedBar, StackedBar100, Pie, Donut, Rings, Gauge }

/// One named data series in a [`Widget::Chart`]. Cartesian styles plot `values` across the chart's
/// x-axis `labels`; circular styles (pie/donut/rings/gauge) collapse the series to a single
/// magnitude (`values` summed). `color` overrides the auto-assigned palette slot; `goal` is the
/// denominator for `Rings`/`Gauge` progress (ignored by the other styles).
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct ChartSeries {
    pub name: String,
    pub values: Vec<f32>,
    pub color: Option<Rgb>,
    pub goal: Option<f32>,
}

impl ChartSeries {
    /// A named series carrying `values`. Color falls back to the chart palette; no goal.
    #[must_use]
    pub fn new(name: impl Into<String>, values: Vec<f32>) -> Self {
        Self { name: name.into(), values, color: None, goal: None }
    }
    /// Override the auto-assigned palette color for this series.
    #[must_use]
    pub fn with_color(mut self, color: Rgb) -> Self {
        self.color = Some(color);
        self
    }
    /// Set the denominator for `Rings`/`Gauge` progress (`sum(values) / goal`). Ignored by
    /// cartesian and pie/donut styles.
    #[must_use]
    pub fn with_goal(mut self, goal: f32) -> Self {
        self.goal = Some(goal);
        self
    }
}

// ----------------------------- region chart -----------------------------
//
// A [`Widget::RegionChart`] is a variable-width stacked-region ("Marimekko" / coverage-gap)
// chart: arbitrary colored rectangles placed in a 2-D `[0, x_max] × [0, y_max]` plane, each with
// an in-cell label, plus horizontal reference lines, an irregular x-axis, an optional right-side
// bracket annotation, and a legend. The app computes the geometry; the shells map domain→pixels.

/// One rectangle in a [`Widget::RegionChart`], spanning `[x0, x1]` horizontally and `[y0, y1]`
/// vertically in the chart's domain. `label` is centered inside (empty = none); `vertical` rotates
/// it 90° for narrow columns. `color` overrides the auto-assigned palette slot.
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct ChartRegion {
    pub x0: f32,
    pub x1: f32,
    pub y0: f32,
    pub y1: f32,
    pub color: Option<Rgb>,
    pub label: String,
    pub vertical: bool,
}

/// A horizontal reference line across a [`Widget::RegionChart`] at `value`, with a right-edge
/// `label` chip. `dashed` draws it dashed (e.g. a "max insured" ceiling) vs solid (a target).
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct ChartRefLine {
    pub value: f32,
    pub label: String,
    pub dashed: bool,
}

/// A right-side bracket annotation spanning `[y0, y1]` with a `label` note (e.g. a ceiling band).
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct ChartBracket {
    pub y0: f32,
    pub y1: f32,
    pub label: String,
    /// Show an ⓘ info marker above the label (e.g. a "Ceiling max …" note). `label` may contain
    /// `\n` for multiple lines.
    pub info: bool,
}

/// An x-axis tick on a [`Widget::RegionChart`] at domain position `at`, labelled `label`. Ticks
/// are irregular (the app places them), so shells position them by fraction, not even spacing.
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct ChartTick {
    pub at: f32,
    pub label: String,
}

/// One legend entry (swatch + name) for a [`Widget::RegionChart`].
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct ChartLegendItem {
    pub label: String,
    pub color: Rgb,
}

impl ChartRegion {
    /// A region spanning `[x0,x1] × [y0,y1]` with a centered `label` (palette color, horizontal).
    #[must_use]
    pub fn new(x0: f32, x1: f32, y0: f32, y1: f32, label: impl Into<String>) -> Self {
        Self { x0, x1, y0, y1, color: None, label: label.into(), vertical: false }
    }
    /// Override the fill color.
    #[must_use]
    pub fn with_color(mut self, color: Rgb) -> Self {
        self.color = Some(color);
        self
    }
    /// Render the label rotated 90° (for tall, narrow regions).
    #[must_use]
    pub fn vertical(mut self) -> Self {
        self.vertical = true;
        self
    }
}

impl ChartRefLine {
    /// A solid target line at `value` with a right-edge chip.
    #[must_use]
    pub fn target(value: f32, label: impl Into<String>) -> Self {
        Self { value, label: label.into(), dashed: false }
    }
    /// A dashed "max"/ceiling line at `value`.
    #[must_use]
    pub fn max(value: f32, label: impl Into<String>) -> Self {
        Self { value, label: label.into(), dashed: true }
    }
}

impl ChartTick {
    #[must_use]
    pub fn new(at: f32, label: impl Into<String>) -> Self {
        Self { at, label: label.into() }
    }
}

impl ChartLegendItem {
    #[must_use]
    pub fn new(label: impl Into<String>, color: Rgb) -> Self {
        Self { label: label.into(), color }
    }
}

impl ChartBracket {
    #[must_use]
    pub fn new(y0: f32, y1: f32, label: impl Into<String>) -> Self {
        Self { y0, y1, label: label.into(), info: false }
    }
    /// Show an ⓘ info marker above the label.
    #[must_use]
    pub fn with_info(mut self) -> Self {
        self.info = true;
        self
    }
}

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum Spacing { Xs, Sm, Md, Lg, Xl }

/// A finite icon set (maps to Material icons / SF Symbols / web glyphs per shell).
/// Grouped: editing, navigation/chrome, content, and domain icons.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum Icon {
    // editing / status
    Delete, Add, Edit, Close, Settings, Check, Star, Info,
    // navigation / chrome
    Home, Search, Menu, Filter, Back, Forward, Down, Bell, Cart, Share, Heart, HeartFilled,
    // people / contact
    Person, People, Phone, Mail, Calendar, Clock, MapPin,
    // content / media
    Camera, Photo, Play,
    // domain
    Scissors,
}

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum ImageShape { Square, Rounded, Circle }

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum ImageRatio { Wide, Square, Tall }

#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum BoxAlign { TopStart, TopEnd, Center, BottomStart, BottomCenter, BottomEnd }

/// Project-identity colors (distinct from semantic `Tone`). Concrete RGB decided
/// in the render layer.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum ProjectColor { Indigo, Teal, Coral, Amber, Lime, Pink }

// ------------------------------- theme -------------------------------

/// A 24-bit RGB color. Used for a theme's brand/seed color — the one place an app
/// supplies an arbitrary color (everything else is intent tokens).
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub struct Rgb {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

impl Rgb {
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b }
    }
}

/// Global corner-radius scale. `Medium` ≈ the current (un-themed) look.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum Corner { None, Small, Medium, Large }

/// Global spacing scale. `Comfortable` ≈ the current (un-themed) spacing.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum Density { Compact, Comfortable }

/// A finite, cross-platform font family (maps to each platform's nearest system
/// font design — no bundled font files). `System` ≈ the current look.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum FontFamily { System, Rounded, Serif, Monospace }

/// App branding as data — the visual twin of `dark_mode`. Set on a [`Widget::Scaffold`]
/// (`theme: None` = the framework defaults, i.e. no visual change). The shell maps these
/// to its native theming: `seed` → the brand/primary color (Android M3 scheme / iOS tint /
/// web `--primary`), plus a global corner, spacing, and font choice.
#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(C)]
pub struct Theme {
    pub seed: Rgb,
    /// Optional secondary brand color. `None` ⇒ derived from `seed`. Used for the
    /// gradient on `CardStyle::Brand` (seed → accent) and as a secondary accent.
    pub accent: Option<Rgb>,
    pub corner: Corner,
    pub density: Density,
    pub font: FontFamily,
}

/// `Theme::default()` matches the framework's un-themed look as closely as a theme can
/// (medium corners, comfortable spacing, system font) with a neutral indigo seed — so an
/// app can override just the bits it cares about: `Theme { seed: brand, ..Default::default() }`.
impl Default for Theme {
    fn default() -> Self {
        Theme {
            seed: Rgb::new(0x5C, 0x6B, 0xC0), // indigo — matches the legacy default accent
            accent: None,
            corner: Corner::Medium,
            density: Density::Comfortable,
            font: FontFamily::System,
        }
    }
}

/// A bottom-navigation tab. `selected` marks the active one; tapping sends
/// `on_select`. `icon` (optional) renders above the label for an icon tab bar.
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub struct Tab {
    pub label: String,
    pub selected: bool,
    pub on_select: ActionToken,
    /// Optional leading icon (icon tab bar). `None` = label-only (the original look).
    pub icon: Option<Icon>,
}

/// A floating action button anchored over the scaffold body (the raised primary action).
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub struct Fab {
    pub icon: Icon,
    pub on_press: ActionToken,
}

/// One option in a [`Widget::Segmented`] control (mirrors [`Tab`]). `selected` marks the
/// active segment; tapping sends `on_select`.
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub struct Segment {
    pub label: String,
    pub selected: bool,
    pub on_select: ActionToken,
}

/// A modal bottom sheet anchored over the scaffold body (a scrim behind, a panel rising from
/// the bottom). Present (`Some`) ⇒ open; tapping the scrim/handle sends `on_dismiss`.
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub struct Sheet {
    pub title: String,
    pub child: Box<Widget>,
    pub on_dismiss: ActionToken,
}

/// One revealed action in a `SwipeAction` row (swipe to reveal, tap to fire).
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub struct SwipeButton {
    pub label: String,
    pub tone: Tone,
    pub on_tap: ActionToken,
}

/// One subtitle/caption track for a [`Widget::Video`]. `url` points at a WebVTT (`.vtt`) file,
/// `language` is a BCP-47 tag (e.g. `"en"`), `label` is the human-readable menu entry, and
/// `default_on` selects it by default. Sidecar tracks work on web (`<track>`) and Android
/// (Media3 subtitle configuration); on iOS only captions already embedded in an HLS manifest are
/// selectable (AVPlayer can't attach a sidecar VTT to an MP4 — a documented v1 gap).
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[repr(C)]
pub struct Caption {
    pub url: String,
    pub label: String,
    pub language: String,
    pub default_on: bool,
}

// ------------------------------- widgets -------------------------------

/// The app-agnostic widget tree the shell renders. **Fixed across all apps.**
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub enum Widget {
    // Content
    Text { content: String, style: TextStyle },
    Image { source: String, shape: ImageShape, ratio: ImageRatio },
    Badge { label: String, tone: Tone },
    /// A circular avatar image with an optional colored status dot.
    Avatar { source: String, status: Option<Tone> },
    /// An in-app PDF viewer showing the document at `url` (a remote https URL or a local
    /// file URI). Each shell uses its native renderer — PDFKit on iOS, a paged `PdfRenderer`
    /// on Android, an `<iframe>` on web — so the app only supplies the URL (e.g. a
    /// backend-generated report). Fills its width; give it room (place in a sized container).
    PdfView { url: String },
    /// An in-app native video player for the stream/file at `url` (MP4 everywhere; HLS `.m3u8` on
    /// iOS/Android natively + Safari on web; or a local file URI). Native player per shell — AVPlayer
    /// (iOS), Media3/ExoPlayer (Android), a `<video>` element (web). **Controllable:** `playing` drives
    /// play/pause (app-owned, like a `Toggle`); set `seek_to_ms` to jump (the shell seeks when the value
    /// CHANGES; `-1` = no seek). The shell reports the current position ~once/second via
    /// `Action::Input { id, value: Int(position_ms) }` (handle it in [`MobilerApp::input`]), and fires
    /// `on_ended` when the clip finishes. `controls` shows the native transport bar; `looping` restarts
    /// on end; `muted` starts muted (needed for reliable autoplay). Fills its width; give it room.
    ///
    /// v2 fields: `poster` shows a thumbnail image before the first play; `start_at_ms` resumes at an
    /// offset (applied once on load, `-1` = start). `captions` adds subtitle tracks (see [`Caption`]).
    /// `rate` sets playback speed (`1.0` = normal) and `volume` the level (`0.0`–`1.0`). For a playlist,
    /// set `urls` (non-empty takes precedence over `url`) with `start_index`; the shell auto-advances
    /// and reports the current track ~as it changes via `Action::Input { id: "{id}.index", … }`, and
    /// `seek_index` jumps to a track when it CHANGES (`-1` = none). The shell also reports
    /// `"{id}.duration"`, `"{id}.state"` (0 idle / 1 buffering / 2 ready-paused / 3 playing / 4 ended)
    /// and `"{id}.buffered"` via the same `Input` path (handle them in [`MobilerApp::input`]). Set
    /// `allow_pip` to enable Picture-in-Picture (the shell adds a PiP affordance).
    Video {
        url: String,
        id: String,
        playing: bool,
        seek_to_ms: i64,
        controls: bool,
        looping: bool,
        muted: bool,
        on_ended: Option<ActionToken>,
        poster: Option<String>,
        start_at_ms: i64,
        captions: Vec<Caption>,
        rate: f32,
        volume: f32,
        urls: Vec<String>,
        start_index: i64,
        seek_index: i64,
        allow_pip: bool,
    },
    /// Displays the web page / embedded player at `url` in a native web view — `WKWebView` on iOS,
    /// `android.webkit.WebView` on Android, an `<iframe>` on web. General-purpose: docs, dashboards,
    /// or a hosted player embed (e.g. a Bunny.net / YouTube embed URL, which brings its own
    /// captions/quality/thumbnails). JavaScript and inline media autoplay are enabled so hosted
    /// players work. This is NOT the default way to play video — use [`Widget::Video`] for a
    /// controllable native player. Fills its width; give it room (place in a sized container).
    WebView { url: String },
    /// A star rating. `value` is in tenths (e.g. `48` = 4.8 of `max` stars). When `on_rate`
    /// is set (one token per star), the stars are tappable — star *i* fires `on_rate[i]`.
    Rating { value: u32, max: u8, on_rate: Option<Vec<ActionToken>> },
    /// Small non-interactive colored dot — a project/identity hint.
    ColorDot { color: ProjectColor },
    Divider,
    /// Progress indicator: `value` 0.0–1.0 for a determinate bar, `None` for an indeterminate spinner.
    Progress { value: Option<f32> },
    /// Shimmer placeholder shown while content loads.
    Skeleton,
    /// A data chart drawing one or more named `series` in the given `style` (see [`ChartStyle`]).
    /// `labels` (optional) annotate the x-axis for cartesian styles. `axis` shows y gridlines +
    /// tick values (cartesian only); `legend` shows a series swatch+name row. Non-interactive.
    Chart { series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool },
    /// A variable-width stacked-region ("Marimekko" / coverage-gap) chart: `regions` are arbitrary
    /// colored rectangles in the `[0, x_max] × [0, y_max]` plane (each with an in-cell label),
    /// `ticks` annotate the irregular x-axis, `ref_lines` are horizontal target/max lines with
    /// right-edge chips, `bracket` is an optional right-side range annotation, and `legend` names
    /// the colors. The app supplies all geometry; shells map domain→pixels. Non-interactive.
    RegionChart {
        regions: Vec<ChartRegion>,
        ticks: Vec<ChartTick>,
        x_max: f32,
        y_max: f32,
        ref_lines: Vec<ChartRefLine>,
        bracket: Option<ChartBracket>,
        legend: Vec<ChartLegendItem>,
    },
    /// An inline month calendar. `first_weekday` is the weekday of day 1 (0=Sun..6=Sat) so the
    /// shells render leading blanks without date math; `on_day[d-1]` fires when day `d` is tapped
    /// (length = days in the month). `selected` highlights a day.
    Calendar { year: u32, month: u8, first_weekday: u8, selected: Option<u8>, on_day: Vec<ActionToken> },
    /// A list row that reveals trailing `actions` on horizontal swipe (each tappable). On web the
    /// actions render inline as a trailing button row (no gesture).
    SwipeAction { child: Box<Widget>, actions: Vec<SwipeButton> },
    /// A scrollable list for long/paged feeds, with shell-detected events at both ends: the bottom
    /// `on_load_more` fires when the user scrolls near the end (infinite scroll), the top
    /// `on_refresh` fires on pull-to-refresh. `loading`/`refreshing`/`has_more` are app-owned: set
    /// `loading` while a page loads (shell shows a spinner, stops firing), `has_more=false` when
    /// exhausted, and `refreshing` while a pull-refresh runs. The app appends to `children` on each
    /// load-more. `on_refresh` is set via [`with_refresh`](mobiler_core::with_refresh).
    LazyList {
        children: Vec<Widget>,
        on_load_more: Option<ActionToken>,
        loading: bool,
        has_more: bool,
        on_refresh: Option<ActionToken>,
        refreshing: bool,
    },
    Spacer { size: Spacing },
    // Layout
    Row { children: Vec<Widget> },
    Column { children: Vec<Widget> },
    /// Card; tappable when `on_press` is set.
    Card { child: Box<Widget>, style: CardStyle, on_press: Option<ActionToken> },
    /// Z-stack: children layered back-to-front, positioned by `align`. With
    /// `scrim`, the first child is a background image, darkened for legibility,
    /// and the rest render on top in light content.
    Box { children: Vec<Widget>, align: BoxAlign, scrim: bool },
    /// Fixed 2-column grid; children flow left-to-right, top-to-bottom.
    Grid { children: Vec<Widget> },
    /// Horizontally scrolling row of children (a carousel / chip rail).
    Scroller { children: Vec<Widget> },
    /// Two-pane master-detail. On a **wide** screen (tablet / landscape — the shell's regular size
    /// class) `primary` and `detail` render side-by-side; on a **compact** screen (phone) it shows
    /// ONE pane: `primary` until `show_detail` is set (the app sets it when a row is selected), then
    /// `detail` with a back chevron that fires `on_back` (the app clears its selection). On wide,
    /// `show_detail`/`on_back` are ignored — both panes stay visible, so `detail` should show a
    /// placeholder until something is selected.
    Split { primary: Box<Widget>, detail: Box<Widget>, show_detail: bool, on_back: Option<ActionToken> },
    // Input
    Button { label: String, style: ButtonStyle, on_press: ActionToken },
    IconButton { icon: Icon, on_press: ActionToken },
    Chip { label: String, selected: bool, on_press: ActionToken },
    /// A text input. `kind` selects keyboard / secure entry / multiline
    /// (see [`FieldKind`]); `error`, when `Some`, shows an inline validation
    /// message below the field and marks it invalid. Emits `Input { id, Text }`.
    TextField { id: String, placeholder: String, value: String, kind: FieldKind, error: Option<String> },
    /// A search input (leading magnifier, pill shape); emits `Input { id, Text }` like `TextField`.
    SearchField { id: String, placeholder: String, value: String },
    /// A single-choice segmented control — exclusive options in a pill (e.g. Men/Women/Kids).
    Segmented { segments: Vec<Segment> },
    Toggle { id: String, label: String, value: bool },
    Checkbox { id: String, label: String, value: bool },
    /// Continuous 0..=`max` slider; emits `Input { id, Int }`.
    Slider { id: String, value: i32, max: i32 },
    /// Numeric stepper with −/+ controls carrying their own events.
    Stepper { value: i32, on_decrement: ActionToken, on_increment: ActionToken },
    /// App shell: a top bar (`title` + optional `back`), a scrollable `body`,
    /// and bottom-nav `tabs`. `dark_mode` is theme-as-data — the shell themes
    /// the whole app from it.
    ///
    /// `route` + `depth` drive navigation: the shell animates the body when
    /// `route` (the current screen's identity) changes — slide for push/pop
    /// (direction from whether `depth` grew or shrank), crossfade for a lateral
    /// move at the same depth — and wires the system back button to `back`.
    Scaffold {
        title: String,
        body: Box<Widget>,
        tabs: Vec<Tab>,
        back: Option<ActionToken>,
        dark_mode: bool,
        /// App branding (brand color, corner, density, font). `None` = framework
        /// defaults (no visual change) — theme-as-data, the visual twin of `dark_mode`.
        theme: Option<Theme>,
        /// Optional floating action button (raised primary action over the body).
        fab: Option<Fab>,
        /// Optional modal bottom sheet over the body (a scrim + a panel from the bottom).
        sheet: Option<Sheet>,
        /// Pull-to-refresh: when set, the body is pull-refreshable and fires this event on pull.
        /// The app owns `refreshing` — set it true when the pull fires, clear it when the async
        /// reload completes (the shell shows a spinner while it's true).
        on_refresh: Option<ActionToken>,
        refreshing: bool,
        route: String,
        depth: u32,
    },
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Serialize;
    use serde::de::DeserializeOwned;

    // Round-trips the ABI without requiring `PartialEq` on the wire types:
    // serialize → deserialize → re-serialize, and compare the two encodings.
    fn round_trips<T: Serialize + DeserializeOwned>(value: &T) {
        let a = serde_json::to_string(value).expect("serialize");
        let back: T = serde_json::from_str(&a).expect("deserialize");
        let b = serde_json::to_string(&back).expect("re-serialize");
        assert_eq!(a, b);
    }

    #[test]
    fn action_round_trips() {
        round_trips(&Action::Start);
        round_trips(&Action::Fired { token: "tok".to_string() });
        round_trips(&Action::Input { id: "field".to_string(), value: InputValue::Bool(true) });
        round_trips(&Action::Restore { data: "{}".to_string() });
    }

    #[test]
    fn widget_round_trips() {
        round_trips(&Widget::Text { content: "hi".to_string(), style: TextStyle::Title });
        round_trips(&Widget::ColorDot { color: ProjectColor::Teal });
        round_trips(&Widget::Chart {
            series: vec![ChartSeries { name: "s".to_string(), values: vec![1.0, 2.5, 3.0], color: None, goal: None }],
            labels: vec!["a".to_string()],
            style: ChartStyle::Bar,
            axis: true,
            legend: false,
        });
        round_trips(&Widget::RegionChart {
            regions: vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical(),
                          ChartRegion::new(3.0, 21.0, 0.0, 80.0, "CHF 80'000").with_color(Rgb::new(0x8E, 0xC6, 0xBA))],
            ticks: vec![ChartTick { at: 3.0, label: "3 Mt.".to_string() }, ChartTick { at: 65.0, label: "65 J.".to_string() }],
            x_max: 65.0,
            y_max: 80.0,
            ref_lines: vec![ChartRefLine { value: 80.0, label: "CHF 80'000".to_string(), dashed: false }],
            bracket: Some(ChartBracket { y0: 60.0, y1: 80.0, label: "Ceiling".to_string(), info: true }),
            legend: vec![ChartLegendItem { label: "Gap".to_string(), color: Rgb::new(0x5A, 0x7D, 0x9A) }],
        });
        round_trips(&Widget::PdfView { url: "https://example.com/report.pdf".to_string() });
        round_trips(&Widget::Video { url: "https://example.com/clip.mp4".to_string(), id: "v1".to_string(), playing: true, seek_to_ms: -1, controls: true, looping: false, muted: true, on_ended: Some("ended".to_string()), poster: Some("https://example.com/poster.jpg".to_string()), start_at_ms: 12000, captions: vec![Caption { url: "https://example.com/en.vtt".to_string(), label: "English".to_string(), language: "en".to_string(), default_on: true }], rate: 1.5, volume: 0.8, urls: vec![], start_index: 0, seek_index: -1, allow_pip: true });
        round_trips(&Widget::Video { url: "https://example.com/live.m3u8".to_string(), id: "v2".to_string(), playing: false, seek_to_ms: 5000, controls: false, looping: true, muted: false, on_ended: None, poster: None, start_at_ms: -1, captions: vec![], rate: 1.0, volume: 1.0, urls: vec!["https://example.com/a.mp4".to_string(), "https://example.com/b.mp4".to_string()], start_index: 1, seek_index: 0, allow_pip: false });
        round_trips(&Widget::WebView { url: "https://iframe.mediadelivery.net/embed/1/abc".to_string() });
        round_trips(&Widget::TextField { id: "email".to_string(), placeholder: "you@co".to_string(), value: "".to_string(), kind: FieldKind::Email, error: None });
        round_trips(&Widget::TextField { id: "pw".to_string(), placeholder: "Password".to_string(), value: "x".to_string(), kind: FieldKind::Secure, error: Some("Too short".to_string()) });
        round_trips(&Widget::Calendar { year: 2026, month: 6, first_weekday: 1, selected: Some(15), on_day: vec!["d1".to_string(), "d2".to_string()] });
        round_trips(&Widget::SwipeAction { child: Box::new(Widget::Divider), actions: vec![SwipeButton { label: "Del".to_string(), tone: Tone::Danger, on_tap: "t".to_string() }] });
        round_trips(&Widget::Split { primary: Box::new(Widget::Divider), detail: Box::new(Widget::Divider), show_detail: true, on_back: Some("back".to_string()) });
        round_trips(&Widget::Split { primary: Box::new(Widget::Divider), detail: Box::new(Widget::Divider), show_detail: false, on_back: None });
        round_trips(&Widget::LazyList { children: vec![Widget::Divider], on_load_more: Some("more".to_string()), loading: false, has_more: true, on_refresh: Some("refresh".to_string()), refreshing: false });
        // Un-themed scaffold (theme: None) — the default, must round-trip.
        round_trips(&Widget::Scaffold {
            title: "T".to_string(),
            body: Box::new(Widget::Divider),
            tabs: vec![Tab { label: "A".to_string(), selected: true, on_select: "t".to_string(), icon: Some(Icon::Home) }],
            back: Some("b".to_string()),
            dark_mode: true,
            theme: None,
            fab: None,
            sheet: None,
            on_refresh: None,
            refreshing: false,
            route: "r".to_string(),
            depth: 2,
        });
        // Themed scaffold — all four theme knobs must round-trip.
        round_trips(&Widget::Scaffold {
            title: "T".to_string(),
            body: Box::new(Widget::Divider),
            tabs: vec![],
            back: None,
            dark_mode: false,
            theme: Some(Theme {
                seed: Rgb::new(0xC8, 0x5A, 0x3C),
                accent: Some(Rgb::new(0xE0, 0x6A, 0x2C)),
                corner: Corner::Large,
                density: Density::Compact,
                font: FontFamily::Rounded,
            }),
            fab: Some(Fab { icon: Icon::Calendar, on_press: "f".to_string() }),
            sheet: Some(Sheet { title: "S".to_string(), child: Box::new(Widget::Divider), on_dismiss: "d".to_string() }),
            on_refresh: Some("r".to_string()),
            refreshing: true,
            route: "r".to_string(),
            depth: 1,
        });
    }
}