Skip to main content

ferro_json_ui/
component.rs

1//! Component catalog for JSON-UI.
2//!
3//! Defines the available UI components with typed props. Each component
4//! uses serde's tagged enum representation so JSON includes `"type": "Card"`.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::action::Action;
10
11/// Visual weight of interactive elements (buttons, action items). `primary` is the default.
12#[derive(
13    Debug,
14    Clone,
15    Copy,
16    Default,
17    PartialEq,
18    Eq,
19    Serialize,
20    Deserialize,
21    JsonSchema,
22    strum::AsRefStr,
23    strum::VariantArray,
24)]
25#[serde(rename_all = "snake_case")]
26#[strum(serialize_all = "snake_case")]
27pub enum Variant {
28    #[default]
29    Primary,
30    Secondary,
31    Outline,
32    Ghost,
33    Destructive,
34}
35
36/// Semantic status color of stateful display components. `neutral` is the default and
37/// reproduces today's non-status look.
38#[derive(
39    Debug,
40    Clone,
41    Copy,
42    Default,
43    PartialEq,
44    Eq,
45    Serialize,
46    Deserialize,
47    JsonSchema,
48    strum::AsRefStr,
49    strum::VariantArray,
50)]
51#[serde(rename_all = "snake_case")]
52#[strum(serialize_all = "snake_case")]
53pub enum Tone {
54    #[default]
55    Neutral,
56    Success,
57    Warning,
58    Destructive,
59}
60
61/// Component size scale. `md` is the default.
62#[derive(
63    Debug,
64    Clone,
65    Copy,
66    Default,
67    PartialEq,
68    Eq,
69    Serialize,
70    Deserialize,
71    JsonSchema,
72    strum::AsRefStr,
73    strum::VariantArray,
74)]
75#[serde(rename_all = "snake_case")]
76#[strum(serialize_all = "snake_case")]
77pub enum Size {
78    Sm,
79    #[default]
80    Md,
81    Lg,
82}
83
84/// Icon placement relative to button label.
85#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "snake_case")]
87pub enum IconPosition {
88    #[default]
89    Left,
90    Right,
91}
92
93/// Sort direction for table columns.
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum SortDirection {
97    #[default]
98    Asc,
99    Desc,
100}
101
102/// Separator orientation.
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(rename_all = "snake_case")]
105pub enum Orientation {
106    #[default]
107    Horizontal,
108    Vertical,
109}
110
111/// Input field types.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum InputType {
115    #[default]
116    Text,
117    Email,
118    Password,
119    Number,
120    Textarea,
121    Hidden,
122    Date,
123    Time,
124    Url,
125    Tel,
126    Search,
127    File,
128}
129
130/// Text element types for semantic HTML rendering.
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(rename_all = "snake_case")]
133pub enum TextElement {
134    #[default]
135    P,
136    H1,
137    H2,
138    H3,
139    Span,
140    Div,
141    Section,
142}
143
144/// Column display format for tables.
145///
146/// `Badge` cells expect the row value to be an object `{tone, label}` matching
147/// [`BadgeProps`]. Other variants are display hints layered over plain cell text.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum ColumnFormat {
151    Date,
152    DateTime,
153    Currency,
154    Boolean,
155    Badge,
156    /// Cell value is an image URL string; rendered as an `<img>` thumbnail.
157    Image,
158    /// Cell value is a built-in icon name (e.g. `folder`, `file`); rendered as
159    /// an inline outline SVG that inherits `currentColor`. Unknown names render
160    /// an empty cell. Use for type/status glyphs that should match the line-icon
161    /// system rather than emoji.
162    Icon,
163}
164
165/// Horizontal text alignment for a table column (header + cells).
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167#[serde(rename_all = "snake_case")]
168pub enum ColumnAlign {
169    #[default]
170    Left,
171    Center,
172    Right,
173}
174
175/// Table column definition.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
177pub struct Column {
178    pub key: String,
179    pub label: String,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub format: Option<ColumnFormat>,
182    /// Horizontal alignment of the header and cells. Defaults to left.
183    /// Use `right` for numeric/currency columns so magnitudes line up.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub align: Option<ColumnAlign>,
186}
187
188/// Select option (value + label pair).
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190pub struct SelectOption {
191    pub value: String,
192    pub label: String,
193}
194
195/// Structural chrome of a Card — NOT a weight or status axis (renamed from `CardVariant`).
196///
197/// - `Bordered` (default): `border + bg-card + shadow-sm` with `p-4`.
198///   Dashboard cards in dense layouts.
199/// - `Elevated`: `bg-card + shadow-md` (no border) with `p-8`.
200///   Auth pages, error pages, standalone marketing cards.
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202#[serde(rename_all = "snake_case")]
203pub enum CardAppearance {
204    #[default]
205    Bordered,
206    Elevated,
207}
208
209/// Props for Card component.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
211pub struct CardProps {
212    pub title: String,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub description: Option<String>,
215    /// Optional muted secondary line rendered immediately below the title and
216    /// above the description. Pattern: name → role, customer → staff,
217    /// title → category. Visually `text-sm text-text-muted`.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub subtitle: Option<String>,
220    /// Optional small badge text rendered alongside the title. Visually a
221    /// Badge-styled pill inside the Card chrome — for status indicators,
222    /// counters, countdown labels, etc. Independent of the title hierarchy.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub badge: Option<String>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub max_width: Option<FormMaxWidth>,
227    /// IDs of footer elements (resolved against `Spec.elements`).
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub footer: Vec<String>,
230    #[serde(default)]
231    pub appearance: CardAppearance,
232}
233
234/// Props for Table component.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
236pub struct TableProps {
237    pub columns: Vec<Column>,
238    pub data_path: String,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub row_actions: Option<Vec<Action>>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub empty_message: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub sortable: Option<bool>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub sort_column: Option<String>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub sort_direction: Option<SortDirection>,
249}
250
251/// Maximum width constraint for form containers.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
253#[serde(rename_all = "snake_case")]
254pub enum FormMaxWidth {
255    #[default]
256    Default,
257    Narrow,
258    Wide,
259}
260
261/// Props for Form component.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
263pub struct FormProps {
264    pub action: Action,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub method: Option<crate::action::HttpMethod>,
267    /// Form guard type. When set, the runtime JS disables the submit button
268    /// until the guard condition is met. Value: `"number-gt-0"` — at least
269    /// one number input must have value > 0.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub guard: Option<String>,
272    /// Optional max-width constraint for the form container.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub max_width: Option<FormMaxWidth>,
275    /// Optional HTML `id` attribute for the rendered `<form>`. Pair with a
276    /// Button's `form` prop to submit this form from a button placed outside
277    /// it (e.g. in a PageHeader actions slot).
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub id: Option<String>,
280    /// HTML form `enctype` attribute. Set to `"multipart/form-data"` for forms
281    /// carrying a file input. Without this, the browser default encoding
282    /// (`application/x-www-form-urlencoded`) is used and file inputs are sent
283    /// as plain text rather than a multipart body.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub enctype: Option<String>,
286    /// When `true`, the rendered `<form>` joins the fill-viewport height chain:
287    /// it emits `flex flex-col h-full min-h-0 [&>*]:flex-1 [&>*]:min-h-0`
288    /// instead of the default `flex flex-wrap` layout, stretching its single
289    /// child to the full height of the parent so an inner fill Grid resolves
290    /// `h-full` against a real (viewport-constrained) height rather than
291    /// content height. Set by the Register layout template
292    /// (`emit_register_root`) so the SelectionPanel footer pins while the
293    /// panes scroll independently (256 D-15). Absent/`false` keeps the default
294    /// content-sized form layout — byte-identical to prior renders.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub fill: Option<bool>,
297}
298
299/// HTML button type attribute.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
301#[serde(rename_all = "snake_case")]
302pub enum ButtonType {
303    #[default]
304    Button,
305    Submit,
306}
307
308/// Props for Button component.
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
310pub struct ButtonProps {
311    pub label: String,
312    #[serde(default)]
313    pub variant: Variant,
314    #[serde(default)]
315    pub size: Size,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub disabled: Option<bool>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub icon: Option<String>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub icon_position: Option<IconPosition>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub button_type: Option<ButtonType>,
324    /// HTML5 `form` attribute. Lets a submit button rendered outside its
325    /// target `<form>` (e.g. in a PageHeader actions slot) still submit
326    /// that form, by matching the form's `id`.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub form: Option<String>,
329    /// When `true`, emits `data-disable-on-submit` on the rendered button; the runtime guard
330    /// disables this button after the first form submission to prevent double-posting (D-16).
331    /// Pairs with a per-render `idempotency_key` hidden input for server-side deduplication
332    /// (see `dispatch_write` step 2 in docs/src/features/write-kernel.md).
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub disable_on_submit: Option<bool>,
335}
336
337/// Props for Input component.
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
339pub struct InputProps {
340    /// Form field name for data binding.
341    pub field: String,
342    pub label: String,
343    #[serde(default)]
344    pub input_type: InputType,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub placeholder: Option<String>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub required: Option<bool>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub disabled: Option<bool>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub error: Option<String>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub description: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub default_value: Option<String>,
357    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub data_path: Option<String>,
360    /// HTML step attribute for number inputs (e.g., "any", "0.01").
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub step: Option<String>,
363    /// HTML datalist id for autocomplete suggestions.
364    /// When set, renders `list="..."` on the input and a companion `<datalist>`
365    /// whose options come from a view data key matching this id.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub list: Option<String>,
368    /// HTML `accept` attribute for `input_type = "file"`. Comma-separated MIME
369    /// types or extensions (e.g. `"image/jpeg,image/png,image/webp"`). Browser-
370    /// side filter hint only — server-side MIME validation is the consumer's
371    /// responsibility (the spec layer does not enforce file content type).
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub accept: Option<String>,
374}
375
376/// Props for RichTextEditor leaf element — rendered by the Quill 2.0.3 plugin.
377///
378/// The plugin emits a container div (`<div data-ferro-quill ...>`) and a hidden
379/// input that receives the editor's HTML on every text-change event. The form
380/// handler receives standard `field=<html>` POST data on submit.
381///
382/// # Security
383/// The editor produces user-controlled HTML. Sanitization on submit is the
384/// consumer's responsibility — handle this in the form handler before
385/// persisting (e.g. via `ammonia`).
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
387pub struct RichTextEditorProps {
388    pub field: String,
389    pub label: String,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub placeholder: Option<String>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub default_value: Option<String>,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub data_path: Option<String>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub error: Option<String>,
398}
399
400/// Props for Select component.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
402pub struct SelectProps {
403    /// Form field name for data binding.
404    pub field: String,
405    pub label: String,
406    pub options: Vec<SelectOption>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub placeholder: Option<String>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub required: Option<bool>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub disabled: Option<bool>,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub error: Option<String>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub description: Option<String>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub default_value: Option<String>,
419    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub data_path: Option<String>,
422}
423
424/// Props for Alert component.
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
426pub struct AlertProps {
427    pub message: String,
428    #[serde(default)]
429    pub tone: Tone,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub title: Option<String>,
432}
433
434/// Props for Badge component.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct BadgeProps {
437    pub label: String,
438    #[serde(default)]
439    pub tone: Tone,
440}
441
442/// Props for Modal component.
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
444pub struct ModalProps {
445    pub id: String,
446    pub title: String,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub description: Option<String>,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub trigger_label: Option<String>,
451    /// IDs of footer elements (resolved against `Spec.elements`).
452    #[serde(default, skip_serializing_if = "Vec::is_empty")]
453    pub footer: Vec<String>,
454}
455
456/// Props for Text component.
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
458pub struct TextProps {
459    pub content: String,
460    #[serde(default)]
461    pub element: TextElement,
462}
463
464/// Props for Checkbox component.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
466pub struct CheckboxProps {
467    /// Form field name for data binding.
468    pub field: String,
469    /// HTML value attribute. When set, the checkbox submits this value instead of "1".
470    /// Required for multi-value checkbox groups (same name, different values).
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub value: Option<String>,
473    pub label: String,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub description: Option<String>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub checked: Option<bool>,
478    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub data_path: Option<String>,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub required: Option<bool>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub disabled: Option<bool>,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub error: Option<String>,
487}
488
489/// Props for CheckboxList component — multi-select checkbox group.
490///
491/// Each checked option submits as `field=value`. Options may be supplied
492/// statically via `options` or resolved at render time from `options_path`.
493/// Pre-selected values are read from `selected_path` (a `Vec<String>`).
494#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
495pub struct CheckboxListProps {
496    /// Shared form field name; each checkbox submits as `field=value`.
497    pub field: String,
498    /// Static options list. When empty and `options_path` is set, options are
499    /// resolved from the data at render time.
500    #[serde(default, skip_serializing_if = "Vec::is_empty")]
501    pub options: Vec<SelectOption>,
502    /// Data path to an array of `{value, label}` objects for data-driven options.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub options_path: Option<String>,
505    /// Data path to a `Vec<String>` of pre-selected values.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub selected_path: Option<String>,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub label: Option<String>,
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub description: Option<String>,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub disabled: Option<bool>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub error: Option<String>,
516}
517
518/// Props for Switch component.
519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
520pub struct SwitchProps {
521    /// Form field name for data binding.
522    pub field: String,
523    pub label: String,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub description: Option<String>,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub checked: Option<bool>,
528    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub data_path: Option<String>,
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub required: Option<bool>,
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub disabled: Option<bool>,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub error: Option<String>,
537    /// Auto-submit action. When set, the switch renders inside a minimal
538    /// form and submits on change.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub action: Option<Action>,
541    /// When true, applies `scale-75 origin-left` CSS to the switch container
542    /// for compact inline display (e.g. per-row settings toggles).
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub compact: Option<bool>,
545}
546
547/// Props for Separator component.
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
549pub struct SeparatorProps {
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub orientation: Option<Orientation>,
552}
553
554/// A single item in a description list.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
556pub struct DescriptionItem {
557    pub label: String,
558    pub value: String,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub format: Option<ColumnFormat>,
561}
562
563/// Props for DescriptionList component.
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
565pub struct DescriptionListProps {
566    #[serde(default, skip_serializing_if = "Vec::is_empty")]
567    pub items: Vec<DescriptionItem>,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub columns: Option<u8>,
570    /// Optional data-path override of `items`. When set, the renderer
571    /// resolves the array at this path and decodes each entry as a
572    /// `DescriptionItem`.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub data_path: Option<String>,
575}
576
577/// A single tab within a Tabs component.
578#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
579pub struct Tab {
580    pub value: String,
581    pub label: String,
582    /// IDs of elements rendered inside this tab's panel.
583    #[serde(default, skip_serializing_if = "Vec::is_empty")]
584    pub children: Vec<String>,
585}
586
587/// Props for Tabs component.
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
589pub struct TabsProps {
590    pub default_tab: String,
591    pub tabs: Vec<Tab>,
592}
593
594/// A single item in a breadcrumb trail.
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
596pub struct BreadcrumbItem {
597    pub label: String,
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub url: Option<String>,
600}
601
602/// Props for Breadcrumb component.
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
604pub struct BreadcrumbProps {
605    pub items: Vec<BreadcrumbItem>,
606}
607
608/// Props for Pagination component.
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct PaginationProps {
611    pub current_page: u32,
612    pub per_page: u32,
613    pub total: u32,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub base_url: Option<String>,
616}
617
618/// Props for Progress component.
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
620pub struct ProgressProps {
621    /// Percentage value (0-100).
622    pub value: u8,
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub max: Option<u8>,
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub label: Option<String>,
627}
628
629/// Props for Image component.
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
631pub struct ImageProps {
632    #[serde(default)]
633    pub src: String,
634    pub alt: String,
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub aspect_ratio: Option<String>,
637    /// Optional label shown in a skeleton placeholder that sits behind the
638    /// image. When the image fails to load (or is still being generated),
639    /// the `<img>` is hidden via `onerror` and the placeholder remains
640    /// visible, keeping the container at its aspect-ratio size.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub placeholder_label: Option<String>,
643    /// Server-rendered inline SVG string. When set, the SVG is emitted verbatim
644    /// inside a `<div aria-label="{alt}">` wrapper; no `<img>` tag is produced.
645    ///
646    /// # Safety
647    /// Content is NOT sanitized. The SVG string is emitted into the response
648    /// verbatim. Pass only server-constructed SVG (e.g. bar charts, QR codes).
649    /// Do NOT pass untrusted input. `alt` is required and is HTML-escaped.
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub inline_svg: Option<String>,
652    /// Optional data-path override of `src`. When set, the renderer resolves
653    /// the value at this path against handler data and uses it as the
654    /// `<img src>`. Falls back to `src` when missing or non-string.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub data_path: Option<String>,
657}
658
659impl ImageProps {
660    /// Convenience constructor for inline-SVG images. `src` is set to the
661    /// empty string; the renderer takes the SVG path when `inline_svg` is `Some`.
662    ///
663    /// # Safety
664    /// `svg` is emitted verbatim. See [`ImageProps::inline_svg`] for the trust model.
665    pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
666        Self {
667            src: String::new(),
668            alt: alt.into(),
669            aspect_ratio: None,
670            placeholder_label: None,
671            inline_svg: Some(svg.into()),
672            data_path: None,
673        }
674    }
675}
676
677/// Props for Avatar component.
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
679pub struct AvatarProps {
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub src: Option<String>,
682    pub alt: String,
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub fallback: Option<String>,
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub size: Option<Size>,
687}
688
689/// Props for Skeleton loading placeholder.
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
691pub struct SkeletonProps {
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub width: Option<String>,
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub height: Option<String>,
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub rounded: Option<bool>,
698}
699
700/// Props for the `RawHtml` component — server-injected HTML island.
701///
702/// # Safety
703/// `html` is emitted into the response VERBATIM with NO sanitization. The
704/// component exists to bridge server-rendered HTML fragments (e.g. a status
705/// pill, a link badge) into a v2 spec where a first-class component would
706/// be over-engineering.
707///
708/// Sanitization is the CONSUMER's responsibility — pass only server-
709/// constructed HTML, or run untrusted input through a sanitiser (e.g.
710/// `ammonia`) in the handler before embedding. This mirrors
711/// `RichTextEditorProps` discipline (see component.rs).
712///
713/// For richer widgets (interactive forms, charts, OAuth flows), use the
714/// first-class plugin system (`JsonUiPlugin`) instead — see
715/// `docs/src/json-ui/plugins.md`.
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717pub struct RawHtmlProps {
718    /// Server-constructed HTML emitted verbatim. NOT sanitized.
719    #[serde(default)]
720    pub html: String,
721}
722
723/// Props for the `StreamText` component — SSE token stream renderer.
724///
725/// Connects to `sse_url` via the browser `EventSource` API and appends arriving
726/// tokens as plain text nodes. The SSE endpoint MUST emit `event: done` on
727/// completion to prevent `EventSource` auto-reconnect.
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
729pub struct StreamTextProps {
730    /// URL of the server-sent-events endpoint that streams tokens.
731    /// Must emit `event: done` on completion.
732    #[serde(default)]
733    pub sse_url: String,
734    /// Text shown inside the content area before the first token arrives.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub placeholder: Option<String>,
737    /// Status text shown while the stream is open.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub loading_text: Option<String>,
740}
741
742/// Props for the `LiveFragment` builtin — binds a child template to a
743/// `ferro-projection` per-key snapshot for server-push in-place re-render.
744///
745/// First paint: the handler resolves `projection` + `key` via
746/// `ProjectionRuntime::read`, serializes the state (or uses `{}` when absent,
747/// per D-04), and passes the `Value` as the data scope for `template`.
748///
749/// On delta: the registered fragment hook re-renders `template` against the
750/// new snapshot and broadcasts `{ html }` on the same
751/// `projection.{name}.{key}` channel; the client runtime swaps `innerHTML`.
752#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
753pub struct LiveFragmentProps {
754    /// ferro-projection NAME — the `Projection::NAME` const of the target projection.
755    #[serde(default)]
756    pub projection: String,
757    /// Per-key channel selector (the `key` segment of `projection.{name}.{key}`).
758    #[serde(default)]
759    pub key: String,
760    /// Child template spec rendered against the snapshot as its data scope.
761    /// A `serde_json::Value` encoding a valid ferro-json-ui `Spec`.
762    pub template: serde_json::Value,
763}
764
765/// A single item in a checklist.
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
767pub struct ChecklistItem {
768    pub label: String,
769    #[serde(default)]
770    pub checked: bool,
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub href: Option<String>,
773}
774
775/// A single item in a notification dropdown.
776#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
777pub struct NotificationItem {
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub icon: Option<String>,
780    pub text: String,
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub timestamp: Option<String>,
783    #[serde(default)]
784    pub read: bool,
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub action_url: Option<String>,
787}
788
789/// A single navigation item in the sidebar.
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
791pub struct SidebarNavItem {
792    pub label: String,
793    pub href: String,
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub icon: Option<String>,
796    #[serde(default)]
797    pub active: bool,
798    /// When true, the item renders as a muted, non-clickable `<span>`
799    /// instead of an `<a>` — useful for "coming soon" placeholders.
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub disabled: Option<bool>,
802}
803
804/// A collapsible group in the sidebar.
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
806pub struct SidebarGroup {
807    pub label: String,
808    #[serde(default)]
809    pub collapsed: bool,
810    pub items: Vec<SidebarNavItem>,
811}
812
813/// Props for StatCard component (live-updatable metric card).
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
815pub struct StatCardProps {
816    pub label: String,
817    pub value: String,
818    /// Semantic status color for the value/icon accent. `neutral` (default)
819    /// reproduces the plain non-status look.
820    #[serde(default)]
821    pub tone: Tone,
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub icon: Option<String>,
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub subtitle: Option<String>,
826    /// SSE target key for live updates; maps to `data-sse-target` on the value element.
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub sse_target: Option<String>,
829    /// Resolves the initial displayed value from handler data at render time.
830    /// Format: `/segment/segment` (same JSON-pointer as `data::resolve_path`).
831    /// Falls back to `value` when missing or non-string. Mirrors
832    /// `ImageProps.data_path` / `DescriptionListProps.data_path`.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub value_path: Option<String>,
835}
836
837/// Props for Checklist component.
838#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
839pub struct ChecklistProps {
840    pub title: String,
841    pub items: Vec<ChecklistItem>,
842    #[serde(default = "default_true")]
843    pub dismissible: bool,
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub dismiss_label: Option<String>,
846    /// Server-side state persistence key for this checklist.
847    #[serde(default, skip_serializing_if = "Option::is_none")]
848    pub data_key: Option<String>,
849}
850
851fn default_true() -> bool {
852    true
853}
854
855/// Props for Toast component (declarative notification intent).
856///
857/// The JS runtime reads data attributes from the rendered element to
858/// display the toast. Timeouts and dismissal are handled client-side.
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
860pub struct ToastProps {
861    pub message: String,
862    #[serde(default)]
863    pub tone: Tone,
864    /// Seconds before auto-dismiss. Default 5. `0` with `dismissible: true`
865    /// keeps the toast visible until manually closed.
866    #[serde(default, skip_serializing_if = "Option::is_none")]
867    pub timeout: Option<u32>,
868    /// Render a manual close button. When `false`, `timeout` is clamped to a
869    /// minimum of 1 second so the toast always auto-dismisses.
870    #[serde(default = "default_true")]
871    pub dismissible: bool,
872}
873
874/// Props for NotificationDropdown component.
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
876pub struct NotificationDropdownProps {
877    pub notifications: Vec<NotificationItem>,
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub empty_text: Option<String>,
880}
881
882/// Props for Sidebar component.
883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
884pub struct SidebarProps {
885    #[serde(default, skip_serializing_if = "Vec::is_empty")]
886    pub fixed_top: Vec<SidebarNavItem>,
887    #[serde(default, skip_serializing_if = "Vec::is_empty")]
888    pub groups: Vec<SidebarGroup>,
889    #[serde(default, skip_serializing_if = "Vec::is_empty")]
890    pub fixed_bottom: Vec<SidebarNavItem>,
891}
892
893/// Props for Header component.
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
895pub struct HeaderProps {
896    pub business_name: String,
897    /// Unread notification count for badge display.
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    pub notification_count: Option<u32>,
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub user_name: Option<String>,
902    #[serde(default, skip_serializing_if = "Option::is_none")]
903    pub user_avatar: Option<String>,
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub logout_url: Option<String>,
906}
907
908/// Gap size for Grid layout.
909#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
910#[serde(rename_all = "snake_case")]
911pub enum GapSize {
912    None,
913    Sm,
914    #[default]
915    Md,
916    Lg,
917    Xl,
918}
919
920/// Props for Grid component — multi-column layout.
921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
922pub struct GridProps {
923    /// Number of columns (1-12) at base (mobile) viewport.
924    #[serde(default = "default_grid_columns")]
925    pub columns: u8,
926    /// Number of columns at md breakpoint (768px+). When set, creates a responsive grid.
927    #[serde(default, skip_serializing_if = "Option::is_none")]
928    pub md_columns: Option<u8>,
929    /// Number of columns at lg breakpoint (1024px+). Optional; falls back to md.
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub lg_columns: Option<u8>,
932    /// Gap between grid items.
933    #[serde(default)]
934    pub gap: GapSize,
935    /// Enables horizontal scroll mode. Children get `min-w-[280px]` and the grid
936    /// uses `grid-flow-col` auto-cols layout for Trello-like horizontal scrolling.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub scrollable: Option<bool>,
939    /// Per-child column spans, aligned positionally with `children` (missing
940    /// entries default to 1). A child with span N occupies N tracks — e.g.
941    /// `columns: 1, md_columns: 3, spans: [2, 1]` renders a 2/3 + 1/3 row.
942    /// Supported spans: 2–4 on the base grid, 2–3 at the `md` breakpoint.
943    /// Ignored in `scrollable` mode.
944    #[serde(default, skip_serializing_if = "Vec::is_empty")]
945    pub spans: Vec<u8>,
946    /// Per-row height weights for fill-mode grids. Positional alignment with
947    /// `children` (missing entries default to equal weight). A row with weight N
948    /// receives N fractional units of available height — e.g. `row_weights: [2, 1]`
949    /// gives the first row 2/3 and the second 1/3. Meaningful only when
950    /// `fill: true`; ignored in `scrollable` mode. The render path (fractional
951    /// `grid-template-rows` via inline style) lands in Phase 256.
952    #[serde(default, skip_serializing_if = "Vec::is_empty")]
953    pub row_weights: Vec<u8>,
954    /// Fill mode for viewport workspaces (pages with `Spec.fill_viewport`):
955    /// the grid stretches to its parent's height with equal-height rows and
956    /// every child cell scrolls internally. The document never scrolls —
957    /// each pane does. Combine with `spans` for asymmetric panes (e.g. a
958    /// POS register: 1/3 cart + 2/3 product grid). Ignored in `scrollable`
959    /// mode.
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub fill: Option<bool>,
962}
963
964fn default_grid_columns() -> u8 {
965    2
966}
967
968/// Props for Collapsible section — expandable `<details>`/`<summary>`.
969#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
970pub struct CollapsibleProps {
971    pub title: String,
972    #[serde(default)]
973    pub expanded: bool,
974}
975
976/// Props for EmptyState component — standardized empty view.
977#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
978pub struct EmptyStateProps {
979    pub title: String,
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub description: Option<String>,
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub action: Option<Action>,
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub action_label: Option<String>,
986}
987
988/// Layout variant for form sections.
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
990#[serde(rename_all = "snake_case")]
991pub enum FormSectionLayout {
992    #[default]
993    Stacked,
994    TwoColumn,
995}
996
997/// Props for FormSection component — visual grouping within forms.
998#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
999pub struct FormSectionProps {
1000    pub title: String,
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub description: Option<String>,
1003    /// Optional layout variant. Defaults to stacked (single column).
1004    #[serde(default, skip_serializing_if = "Option::is_none")]
1005    pub layout: Option<FormSectionLayout>,
1006}
1007
1008/// Props for PageHeader component -- page title with optional breadcrumb and action buttons.
1009#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1010pub struct PageHeaderProps {
1011    pub title: String,
1012    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1013    pub breadcrumb: Vec<BreadcrumbItem>,
1014    /// IDs of action button elements rendered to the right of the title.
1015    #[serde(
1016        default,
1017        deserialize_with = "deserialize_actions_lax",
1018        skip_serializing_if = "Vec::is_empty"
1019    )]
1020    pub actions: Vec<String>,
1021}
1022
1023/// Props for ButtonGroup component -- horizontal button row with consistent gap.
1024#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1025pub struct ButtonGroupProps {
1026    /// Gap between buttons. Defaults to small spacing.
1027    #[serde(default)]
1028    pub gap: GapSize,
1029}
1030
1031/// A single action in an `ActionGroup`'s ordered item list.
1032///
1033/// Inline items (non-destructive, within `max_inline`) render as buttons.
1034/// The `destructive` flag forces the item into the overflow kebab and renders
1035/// it last regardless of its position in `items`.
1036///
1037/// `visible_if` is a fail-closed row gate (same semantics as
1038/// `DropdownMenuAction.visible_if`): when set, the item is hidden unless
1039/// `row[field]` is truthy. An absent or falsy field hides the item — a typo
1040/// in the field name cannot leak an action.
1041#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1042pub struct ActionItem {
1043    pub label: String,
1044    pub action: Action,
1045    /// When true, this item is forced into the overflow kebab and rendered last,
1046    /// regardless of position in `items`. Does not count toward `max_inline`.
1047    #[serde(default)]
1048    pub destructive: bool,
1049    #[serde(default, skip_serializing_if = "Option::is_none")]
1050    pub variant: Option<Variant>,
1051    #[serde(default, skip_serializing_if = "Option::is_none")]
1052    pub icon: Option<String>,
1053    /// Fail-closed row gate (same semantics as `DropdownMenuAction.visible_if`).
1054    /// When set, the item is only shown when `row[visible_if]` is truthy.
1055    /// Absent/falsy field hides the item.
1056    #[serde(default, skip_serializing_if = "Option::is_none")]
1057    pub visible_if: Option<String>,
1058}
1059
1060/// Props for `ActionGroup` — ordered action list rendering inline buttons (up to
1061/// `max_inline`) plus a trailing overflow kebab for the remainder. Destructive
1062/// items are always in the kebab, rendered last, regardless of input order.
1063///
1064/// Input order determines button priority: the first item in `items` is the
1065/// primary action and renders first inline. Use `variant` on an item to control
1066/// button styling.
1067///
1068/// The overflow kebab is hidden entirely when nothing overflows (≤ `max_inline`
1069/// non-destructive items and zero destructive items).
1070#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1071pub struct ActionGroupProps {
1072    pub items: Vec<ActionItem>,
1073    /// ID pairing the overflow popover to its trigger button. Required; callers
1074    /// must supply a unique value per page to prevent DOM id collisions.
1075    pub menu_id: String,
1076    /// Maximum non-destructive items rendered inline (default 2).
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub max_inline: Option<u8>,
1079    /// Aria-label for the overflow trigger button (default "Azioni").
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub overflow_label: Option<String>,
1082    /// Key used for `{row_key}` substitution in action URLs (DataTable / Kanban context).
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub row_key: Option<String>,
1085}
1086
1087/// Props for SegmentedControl — a tightly-packed cluster of toggle/nav links
1088/// rendered as a single bordered group with no gap between segments.
1089///
1090/// Items come either as a literal `items` array or from runtime data via
1091/// `data_path` (controller-built). At least one of the two must be supplied;
1092/// `items` wins when both are present.
1093///
1094/// Visual model: rounded outer container with a single border, internal
1095/// dividers between segments, one segment marked `active=true` and styled
1096/// distinctly. The label can be the literal segment text (e.g. "Oggi") or a
1097/// glyph (e.g. "←", "→"). Each segment carries an optional `aria_label`
1098/// override for accessibility on glyph-only segments.
1099///
1100/// Use cases captured by this primitive: date scroll clusters (prev/today/next),
1101/// view toggles (Day/Month, List/Grid), pagination steppers, mode switchers.
1102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1103pub struct SegmentedControlProps {
1104    /// Literal items list. Skipped when empty; `data_path` is the fallback.
1105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1106    pub items: Vec<SegmentedItem>,
1107    /// JSON Pointer into runtime data resolving to an array of `SegmentedItem`s.
1108    /// Used when items shape depends on per-request data.
1109    #[serde(default, skip_serializing_if = "Option::is_none")]
1110    pub data_path: Option<String>,
1111    /// Visual size — defaults to `default`.
1112    #[serde(default)]
1113    pub size: Size,
1114    /// Accessible label for the group (`<div role="tablist" aria-label="...">`).
1115    /// Omit when the surrounding context already announces purpose.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub aria_label: Option<String>,
1118}
1119
1120/// One segment of a `SegmentedControl`.
1121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1122pub struct SegmentedItem {
1123    /// Visible label or glyph.
1124    pub label: String,
1125    /// Destination URL — segments render as `<a href>` so they work without JS.
1126    pub href: String,
1127    /// Active segment (one per group, typically). Highlighted, `aria-selected=true`.
1128    #[serde(default)]
1129    pub active: bool,
1130    /// Optional accessible label override — useful when `label` is a glyph
1131    /// like "←" or "→" that screen readers cannot pronounce.
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub aria_label: Option<String>,
1134}
1135
1136/// Props for SidebarLayout — a two-column layout with a sticky vertical nav
1137/// on the left and a main content slot on the right. Replaces the common
1138/// pattern of opener/closer `RawHtml` blocks faking asymmetric grids.
1139///
1140/// The element's `children` IDs render inside the main slot. Each child is
1141/// expected to carry its own `visible` rule keyed against `active` (typically
1142/// `{ path: "/active_tab", operator: "eq", value: "<slug>" }`) so only the
1143/// matching section is in the DOM at a time.
1144///
1145/// On mobile (below `md`), the sidebar collapses into a horizontally
1146/// scrollable strip above the main content, and the asymmetric grid layout
1147/// flattens to a single column.
1148///
1149/// Use cases: settings pages with many sections, account dashboards,
1150/// onboarding wizards with persistent navigation, admin consoles.
1151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1152pub struct SidebarLayoutProps {
1153    /// Literal sidebar items. Skipped when empty; `data_path` is the fallback.
1154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1155    pub items: Vec<SidebarLayoutItem>,
1156    /// JSON Pointer into runtime data resolving to an array of `SidebarLayoutItem`s.
1157    #[serde(default, skip_serializing_if = "Option::is_none")]
1158    pub data_path: Option<String>,
1159    /// Slug of the currently-active item. Matched against `SidebarLayoutItem.slug`.
1160    /// Typically bound via `{ "$data": "/active_tab" }`.
1161    pub active: String,
1162    /// Accessible label for the nav (`<nav aria-label="...">`).
1163    #[serde(default, skip_serializing_if = "Option::is_none")]
1164    pub aria_label: Option<String>,
1165}
1166
1167/// One sidebar nav item in a `SidebarLayout`.
1168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1169pub struct SidebarLayoutItem {
1170    /// Item identifier — matched against `SidebarLayoutProps.active` to determine
1171    /// which item is highlighted.
1172    pub slug: String,
1173    /// Visible label.
1174    pub label: String,
1175    /// Destination URL. Typically `"?tab={slug}"` for query-driven routing,
1176    /// but can be any absolute or relative URL.
1177    pub url: String,
1178}
1179
1180/// Props for DetailPage component -- opinionated resource-detail skeleton.
1181///
1182/// Renders a PageHeader (title + breadcrumb + actions), an info Card
1183/// wrapping the `info` slot IDs (typically a Badge plus a DescriptionList),
1184/// and `Element.children` as stacked sections below the card (tabs,
1185/// related-resource lists, action panels). Centralizes the visual contract
1186/// every dashboard detail page follows so per-page rebuilds cannot drift
1187/// from the canonical shape.
1188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1189pub struct DetailPageProps {
1190    pub title: String,
1191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1192    pub breadcrumb: Vec<BreadcrumbItem>,
1193    /// IDs of action button elements rendered to the right of the title.
1194    #[serde(
1195        default,
1196        deserialize_with = "deserialize_actions_lax",
1197        skip_serializing_if = "Vec::is_empty"
1198    )]
1199    pub actions: Vec<String>,
1200    /// IDs of elements rendered inside the info Card
1201    /// (typically a Badge and a DescriptionList). Omit to skip the card.
1202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1203    pub info: Vec<String>,
1204}
1205
1206/// A single action item in a dropdown menu.
1207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1208pub struct DropdownMenuAction {
1209    pub label: String,
1210    pub action: Action,
1211    #[serde(default)]
1212    pub destructive: bool,
1213    /// When set, this item is only emitted in a DataTable row when the row's
1214    /// `visible_if` field is truthy (true / non-zero number / non-empty string /
1215    /// non-empty array or object). An absent or falsy field hides the item —
1216    /// fail-closed so a typo in the view spec cannot leak an action onto every
1217    /// row. Outside DataTable contexts (e.g. standalone `DropdownMenu` element)
1218    /// the field is ignored.
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub visible_if: Option<String>,
1221}
1222
1223/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
1224/// mobile card fallback, and empty state.
1225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1226pub struct DataTableProps {
1227    pub columns: Vec<Column>,
1228    pub data_path: String,
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub row_actions: Option<Vec<DropdownMenuAction>>,
1231    #[serde(default, skip_serializing_if = "Option::is_none")]
1232    pub empty_message: Option<String>,
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub row_key: Option<String>,
1235    /// URL pattern for row click navigation. Use `{row_key}` as placeholder.
1236    #[serde(default, skip_serializing_if = "Option::is_none")]
1237    pub row_href: Option<String>,
1238}
1239
1240/// Props for MediaCardGrid — a responsive card grid backed by a data array.
1241/// Mirrors DataTable's row_key/row_actions/data_path contract but renders
1242/// cards with an optional screenshot image instead of table rows.
1243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1244pub struct MediaCardGridProps {
1245    pub data_path: String,
1246    /// Key in each row object whose value becomes the card title.
1247    pub title_key: String,
1248    /// Key for the subtitle/URL line below the title.
1249    #[serde(default, skip_serializing_if = "Option::is_none")]
1250    pub description_key: Option<String>,
1251    /// Key for the screenshot image URL. No image rendered when absent or empty.
1252    #[serde(default, skip_serializing_if = "Option::is_none")]
1253    pub image_key: Option<String>,
1254    /// Key for the URL the image links to (opens in new tab).
1255    #[serde(default, skip_serializing_if = "Option::is_none")]
1256    pub image_href_key: Option<String>,
1257    /// CSS aspect-ratio value for the image (default "4/5").
1258    #[serde(default, skip_serializing_if = "Option::is_none")]
1259    pub image_aspect_ratio: Option<String>,
1260    /// CSS object-position for the cropped image: "top" | "center" | "bottom"
1261    /// (or any valid object-position value). Default "center".
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub image_position: Option<String>,
1264    /// Key for the footer badge label text.
1265    #[serde(default, skip_serializing_if = "Option::is_none")]
1266    pub badge_key: Option<String>,
1267    /// Key for the badge tone string: "neutral" | "success" | "warning" | "destructive".
1268    #[serde(default, skip_serializing_if = "Option::is_none")]
1269    pub badge_tone_key: Option<String>,
1270    /// Key used for {row_key} substitution in row_action URLs.
1271    #[serde(default, skip_serializing_if = "Option::is_none")]
1272    pub row_key: Option<String>,
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    pub row_actions: Option<Vec<DropdownMenuAction>>,
1275    #[serde(default, skip_serializing_if = "Option::is_none")]
1276    pub empty_message: Option<String>,
1277    /// Number of columns in the grid (default 3).
1278    #[serde(default, skip_serializing_if = "Option::is_none")]
1279    pub columns: Option<u8>,
1280}
1281
1282/// Props for a single column (lane) in a KanbanBoard.
1283///
1284/// A column is structure: its `id` is the lane key matched against each
1285/// item's `group_by` value, and `title` is the lane header. `count` and
1286/// `children` are only honored by static specs that set neither
1287/// `KanbanBoardProps.items_path` nor `group_by`; in the data-bound path the
1288/// renderer computes the count and renders cards from `items_path`.
1289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1290pub struct KanbanColumnProps {
1291    pub id: String,
1292    pub title: String,
1293    #[serde(default)]
1294    pub count: u32,
1295    /// IDs of elements rendered inside this column (static specs only).
1296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1297    pub children: Vec<String>,
1298}
1299
1300/// Props for KanbanBoard — horizontal scrollable columns on desktop, tab-based
1301/// on mobile.
1302///
1303/// A kanban is fixed lanes plus items sorted into them by a status field.
1304/// `columns` is structure only (lane `id` + `title`) and is always rendered —
1305/// an empty lane still shows its header and a zero count. Card content is
1306/// data-bound: `items_path` resolves a flat array of entity objects, each
1307/// bucketed into the column whose `id` equals the item's `group_by` value,
1308/// then rendered as a card via the `card_*` / `row_*` bindings. This is the
1309/// same prescribed-card + field-key convention used by `DataTable` and
1310/// `MediaCardGrid`. For fully-custom card structure, template the cards with
1311/// the `$each` directive inside a `KanbanColumn` instead.
1312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1313pub struct KanbanBoardProps {
1314    /// Lane structure — `id` + `title`. Always rendered.
1315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316    pub columns: Vec<KanbanColumnProps>,
1317    /// JSON-Pointer to a flat array of entity objects. Each item is bucketed
1318    /// into the column whose `id` equals the item's `group_by` value.
1319    #[serde(default, skip_serializing_if = "Option::is_none")]
1320    pub items_path: Option<String>,
1321    /// Field on each item that selects its lane: `column.id == item[group_by]`.
1322    #[serde(default, skip_serializing_if = "Option::is_none")]
1323    pub group_by: Option<String>,
1324    /// Item field whose value becomes the card title.
1325    #[serde(default, skip_serializing_if = "Option::is_none")]
1326    pub card_title_key: Option<String>,
1327    /// Item field whose value becomes the card subtitle/description.
1328    #[serde(default, skip_serializing_if = "Option::is_none")]
1329    pub card_description_key: Option<String>,
1330    /// Per-card dropdown actions. `{row_key}` / `{id}` interpolate from the
1331    /// item, matching `DataTable` / `MediaCardGrid`.
1332    #[serde(default, skip_serializing_if = "Option::is_none")]
1333    pub row_actions: Option<Vec<DropdownMenuAction>>,
1334    /// Item field used for `{row_key}` substitution in action URLs
1335    /// (defaults to `id`).
1336    #[serde(default, skip_serializing_if = "Option::is_none")]
1337    pub row_key: Option<String>,
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub mobile_default_column: Option<String>,
1340    /// Placeholder text shown inside empty lanes. When `None`, empty lanes
1341    /// render no placeholder (back-compat). Provide a short, neutral message —
1342    /// e.g. "Nessun ordine", "Nothing here".
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub empty_label: Option<String>,
1345}
1346
1347/// Props for a calendar day cell.
1348///
1349/// Renders a single day in a month grid with today highlight,
1350/// out-of-month muting, and event count indicator.
1351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1352pub struct CalendarCellProps {
1353    pub day: u8,
1354    #[serde(default)]
1355    pub is_today: bool,
1356    #[serde(default)]
1357    pub is_current_month: bool,
1358    #[serde(default)]
1359    pub event_count: u32,
1360    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
1361    /// When non-empty, colored dots are rendered instead of plain primary dots.
1362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1363    pub dot_colors: Vec<String>,
1364    /// When true the day is marked closed (unavailable): a neutral diagonal hatch
1365    /// (repeating stripes) is drawn across the cell. Independent of `event_count`
1366    /// — a closed day may still carry existing bookings, so the dots still render.
1367    #[serde(default)]
1368    pub closed: bool,
1369}
1370
1371/// Props for a horizontal action card with tone-colored left border.
1372///
1373/// Renders icon + title + description + chevron in a clickable row.
1374/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
1375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1376pub struct ActionCardProps {
1377    pub title: String,
1378    pub description: String,
1379    #[serde(default, skip_serializing_if = "Option::is_none")]
1380    pub icon: Option<String>,
1381    #[serde(default)]
1382    pub tone: Tone,
1383    /// Optional navigation URL. When set, the card renders as an `<a>` element.
1384    #[serde(default, skip_serializing_if = "Option::is_none")]
1385    pub href: Option<String>,
1386}
1387
1388/// Props for a touch-friendly product tile with quantity controls.
1389///
1390/// Renders item name, price, and +/- buttons that drive a hidden input
1391/// via JS. Used for touch-first selection screens (e.g. POS-style order creation).
1392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1393pub struct TileProps {
1394    pub item_id: String,
1395    pub name: String,
1396    pub price: String,
1397    pub field: String,
1398    #[serde(default, skip_serializing_if = "Option::is_none")]
1399    pub default_quantity: Option<u32>,
1400    /// Category memberships for client-side filtering. Rendered by
1401    /// `render_tile` as a space-separated `data-filter-tokens` attribute,
1402    /// emitted only when non-empty; the `setupFilters` runtime reads it.
1403    /// Plural because an item may belong to several categories (a one-element
1404    /// vec covers the singular case).
1405    ///
1406    /// Token-list constraint: because the attribute is space-separated, spaces
1407    /// inside a category name are normalized to hyphens at render time
1408    /// (`"Bevande calde"` becomes the token `Bevande-calde`). Filter runtimes
1409    /// must apply the same normalization to category labels before matching.
1410    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1411    pub categories: Vec<String>,
1412    /// Optional item image URL. Declared here for the Phase 256 tile visual;
1413    /// not rendered in Phase 254 (D-03).
1414    #[serde(default, skip_serializing_if = "Option::is_none")]
1415    pub image_url: Option<String>,
1416    /// Optional accent tone for the tile border (Phase 256 visual, D-03).
1417    /// Maps through an exhaustive match to a full-literal border class in
1418    /// `render_tile`; `None` or `Neutral` → the default `border-border`.
1419    #[serde(default, skip_serializing_if = "Option::is_none")]
1420    pub color: Option<Tone>,
1421    /// Optional stock badge text (e.g. "Low", "Out"). Phase 256 visual (D-03).
1422    #[serde(default, skip_serializing_if = "Option::is_none")]
1423    pub stock_badge: Option<String>,
1424    /// Machine-readable unit price in integer cents. Rendered as
1425    /// `data-unit-price="{cents}"` on the tile root wrapper. The client-computed
1426    /// running total (SelectionPanel, Phase 256) reads this attribute because
1427    /// `price` is a display string that cannot be parsed. Both fields are
1428    /// expected to agree — the Phase 257 projector emits both from one source.
1429    /// The runtime treats a missing attribute as 0 cents. Integer cents only —
1430    /// never float (see PITFALLS.md).
1431    #[serde(default, skip_serializing_if = "Option::is_none")]
1432    pub price_cents: Option<u64>,
1433}
1434
1435/// Props for the TileGrid builtin — a touch-first, responsive tile grid
1436/// whose Tile children iterate via the `$each` contract (Phase 257 target).
1437/// Renderer + registration land in Phase 256; this is the contract only.
1438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1439pub struct TileGridProps {
1440    /// JSON pointer to the product array the grid iterates over via `$each`.
1441    pub data_path: String,
1442    /// The HTML `id` of the `Form` element that owns this grid's hidden
1443    /// inputs. Both the grid and its paired SelectionPanel must be descendants
1444    /// of that form (D-11): the selection runtime scopes its queries and its
1445    /// input-event listener to `document.getElementById(form_id)`, so tiles
1446    /// placed outside the form neither submit with it nor appear in the panel.
1447    /// Emitted as `data-selection-form="{form_id}"` on the grid root — the
1448    /// same attribute the SelectionPanel root carries — so the pairing is
1449    /// introspectable in markup.
1450    pub form_id: String,
1451    /// JSON pointer to a category string array for the integrated category strip.
1452    /// Absent → no category strip is rendered.
1453    #[serde(default, skip_serializing_if = "Option::is_none")]
1454    pub categories_path: Option<String>,
1455    /// Override for the base-viewport grid column count (Phase 256 render default is 2).
1456    #[serde(default, skip_serializing_if = "Option::is_none")]
1457    pub columns: Option<u8>,
1458    /// Enables the client-side text-search input (Phase 255 `setupFilters`).
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub search: Option<bool>,
1461    /// Placeholder text for the search input. Render default is "Search"
1462    /// (neutral English — this crate is project-agnostic). Pass
1463    /// `search_placeholder: "Cerca"` or any locale string from the consumer.
1464    /// Ignored when `search` is absent/false (no input is rendered).
1465    #[serde(default, skip_serializing_if = "Option::is_none")]
1466    pub search_placeholder: Option<String>,
1467    /// Label for the integrated category strip's "show all" tab. Render
1468    /// default is "All" (neutral English — this crate is project-agnostic).
1469    /// Pass `all_label: "Tutte"` or any locale string from the consumer.
1470    /// Ignored when `categories_path` is absent (no strip is rendered).
1471    #[serde(default, skip_serializing_if = "Option::is_none")]
1472    pub all_label: Option<String>,
1473}
1474
1475/// Props for the SelectionPanel builtin — a server-rendered selection summary that
1476/// pins and scrolls internally under `fill_viewport`. Renderer lands in Phase 256.
1477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1478pub struct SelectionPanelProps {
1479    /// Scope isolator matching the paired TileGrid `form_id`.
1480    pub form_id: String,
1481    /// Placeholder text shown by the EmptyState when the panel has no line items.
1482    #[serde(default, skip_serializing_if = "Option::is_none")]
1483    pub empty_message: Option<String>,
1484    /// Optional currency symbol (e.g. "€") emitted as `data-selection-currency`
1485    /// on the running-total element. Neutral default is no symbol — the runtime
1486    /// formats the integer-cents total with two decimals and a "." separator and
1487    /// prepends this symbol only when present. No locale tables; display only.
1488    #[serde(default, skip_serializing_if = "Option::is_none")]
1489    pub currency: Option<String>,
1490    /// Label for the running-total row. Render default is "Total" (neutral
1491    /// English — this crate is project-agnostic). Pass `total_label:
1492    /// "Totale"` or any locale string from the consumer.
1493    #[serde(default, skip_serializing_if = "Option::is_none")]
1494    pub total_label: Option<String>,
1495}
1496
1497/// Props for the FilterTabs builtin (standalone builtin, operator-locked).
1498/// Filters visible tiles client-side via `data-filter-tokens` matching
1499/// (Phase 255 runtime). Renderer lands in Phase 256.
1500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1501pub struct FilterTabsProps {
1502    /// Category labels rendered as filter tabs. May be `$data`-bound at render
1503    /// time. Matching against `data-filter-tokens` must normalize spaces to
1504    /// hyphens, mirroring `TileProps::categories` rendering.
1505    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1506    pub items: Vec<String>,
1507    /// Label for the "show all" tab. Phase 256 render default is "All" (neutral
1508    /// English — this crate is project-agnostic). Pass `all_label: "Tutte"` or
1509    /// any locale string from the consumer.
1510    #[serde(default, skip_serializing_if = "Option::is_none")]
1511    pub all_label: Option<String>,
1512}
1513
1514/// Props for the QuantityStepper POS builtin — a reusable +/- stepper driving a
1515/// hidden input on the Tile contract. Renderer lands in Phase 256.
1516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1517pub struct QuantityStepperProps {
1518    /// Name of the hidden input this stepper increments/decrements.
1519    pub field: String,
1520    /// Lower bound (Phase 256 render default is 0).
1521    #[serde(default, skip_serializing_if = "Option::is_none")]
1522    pub min: Option<u32>,
1523    /// Upper bound; unbounded when absent.
1524    #[serde(default, skip_serializing_if = "Option::is_none")]
1525    pub max: Option<u32>,
1526    /// Increment size (Phase 256 render default is 1).
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub step: Option<u32>,
1529}
1530
1531/// Input mode for the Numpad — governs which characters the keypad accepts.
1532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1533#[serde(rename_all = "snake_case")]
1534pub enum NumpadMode {
1535    /// Integer entry only.
1536    #[default]
1537    Quantity,
1538    /// Two-decimal-place monetary entry.
1539    Price,
1540}
1541
1542/// Props for the Numpad POS builtin — a tap-surface numeric keypad that writes to a
1543/// target field and NEVER renders a native input (so the software keyboard is never
1544/// triggered). Renderer lands in Phase 256.
1545#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1546pub struct NumpadProps {
1547    /// Name of the input this numpad writes into.
1548    pub target_field: String,
1549    /// Entry mode (quantity | price). Defaults to quantity.
1550    #[serde(default)]
1551    pub mode: NumpadMode,
1552}
1553
1554/// Lax deserializer for PageHeader.actions. Per D-19/F6:
1555/// Accepts: missing field (via #[serde(default)]), null, [], empty string "",
1556/// and array of strings. Rejects: non-empty strings, arrays of non-strings.
1557/// This loosens the wire-format contract for actions only — other Vec<String>
1558/// ID-slot fields (e.g. CardProps.footer) remain strict.
1559fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1560    d: D,
1561) -> Result<Vec<String>, D::Error> {
1562    use serde::de::Error;
1563    let v = serde_json::Value::deserialize(d)?;
1564    match v {
1565        serde_json::Value::Null => Ok(Vec::new()),
1566        serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1567        serde_json::Value::Array(arr) => arr
1568            .into_iter()
1569            .map(|item| {
1570                item.as_str()
1571                    .map(String::from)
1572                    .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1573            })
1574            .collect(),
1575        other => Err(D::Error::custom(format!(
1576            "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1577        ))),
1578    }
1579}
1580
1581#[cfg(test)]
1582mod schema_smoke_tests {
1583    //! Runtime `schema_for!` smoke tests per D-32.
1584    //!
1585    //! Each test asserts that the generated JSON Schema for the given Props
1586    //! struct is a non-empty JSON object with a populated `properties` field.
1587    //! This proves the `JsonSchema` derive executes without panic on every
1588    //! surviving Props struct — a compile-time `#[derive(JsonSchema)]` alone
1589    //! does not prove the generated code runs.
1590    //!
1591    //! One `#[test]` per type for clear failure localization.
1592
1593    use super::*;
1594
1595    fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1596        let schema = schemars::schema_for!(T);
1597        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1598        assert!(
1599            value.is_object(),
1600            "{type_label}: schema must be a JSON object"
1601        );
1602        let props = value
1603            .get("properties")
1604            .and_then(|p| p.as_object())
1605            .map(|o| !o.is_empty())
1606            .unwrap_or(false);
1607        assert!(
1608            props,
1609            "{type_label}: schema must have a non-empty `properties` field"
1610        );
1611    }
1612
1613    #[test]
1614    fn schema_for_card_props_generates() {
1615        assert_schema_nonempty_object::<CardProps>("CardProps");
1616    }
1617
1618    #[test]
1619    fn schema_for_table_props_generates() {
1620        assert_schema_nonempty_object::<TableProps>("TableProps");
1621    }
1622
1623    #[test]
1624    fn schema_for_form_props_generates() {
1625        assert_schema_nonempty_object::<FormProps>("FormProps");
1626    }
1627
1628    #[test]
1629    fn schema_for_button_props_generates() {
1630        assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1631    }
1632
1633    #[test]
1634    fn schema_for_input_props_generates() {
1635        assert_schema_nonempty_object::<InputProps>("InputProps");
1636    }
1637
1638    #[test]
1639    fn schema_for_select_props_generates() {
1640        assert_schema_nonempty_object::<SelectProps>("SelectProps");
1641    }
1642
1643    #[test]
1644    fn schema_for_alert_props_generates() {
1645        assert_schema_nonempty_object::<AlertProps>("AlertProps");
1646    }
1647
1648    #[test]
1649    fn schema_for_badge_props_generates() {
1650        assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1651    }
1652
1653    #[test]
1654    fn schema_for_modal_props_generates() {
1655        assert_schema_nonempty_object::<ModalProps>("ModalProps");
1656    }
1657
1658    #[test]
1659    fn schema_for_text_props_generates() {
1660        assert_schema_nonempty_object::<TextProps>("TextProps");
1661    }
1662
1663    #[test]
1664    fn schema_for_checkbox_props_generates() {
1665        assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1666    }
1667
1668    #[test]
1669    fn schema_for_switch_props_generates() {
1670        assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1671    }
1672
1673    #[test]
1674    fn schema_for_separator_props_generates() {
1675        assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1676    }
1677
1678    #[test]
1679    fn schema_for_description_list_props_generates() {
1680        assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1681    }
1682
1683    #[test]
1684    fn schema_for_tab_generates() {
1685        assert_schema_nonempty_object::<Tab>("Tab");
1686    }
1687
1688    #[test]
1689    fn schema_for_tabs_props_generates() {
1690        assert_schema_nonempty_object::<TabsProps>("TabsProps");
1691    }
1692
1693    #[test]
1694    fn schema_for_breadcrumb_props_generates() {
1695        assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1696    }
1697
1698    #[test]
1699    fn schema_for_pagination_props_generates() {
1700        assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1701    }
1702
1703    #[test]
1704    fn schema_for_progress_props_generates() {
1705        assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1706    }
1707
1708    #[test]
1709    fn schema_for_image_props_generates() {
1710        assert_schema_nonempty_object::<ImageProps>("ImageProps");
1711    }
1712
1713    #[test]
1714    fn image_inline_svg_factory_roundtrips_via_serde() {
1715        let p = ImageProps::inline_svg("<svg/>", "alt");
1716        let json = serde_json::to_value(&p).expect("serialization must not fail");
1717        let parsed: ImageProps =
1718            serde_json::from_value(json).expect("deserialization must not fail");
1719        assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1720        assert_eq!(parsed.alt, "alt");
1721        assert_eq!(parsed.src, "");
1722    }
1723
1724    #[test]
1725    fn schema_for_avatar_props_generates() {
1726        assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1727    }
1728
1729    #[test]
1730    fn schema_for_skeleton_props_generates() {
1731        assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1732    }
1733
1734    #[test]
1735    fn schema_for_stat_card_props_generates() {
1736        assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1737    }
1738
1739    #[test]
1740    fn schema_for_checklist_props_generates() {
1741        assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1742    }
1743
1744    #[test]
1745    fn schema_for_toast_props_generates() {
1746        assert_schema_nonempty_object::<ToastProps>("ToastProps");
1747    }
1748
1749    #[test]
1750    fn schema_for_notification_dropdown_props_generates() {
1751        assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1752    }
1753
1754    #[test]
1755    fn schema_for_sidebar_props_generates() {
1756        assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1757    }
1758
1759    #[test]
1760    fn schema_for_header_props_generates() {
1761        assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1762    }
1763
1764    #[test]
1765    fn schema_for_grid_props_generates() {
1766        assert_schema_nonempty_object::<GridProps>("GridProps");
1767    }
1768
1769    #[test]
1770    fn schema_for_collapsible_props_generates() {
1771        assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1772    }
1773
1774    #[test]
1775    fn schema_for_empty_state_props_generates() {
1776        assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1777    }
1778
1779    #[test]
1780    fn schema_for_form_section_props_generates() {
1781        assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1782    }
1783
1784    #[test]
1785    fn schema_for_page_header_props_generates() {
1786        assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1787    }
1788
1789    #[test]
1790    fn schema_for_button_group_props_generates() {
1791        assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1792    }
1793
1794    #[test]
1795    fn schema_for_action_item_generates() {
1796        assert_schema_nonempty_object::<ActionItem>("ActionItem");
1797    }
1798
1799    #[test]
1800    fn schema_for_action_group_props_generates() {
1801        assert_schema_nonempty_object::<ActionGroupProps>("ActionGroupProps");
1802    }
1803
1804    #[test]
1805    fn schema_for_dropdown_menu_action_generates() {
1806        assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1807    }
1808
1809    #[test]
1810    fn schema_for_data_table_props_generates() {
1811        assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1812    }
1813
1814    #[test]
1815    fn schema_for_kanban_column_props_generates() {
1816        assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1817    }
1818
1819    #[test]
1820    fn schema_for_kanban_board_props_generates() {
1821        assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1822    }
1823
1824    #[test]
1825    fn schema_for_calendar_cell_props_generates() {
1826        assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1827    }
1828
1829    #[test]
1830    fn schema_for_action_card_props_generates() {
1831        assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1832    }
1833
1834    #[test]
1835    fn schema_for_tile_props_generates() {
1836        assert_schema_nonempty_object::<TileProps>("TileProps");
1837    }
1838
1839    #[test]
1840    fn card_props_round_trips_footer() {
1841        let original = CardProps {
1842            title: "Hero".to_string(),
1843            description: None,
1844            subtitle: None,
1845            badge: None,
1846            max_width: None,
1847            footer: vec!["btn1".to_string(), "btn2".to_string()],
1848            appearance: CardAppearance::Bordered,
1849        };
1850        let json = serde_json::to_string(&original).unwrap();
1851        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1852        assert_eq!(original.footer, parsed.footer);
1853    }
1854
1855    #[test]
1856    fn tab_round_trips_children() {
1857        let original = Tab {
1858            value: "overview".to_string(),
1859            label: "Overview".to_string(),
1860            children: vec!["panel1".to_string()],
1861        };
1862        let json = serde_json::to_string(&original).unwrap();
1863        let parsed: Tab = serde_json::from_str(&json).unwrap();
1864        assert_eq!(original.children, parsed.children);
1865    }
1866
1867    #[test]
1868    fn card_props_omits_empty_footer_in_json() {
1869        let card = CardProps {
1870            title: "Card".to_string(),
1871            description: None,
1872            subtitle: None,
1873            badge: None,
1874            max_width: None,
1875            footer: Vec::new(),
1876            appearance: CardAppearance::Bordered,
1877        };
1878        let json = serde_json::to_string(&card).unwrap();
1879        assert!(
1880            !json.contains("\"footer\""),
1881            "empty footer must be skipped, got: {json}"
1882        );
1883    }
1884
1885    #[test]
1886    fn card_props_round_trips_badge() {
1887        let original = CardProps {
1888            title: "Hero".to_string(),
1889            description: None,
1890            subtitle: None,
1891            badge: Some("Scade tra 9m".to_string()),
1892            max_width: None,
1893            footer: Vec::new(),
1894            appearance: CardAppearance::Bordered,
1895        };
1896        let json = serde_json::to_string(&original).unwrap();
1897        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1898        assert_eq!(original.badge, parsed.badge);
1899    }
1900
1901    #[test]
1902    fn card_props_omits_empty_badge_in_json() {
1903        let card = CardProps {
1904            title: "Card".to_string(),
1905            description: None,
1906            subtitle: None,
1907            badge: None,
1908            max_width: None,
1909            footer: Vec::new(),
1910            appearance: CardAppearance::Bordered,
1911        };
1912        let json = serde_json::to_string(&card).unwrap();
1913        assert!(
1914            !json.contains("\"badge\""),
1915            "empty badge must be skipped, got: {json}"
1916        );
1917    }
1918
1919    #[test]
1920    fn card_props_round_trips_subtitle() {
1921        let original = CardProps {
1922            title: "Hero".to_string(),
1923            description: None,
1924            subtitle: Some("Marco Rossi".to_string()),
1925            badge: None,
1926            max_width: None,
1927            footer: Vec::new(),
1928            appearance: CardAppearance::Bordered,
1929        };
1930        let json = serde_json::to_string(&original).unwrap();
1931        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1932        assert_eq!(original.subtitle, parsed.subtitle);
1933    }
1934
1935    #[test]
1936    fn card_props_omits_empty_subtitle_in_json() {
1937        let card = CardProps {
1938            title: "Card".to_string(),
1939            description: None,
1940            subtitle: None,
1941            badge: None,
1942            max_width: None,
1943            footer: Vec::new(),
1944            appearance: CardAppearance::Bordered,
1945        };
1946        let json = serde_json::to_string(&card).unwrap();
1947        assert!(
1948            !json.contains("\"subtitle\""),
1949            "empty subtitle must be skipped, got: {json}"
1950        );
1951    }
1952
1953    #[test]
1954    fn card_props_schema_includes_badge() {
1955        let schema = schemars::schema_for!(CardProps);
1956        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1957        let props = value
1958            .get("properties")
1959            .and_then(|p| p.as_object())
1960            .expect("schema has a properties object");
1961        assert!(
1962            props.contains_key("badge"),
1963            "CardProps schema must expose a `badge` property; got keys: {:?}",
1964            props.keys().collect::<Vec<_>>()
1965        );
1966        // `badge: Option<String>` — schemars 1.x emits either {"type": ["string","null"]}
1967        // or a {"type":"string"} entry inside a oneOf/anyOf branch. We only assert
1968        // presence + that the rendered schema mentions a string somewhere under
1969        // the badge entry, which is robust to either encoding.
1970        let badge_schema = props.get("badge").expect("badge entry");
1971        let badge_json = badge_schema.to_string();
1972        assert!(
1973            badge_json.contains("\"string\""),
1974            "badge schema entry must mention string type; got: {badge_json}"
1975        );
1976    }
1977
1978    #[test]
1979    fn card_props_schema_includes_subtitle() {
1980        let schema = schemars::schema_for!(CardProps);
1981        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1982        let props = value
1983            .get("properties")
1984            .and_then(|p| p.as_object())
1985            .expect("schema has a properties object");
1986        assert!(
1987            props.contains_key("subtitle"),
1988            "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1989            props.keys().collect::<Vec<_>>()
1990        );
1991        // Same robustness note as `card_props_schema_includes_badge` —
1992        // `subtitle: Option<String>` may surface as type-union or oneOf depending
1993        // on the schemars version. Assert string is mentioned in the rendered
1994        // entry rather than locking down the exact null encoding.
1995        let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1996        let subtitle_json = subtitle_schema.to_string();
1997        assert!(
1998            subtitle_json.contains("\"string\""),
1999            "subtitle schema entry must mention string type; got: {subtitle_json}"
2000        );
2001    }
2002
2003    #[test]
2004    fn schema_for_checkbox_list_props_generates() {
2005        assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
2006    }
2007
2008    #[test]
2009    fn checkbox_list_props_serde_roundtrip() {
2010        let json = serde_json::json!({
2011            "field": "services",
2012            "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
2013            "selected_path": "/preselected"
2014        });
2015        let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
2016        assert_eq!(parsed.field, "services");
2017        assert_eq!(parsed.options.len(), 2);
2018        assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
2019        let reserialized = serde_json::to_value(&parsed).expect("encode");
2020        // None/empty fields are omitted by serde.
2021        assert!(reserialized.get("label").is_none());
2022        assert!(reserialized.get("disabled").is_none());
2023    }
2024
2025    #[test]
2026    fn schema_for_rich_text_editor_props_generates() {
2027        assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
2028    }
2029
2030    #[test]
2031    fn rich_text_editor_props_serde_roundtrip() {
2032        let json = serde_json::json!({
2033            "field": "body",
2034            "label": "Body"
2035        });
2036        let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
2037        assert_eq!(parsed.field, "body");
2038        assert_eq!(parsed.label, "Body");
2039        assert!(parsed.placeholder.is_none());
2040        assert!(parsed.default_value.is_none());
2041        assert!(parsed.data_path.is_none());
2042        assert!(parsed.error.is_none());
2043        let reserialized = serde_json::to_value(&parsed).expect("encode");
2044        // Optional None fields are omitted.
2045        assert!(reserialized.get("placeholder").is_none());
2046        assert!(reserialized.get("error").is_none());
2047    }
2048
2049    #[test]
2050    fn schema_for_tile_grid_props_generates() {
2051        assert_schema_nonempty_object::<TileGridProps>("TileGridProps");
2052    }
2053
2054    #[test]
2055    fn schema_for_selection_panel_props_generates() {
2056        assert_schema_nonempty_object::<SelectionPanelProps>("SelectionPanelProps");
2057    }
2058
2059    #[test]
2060    fn schema_for_filter_tabs_props_generates() {
2061        assert_schema_nonempty_object::<FilterTabsProps>("FilterTabsProps");
2062    }
2063
2064    #[test]
2065    fn schema_for_quantity_stepper_props_generates() {
2066        assert_schema_nonempty_object::<QuantityStepperProps>("QuantityStepperProps");
2067    }
2068
2069    #[test]
2070    fn schema_for_numpad_props_generates() {
2071        assert_schema_nonempty_object::<NumpadProps>("NumpadProps");
2072    }
2073}
2074
2075#[cfg(test)]
2076mod strum_tests {
2077    use super::*;
2078
2079    use strum::VariantArray;
2080
2081    /// Assert AsRef<str> matches serde JSON wire format for EVERY variant of
2082    /// the canonical `Variant`, `Tone`, and `Size` enums.
2083    /// Threat T-162-08-01: strum and serde must agree on every snake_case string.
2084    /// The variant lists come from `strum::VariantArray`, so omitting a
2085    /// variant (the pre-251 `BadgeVariant::Warning` gap) is structurally
2086    /// impossible.
2087    #[test]
2088    fn variant_enums_strum_matches_serde_wire_format() {
2089        fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
2090            for v in variants {
2091                let json = serde_json::to_string(v).expect("serialize");
2092                let json_stripped = json.trim_matches('"');
2093                assert_eq!(
2094                    v.as_ref(),
2095                    json_stripped,
2096                    "strum AsRefStr drifted from serde for {label} variant"
2097                );
2098            }
2099        }
2100        check(Variant::VARIANTS, "Variant");
2101        check(Tone::VARIANTS, "Tone");
2102        check(Size::VARIANTS, "Size");
2103    }
2104
2105    /// Pin the canonical value-set sizes so an added/removed variant is a
2106    /// conscious decision (the D-19 schema guard in Plan 03 walks the catalog;
2107    /// this is the enum-side anchor).
2108    #[test]
2109    fn canonical_enums_have_expected_variant_counts() {
2110        assert_eq!(Variant::VARIANTS.len(), 5);
2111        assert_eq!(Tone::VARIANTS.len(), 4);
2112        assert_eq!(Size::VARIANTS.len(), 3);
2113    }
2114
2115    #[test]
2116    fn tone_as_ref_str_matches_wire_format() {
2117        assert_eq!(Tone::Neutral.as_ref(), "neutral");
2118        assert_eq!(Tone::Success.as_ref(), "success");
2119        assert_eq!(Tone::Warning.as_ref(), "warning");
2120        assert_eq!(Tone::Destructive.as_ref(), "destructive");
2121    }
2122}
2123
2124#[cfg(test)]
2125mod canonical_enum_tests {
2126    use super::*;
2127
2128    // ── Defaults ────────────────────────────────────────────────────────
2129
2130    #[test]
2131    fn variant_default_is_primary() {
2132        assert_eq!(Variant::default(), Variant::Primary);
2133    }
2134
2135    #[test]
2136    fn tone_default_is_neutral() {
2137        assert_eq!(Tone::default(), Tone::Neutral);
2138    }
2139
2140    #[test]
2141    fn size_default_is_md() {
2142        assert_eq!(Size::default(), Size::Md);
2143    }
2144
2145    // ── snake_case wire format (serialize + deserialize + roundtrip) ───
2146
2147    #[test]
2148    fn variant_serde_snake_case_roundtrip() {
2149        use strum::VariantArray;
2150        for v in Variant::VARIANTS {
2151            let json = serde_json::to_value(v).unwrap();
2152            assert_eq!(json, serde_json::json!(v.as_ref()));
2153            let back: Variant = serde_json::from_value(json).unwrap();
2154            assert_eq!(back, *v);
2155        }
2156        assert_eq!(
2157            serde_json::from_str::<Variant>("\"primary\"").unwrap(),
2158            Variant::Primary
2159        );
2160    }
2161
2162    #[test]
2163    fn tone_serde_snake_case_roundtrip() {
2164        use strum::VariantArray;
2165        for t in Tone::VARIANTS {
2166            let json = serde_json::to_value(t).unwrap();
2167            assert_eq!(json, serde_json::json!(t.as_ref()));
2168            let back: Tone = serde_json::from_value(json).unwrap();
2169            assert_eq!(back, *t);
2170        }
2171    }
2172
2173    #[test]
2174    fn size_serde_snake_case_roundtrip() {
2175        use strum::VariantArray;
2176        for s in Size::VARIANTS {
2177            let json = serde_json::to_value(s).unwrap();
2178            assert_eq!(json, serde_json::json!(s.as_ref()));
2179            let back: Size = serde_json::from_value(json).unwrap();
2180            assert_eq!(back, *s);
2181        }
2182        assert!(serde_json::from_str::<Size>("\"md\"").is_ok());
2183        assert!(serde_json::from_str::<Size>("\"sm\"").is_ok());
2184        assert!(serde_json::from_str::<Size>("\"lg\"").is_ok());
2185    }
2186
2187    // ── Retired values are rejected at parse (D-12: no serde aliases) ──
2188
2189    #[test]
2190    fn retired_size_values_are_rejected() {
2191        assert!(
2192            serde_json::from_str::<Size>("\"xs\"").is_err(),
2193            "size 'xs' was retired (migrate to 'sm')"
2194        );
2195        assert!(
2196            serde_json::from_str::<Size>("\"default\"").is_err(),
2197            "size 'default' was retired (migrate to 'md')"
2198        );
2199    }
2200
2201    #[test]
2202    fn retired_variant_values_are_rejected() {
2203        assert!(
2204            serde_json::from_str::<Variant>("\"default\"").is_err(),
2205            "variant 'default' was retired (migrate to 'primary')"
2206        );
2207        assert!(
2208            serde_json::from_str::<Variant>("\"link\"").is_err(),
2209            "variant 'link' was removed (migrate to 'ghost')"
2210        );
2211    }
2212
2213    #[test]
2214    fn retired_tone_values_are_rejected() {
2215        assert!(
2216            serde_json::from_str::<Tone>("\"info\"").is_err(),
2217            "tone 'info' was retired (migrate to 'neutral')"
2218        );
2219        assert!(
2220            serde_json::from_str::<Tone>("\"error\"").is_err(),
2221            "tone 'error' was retired (migrate to 'destructive')"
2222        );
2223    }
2224
2225    #[test]
2226    fn button_spec_with_link_variant_fails_to_decode() {
2227        let v = serde_json::json!({"variant": "link", "label": "x"});
2228        assert!(
2229            serde_json::from_value::<ButtonProps>(v).is_err(),
2230            "Button variant 'link' must fail decode (migrate to 'ghost')"
2231        );
2232    }
2233
2234    // ── Props defaults ──────────────────────────────────────────────────
2235
2236    #[test]
2237    fn button_props_defaults_to_primary_md() {
2238        let v = serde_json::json!({"label": "x"});
2239        let p: ButtonProps = serde_json::from_value(v).unwrap();
2240        assert_eq!(p.variant, Variant::Primary);
2241        assert_eq!(p.size, Size::Md);
2242    }
2243
2244    #[test]
2245    fn alert_props_without_tone_defaults_to_neutral() {
2246        let v = serde_json::json!({"message": "x"});
2247        let p: AlertProps = serde_json::from_value(v).unwrap();
2248        assert_eq!(p.tone, Tone::Neutral);
2249    }
2250
2251    #[test]
2252    fn alert_props_with_tone_neutral_decodes() {
2253        let v = serde_json::json!({"message": "x", "tone": "neutral"});
2254        let p: AlertProps = serde_json::from_value(v).unwrap();
2255        assert_eq!(p.tone, Tone::Neutral);
2256    }
2257
2258    #[test]
2259    fn badge_props_without_tone_defaults_to_neutral() {
2260        let v = serde_json::json!({"label": "x"});
2261        let p: BadgeProps = serde_json::from_value(v).unwrap();
2262        assert_eq!(p.tone, Tone::Neutral);
2263    }
2264
2265    #[test]
2266    fn toast_props_without_tone_defaults_to_neutral() {
2267        let v = serde_json::json!({"message": "x"});
2268        let p: ToastProps = serde_json::from_value(v).unwrap();
2269        assert_eq!(p.tone, Tone::Neutral);
2270    }
2271
2272    #[test]
2273    fn action_card_props_with_success_tone_decodes() {
2274        let v = serde_json::json!({"title": "x", "description": "y", "tone": "success"});
2275        let p: ActionCardProps = serde_json::from_value(v).unwrap();
2276        assert_eq!(p.tone, Tone::Success);
2277    }
2278
2279    #[test]
2280    fn stat_card_props_without_tone_defaults_to_neutral() {
2281        let v = serde_json::json!({"label": "x", "value": "1"});
2282        let p: StatCardProps = serde_json::from_value(v).unwrap();
2283        assert_eq!(p.tone, Tone::Neutral);
2284    }
2285
2286    #[test]
2287    fn stat_card_props_roundtrip_preserves_tone() {
2288        let v = serde_json::json!({"label": "x", "value": "1", "tone": "warning"});
2289        let p: StatCardProps = serde_json::from_value(v).unwrap();
2290        assert_eq!(p.tone, Tone::Warning);
2291        let j = serde_json::to_value(&p).unwrap();
2292        let back: StatCardProps = serde_json::from_value(j).unwrap();
2293        assert_eq!(back.tone, Tone::Warning);
2294    }
2295}
2296
2297#[cfg(test)]
2298mod card_appearance_tests {
2299    use super::*;
2300
2301    #[test]
2302    fn card_appearance_default_is_bordered() {
2303        assert_eq!(CardAppearance::default(), CardAppearance::Bordered);
2304    }
2305
2306    #[test]
2307    fn card_appearance_serializes_snake_case() {
2308        assert_eq!(
2309            serde_json::to_value(CardAppearance::Bordered).unwrap(),
2310            serde_json::json!("bordered")
2311        );
2312        assert_eq!(
2313            serde_json::to_value(CardAppearance::Elevated).unwrap(),
2314            serde_json::json!("elevated")
2315        );
2316    }
2317
2318    #[test]
2319    fn card_appearance_deserializes_snake_case() {
2320        assert_eq!(
2321            serde_json::from_value::<CardAppearance>(serde_json::json!("bordered")).unwrap(),
2322            CardAppearance::Bordered
2323        );
2324        assert_eq!(
2325            serde_json::from_value::<CardAppearance>(serde_json::json!("elevated")).unwrap(),
2326            CardAppearance::Elevated
2327        );
2328    }
2329
2330    #[test]
2331    fn card_props_without_appearance_defaults_to_bordered() {
2332        let v = serde_json::json!({"title": "x"});
2333        let p: CardProps = serde_json::from_value(v).unwrap();
2334        assert_eq!(p.appearance, CardAppearance::Bordered);
2335    }
2336
2337    #[test]
2338    fn card_props_with_elevated_appearance() {
2339        let v = serde_json::json!({"title": "x", "appearance": "elevated"});
2340        let p: CardProps = serde_json::from_value(v).unwrap();
2341        assert_eq!(p.appearance, CardAppearance::Elevated);
2342    }
2343
2344    #[test]
2345    fn card_props_roundtrip_preserves_appearance() {
2346        let p = CardProps {
2347            title: "x".into(),
2348            description: None,
2349            subtitle: None,
2350            badge: None,
2351            max_width: None,
2352            footer: vec![],
2353            appearance: CardAppearance::Elevated,
2354        };
2355        let j = serde_json::to_value(&p).unwrap();
2356        let back: CardProps = serde_json::from_value(j).unwrap();
2357        assert_eq!(back.appearance, CardAppearance::Elevated);
2358    }
2359}
2360
2361#[cfg(test)]
2362mod kanban_board_props_tests {
2363    use super::*;
2364
2365    #[test]
2366    fn kanban_board_props_serde_static_columns() {
2367        let v = serde_json::json!({
2368            "columns": [{"title": "To Do", "id": "todo", "count": 0}]
2369        });
2370        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2371        assert_eq!(p.columns.len(), 1);
2372        assert!(p.items_path.is_none());
2373        assert!(p.group_by.is_none());
2374    }
2375
2376    #[test]
2377    fn kanban_board_props_serde_data_bound() {
2378        let v = serde_json::json!({
2379            "columns": [{"title": "Open", "id": "open"}],
2380            "items_path": "/data/order",
2381            "group_by": "status",
2382            "card_title_key": "name"
2383        });
2384        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2385        assert_eq!(p.columns.len(), 1);
2386        assert_eq!(p.items_path.as_deref(), Some("/data/order"));
2387        assert_eq!(p.group_by.as_deref(), Some("status"));
2388        assert_eq!(p.card_title_key.as_deref(), Some("name"));
2389    }
2390
2391    #[test]
2392    fn kanban_board_props_serde_neither() {
2393        let v = serde_json::json!({});
2394        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2395        assert!(p.columns.is_empty());
2396        assert!(p.items_path.is_none());
2397        assert!(p.group_by.is_none());
2398    }
2399
2400    #[test]
2401    fn kanban_board_props_empty_columns_skipped_on_serialize() {
2402        let p = KanbanBoardProps {
2403            columns: vec![],
2404            items_path: Some("/data/order".into()),
2405            group_by: Some("status".into()),
2406            card_title_key: None,
2407            card_description_key: None,
2408            row_actions: None,
2409            row_key: None,
2410            mobile_default_column: None,
2411            empty_label: None,
2412        };
2413        let j = serde_json::to_value(&p).unwrap();
2414        assert!(
2415            j.get("columns").is_none(),
2416            "empty columns must be skipped, got: {j}"
2417        );
2418        assert_eq!(
2419            j.get("items_path").and_then(|v| v.as_str()),
2420            Some("/data/order")
2421        );
2422    }
2423}
2424
2425#[cfg(test)]
2426mod page_header_actions_tests {
2427    use super::*;
2428
2429    #[test]
2430    fn page_header_actions_missing_field() {
2431        let v = serde_json::json!({"title": "X"});
2432        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2433        assert!(p.actions.is_empty());
2434    }
2435
2436    #[test]
2437    fn page_header_actions_null() {
2438        let v = serde_json::json!({"title": "X", "actions": null});
2439        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2440        assert!(p.actions.is_empty());
2441    }
2442
2443    #[test]
2444    fn page_header_actions_empty_string() {
2445        let v = serde_json::json!({"title": "X", "actions": ""});
2446        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2447        assert!(p.actions.is_empty());
2448    }
2449
2450    #[test]
2451    fn page_header_actions_empty_array() {
2452        let v = serde_json::json!({"title": "X", "actions": []});
2453        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2454        assert!(p.actions.is_empty());
2455    }
2456
2457    #[test]
2458    fn page_header_actions_non_empty_array() {
2459        let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
2460        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2461        assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
2462    }
2463
2464    #[test]
2465    fn page_header_actions_non_empty_string_rejected() {
2466        let v = serde_json::json!({"title": "X", "actions": "not-empty"});
2467        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2468        assert!(result.is_err(), "non-empty string must be rejected");
2469    }
2470
2471    #[test]
2472    fn page_header_actions_non_string_array_rejected() {
2473        let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
2474        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2475        assert!(result.is_err(), "array of non-strings must be rejected");
2476    }
2477}
2478
2479#[cfg(test)]
2480mod tile_contract_tests {
2481    //! Backward-compatibility and field-contract tests for TileProps and GridProps.
2482    //! RED-phase guard: these tests are written before the new fields exist and must
2483    //! fail to compile until the GREEN-phase fields are added.
2484
2485    use super::*;
2486
2487    /// Legacy Tile JSON (no new fields) must deserialize cleanly and
2488    /// re-serialize without emitting the new keys — SC-1 / D-04 backward-compat.
2489    #[test]
2490    fn tile_legacy_json_round_trips_unchanged() {
2491        let json = r#"{"item_id":"p1","name":"Widget","price":"€10,00","field":"qty_p1"}"#;
2492        let tile: TileProps = serde_json::from_str(json).expect("legacy json must deserialize");
2493        assert!(
2494            tile.categories.is_empty(),
2495            "categories must default to empty vec"
2496        );
2497        assert!(tile.image_url.is_none(), "image_url must default to None");
2498        assert!(tile.color.is_none(), "color must default to None");
2499        assert!(
2500            tile.stock_badge.is_none(),
2501            "stock_badge must default to None"
2502        );
2503        let serialized = serde_json::to_string(&tile).expect("must serialize");
2504        assert!(
2505            !serialized.contains("categories"),
2506            "re-serialized must not contain 'categories'; got: {serialized}"
2507        );
2508        assert!(
2509            !serialized.contains("image_url"),
2510            "re-serialized must not contain 'image_url'; got: {serialized}"
2511        );
2512        assert!(
2513            !serialized.contains("color"),
2514            "re-serialized must not contain 'color'; got: {serialized}"
2515        );
2516        assert!(
2517            !serialized.contains("stock_badge"),
2518            "re-serialized must not contain 'stock_badge'; got: {serialized}"
2519        );
2520    }
2521
2522    /// TileProps with categories set must re-serialize with the categories key present.
2523    #[test]
2524    fn tile_with_categories_serializes() {
2525        let tile = TileProps {
2526            item_id: "p2".to_string(),
2527            name: "Espresso".to_string(),
2528            price: "\u{20ac}2,00".to_string(),
2529            field: "qty_p2".to_string(),
2530            default_quantity: None,
2531            categories: vec!["drinks".to_string(), "food".to_string()],
2532            image_url: None,
2533            color: None,
2534            stock_badge: None,
2535            price_cents: None,
2536        };
2537        let serialized = serde_json::to_string(&tile).expect("must serialize");
2538        assert!(
2539            serialized.contains(r#""categories":["drinks","food"]"#),
2540            "serialized must contain categories array; got: {serialized}"
2541        );
2542    }
2543
2544    /// GridProps with empty row_weights omits the key; with weights set, round-trips.
2545    #[test]
2546    fn grid_props_row_weights_round_trips() {
2547        // Empty row_weights must be skipped in serialization.
2548        let default_grid: GridProps = serde_json::from_value(serde_json::json!({}))
2549            .expect("must deserialize default GridProps");
2550        let json = serde_json::to_string(&default_grid).expect("must serialize");
2551        assert!(
2552            !json.contains("row_weights"),
2553            "empty row_weights must be skipped in serialization; got: {json}"
2554        );
2555
2556        // Non-empty row_weights must appear in serialization and round-trip.
2557        let with_weights: GridProps =
2558            serde_json::from_value(serde_json::json!({"row_weights": [2, 1]}))
2559                .expect("must deserialize GridProps with row_weights");
2560        let json2 = serde_json::to_string(&with_weights).expect("must serialize with weights");
2561        assert!(
2562            json2.contains(r#""row_weights":[2,1]"#),
2563            "row_weights must appear in serialization; got: {json2}"
2564        );
2565        let parsed: GridProps =
2566            serde_json::from_str(&json2).expect("must deserialize from serialized");
2567        assert_eq!(
2568            parsed.row_weights,
2569            vec![2u8, 1u8],
2570            "row_weights must round-trip unchanged"
2571        );
2572    }
2573
2574    /// TileProps.price_cents round-trips; absent price_cents is skipped.
2575    #[test]
2576    fn tile_props_price_cents_round_trips() {
2577        // Absent price_cents must not appear in serialization.
2578        let no_price: TileProps = serde_json::from_str(
2579            r#"{"item_id":"p1","name":"Coffee","price":"€2,00","field":"qty_p1"}"#,
2580        )
2581        .expect("must deserialize");
2582        assert!(
2583            no_price.price_cents.is_none(),
2584            "price_cents must default to None"
2585        );
2586        let json = serde_json::to_string(&no_price).expect("must serialize");
2587        assert!(
2588            !json.contains("price_cents"),
2589            "absent price_cents must be skipped; got: {json}"
2590        );
2591
2592        // price_cents: Some(250) must round-trip.
2593        let with_price: TileProps =
2594            serde_json::from_str(r#"{"item_id":"p1","name":"Coffee","price":"€2,50","field":"qty_p1","price_cents":250}"#)
2595                .expect("must deserialize with price_cents");
2596        assert_eq!(
2597            with_price.price_cents,
2598            Some(250u64),
2599            "price_cents must round-trip"
2600        );
2601        let json2 = serde_json::to_string(&with_price).expect("must serialize");
2602        assert!(
2603            json2.contains(r#""price_cents":250"#),
2604            "price_cents must appear in serialization; got: {json2}"
2605        );
2606        let parsed: TileProps =
2607            serde_json::from_str(&json2).expect("must deserialize from serialized");
2608        assert_eq!(
2609            parsed.price_cents,
2610            Some(250u64),
2611            "price_cents must round-trip unchanged"
2612        );
2613    }
2614
2615    /// TileProps.color as Option<Tone> round-trips; arbitrary string fails.
2616    #[test]
2617    fn tile_props_color_tone_round_trips_and_rejects_unknown() {
2618        // color: Some(Tone::Success) must round-trip.
2619        let with_color: TileProps = serde_json::from_str(
2620            r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"success"}"#,
2621        )
2622        .expect("must deserialize color:success");
2623        assert_eq!(
2624            with_color.color,
2625            Some(Tone::Success),
2626            "color:success must deserialize to Tone::Success"
2627        );
2628        let json = serde_json::to_string(&with_color).expect("must serialize");
2629        assert!(
2630            json.contains(r#""color":"success""#),
2631            "Tone::Success must serialize as \"success\"; got: {json}"
2632        );
2633        let parsed: TileProps =
2634            serde_json::from_str(&json).expect("must deserialize from serialized");
2635        assert_eq!(
2636            parsed.color,
2637            Some(Tone::Success),
2638            "color must round-trip unchanged"
2639        );
2640
2641        // Arbitrary string "blue" must fail to deserialize (enum-enforced).
2642        let result: Result<TileProps, _> = serde_json::from_str(
2643            r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"blue"}"#,
2644        );
2645        assert!(
2646            result.is_err(),
2647            "color:\"blue\" must fail to deserialize — Tone enum enforced"
2648        );
2649    }
2650
2651    /// SelectionPanelProps.currency round-trips; absent currency is skipped.
2652    #[test]
2653    fn selection_panel_props_currency_round_trips() {
2654        // Absent currency must not appear in serialization.
2655        let no_currency: SelectionPanelProps =
2656            serde_json::from_str(r#"{"form_id":"order-form"}"#).expect("must deserialize");
2657        assert!(
2658            no_currency.currency.is_none(),
2659            "currency must default to None"
2660        );
2661        let json = serde_json::to_string(&no_currency).expect("must serialize");
2662        assert!(
2663            !json.contains("currency"),
2664            "absent currency must be skipped; got: {json}"
2665        );
2666
2667        // currency: Some("€") must round-trip.
2668        let with_currency: SelectionPanelProps =
2669            serde_json::from_str(r#"{"form_id":"order-form","currency":"€"}"#)
2670                .expect("must deserialize with currency");
2671        assert_eq!(
2672            with_currency.currency,
2673            Some("€".to_string()),
2674            "currency must round-trip"
2675        );
2676        let json2 = serde_json::to_string(&with_currency).expect("must serialize");
2677        assert!(
2678            json2.contains(r#""currency":"€""#),
2679            "currency must appear in serialization; got: {json2}"
2680        );
2681    }
2682}