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::de::{self, Deserializer};
8use serde::ser::{SerializeMap, Serializer};
9use serde::{Deserialize, Serialize};
10
11use crate::action::Action;
12use crate::visibility::Visibility;
13
14/// Shared size enum for components (Button, Badge, Avatar, Input).
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum Size {
18    Xs,
19    Sm,
20    #[default]
21    Default,
22    Lg,
23}
24
25/// Icon placement relative to button label.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum IconPosition {
29    #[default]
30    Left,
31    Right,
32}
33
34/// Sort direction for table columns.
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum SortDirection {
38    #[default]
39    Asc,
40    Desc,
41}
42
43/// Separator orientation.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum Orientation {
47    #[default]
48    Horizontal,
49    Vertical,
50}
51
52/// Button visual variants (aligned to shadcn/ui).
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ButtonVariant {
56    #[default]
57    Default,
58    Secondary,
59    Destructive,
60    Outline,
61    Ghost,
62    Link,
63}
64
65/// Input field types.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum InputType {
69    #[default]
70    Text,
71    Email,
72    Password,
73    Number,
74    Textarea,
75    Hidden,
76    Date,
77    Time,
78    Url,
79    Tel,
80    Search,
81}
82
83/// Alert visual variants.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "snake_case")]
86pub enum AlertVariant {
87    #[default]
88    Info,
89    Success,
90    Warning,
91    Error,
92}
93
94/// Badge visual variants (aligned to shadcn/ui).
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "snake_case")]
97pub enum BadgeVariant {
98    #[default]
99    Default,
100    Secondary,
101    Destructive,
102    Outline,
103}
104
105/// Text element types for semantic HTML rendering.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107#[serde(rename_all = "snake_case")]
108pub enum TextElement {
109    #[default]
110    P,
111    H1,
112    H2,
113    H3,
114    Span,
115    Div,
116    Section,
117}
118
119/// Column display format for tables.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum ColumnFormat {
123    Date,
124    DateTime,
125    Currency,
126    Boolean,
127}
128
129/// Table column definition.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
131pub struct Column {
132    pub key: String,
133    pub label: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub format: Option<ColumnFormat>,
136}
137
138/// Select option (value + label pair).
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
140pub struct SelectOption {
141    pub value: String,
142    pub label: String,
143}
144
145/// Props for Card component.
146// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct CardProps {
149    pub title: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub description: Option<String>,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub children: Vec<ComponentNode>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub footer: Vec<ComponentNode>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub max_width: Option<FormMaxWidth>,
158}
159
160/// Props for Table component.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
162pub struct TableProps {
163    pub columns: Vec<Column>,
164    pub data_path: String,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub row_actions: Option<Vec<Action>>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub empty_message: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub sortable: Option<bool>,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub sort_column: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub sort_direction: Option<SortDirection>,
175}
176
177/// Maximum width constraint for form containers.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
179#[serde(rename_all = "snake_case")]
180pub enum FormMaxWidth {
181    #[default]
182    Default,
183    Narrow,
184    Wide,
185}
186
187/// Props for Form component.
188// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct FormProps {
191    pub action: Action,
192    pub fields: Vec<ComponentNode>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub method: Option<crate::action::HttpMethod>,
195    /// Form guard type. When set, the runtime JS disables the submit button
196    /// until the guard condition is met. Value: `"number-gt-0"` — at least
197    /// one number input must have value > 0.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub guard: Option<String>,
200    /// Optional max-width constraint for the form container.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub max_width: Option<FormMaxWidth>,
203}
204
205/// Which display mode the component uses.
206///
207/// Set from a URL query parameter — typically `?mode=edit` — by
208/// [`EditMode::from_query`]. Defaults to [`EditMode::View`].
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
210#[serde(rename_all = "snake_case")]
211pub enum EditMode {
212    /// Read-only display with a "Modifica" action.
213    #[default]
214    View,
215    /// Inline-edit form with "Salva" / "Annulla" actions.
216    Edit,
217}
218
219impl EditMode {
220    /// Parse a URL query-parameter value into an `EditMode`.
221    ///
222    /// Returns [`EditMode::Edit`] when `raw` equals `"edit"` (ASCII
223    /// case-insensitive); [`EditMode::View`] otherwise, including when
224    /// `raw` is `None` or any other string.
225    ///
226    /// Handlers typically call this with `req.query("mode").as_deref()`.
227    pub fn from_query(raw: Option<&str>) -> Self {
228        match raw {
229            Some(s) if s.eq_ignore_ascii_case("edit") => EditMode::Edit,
230            _ => EditMode::View,
231        }
232    }
233}
234
235/// A single field row within a [`DetailFormProps`].
236///
237/// Rendered as `<div><dt>{label}</dt><dd>…</dd></div>` in both modes.
238/// In View mode, `<dd>` shows the escaped `value` as plain text.
239/// In Edit mode, `<dd>` renders the `input` [`ComponentNode`] via the
240/// normal component dispatch — making any form-field component (Input,
241/// Select, Textarea, Switch, Checkbox, plugin) usable inside.
242///
243/// **Authoring rule (Option A).** When `input` is an `Input` / `Select` /
244/// `Textarea` / `Checkbox` / `Switch` component, the caller MUST set its
245/// `label` prop to an empty string. The `<dt>` already provides the
246/// visible label; a non-empty input label produces duplicate UI text.
247/// DetailForm does not mutate caller-supplied props.
248// JsonSchema skipped: contains ComponentNode — Component has custom Serialize/Deserialize
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct DetailField {
251    /// Description term shown in both modes as the field label.
252    pub label: String,
253    /// Display string shown in View mode (plain text, html_escape'd at render).
254    pub value: String,
255    /// Component rendered in Edit mode in place of `value`.
256    pub input: ComponentNode,
257}
258
259impl DetailField {
260    /// Convenience constructor mirroring [`ComponentNode::input`] ergonomics.
261    pub fn new(label: impl Into<String>, value: impl Into<String>, input: ComponentNode) -> Self {
262        Self {
263            label: label.into(),
264            value: value.into(),
265            input,
266        }
267    }
268}
269
270/// Props for [`crate::Component::DetailForm`].
271///
272/// Renders a description-list-style block in two modes — View and Edit —
273/// driven by the `mode` field. The outer scaffold (`<dl>` + rows) is
274/// byte-for-byte identical across modes per the structural coherence
275/// contract (147-UI-SPEC §5); only the inner `<dd>` content and the
276/// action bar differ. Edit mode additionally wraps the scaffold in a
277/// `<form>` with method spoofing for PUT/PATCH/DELETE.
278///
279/// Mode is URL-driven (server-side only) — no JavaScript. Callers
280/// typically derive `mode` via [`EditMode::from_query`] on
281/// `req.query("mode").as_deref()`.
282// JsonSchema skipped: contains Vec<DetailField> which contains ComponentNode
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub struct DetailFormProps {
285    /// Which mode to render. Defaults to [`EditMode::View`].
286    #[serde(default)]
287    pub mode: EditMode,
288    /// Form submit target (used only in Edit mode; resolver populates `action.url`).
289    pub action: crate::action::Action,
290    /// The rows.
291    pub fields: Vec<DetailField>,
292    /// Href for the "Modifica" button (View mode only). Emitted verbatim after html_escape.
293    pub edit_url: String,
294    /// Href for the "Annulla" button (Edit mode only). Emitted verbatim after html_escape.
295    pub cancel_url: String,
296    /// Override for the default "Modifica" label.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub edit_label: Option<String>,
299    /// Override for the default "Salva" label.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub save_label: Option<String>,
302    /// Override for the default "Annulla" label.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub cancel_label: Option<String>,
305    /// Override for the form tag's HTTP method (mirrors [`FormProps::method`]).
306    /// Unset means use `action.method`.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub method: Option<crate::action::HttpMethod>,
309}
310
311/// HTML button type attribute.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
313#[serde(rename_all = "snake_case")]
314pub enum ButtonType {
315    #[default]
316    Button,
317    Submit,
318}
319
320/// Props for Button component.
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
322pub struct ButtonProps {
323    pub label: String,
324    #[serde(default)]
325    pub variant: ButtonVariant,
326    #[serde(default)]
327    pub size: Size,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub disabled: Option<bool>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub icon: Option<String>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub icon_position: Option<IconPosition>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub button_type: Option<ButtonType>,
336}
337
338/// Props for Input component.
339#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
340pub struct InputProps {
341    /// Form field name for data binding.
342    pub field: String,
343    pub label: String,
344    #[serde(default)]
345    pub input_type: InputType,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub placeholder: Option<String>,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub required: Option<bool>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub disabled: Option<bool>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub error: Option<String>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub description: Option<String>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub default_value: Option<String>,
358    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub data_path: Option<String>,
361    /// HTML step attribute for number inputs (e.g., "any", "0.01").
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub step: Option<String>,
364    /// HTML datalist id for autocomplete suggestions.
365    /// When set, renders `list="..."` on the input and a companion `<datalist>`
366    /// whose options come from a view data key matching this id.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub list: Option<String>,
369}
370
371/// Props for Select component.
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
373pub struct SelectProps {
374    /// Form field name for data binding.
375    pub field: String,
376    pub label: String,
377    pub options: Vec<SelectOption>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub placeholder: Option<String>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub required: Option<bool>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub disabled: Option<bool>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub error: Option<String>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub description: Option<String>,
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub default_value: Option<String>,
390    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub data_path: Option<String>,
393}
394
395/// Props for Alert component.
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
397pub struct AlertProps {
398    pub message: String,
399    #[serde(default)]
400    pub variant: AlertVariant,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub title: Option<String>,
403}
404
405/// Props for Badge component.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
407pub struct BadgeProps {
408    pub label: String,
409    #[serde(default)]
410    pub variant: BadgeVariant,
411}
412
413/// Props for Modal component.
414// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct ModalProps {
417    pub id: String,
418    pub title: String,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub description: Option<String>,
421    #[serde(default, skip_serializing_if = "Vec::is_empty")]
422    pub children: Vec<ComponentNode>,
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub footer: Vec<ComponentNode>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub trigger_label: Option<String>,
427}
428
429/// Props for Text component.
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub struct TextProps {
432    pub content: String,
433    #[serde(default)]
434    pub element: TextElement,
435}
436
437/// Props for Checkbox component.
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
439pub struct CheckboxProps {
440    /// Form field name for data binding.
441    pub field: String,
442    /// HTML value attribute. When set, the checkbox submits this value instead of "1".
443    /// Required for multi-value checkbox groups (same name, different values).
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub value: Option<String>,
446    pub label: 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 checked: Option<bool>,
451    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub data_path: Option<String>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub required: Option<bool>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub disabled: Option<bool>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub error: Option<String>,
460}
461
462/// Props for Switch component.
463// JsonSchema skipped: contains Option<Action> — Action has custom Serialize/Deserialize
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465pub struct SwitchProps {
466    /// Form field name for data binding.
467    pub field: String,
468    pub label: String,
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub description: Option<String>,
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub checked: Option<bool>,
473    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub data_path: Option<String>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub required: Option<bool>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub disabled: Option<bool>,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub error: Option<String>,
482    /// Auto-submit action. When set, the switch renders inside a minimal
483    /// form and submits on change.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub action: Option<Action>,
486    /// Compact mode: label hugs the toggle (gap-3) instead of justify-between.
487    /// Use when the switch is inside a narrow grid column.
488    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
489    pub compact: bool,
490}
491
492/// Props for `KeyValueEditor` component.
493///
494/// Renders a dynamic list of key/value rows backed by a hidden `<input>`
495/// that holds the current pairs serialized as a JSON object. The runtime
496/// module `key_value_editor` wires add/remove/input events and keeps the
497/// hidden field in sync on every mutation.
498///
499/// When `data_path` resolves to a JSON object, each entry seeds one row.
500/// `suggested_keys` drives a `<datalist>` (when `allow_custom_keys`) or a
501/// `<select>` (when `!allow_custom_keys`).
502#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
503pub struct KeyValueEditorProps {
504    /// Name of the hidden input that receives the serialized JSON.
505    pub field: String,
506    /// Optional visible label above the editor block.
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub label: Option<String>,
509    /// Keys offered as suggestions (via `<datalist>` or `<select>`).
510    #[serde(default)]
511    pub suggested_keys: Vec<String>,
512    /// If true (default), the key input accepts any text with suggestions;
513    /// if false, the key input is a `<select>` restricted to `suggested_keys`.
514    #[serde(default = "default_true")]
515    pub allow_custom_keys: bool,
516    /// JSON pointer path whose resolved object seeds the initial rows.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub data_path: Option<String>,
519    /// Validation error message rendered below the editor.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub error: Option<String>,
522}
523
524/// Props for [`Component::RichTextEditor`].
525///
526/// Renders a `<div data-rich-text-editor>` host whose runtime IIFE attaches a
527/// Quill 2.0.3 (Snow theme) editor. On form submit the IIFE writes two hidden
528/// inputs — `{name}_delta` (canonical Delta JSON, lossless) and `{name}_html`
529/// (sanitized HTML, rendering input). The `formats` allowlist is the single
530/// source of truth: it constrains both the Quill toolbar (init time) and the
531/// HTML post-process (submit time). Image / video / HTML-paste paths are not
532/// reachable through the prop surface.
533///
534/// Quill JS+CSS are loaded once per page from jsDelivr, SHA-384 SRI-pinned via
535/// the `RichTextEditorPlugin` asset adapter. Multiple editor instances on the
536/// same page deduplicate the asset to a single load.
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
538pub struct RichTextEditorProps {
539    /// Base form field name. The IIFE emits two hidden inputs on submit:
540    /// `{name}_delta` (Delta JSON) and `{name}_html` (sanitized HTML).
541    ///
542    /// Must be unique within the **page** (not just within the form). The
543    /// renderer uses `name` as the DOM `id` of the editor host and the error
544    /// element (`err-{name}`). Two editors with the same `name` on one page
545    /// produce duplicate DOM ids, which breaks label association and the
546    /// error-element selector.
547    pub name: String,
548    /// Initial editor content. Auto-detected at runtime: parses as JSON
549    /// (Delta) when the string has an `ops` array; otherwise loaded as HTML
550    /// via `clipboard.dangerouslyPasteHTML` filtered by the `formats`
551    /// allowlist.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub value: Option<String>,
554    /// Toolbar/allowlist whitelist. Drives both Quill's toolbar config and
555    /// the HTML post-process. Default: `["bold", "italic", "underline",
556    /// "list", "header", "link"]` (D-18).
557    #[serde(default = "default_rte_formats")]
558    pub formats: Vec<String>,
559    /// Optional placeholder shown by Quill when the editor is empty.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub placeholder: Option<String>,
562    /// Quill theme — only `"snow"` is supported in v1. Defaults to `"snow"`.
563    #[serde(default = "default_rte_theme")]
564    pub theme: String,
565    /// Optional visible label rendered above the editor host.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub label: Option<String>,
568    /// Validation error message rendered below the editor with destructive
569    /// token styling.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub error: Option<String>,
572    /// JSON pointer for pre-fill at render time. Resolved value is passed as
573    /// the initial editor body (after html_escape). When both `value` and
574    /// `data_path` are set, `value` wins.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub data_path: Option<String>,
577    /// When true, the IIFE installs a guard that calls `event.preventDefault`
578    /// on form submit if `quill.getText().trim().length === 0`, surfacing the
579    /// destructive-token error region with a "Required" message.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub required: Option<bool>,
582}
583
584/// Props for Separator component.
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
586pub struct SeparatorProps {
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub orientation: Option<Orientation>,
589}
590
591/// A single item in a description list.
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
593pub struct DescriptionItem {
594    pub label: String,
595    pub value: String,
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub format: Option<ColumnFormat>,
598}
599
600/// Props for DescriptionList component.
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602pub struct DescriptionListProps {
603    pub items: Vec<DescriptionItem>,
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub columns: Option<u8>,
606}
607
608/// A single tab within a Tabs component.
609// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611pub struct Tab {
612    pub value: String,
613    pub label: String,
614    #[serde(default, skip_serializing_if = "Vec::is_empty")]
615    pub children: Vec<ComponentNode>,
616}
617
618/// Props for Tabs component.
619// JsonSchema skipped: contains Vec<Tab> which contains Vec<ComponentNode>
620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
621pub struct TabsProps {
622    pub default_tab: String,
623    pub tabs: Vec<Tab>,
624}
625
626/// A single item in a breadcrumb trail.
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
628pub struct BreadcrumbItem {
629    pub label: String,
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub url: Option<String>,
632}
633
634/// Props for Breadcrumb component.
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
636pub struct BreadcrumbProps {
637    pub items: Vec<BreadcrumbItem>,
638}
639
640/// Props for Pagination component.
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
642pub struct PaginationProps {
643    pub current_page: u32,
644    pub per_page: u32,
645    pub total: u32,
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub base_url: Option<String>,
648}
649
650/// Props for Progress component.
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
652pub struct ProgressProps {
653    /// Percentage value (0-100).
654    pub value: u8,
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub max: Option<u8>,
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub label: Option<String>,
659}
660
661/// Source for an [`ImageProps`] component — exactly one of `src` or `svg`.
662///
663/// Wire-format discrimination is by field presence via `#[serde(untagged)]`:
664/// - `{"src": "/logo.png"}` deserializes to `ImageSource::Url`.
665/// - `{"svg": "<svg>…</svg>"}` deserializes to `ImageSource::InlineSvg`.
666///
667/// The `Url` variant is backward-compatible with pre-phase-148 JSON:
668/// existing callers sending `{"type":"Image","src":"…","alt":"…"}` continue
669/// to deserialize unchanged.
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
671#[serde(untagged)]
672pub enum ImageSource {
673    /// External image URL. The `src` attribute is HTML-escaped before emission.
674    Url {
675        /// Image source URL. Rendered as `<img src="{escaped}">`.
676        src: String,
677    },
678    /// Server-constructed inline SVG string.
679    ///
680    /// # Safety
681    ///
682    /// The `svg` value is emitted **verbatim, without HTML escaping**. This is
683    /// the single deliberate `html_escape` bypass in `ferro-json-ui`. It is
684    /// intended for server-constructed SVG — charts, sparklines, icons —
685    /// **not** for user-supplied strings.
686    ///
687    /// Callers that incorporate user data into SVG output are responsible for
688    /// sanitization **before** constructing this variant. Contrast with
689    /// [`ImageSource::Url`]: `src` is always HTML-escaped as an attribute
690    /// value, so `Url` is safe with caller-controlled URL strings.
691    InlineSvg {
692        /// Inline SVG emitted verbatim. See variant-level `# Safety`.
693        svg: String,
694    },
695}
696
697/// Props for an [`Component::Image`] component.
698///
699/// `source` is a flattened [`ImageSource`] — the wire format carries
700/// either `"src"` (URL variant) or `"svg"` (inline-SVG variant) directly
701/// at the top level, with no wrapping `"source"` key. `alt` is required
702/// on both variants (compile-enforced accessibility for the SVG branch).
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
704pub struct ImageProps {
705    /// Image source — URL or server-constructed inline SVG.
706    ///
707    /// Flattened into the parent JSON object: the `src` or `svg` field
708    /// appears directly on `ImageProps` in the wire format.
709    #[serde(flatten)]
710    pub source: ImageSource,
711    /// Required alt text for accessibility. HTML-escaped on both source variants.
712    pub alt: String,
713    /// Optional CSS aspect ratio (e.g. `"16/9"`). Applied to both variants.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub aspect_ratio: Option<String>,
716    /// Optional label shown in a skeleton placeholder that sits behind the
717    /// image in the URL variant. When the `<img>` fails to load (or is still
718    /// being generated), the element is hidden via `onerror` and the
719    /// placeholder remains visible. Not rendered for the `InlineSvg` variant.
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub placeholder_label: Option<String>,
722}
723
724impl ImageProps {
725    /// Construct an [`ImageProps`] backed by an external URL.
726    ///
727    /// Both `src` and `alt` accept `impl Into<String>` for ergonomic call
728    /// sites. `aspect_ratio` and `placeholder_label` default to `None` — set
729    /// them explicitly on the returned struct if needed.
730    pub fn url(src: impl Into<String>, alt: impl Into<String>) -> Self {
731        Self {
732            source: ImageSource::Url { src: src.into() },
733            alt: alt.into(),
734            aspect_ratio: None,
735            placeholder_label: None,
736        }
737    }
738
739    /// Construct an [`ImageProps`] backed by a server-constructed inline SVG.
740    ///
741    /// # Safety
742    ///
743    /// `svg` is emitted verbatim without HTML escaping. Intended for
744    /// server-constructed SVG (charts, sparklines, icons). **Not suitable
745    /// for user-supplied strings.** Callers that incorporate user data into
746    /// `svg` are responsible for sanitization before calling this
747    /// constructor. See [`ImageSource::InlineSvg`] for the full contract.
748    pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
749        Self {
750            source: ImageSource::InlineSvg { svg: svg.into() },
751            alt: alt.into(),
752            aspect_ratio: None,
753            placeholder_label: None,
754        }
755    }
756}
757
758/// Props for Avatar component.
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
760pub struct AvatarProps {
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub src: Option<String>,
763    pub alt: String,
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub fallback: Option<String>,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub size: Option<Size>,
768}
769
770/// Props for Skeleton loading placeholder.
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
772pub struct SkeletonProps {
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub width: Option<String>,
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub height: Option<String>,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub rounded: Option<bool>,
779}
780
781/// Toast visual variants.
782#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
783#[serde(rename_all = "snake_case")]
784pub enum ToastVariant {
785    #[default]
786    Info,
787    Success,
788    Warning,
789    Error,
790}
791
792/// A single item in a checklist.
793#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
794pub struct ChecklistItem {
795    pub label: String,
796    #[serde(default)]
797    pub checked: bool,
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub href: Option<String>,
800}
801
802/// A single item in a notification dropdown.
803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
804pub struct NotificationItem {
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub icon: Option<String>,
807    pub text: String,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub timestamp: Option<String>,
810    #[serde(default)]
811    pub read: bool,
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub action_url: Option<String>,
814}
815
816/// A single navigation item in the sidebar.
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
818pub struct SidebarNavItem {
819    pub label: String,
820    pub href: String,
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub icon: Option<String>,
823    #[serde(default)]
824    pub active: bool,
825}
826
827/// A collapsible group in the sidebar.
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
829pub struct SidebarGroup {
830    pub label: String,
831    #[serde(default)]
832    pub collapsed: bool,
833    pub items: Vec<SidebarNavItem>,
834}
835
836/// Props for StatCard component (live-updatable metric card).
837#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
838pub struct StatCardProps {
839    pub label: String,
840    pub value: String,
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub icon: Option<String>,
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub subtitle: Option<String>,
845    /// SSE target key for live updates; maps to `data-sse-target` on the value element.
846    #[serde(default, skip_serializing_if = "Option::is_none")]
847    pub sse_target: Option<String>,
848}
849
850/// Props for Checklist component.
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
852pub struct ChecklistProps {
853    pub title: String,
854    pub items: Vec<ChecklistItem>,
855    #[serde(default = "default_true")]
856    pub dismissible: bool,
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub dismiss_label: Option<String>,
859    /// Server-side state persistence key for this checklist.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub data_key: Option<String>,
862}
863
864fn default_true() -> bool {
865    true
866}
867
868/// Default `formats` allowlist for `RichTextEditorProps` (D-18).
869///
870/// Six entries mapping to Quill's actual format names: `list` covers both bullet
871/// and ordered (Quill represents lists as one format with an `ordered` attribute);
872/// `header` covers H1..H3 (Quill represents headings as one format with a level
873/// attribute). The IIFE expands these to the toolbar config at runtime via the
874/// deterministic mapping in D-19.
875fn default_rte_formats() -> Vec<String> {
876    vec![
877        "bold".to_string(),
878        "italic".to_string(),
879        "underline".to_string(),
880        "list".to_string(),
881        "header".to_string(),
882        "link".to_string(),
883    ]
884}
885
886/// Default Quill theme for `RichTextEditorProps` (D-07 / SC-1).
887///
888/// Only `"snow"` is supported in v1 — bubble/custom themes are a deferred phase.
889/// The IIFE treats any non-`"snow"` value as `"snow"` (no error).
890fn default_rte_theme() -> String {
891    "snow".to_string()
892}
893
894/// Props for Toast component (declarative notification intent).
895///
896/// The JS runtime reads data attributes from the rendered element to
897/// display the toast. Timeouts and dismissal are handled client-side.
898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
899pub struct ToastProps {
900    pub message: String,
901    #[serde(default)]
902    pub variant: ToastVariant,
903    /// Seconds before auto-dismiss. Default 5.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub timeout: Option<u32>,
906    #[serde(default = "default_true")]
907    pub dismissible: bool,
908}
909
910/// Props for NotificationDropdown component.
911#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
912pub struct NotificationDropdownProps {
913    pub notifications: Vec<NotificationItem>,
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub empty_text: Option<String>,
916}
917
918/// Props for Sidebar component.
919#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
920pub struct SidebarProps {
921    #[serde(default, skip_serializing_if = "Vec::is_empty")]
922    pub fixed_top: Vec<SidebarNavItem>,
923    #[serde(default, skip_serializing_if = "Vec::is_empty")]
924    pub groups: Vec<SidebarGroup>,
925    #[serde(default, skip_serializing_if = "Vec::is_empty")]
926    pub fixed_bottom: Vec<SidebarNavItem>,
927}
928
929/// Props for Header component.
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
931pub struct HeaderProps {
932    pub business_name: String,
933    /// Unread notification count for badge display.
934    #[serde(default, skip_serializing_if = "Option::is_none")]
935    pub notification_count: Option<u32>,
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub user_name: Option<String>,
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    pub user_avatar: Option<String>,
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub logout_url: Option<String>,
942}
943
944/// Gap size for Grid layout.
945#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
946#[serde(rename_all = "snake_case")]
947pub enum GapSize {
948    None,
949    Sm,
950    #[default]
951    Md,
952    Lg,
953    Xl,
954}
955
956/// Props for Grid component — multi-column layout.
957// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
958#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
959pub struct GridProps {
960    /// Number of columns (1-12) at base (mobile) viewport.
961    #[serde(default = "default_grid_columns")]
962    pub columns: u8,
963    /// Number of columns at md breakpoint (768px+). When set, creates a responsive grid.
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub md_columns: Option<u8>,
966    /// Number of columns at lg breakpoint (1024px+). Optional; falls back to md.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub lg_columns: Option<u8>,
969    /// Gap between grid items.
970    #[serde(default)]
971    pub gap: GapSize,
972    /// Enables horizontal scroll mode. Children get `min-w-[280px]` and the grid
973    /// uses `grid-flow-col` auto-cols layout for Trello-like horizontal scrolling.
974    #[serde(default, skip_serializing_if = "Option::is_none")]
975    pub scrollable: Option<bool>,
976    #[serde(default, skip_serializing_if = "Vec::is_empty")]
977    pub children: Vec<ComponentNode>,
978}
979
980fn default_grid_columns() -> u8 {
981    2
982}
983
984/// Props for Collapsible section — expandable `<details>`/`<summary>`.
985// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
986#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
987pub struct CollapsibleProps {
988    pub title: String,
989    #[serde(default)]
990    pub expanded: bool,
991    #[serde(default, skip_serializing_if = "Vec::is_empty")]
992    pub children: Vec<ComponentNode>,
993}
994
995/// Props for EmptyState component — standardized empty view.
996#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
997pub struct EmptyStateProps {
998    pub title: String,
999    #[serde(default, skip_serializing_if = "Option::is_none")]
1000    pub description: Option<String>,
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub action: Option<Action>,
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub action_label: Option<String>,
1005}
1006
1007/// Layout variant for form sections.
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1009#[serde(rename_all = "snake_case")]
1010pub enum FormSectionLayout {
1011    #[default]
1012    Stacked,
1013    TwoColumn,
1014}
1015
1016/// Props for FormSection component — visual grouping within forms.
1017// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
1018#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1019pub struct FormSectionProps {
1020    pub title: String,
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub description: Option<String>,
1023    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1024    pub children: Vec<ComponentNode>,
1025    /// Optional layout variant. Defaults to stacked (single column).
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub layout: Option<FormSectionLayout>,
1028}
1029
1030/// Props for PageHeader component -- page title with optional breadcrumb and action buttons.
1031// JsonSchema skipped: contains Vec<ComponentNode>
1032#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1033pub struct PageHeaderProps {
1034    pub title: String,
1035    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1036    pub breadcrumb: Vec<BreadcrumbItem>,
1037    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1038    pub actions: Vec<ComponentNode>,
1039}
1040
1041/// Props for ButtonGroup component -- horizontal button row with consistent gap.
1042// JsonSchema skipped: contains Vec<ComponentNode>
1043#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1044pub struct ButtonGroupProps {
1045    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1046    pub buttons: Vec<ComponentNode>,
1047}
1048
1049/// A single action item in a dropdown menu.
1050#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1051pub struct DropdownMenuAction {
1052    pub label: String,
1053    pub action: Action,
1054    #[serde(default)]
1055    pub destructive: bool,
1056}
1057
1058/// Props for DropdownMenu component — trigger button with absolutely-positioned action panel.
1059#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1060pub struct DropdownMenuProps {
1061    pub menu_id: String,
1062    pub trigger_label: String,
1063    pub items: Vec<DropdownMenuAction>,
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub trigger_variant: Option<ButtonVariant>,
1066}
1067
1068/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
1069/// mobile card fallback, and empty state.
1070#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1071pub struct DataTableProps {
1072    pub columns: Vec<Column>,
1073    pub data_path: String,
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub row_actions: Option<Vec<DropdownMenuAction>>,
1076    #[serde(default, skip_serializing_if = "Option::is_none")]
1077    pub empty_message: Option<String>,
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub row_key: Option<String>,
1080    /// URL pattern for row click navigation. Use `{row_key}` as placeholder.
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub row_href: Option<String>,
1083}
1084
1085/// Props for a single column in a KanbanBoard.
1086// JsonSchema skipped: contains Vec<ComponentNode>
1087#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1088pub struct KanbanColumnProps {
1089    pub id: String,
1090    pub title: String,
1091    pub count: u32,
1092    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1093    pub children: Vec<ComponentNode>,
1094}
1095
1096/// Props for KanbanBoard component — horizontal scrollable columns on desktop, tab-based on mobile.
1097// JsonSchema skipped: contains Vec<ComponentNode> (via KanbanColumnProps)
1098#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1099pub struct KanbanBoardProps {
1100    pub columns: Vec<KanbanColumnProps>,
1101    #[serde(default, skip_serializing_if = "Option::is_none")]
1102    pub mobile_default_column: Option<String>,
1103}
1104
1105/// Props for a calendar day cell.
1106///
1107/// Renders a single day in a month grid with today highlight,
1108/// out-of-month muting, and event count indicator.
1109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1110pub struct CalendarCellProps {
1111    pub day: u8,
1112    #[serde(default)]
1113    pub is_today: bool,
1114    #[serde(default)]
1115    pub is_current_month: bool,
1116    #[serde(default)]
1117    pub event_count: u32,
1118    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
1119    /// When non-empty, colored dots are rendered instead of plain primary dots.
1120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1121    pub dot_colors: Vec<String>,
1122}
1123
1124/// Visual variant for action cards.
1125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1126#[serde(rename_all = "snake_case")]
1127pub enum ActionCardVariant {
1128    #[default]
1129    Default,
1130    Setup,
1131    Danger,
1132}
1133
1134/// Props for a horizontal action card with variant-colored left border.
1135///
1136/// Renders icon + title + description + chevron in a clickable row.
1137/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
1138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1139pub struct ActionCardProps {
1140    pub title: String,
1141    pub description: String,
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub icon: Option<String>,
1144    #[serde(default)]
1145    pub variant: ActionCardVariant,
1146    /// Optional navigation URL. When set, the card renders as an `<a>` element.
1147    #[serde(default, skip_serializing_if = "Option::is_none")]
1148    pub href: Option<String>,
1149}
1150
1151/// Props for a touch-friendly product tile with quantity controls.
1152///
1153/// Renders product name, price, and +/- buttons that drive a hidden input
1154/// via JS. Used for POS-style product selection during order creation.
1155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1156pub struct ProductTileProps {
1157    pub product_id: String,
1158    pub name: String,
1159    pub price: String,
1160    pub field: String,
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub default_quantity: Option<u32>,
1163}
1164
1165/// Props for a plugin component.
1166///
1167/// The `plugin_type` field holds the original `"type"` value from JSON,
1168/// and `props` holds the remaining fields. Used for custom interactive
1169/// components registered via the plugin system.
1170// JsonSchema skipped: custom Serialize/Deserialize impl
1171#[derive(Debug, Clone, PartialEq)]
1172pub struct PluginProps {
1173    /// The plugin component type name (e.g., "Map").
1174    pub plugin_type: String,
1175    /// Raw props passed to the plugin's render function.
1176    pub props: serde_json::Value,
1177}
1178
1179impl Serialize for PluginProps {
1180    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1181        // Flatten plugin_type back as "type" and merge in the props object.
1182        let obj = self.props.as_object();
1183        let extra_len = obj.map_or(0, |m| m.len());
1184        let mut map = serializer.serialize_map(Some(1 + extra_len))?;
1185        map.serialize_entry("type", &self.plugin_type)?;
1186        if let Some(obj) = obj {
1187            for (k, v) in obj {
1188                if k != "type" {
1189                    map.serialize_entry(k, v)?;
1190                }
1191            }
1192        }
1193        map.end()
1194    }
1195}
1196
1197impl<'de> Deserialize<'de> for PluginProps {
1198    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1199        let mut value = serde_json::Value::deserialize(deserializer)?;
1200        let plugin_type = value
1201            .get("type")
1202            .and_then(|v| v.as_str())
1203            .map(|s| s.to_string())
1204            .ok_or_else(|| de::Error::missing_field("type"))?;
1205        // Remove "type" from the props to avoid redundancy.
1206        if let Some(obj) = value.as_object_mut() {
1207            obj.remove("type");
1208        }
1209        Ok(PluginProps {
1210            plugin_type,
1211            props: value,
1212        })
1213    }
1214}
1215
1216/// Component catalog enum. Built-in types are deserialized by name,
1217/// unknown types fall through to the `Plugin` variant.
1218///
1219/// Serializes built-in variants with `"type": "Card"` etc. The Plugin
1220/// variant serializes with the plugin's own type name.
1221// JsonSchema skipped: custom Serialize/Deserialize impl
1222#[derive(Debug, Clone, PartialEq)]
1223pub enum Component {
1224    Card(CardProps),
1225    Table(TableProps),
1226    Form(FormProps),
1227    Button(ButtonProps),
1228    Input(InputProps),
1229    Select(SelectProps),
1230    Alert(AlertProps),
1231    Badge(BadgeProps),
1232    Modal(ModalProps),
1233    Text(TextProps),
1234    Checkbox(CheckboxProps),
1235    Switch(SwitchProps),
1236    Separator(SeparatorProps),
1237    DescriptionList(DescriptionListProps),
1238    Tabs(TabsProps),
1239    Breadcrumb(BreadcrumbProps),
1240    Pagination(PaginationProps),
1241    Progress(ProgressProps),
1242    Avatar(AvatarProps),
1243    Skeleton(SkeletonProps),
1244    StatCard(StatCardProps),
1245    Checklist(ChecklistProps),
1246    Toast(ToastProps),
1247    NotificationDropdown(NotificationDropdownProps),
1248    Sidebar(SidebarProps),
1249    Header(HeaderProps),
1250    Grid(GridProps),
1251    Collapsible(CollapsibleProps),
1252    EmptyState(EmptyStateProps),
1253    FormSection(FormSectionProps),
1254    PageHeader(PageHeaderProps),
1255    ButtonGroup(ButtonGroupProps),
1256    DropdownMenu(DropdownMenuProps),
1257    KanbanBoard(KanbanBoardProps),
1258    CalendarCell(CalendarCellProps),
1259    ActionCard(ActionCardProps),
1260    ProductTile(ProductTileProps),
1261    DataTable(DataTableProps),
1262    Image(ImageProps),
1263    KeyValueEditor(KeyValueEditorProps),
1264    RichTextEditor(RichTextEditorProps),
1265    DetailForm(DetailFormProps),
1266    Plugin(PluginProps),
1267}
1268
1269// ── Custom Serialize for Component ───────────────────────────────────────
1270
1271/// Helper: serialize a built-in variant by serializing its props, then
1272/// injecting `"type": "<name>"` into the resulting JSON object.
1273fn serialize_tagged<S: Serializer, T: Serialize>(
1274    serializer: S,
1275    type_name: &str,
1276    props: &T,
1277) -> Result<S::Ok, S::Error> {
1278    let mut value = serde_json::to_value(props).map_err(serde::ser::Error::custom)?;
1279    if let Some(obj) = value.as_object_mut() {
1280        obj.insert(
1281            "type".to_string(),
1282            serde_json::Value::String(type_name.to_string()),
1283        );
1284    }
1285    value.serialize(serializer)
1286}
1287
1288impl Serialize for Component {
1289    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1290        match self {
1291            Component::Card(p) => serialize_tagged(serializer, "Card", p),
1292            Component::Table(p) => serialize_tagged(serializer, "Table", p),
1293            Component::Form(p) => serialize_tagged(serializer, "Form", p),
1294            Component::Button(p) => serialize_tagged(serializer, "Button", p),
1295            Component::Input(p) => serialize_tagged(serializer, "Input", p),
1296            Component::Select(p) => serialize_tagged(serializer, "Select", p),
1297            Component::Alert(p) => serialize_tagged(serializer, "Alert", p),
1298            Component::Badge(p) => serialize_tagged(serializer, "Badge", p),
1299            Component::Modal(p) => serialize_tagged(serializer, "Modal", p),
1300            Component::Text(p) => serialize_tagged(serializer, "Text", p),
1301            Component::Checkbox(p) => serialize_tagged(serializer, "Checkbox", p),
1302            Component::Switch(p) => serialize_tagged(serializer, "Switch", p),
1303            Component::Separator(p) => serialize_tagged(serializer, "Separator", p),
1304            Component::DescriptionList(p) => serialize_tagged(serializer, "DescriptionList", p),
1305            Component::Tabs(p) => serialize_tagged(serializer, "Tabs", p),
1306            Component::Breadcrumb(p) => serialize_tagged(serializer, "Breadcrumb", p),
1307            Component::Pagination(p) => serialize_tagged(serializer, "Pagination", p),
1308            Component::Progress(p) => serialize_tagged(serializer, "Progress", p),
1309            Component::Avatar(p) => serialize_tagged(serializer, "Avatar", p),
1310            Component::Skeleton(p) => serialize_tagged(serializer, "Skeleton", p),
1311            Component::StatCard(p) => serialize_tagged(serializer, "StatCard", p),
1312            Component::Checklist(p) => serialize_tagged(serializer, "Checklist", p),
1313            Component::Toast(p) => serialize_tagged(serializer, "Toast", p),
1314            Component::NotificationDropdown(p) => {
1315                serialize_tagged(serializer, "NotificationDropdown", p)
1316            }
1317            Component::Sidebar(p) => serialize_tagged(serializer, "Sidebar", p),
1318            Component::Header(p) => serialize_tagged(serializer, "Header", p),
1319            Component::Grid(p) => serialize_tagged(serializer, "Grid", p),
1320            Component::Collapsible(p) => serialize_tagged(serializer, "Collapsible", p),
1321            Component::EmptyState(p) => serialize_tagged(serializer, "EmptyState", p),
1322            Component::FormSection(p) => serialize_tagged(serializer, "FormSection", p),
1323            Component::PageHeader(p) => serialize_tagged(serializer, "PageHeader", p),
1324            Component::ButtonGroup(p) => serialize_tagged(serializer, "ButtonGroup", p),
1325            Component::DropdownMenu(p) => serialize_tagged(serializer, "DropdownMenu", p),
1326            Component::KanbanBoard(p) => serialize_tagged(serializer, "KanbanBoard", p),
1327            Component::CalendarCell(p) => serialize_tagged(serializer, "CalendarCell", p),
1328            Component::ActionCard(p) => serialize_tagged(serializer, "ActionCard", p),
1329            Component::ProductTile(p) => serialize_tagged(serializer, "ProductTile", p),
1330            Component::DataTable(p) => serialize_tagged(serializer, "DataTable", p),
1331            Component::Image(p) => serialize_tagged(serializer, "Image", p),
1332            Component::KeyValueEditor(p) => serialize_tagged(serializer, "KeyValueEditor", p),
1333            Component::RichTextEditor(p) => serialize_tagged(serializer, "RichTextEditor", p),
1334            Component::DetailForm(p) => serialize_tagged(serializer, "DetailForm", p),
1335            Component::Plugin(p) => p.serialize(serializer),
1336        }
1337    }
1338}
1339
1340// ── Custom Deserialize for Component ─────────────────────────────────────
1341
1342impl<'de> Deserialize<'de> for Component {
1343    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1344        let value = serde_json::Value::deserialize(deserializer)?;
1345        let type_str = value
1346            .get("type")
1347            .and_then(|v| v.as_str())
1348            .ok_or_else(|| de::Error::missing_field("type"))?;
1349
1350        match type_str {
1351            "Card" => serde_json::from_value::<CardProps>(value)
1352                .map(Component::Card)
1353                .map_err(de::Error::custom),
1354            "Table" => serde_json::from_value::<TableProps>(value)
1355                .map(Component::Table)
1356                .map_err(de::Error::custom),
1357            "Form" => serde_json::from_value::<FormProps>(value)
1358                .map(Component::Form)
1359                .map_err(de::Error::custom),
1360            "Button" => serde_json::from_value::<ButtonProps>(value)
1361                .map(Component::Button)
1362                .map_err(de::Error::custom),
1363            "Input" => serde_json::from_value::<InputProps>(value)
1364                .map(Component::Input)
1365                .map_err(de::Error::custom),
1366            "Select" => serde_json::from_value::<SelectProps>(value)
1367                .map(Component::Select)
1368                .map_err(de::Error::custom),
1369            "Alert" => serde_json::from_value::<AlertProps>(value)
1370                .map(Component::Alert)
1371                .map_err(de::Error::custom),
1372            "Badge" => serde_json::from_value::<BadgeProps>(value)
1373                .map(Component::Badge)
1374                .map_err(de::Error::custom),
1375            "Modal" => serde_json::from_value::<ModalProps>(value)
1376                .map(Component::Modal)
1377                .map_err(de::Error::custom),
1378            "Text" => serde_json::from_value::<TextProps>(value)
1379                .map(Component::Text)
1380                .map_err(de::Error::custom),
1381            "Checkbox" => serde_json::from_value::<CheckboxProps>(value)
1382                .map(Component::Checkbox)
1383                .map_err(de::Error::custom),
1384            "Switch" => serde_json::from_value::<SwitchProps>(value)
1385                .map(Component::Switch)
1386                .map_err(de::Error::custom),
1387            "Separator" => serde_json::from_value::<SeparatorProps>(value)
1388                .map(Component::Separator)
1389                .map_err(de::Error::custom),
1390            "DescriptionList" => serde_json::from_value::<DescriptionListProps>(value)
1391                .map(Component::DescriptionList)
1392                .map_err(de::Error::custom),
1393            "Tabs" => serde_json::from_value::<TabsProps>(value)
1394                .map(Component::Tabs)
1395                .map_err(de::Error::custom),
1396            "Breadcrumb" => serde_json::from_value::<BreadcrumbProps>(value)
1397                .map(Component::Breadcrumb)
1398                .map_err(de::Error::custom),
1399            "Pagination" => serde_json::from_value::<PaginationProps>(value)
1400                .map(Component::Pagination)
1401                .map_err(de::Error::custom),
1402            "Progress" => serde_json::from_value::<ProgressProps>(value)
1403                .map(Component::Progress)
1404                .map_err(de::Error::custom),
1405            "Avatar" => serde_json::from_value::<AvatarProps>(value)
1406                .map(Component::Avatar)
1407                .map_err(de::Error::custom),
1408            "Skeleton" => serde_json::from_value::<SkeletonProps>(value)
1409                .map(Component::Skeleton)
1410                .map_err(de::Error::custom),
1411            "StatCard" => serde_json::from_value::<StatCardProps>(value)
1412                .map(Component::StatCard)
1413                .map_err(de::Error::custom),
1414            "Checklist" => serde_json::from_value::<ChecklistProps>(value)
1415                .map(Component::Checklist)
1416                .map_err(de::Error::custom),
1417            "Toast" => serde_json::from_value::<ToastProps>(value)
1418                .map(Component::Toast)
1419                .map_err(de::Error::custom),
1420            "NotificationDropdown" => serde_json::from_value::<NotificationDropdownProps>(value)
1421                .map(Component::NotificationDropdown)
1422                .map_err(de::Error::custom),
1423            "Sidebar" => serde_json::from_value::<SidebarProps>(value)
1424                .map(Component::Sidebar)
1425                .map_err(de::Error::custom),
1426            "Header" => serde_json::from_value::<HeaderProps>(value)
1427                .map(Component::Header)
1428                .map_err(de::Error::custom),
1429            "Grid" => serde_json::from_value::<GridProps>(value)
1430                .map(Component::Grid)
1431                .map_err(de::Error::custom),
1432            "Collapsible" => serde_json::from_value::<CollapsibleProps>(value)
1433                .map(Component::Collapsible)
1434                .map_err(de::Error::custom),
1435            "EmptyState" => serde_json::from_value::<EmptyStateProps>(value)
1436                .map(Component::EmptyState)
1437                .map_err(de::Error::custom),
1438            "FormSection" => serde_json::from_value::<FormSectionProps>(value)
1439                .map(Component::FormSection)
1440                .map_err(de::Error::custom),
1441            "PageHeader" => serde_json::from_value::<PageHeaderProps>(value)
1442                .map(Component::PageHeader)
1443                .map_err(de::Error::custom),
1444            "ButtonGroup" => serde_json::from_value::<ButtonGroupProps>(value)
1445                .map(Component::ButtonGroup)
1446                .map_err(de::Error::custom),
1447            "DropdownMenu" => serde_json::from_value::<DropdownMenuProps>(value)
1448                .map(Component::DropdownMenu)
1449                .map_err(de::Error::custom),
1450            "KanbanBoard" => serde_json::from_value::<KanbanBoardProps>(value)
1451                .map(Component::KanbanBoard)
1452                .map_err(de::Error::custom),
1453            "CalendarCell" => serde_json::from_value::<CalendarCellProps>(value)
1454                .map(Component::CalendarCell)
1455                .map_err(de::Error::custom),
1456            "ActionCard" => serde_json::from_value::<ActionCardProps>(value)
1457                .map(Component::ActionCard)
1458                .map_err(de::Error::custom),
1459            "ProductTile" => serde_json::from_value::<ProductTileProps>(value)
1460                .map(Component::ProductTile)
1461                .map_err(de::Error::custom),
1462            "DataTable" => serde_json::from_value::<DataTableProps>(value)
1463                .map(Component::DataTable)
1464                .map_err(de::Error::custom),
1465            "Image" => serde_json::from_value::<ImageProps>(value)
1466                .map(Component::Image)
1467                .map_err(de::Error::custom),
1468            "KeyValueEditor" => serde_json::from_value::<KeyValueEditorProps>(value)
1469                .map(Component::KeyValueEditor)
1470                .map_err(de::Error::custom),
1471            "RichTextEditor" => serde_json::from_value::<RichTextEditorProps>(value)
1472                .map(Component::RichTextEditor)
1473                .map_err(de::Error::custom),
1474            "DetailForm" => serde_json::from_value::<DetailFormProps>(value)
1475                .map(Component::DetailForm)
1476                .map_err(de::Error::custom),
1477            _ => {
1478                // Unknown type: treat as a plugin component.
1479                let plugin_type = type_str.to_string();
1480                let mut props = value;
1481                if let Some(obj) = props.as_object_mut() {
1482                    obj.remove("type");
1483                }
1484                Ok(Component::Plugin(PluginProps { plugin_type, props }))
1485            }
1486        }
1487    }
1488}
1489
1490/// A component node wrapping a component with shared fields.
1491///
1492/// Every component in a view tree is wrapped in a `ComponentNode` that
1493/// provides a unique key, optional action binding, and optional visibility
1494/// rules. The component itself is flattened into the node's JSON.
1495// JsonSchema skipped: contains Component via flatten — Component has custom Serialize/Deserialize
1496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1497pub struct ComponentNode {
1498    pub key: String,
1499    #[serde(flatten)]
1500    pub component: Component,
1501    #[serde(default, skip_serializing_if = "Option::is_none")]
1502    pub action: Option<Action>,
1503    #[serde(default, skip_serializing_if = "Option::is_none")]
1504    pub visibility: Option<Visibility>,
1505}
1506
1507impl ComponentNode {
1508    /// Create a Card component node.
1509    pub fn card(key: impl Into<String>, props: CardProps) -> Self {
1510        Self {
1511            key: key.into(),
1512            component: Component::Card(props),
1513            action: None,
1514            visibility: None,
1515        }
1516    }
1517
1518    /// Create a Table component node.
1519    pub fn table(key: impl Into<String>, props: TableProps) -> Self {
1520        Self {
1521            key: key.into(),
1522            component: Component::Table(props),
1523            action: None,
1524            visibility: None,
1525        }
1526    }
1527
1528    /// Create a Form component node.
1529    pub fn form(key: impl Into<String>, props: FormProps) -> Self {
1530        Self {
1531            key: key.into(),
1532            component: Component::Form(props),
1533            action: None,
1534            visibility: None,
1535        }
1536    }
1537
1538    /// Create a DetailForm component node.
1539    ///
1540    /// Renders the same outer scaffold in both [`EditMode::View`] and
1541    /// [`EditMode::Edit`] modes per the structural coherence contract
1542    /// (147-UI-SPEC §5). In Edit mode the scaffold is additionally
1543    /// wrapped in a `<form>` with method-spoofing for PUT/PATCH/DELETE.
1544    ///
1545    /// **Authoring rule (Option A, 147-UI-SPEC §9).** When a
1546    /// [`DetailField::input`] is an `Input` / `Select` / `Textarea` /
1547    /// `Checkbox` / `Switch` component, the caller MUST set the input's
1548    /// `label` prop to an empty string. The `<dt>` already provides the
1549    /// visible label; DetailForm does not mutate caller-supplied props.
1550    /// When Option A is applied, the renderer also emits `aria-label`
1551    /// derived from the field's `label` so screen readers retain context.
1552    pub fn detail_form(key: impl Into<String>, props: DetailFormProps) -> Self {
1553        Self {
1554            key: key.into(),
1555            component: Component::DetailForm(props),
1556            action: None,
1557            visibility: None,
1558        }
1559    }
1560
1561    /// Create a [`Component::RichTextEditor`] node.
1562    ///
1563    /// If `props.name` is empty, the `name` argument is used to populate it.
1564    /// Otherwise `props.name` wins — callers that already set the field on
1565    /// `props` keep their value (D-26).
1566    pub fn rich_text_editor(name: impl Into<String>, props: RichTextEditorProps) -> Self {
1567        let name_arg: String = name.into();
1568        let final_props = if props.name.is_empty() {
1569            let mut p = props;
1570            p.name = name_arg.clone();
1571            p
1572        } else {
1573            props
1574        };
1575        Self {
1576            key: name_arg,
1577            component: Component::RichTextEditor(final_props),
1578            action: None,
1579            visibility: None,
1580        }
1581    }
1582
1583    /// Create a Button component node.
1584    pub fn button(key: impl Into<String>, props: ButtonProps) -> Self {
1585        Self {
1586            key: key.into(),
1587            component: Component::Button(props),
1588            action: None,
1589            visibility: None,
1590        }
1591    }
1592
1593    /// Create an Input component node.
1594    pub fn input(key: impl Into<String>, props: InputProps) -> Self {
1595        Self {
1596            key: key.into(),
1597            component: Component::Input(props),
1598            action: None,
1599            visibility: None,
1600        }
1601    }
1602
1603    /// Create a Select component node.
1604    pub fn select(key: impl Into<String>, props: SelectProps) -> Self {
1605        Self {
1606            key: key.into(),
1607            component: Component::Select(props),
1608            action: None,
1609            visibility: None,
1610        }
1611    }
1612
1613    /// Create an Alert component node.
1614    pub fn alert(key: impl Into<String>, props: AlertProps) -> Self {
1615        Self {
1616            key: key.into(),
1617            component: Component::Alert(props),
1618            action: None,
1619            visibility: None,
1620        }
1621    }
1622
1623    /// Create a Badge component node.
1624    pub fn badge(key: impl Into<String>, props: BadgeProps) -> Self {
1625        Self {
1626            key: key.into(),
1627            component: Component::Badge(props),
1628            action: None,
1629            visibility: None,
1630        }
1631    }
1632
1633    /// Create a Modal component node.
1634    pub fn modal(key: impl Into<String>, props: ModalProps) -> Self {
1635        Self {
1636            key: key.into(),
1637            component: Component::Modal(props),
1638            action: None,
1639            visibility: None,
1640        }
1641    }
1642
1643    /// Create a Text component node.
1644    pub fn text(key: impl Into<String>, props: TextProps) -> Self {
1645        Self {
1646            key: key.into(),
1647            component: Component::Text(props),
1648            action: None,
1649            visibility: None,
1650        }
1651    }
1652
1653    /// Create a Checkbox component node.
1654    pub fn checkbox(key: impl Into<String>, props: CheckboxProps) -> Self {
1655        Self {
1656            key: key.into(),
1657            component: Component::Checkbox(props),
1658            action: None,
1659            visibility: None,
1660        }
1661    }
1662
1663    /// Create a Switch component node.
1664    pub fn switch(key: impl Into<String>, props: SwitchProps) -> Self {
1665        Self {
1666            key: key.into(),
1667            component: Component::Switch(props),
1668            action: None,
1669            visibility: None,
1670        }
1671    }
1672
1673    /// Create a Separator component node.
1674    pub fn separator(key: impl Into<String>, props: SeparatorProps) -> Self {
1675        Self {
1676            key: key.into(),
1677            component: Component::Separator(props),
1678            action: None,
1679            visibility: None,
1680        }
1681    }
1682
1683    /// Create a DescriptionList component node.
1684    pub fn description_list(key: impl Into<String>, props: DescriptionListProps) -> Self {
1685        Self {
1686            key: key.into(),
1687            component: Component::DescriptionList(props),
1688            action: None,
1689            visibility: None,
1690        }
1691    }
1692
1693    /// Create a Tabs component node.
1694    pub fn tabs(key: impl Into<String>, props: TabsProps) -> Self {
1695        Self {
1696            key: key.into(),
1697            component: Component::Tabs(props),
1698            action: None,
1699            visibility: None,
1700        }
1701    }
1702
1703    /// Create a Breadcrumb component node.
1704    pub fn breadcrumb(key: impl Into<String>, props: BreadcrumbProps) -> Self {
1705        Self {
1706            key: key.into(),
1707            component: Component::Breadcrumb(props),
1708            action: None,
1709            visibility: None,
1710        }
1711    }
1712
1713    /// Create a Pagination component node.
1714    pub fn pagination(key: impl Into<String>, props: PaginationProps) -> Self {
1715        Self {
1716            key: key.into(),
1717            component: Component::Pagination(props),
1718            action: None,
1719            visibility: None,
1720        }
1721    }
1722
1723    /// Create a Progress component node.
1724    pub fn progress(key: impl Into<String>, props: ProgressProps) -> Self {
1725        Self {
1726            key: key.into(),
1727            component: Component::Progress(props),
1728            action: None,
1729            visibility: None,
1730        }
1731    }
1732
1733    /// Create an Avatar component node.
1734    pub fn avatar(key: impl Into<String>, props: AvatarProps) -> Self {
1735        Self {
1736            key: key.into(),
1737            component: Component::Avatar(props),
1738            action: None,
1739            visibility: None,
1740        }
1741    }
1742
1743    /// Create a Skeleton component node.
1744    pub fn skeleton(key: impl Into<String>, props: SkeletonProps) -> Self {
1745        Self {
1746            key: key.into(),
1747            component: Component::Skeleton(props),
1748            action: None,
1749            visibility: None,
1750        }
1751    }
1752
1753    /// Create a StatCard component node.
1754    pub fn stat_card(key: impl Into<String>, props: StatCardProps) -> Self {
1755        Self {
1756            key: key.into(),
1757            component: Component::StatCard(props),
1758            action: None,
1759            visibility: None,
1760        }
1761    }
1762
1763    /// Create a Checklist component node.
1764    pub fn checklist(key: impl Into<String>, props: ChecklistProps) -> Self {
1765        Self {
1766            key: key.into(),
1767            component: Component::Checklist(props),
1768            action: None,
1769            visibility: None,
1770        }
1771    }
1772
1773    /// Create a Toast component node.
1774    pub fn toast(key: impl Into<String>, props: ToastProps) -> Self {
1775        Self {
1776            key: key.into(),
1777            component: Component::Toast(props),
1778            action: None,
1779            visibility: None,
1780        }
1781    }
1782
1783    /// Create a NotificationDropdown component node.
1784    pub fn notification_dropdown(key: impl Into<String>, props: NotificationDropdownProps) -> Self {
1785        Self {
1786            key: key.into(),
1787            component: Component::NotificationDropdown(props),
1788            action: None,
1789            visibility: None,
1790        }
1791    }
1792
1793    /// Create a Sidebar component node.
1794    pub fn sidebar(key: impl Into<String>, props: SidebarProps) -> Self {
1795        Self {
1796            key: key.into(),
1797            component: Component::Sidebar(props),
1798            action: None,
1799            visibility: None,
1800        }
1801    }
1802
1803    /// Create a Header component node.
1804    pub fn header(key: impl Into<String>, props: HeaderProps) -> Self {
1805        Self {
1806            key: key.into(),
1807            component: Component::Header(props),
1808            action: None,
1809            visibility: None,
1810        }
1811    }
1812
1813    /// Create a Grid component node.
1814    pub fn grid(key: impl Into<String>, props: GridProps) -> Self {
1815        Self {
1816            key: key.into(),
1817            component: Component::Grid(props),
1818            action: None,
1819            visibility: None,
1820        }
1821    }
1822
1823    /// Create a Collapsible component node.
1824    pub fn collapsible(key: impl Into<String>, props: CollapsibleProps) -> Self {
1825        Self {
1826            key: key.into(),
1827            component: Component::Collapsible(props),
1828            action: None,
1829            visibility: None,
1830        }
1831    }
1832
1833    /// Create an EmptyState component node.
1834    pub fn empty_state(key: impl Into<String>, props: EmptyStateProps) -> Self {
1835        Self {
1836            key: key.into(),
1837            component: Component::EmptyState(props),
1838            action: None,
1839            visibility: None,
1840        }
1841    }
1842
1843    /// Create a FormSection component node.
1844    pub fn form_section(key: impl Into<String>, props: FormSectionProps) -> Self {
1845        Self {
1846            key: key.into(),
1847            component: Component::FormSection(props),
1848            action: None,
1849            visibility: None,
1850        }
1851    }
1852
1853    /// Create a DropdownMenu component node.
1854    pub fn dropdown_menu(key: impl Into<String>, props: DropdownMenuProps) -> Self {
1855        Self {
1856            key: key.into(),
1857            component: Component::DropdownMenu(props),
1858            action: None,
1859            visibility: None,
1860        }
1861    }
1862
1863    /// Create a KanbanBoard component node.
1864    pub fn kanban_board(key: impl Into<String>, props: KanbanBoardProps) -> Self {
1865        Self {
1866            key: key.into(),
1867            component: Component::KanbanBoard(props),
1868            action: None,
1869            visibility: None,
1870        }
1871    }
1872
1873    /// Create a CalendarCell component node.
1874    pub fn calendar_cell(key: impl Into<String>, props: CalendarCellProps) -> Self {
1875        Self {
1876            key: key.into(),
1877            component: Component::CalendarCell(props),
1878            action: None,
1879            visibility: None,
1880        }
1881    }
1882
1883    /// Create an ActionCard component node.
1884    pub fn action_card(key: impl Into<String>, props: ActionCardProps) -> Self {
1885        Self {
1886            key: key.into(),
1887            component: Component::ActionCard(props),
1888            action: None,
1889            visibility: None,
1890        }
1891    }
1892
1893    /// Create a ProductTile component node.
1894    pub fn product_tile(key: impl Into<String>, props: ProductTileProps) -> Self {
1895        Self {
1896            key: key.into(),
1897            component: Component::ProductTile(props),
1898            action: None,
1899            visibility: None,
1900        }
1901    }
1902
1903    /// Create a DataTable component node.
1904    pub fn data_table(key: impl Into<String>, props: DataTableProps) -> Self {
1905        Self {
1906            key: key.into(),
1907            component: Component::DataTable(props),
1908            action: None,
1909            visibility: None,
1910        }
1911    }
1912
1913    /// Create an Image component node.
1914    pub fn image(key: impl Into<String>, props: ImageProps) -> Self {
1915        Self {
1916            key: key.into(),
1917            component: Component::Image(props),
1918            action: None,
1919            visibility: None,
1920        }
1921    }
1922
1923    /// Create a Plugin component node.
1924    ///
1925    /// Use `plugin_component` to avoid ambiguity with any `plugin` module.
1926    pub fn plugin_component(key: impl Into<String>, props: PluginProps) -> Self {
1927        Self {
1928            key: key.into(),
1929            component: Component::Plugin(props),
1930            action: None,
1931            visibility: None,
1932        }
1933    }
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938    use super::*;
1939    use crate::action::HttpMethod;
1940    use crate::visibility::{VisibilityCondition, VisibilityOperator};
1941
1942    #[test]
1943    fn card_component_tagged_serialization() {
1944        let card = Component::Card(CardProps {
1945            title: "Test Card".to_string(),
1946            description: Some("A description".to_string()),
1947            children: vec![],
1948            footer: vec![],
1949            max_width: None,
1950        });
1951        let json = serde_json::to_value(&card).unwrap();
1952        assert_eq!(json["type"], "Card");
1953        assert_eq!(json["title"], "Test Card");
1954        assert_eq!(json["description"], "A description");
1955    }
1956
1957    #[test]
1958    fn button_variant_defaults_to_default() {
1959        let json = r#"{"type": "Button", "label": "Click me"}"#;
1960        let component: Component = serde_json::from_str(json).unwrap();
1961        match component {
1962            Component::Button(props) => {
1963                assert_eq!(props.variant, ButtonVariant::Default);
1964                assert_eq!(props.label, "Click me");
1965            }
1966            _ => panic!("expected Button"),
1967        }
1968    }
1969
1970    #[test]
1971    fn input_type_defaults_to_text() {
1972        let json = r#"{"type": "Input", "field": "email", "label": "Email"}"#;
1973        let component: Component = serde_json::from_str(json).unwrap();
1974        match component {
1975            Component::Input(props) => {
1976                assert_eq!(props.input_type, InputType::Text);
1977                assert_eq!(props.field, "email");
1978            }
1979            _ => panic!("expected Input"),
1980        }
1981    }
1982
1983    #[test]
1984    fn alert_variant_defaults_to_info() {
1985        let json = r#"{"type": "Alert", "message": "Hello"}"#;
1986        let component: Component = serde_json::from_str(json).unwrap();
1987        match component {
1988            Component::Alert(props) => assert_eq!(props.variant, AlertVariant::Info),
1989            _ => panic!("expected Alert"),
1990        }
1991    }
1992
1993    #[test]
1994    fn badge_variant_defaults_to_default() {
1995        let json = r#"{"type": "Badge", "label": "New"}"#;
1996        let component: Component = serde_json::from_str(json).unwrap();
1997        match component {
1998            Component::Badge(props) => assert_eq!(props.variant, BadgeVariant::Default),
1999            _ => panic!("expected Badge"),
2000        }
2001    }
2002
2003    #[test]
2004    fn text_element_defaults_to_p() {
2005        let json = r#"{"type": "Text", "content": "Hello world"}"#;
2006        let component: Component = serde_json::from_str(json).unwrap();
2007        match component {
2008            Component::Text(props) => {
2009                assert_eq!(props.element, TextElement::P);
2010                assert_eq!(props.content, "Hello world");
2011            }
2012            _ => panic!("expected Text"),
2013        }
2014    }
2015
2016    #[test]
2017    fn table_component_round_trips() {
2018        let table = Component::Table(TableProps {
2019            columns: vec![
2020                Column {
2021                    key: "name".to_string(),
2022                    label: "Name".to_string(),
2023                    format: None,
2024                },
2025                Column {
2026                    key: "created_at".to_string(),
2027                    label: "Created".to_string(),
2028                    format: Some(ColumnFormat::Date),
2029                },
2030            ],
2031            data_path: "/data/users".to_string(),
2032            row_actions: None,
2033            empty_message: Some("No users found".to_string()),
2034            sortable: None,
2035            sort_column: None,
2036            sort_direction: None,
2037        });
2038        let json = serde_json::to_string(&table).unwrap();
2039        let parsed: Component = serde_json::from_str(&json).unwrap();
2040        assert_eq!(parsed, table);
2041    }
2042
2043    #[test]
2044    fn select_component_round_trips() {
2045        let select = Component::Select(SelectProps {
2046            field: "role".to_string(),
2047            label: "Role".to_string(),
2048            options: vec![
2049                SelectOption {
2050                    value: "admin".to_string(),
2051                    label: "Administrator".to_string(),
2052                },
2053                SelectOption {
2054                    value: "user".to_string(),
2055                    label: "User".to_string(),
2056                },
2057            ],
2058            placeholder: Some("Select a role".to_string()),
2059            required: Some(true),
2060            disabled: None,
2061            error: None,
2062            description: None,
2063            default_value: None,
2064            data_path: None,
2065        });
2066        let json = serde_json::to_string(&select).unwrap();
2067        let parsed: Component = serde_json::from_str(&json).unwrap();
2068        assert_eq!(parsed, select);
2069    }
2070
2071    #[test]
2072    fn modal_component_round_trips() {
2073        let modal = Component::Modal(ModalProps {
2074            id: "modal-confirm".to_string(),
2075            title: "Confirm".to_string(),
2076            description: None,
2077            children: vec![ComponentNode {
2078                key: "msg".to_string(),
2079                component: Component::Text(TextProps {
2080                    content: "Are you sure?".to_string(),
2081                    element: TextElement::P,
2082                }),
2083                action: None,
2084                visibility: None,
2085            }],
2086            footer: vec![],
2087            trigger_label: Some("Open".to_string()),
2088        });
2089        let json = serde_json::to_string(&modal).unwrap();
2090        let parsed: Component = serde_json::from_str(&json).unwrap();
2091        assert_eq!(parsed, modal);
2092    }
2093
2094    #[test]
2095    fn form_component_round_trips() {
2096        let form = Component::Form(FormProps {
2097            action: Action {
2098                handler: "users.store".to_string(),
2099                url: None,
2100                method: HttpMethod::Post,
2101                confirm: None,
2102                on_success: None,
2103                on_error: None,
2104                target: None,
2105            },
2106            fields: vec![ComponentNode {
2107                key: "email-input".to_string(),
2108                component: Component::Input(InputProps {
2109                    field: "email".to_string(),
2110                    label: "Email".to_string(),
2111                    input_type: InputType::Email,
2112                    placeholder: Some("user@example.com".to_string()),
2113                    required: Some(true),
2114                    disabled: None,
2115                    error: None,
2116                    description: None,
2117                    default_value: None,
2118                    data_path: None,
2119                    step: None,
2120                    list: None,
2121                }),
2122                action: None,
2123                visibility: None,
2124            }],
2125            method: None,
2126            guard: None,
2127            max_width: None,
2128        });
2129        let json = serde_json::to_string(&form).unwrap();
2130        let parsed: Component = serde_json::from_str(&json).unwrap();
2131        assert_eq!(parsed, form);
2132    }
2133
2134    #[test]
2135    fn component_node_with_action_and_visibility() {
2136        let node = ComponentNode {
2137            key: "create-btn".to_string(),
2138            component: Component::Button(ButtonProps {
2139                label: "Create User".to_string(),
2140                variant: ButtonVariant::Default,
2141                size: Size::Default,
2142                disabled: None,
2143                icon: None,
2144                icon_position: None,
2145                button_type: None,
2146            }),
2147            action: Some(Action {
2148                handler: "users.create".to_string(),
2149                url: None,
2150                method: HttpMethod::Post,
2151                confirm: None,
2152                on_success: None,
2153                on_error: None,
2154                target: None,
2155            }),
2156            visibility: Some(Visibility::Condition(VisibilityCondition {
2157                path: "/auth/user/role".to_string(),
2158                operator: VisibilityOperator::Eq,
2159                value: Some(serde_json::Value::String("admin".to_string())),
2160            })),
2161        };
2162        let json = serde_json::to_string(&node).unwrap();
2163        let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
2164        assert_eq!(parsed, node);
2165
2166        // Verify flattened structure includes type
2167        let value = serde_json::to_value(&node).unwrap();
2168        assert_eq!(value["type"], "Button");
2169        assert_eq!(value["key"], "create-btn");
2170        assert!(value.get("action").is_some());
2171        assert!(value.get("visibility").is_some());
2172    }
2173
2174    #[test]
2175    fn all_component_variants_serialize() {
2176        let components: Vec<Component> = vec![
2177            Component::Card(CardProps {
2178                title: "t".to_string(),
2179                description: None,
2180                children: vec![],
2181                footer: vec![],
2182                max_width: None,
2183            }),
2184            Component::Table(TableProps {
2185                columns: vec![],
2186                data_path: "/d".to_string(),
2187                row_actions: None,
2188                empty_message: None,
2189                sortable: None,
2190                sort_column: None,
2191                sort_direction: None,
2192            }),
2193            Component::Form(FormProps {
2194                action: Action {
2195                    handler: "h.m".to_string(),
2196                    url: None,
2197                    method: HttpMethod::Post,
2198                    confirm: None,
2199                    on_success: None,
2200                    on_error: None,
2201                    target: None,
2202                },
2203                fields: vec![],
2204                method: None,
2205                guard: None,
2206                max_width: None,
2207            }),
2208            Component::Button(ButtonProps {
2209                label: "b".to_string(),
2210                variant: ButtonVariant::Default,
2211                size: Size::Default,
2212                disabled: None,
2213                icon: None,
2214                icon_position: None,
2215                button_type: None,
2216            }),
2217            Component::Input(InputProps {
2218                field: "f".to_string(),
2219                label: "l".to_string(),
2220                input_type: InputType::Text,
2221                placeholder: None,
2222                required: None,
2223                disabled: None,
2224                error: None,
2225                description: None,
2226                default_value: None,
2227                data_path: None,
2228                step: None,
2229                list: None,
2230            }),
2231            Component::Select(SelectProps {
2232                field: "f".to_string(),
2233                label: "l".to_string(),
2234                options: vec![],
2235                placeholder: None,
2236                required: None,
2237                disabled: None,
2238                error: None,
2239                description: None,
2240                default_value: None,
2241                data_path: None,
2242            }),
2243            Component::Alert(AlertProps {
2244                message: "m".to_string(),
2245                variant: AlertVariant::Info,
2246                title: None,
2247            }),
2248            Component::Badge(BadgeProps {
2249                label: "b".to_string(),
2250                variant: BadgeVariant::Default,
2251            }),
2252            Component::Modal(ModalProps {
2253                id: "modal-t".to_string(),
2254                title: "t".to_string(),
2255                description: None,
2256                children: vec![],
2257                footer: vec![],
2258                trigger_label: None,
2259            }),
2260            Component::Text(TextProps {
2261                content: "c".to_string(),
2262                element: TextElement::P,
2263            }),
2264            Component::Checkbox(CheckboxProps {
2265                field: "f".to_string(),
2266                value: None,
2267                label: "l".to_string(),
2268                description: None,
2269                checked: None,
2270                data_path: None,
2271                required: None,
2272                disabled: None,
2273                error: None,
2274            }),
2275            Component::Switch(SwitchProps {
2276                field: "f".to_string(),
2277                label: "l".to_string(),
2278                description: None,
2279                checked: None,
2280                data_path: None,
2281                required: None,
2282                disabled: None,
2283                error: None,
2284                action: None,
2285                compact: false,
2286            }),
2287            Component::Separator(SeparatorProps { orientation: None }),
2288            Component::DescriptionList(DescriptionListProps {
2289                items: vec![DescriptionItem {
2290                    label: "k".to_string(),
2291                    value: "v".to_string(),
2292                    format: None,
2293                }],
2294                columns: None,
2295            }),
2296            Component::Tabs(TabsProps {
2297                default_tab: "t1".to_string(),
2298                tabs: vec![Tab {
2299                    value: "t1".to_string(),
2300                    label: "Tab 1".to_string(),
2301                    children: vec![],
2302                }],
2303            }),
2304            Component::Breadcrumb(BreadcrumbProps {
2305                items: vec![BreadcrumbItem {
2306                    label: "Home".to_string(),
2307                    url: Some("/".to_string()),
2308                }],
2309            }),
2310            Component::Pagination(PaginationProps {
2311                current_page: 1,
2312                per_page: 10,
2313                total: 100,
2314                base_url: None,
2315            }),
2316            Component::Progress(ProgressProps {
2317                value: 50,
2318                max: None,
2319                label: None,
2320            }),
2321            Component::Avatar(AvatarProps {
2322                src: None,
2323                alt: "User".to_string(),
2324                fallback: Some("U".to_string()),
2325                size: None,
2326            }),
2327            Component::Skeleton(SkeletonProps {
2328                width: None,
2329                height: None,
2330                rounded: None,
2331            }),
2332            Component::StatCard(StatCardProps {
2333                label: "Revenue".to_string(),
2334                value: "$1,234".to_string(),
2335                icon: None,
2336                subtitle: None,
2337                sse_target: None,
2338            }),
2339            Component::Checklist(ChecklistProps {
2340                title: "Tasks".to_string(),
2341                items: vec![],
2342                dismissible: true,
2343                dismiss_label: None,
2344                data_key: None,
2345            }),
2346            Component::Toast(ToastProps {
2347                message: "Saved!".to_string(),
2348                variant: ToastVariant::Success,
2349                timeout: None,
2350                dismissible: true,
2351            }),
2352            Component::NotificationDropdown(NotificationDropdownProps {
2353                notifications: vec![],
2354                empty_text: None,
2355            }),
2356            Component::Sidebar(SidebarProps {
2357                fixed_top: vec![],
2358                groups: vec![],
2359                fixed_bottom: vec![],
2360            }),
2361            Component::Header(HeaderProps {
2362                business_name: "Acme".to_string(),
2363                notification_count: None,
2364                user_name: None,
2365                user_avatar: None,
2366                logout_url: None,
2367            }),
2368            Component::Image(ImageProps::url("/img/screenshot.png", "Page screenshot")),
2369        ];
2370        assert_eq!(components.len(), 27, "should have 27 component variants");
2371        let expected_types = [
2372            "Card",
2373            "Table",
2374            "Form",
2375            "Button",
2376            "Input",
2377            "Select",
2378            "Alert",
2379            "Badge",
2380            "Modal",
2381            "Text",
2382            "Checkbox",
2383            "Switch",
2384            "Separator",
2385            "DescriptionList",
2386            "Tabs",
2387            "Breadcrumb",
2388            "Pagination",
2389            "Progress",
2390            "Avatar",
2391            "Skeleton",
2392            "StatCard",
2393            "Checklist",
2394            "Toast",
2395            "NotificationDropdown",
2396            "Sidebar",
2397            "Header",
2398            "Image",
2399        ];
2400        for (component, expected_type) in components.iter().zip(expected_types.iter()) {
2401            let json = serde_json::to_value(component).unwrap();
2402            assert_eq!(
2403                json["type"], *expected_type,
2404                "component should serialize with type={expected_type}"
2405            );
2406            let roundtripped: Component = serde_json::from_value(json).unwrap();
2407            assert_eq!(&roundtripped, component);
2408        }
2409    }
2410
2411    #[test]
2412    fn size_enum_serialization() {
2413        let cases = [
2414            (Size::Xs, "xs"),
2415            (Size::Sm, "sm"),
2416            (Size::Default, "default"),
2417            (Size::Lg, "lg"),
2418        ];
2419        for (size, expected) in &cases {
2420            let json = serde_json::to_value(size).unwrap();
2421            assert_eq!(json, *expected);
2422            let parsed: Size = serde_json::from_value(json).unwrap();
2423            assert_eq!(&parsed, size);
2424        }
2425    }
2426
2427    #[test]
2428    fn icon_position_serialization() {
2429        let cases = [(IconPosition::Left, "left"), (IconPosition::Right, "right")];
2430        for (pos, expected) in &cases {
2431            let json = serde_json::to_value(pos).unwrap();
2432            assert_eq!(json, *expected);
2433            let parsed: IconPosition = serde_json::from_value(json).unwrap();
2434            assert_eq!(&parsed, pos);
2435        }
2436    }
2437
2438    #[test]
2439    fn sort_direction_serialization() {
2440        let cases = [(SortDirection::Asc, "asc"), (SortDirection::Desc, "desc")];
2441        for (dir, expected) in &cases {
2442            let json = serde_json::to_value(dir).unwrap();
2443            assert_eq!(json, *expected);
2444            let parsed: SortDirection = serde_json::from_value(json).unwrap();
2445            assert_eq!(&parsed, dir);
2446        }
2447    }
2448
2449    #[test]
2450    fn button_with_size_and_icon() {
2451        let button = Component::Button(ButtonProps {
2452            label: "Save".to_string(),
2453            variant: ButtonVariant::Default,
2454            size: Size::Lg,
2455            disabled: None,
2456            icon: Some("save".to_string()),
2457            icon_position: Some(IconPosition::Left),
2458            button_type: None,
2459        });
2460        let json = serde_json::to_value(&button).unwrap();
2461        assert_eq!(json["size"], "lg");
2462        assert_eq!(json["icon"], "save");
2463        assert_eq!(json["icon_position"], "left");
2464        let parsed: Component = serde_json::from_value(json).unwrap();
2465        assert_eq!(parsed, button);
2466    }
2467
2468    #[test]
2469    fn card_with_footer() {
2470        let card = Component::Card(CardProps {
2471            title: "Actions".to_string(),
2472            description: None,
2473            children: vec![],
2474            max_width: None,
2475            footer: vec![ComponentNode {
2476                key: "cancel".to_string(),
2477                component: Component::Button(ButtonProps {
2478                    label: "Cancel".to_string(),
2479                    variant: ButtonVariant::Outline,
2480                    size: Size::Default,
2481                    disabled: None,
2482                    icon: None,
2483                    icon_position: None,
2484                    button_type: None,
2485                }),
2486                action: None,
2487                visibility: None,
2488            }],
2489        });
2490        let json = serde_json::to_value(&card).unwrap();
2491        assert!(json["footer"].is_array());
2492        assert_eq!(json["footer"][0]["label"], "Cancel");
2493        let parsed: Component = serde_json::from_value(json).unwrap();
2494        assert_eq!(parsed, card);
2495    }
2496
2497    #[test]
2498    fn input_with_error_and_description() {
2499        let input = Component::Input(InputProps {
2500            field: "email".to_string(),
2501            label: "Email".to_string(),
2502            input_type: InputType::Email,
2503            placeholder: None,
2504            required: Some(true),
2505            disabled: Some(false),
2506            error: Some("Invalid email".to_string()),
2507            description: Some("Your work email".to_string()),
2508            default_value: Some("user@example.com".to_string()),
2509            data_path: None,
2510            step: None,
2511            list: None,
2512        });
2513        let json = serde_json::to_value(&input).unwrap();
2514        assert_eq!(json["error"], "Invalid email");
2515        assert_eq!(json["description"], "Your work email");
2516        assert_eq!(json["default_value"], "user@example.com");
2517        assert_eq!(json["disabled"], false);
2518        let parsed: Component = serde_json::from_value(json).unwrap();
2519        assert_eq!(parsed, input);
2520    }
2521
2522    #[test]
2523    fn select_with_default_value() {
2524        let select = Component::Select(SelectProps {
2525            field: "role".to_string(),
2526            label: "Role".to_string(),
2527            options: vec![SelectOption {
2528                value: "admin".to_string(),
2529                label: "Admin".to_string(),
2530            }],
2531            placeholder: None,
2532            required: None,
2533            disabled: Some(true),
2534            error: Some("Required field".to_string()),
2535            description: Some("User role".to_string()),
2536            default_value: Some("admin".to_string()),
2537            data_path: None,
2538        });
2539        let json = serde_json::to_value(&select).unwrap();
2540        assert_eq!(json["default_value"], "admin");
2541        assert_eq!(json["error"], "Required field");
2542        assert_eq!(json["description"], "User role");
2543        assert_eq!(json["disabled"], true);
2544        let parsed: Component = serde_json::from_value(json).unwrap();
2545        assert_eq!(parsed, select);
2546    }
2547
2548    #[test]
2549    fn alert_with_title() {
2550        let alert = Component::Alert(AlertProps {
2551            message: "Something happened".to_string(),
2552            variant: AlertVariant::Warning,
2553            title: Some("Warning".to_string()),
2554        });
2555        let json = serde_json::to_value(&alert).unwrap();
2556        assert_eq!(json["title"], "Warning");
2557        assert_eq!(json["message"], "Something happened");
2558        let parsed: Component = serde_json::from_value(json).unwrap();
2559        assert_eq!(parsed, alert);
2560    }
2561
2562    #[test]
2563    fn modal_with_footer_and_description() {
2564        let modal = Component::Modal(ModalProps {
2565            id: "modal-delete-item".to_string(),
2566            title: "Delete Item".to_string(),
2567            description: Some("This action cannot be undone.".to_string()),
2568            children: vec![],
2569            footer: vec![ComponentNode {
2570                key: "confirm".to_string(),
2571                component: Component::Button(ButtonProps {
2572                    label: "Delete".to_string(),
2573                    variant: ButtonVariant::Destructive,
2574                    size: Size::Default,
2575                    disabled: None,
2576                    icon: None,
2577                    icon_position: None,
2578                    button_type: None,
2579                }),
2580                action: None,
2581                visibility: None,
2582            }],
2583            trigger_label: Some("Delete".to_string()),
2584        });
2585        let json = serde_json::to_value(&modal).unwrap();
2586        assert_eq!(json["description"], "This action cannot be undone.");
2587        assert!(json["footer"].is_array());
2588        assert_eq!(json["footer"][0]["label"], "Delete");
2589        let parsed: Component = serde_json::from_value(json).unwrap();
2590        assert_eq!(parsed, modal);
2591    }
2592
2593    #[test]
2594    fn table_with_sort_props() {
2595        let table = Component::Table(TableProps {
2596            columns: vec![Column {
2597                key: "name".to_string(),
2598                label: "Name".to_string(),
2599                format: None,
2600            }],
2601            data_path: "/data/users".to_string(),
2602            row_actions: None,
2603            empty_message: None,
2604            sortable: Some(true),
2605            sort_column: Some("name".to_string()),
2606            sort_direction: Some(SortDirection::Desc),
2607        });
2608        let json = serde_json::to_value(&table).unwrap();
2609        assert_eq!(json["sortable"], true);
2610        assert_eq!(json["sort_column"], "name");
2611        assert_eq!(json["sort_direction"], "desc");
2612        let parsed: Component = serde_json::from_value(json).unwrap();
2613        assert_eq!(parsed, table);
2614    }
2615
2616    #[test]
2617    fn aligned_button_variants_serialize() {
2618        let cases = [
2619            (ButtonVariant::Default, "default"),
2620            (ButtonVariant::Secondary, "secondary"),
2621            (ButtonVariant::Destructive, "destructive"),
2622            (ButtonVariant::Outline, "outline"),
2623            (ButtonVariant::Ghost, "ghost"),
2624            (ButtonVariant::Link, "link"),
2625        ];
2626        for (variant, expected) in &cases {
2627            let json = serde_json::to_value(variant).unwrap();
2628            assert_eq!(
2629                json, *expected,
2630                "ButtonVariant::{variant:?} should serialize as {expected}"
2631            );
2632            let parsed: ButtonVariant = serde_json::from_value(json).unwrap();
2633            assert_eq!(&parsed, variant);
2634        }
2635    }
2636
2637    #[test]
2638    fn aligned_badge_variants_serialize() {
2639        let cases = [
2640            (BadgeVariant::Default, "default"),
2641            (BadgeVariant::Secondary, "secondary"),
2642            (BadgeVariant::Destructive, "destructive"),
2643            (BadgeVariant::Outline, "outline"),
2644        ];
2645        for (variant, expected) in &cases {
2646            let json = serde_json::to_value(variant).unwrap();
2647            assert_eq!(
2648                json, *expected,
2649                "BadgeVariant::{variant:?} should serialize as {expected}"
2650            );
2651            let parsed: BadgeVariant = serde_json::from_value(json).unwrap();
2652            assert_eq!(&parsed, variant);
2653        }
2654    }
2655
2656    #[test]
2657    fn checkbox_round_trips() {
2658        let checkbox = Component::Checkbox(CheckboxProps {
2659            field: "terms".to_string(),
2660            value: None,
2661            label: "Accept Terms".to_string(),
2662            description: Some("You must accept the terms".to_string()),
2663            checked: Some(true),
2664            data_path: None,
2665            required: Some(true),
2666            disabled: Some(false),
2667            error: None,
2668        });
2669        let json = serde_json::to_value(&checkbox).unwrap();
2670        assert_eq!(json["type"], "Checkbox");
2671        assert_eq!(json["field"], "terms");
2672        assert_eq!(json["checked"], true);
2673        assert_eq!(json["description"], "You must accept the terms");
2674        let parsed: Component = serde_json::from_value(json).unwrap();
2675        assert_eq!(parsed, checkbox);
2676    }
2677
2678    #[test]
2679    fn switch_round_trips() {
2680        let switch = Component::Switch(SwitchProps {
2681            field: "notifications".to_string(),
2682            label: "Enable Notifications".to_string(),
2683            description: Some("Receive email notifications".to_string()),
2684            checked: Some(false),
2685            data_path: None,
2686            required: None,
2687            disabled: Some(false),
2688            error: None,
2689            action: None,
2690            compact: false,
2691        });
2692        let json = serde_json::to_value(&switch).unwrap();
2693        assert_eq!(json["type"], "Switch");
2694        assert_eq!(json["field"], "notifications");
2695        assert_eq!(json["checked"], false);
2696        let parsed: Component = serde_json::from_value(json).unwrap();
2697        assert_eq!(parsed, switch);
2698    }
2699
2700    #[test]
2701    fn separator_defaults_to_horizontal() {
2702        let json = r#"{"type": "Separator"}"#;
2703        let component: Component = serde_json::from_str(json).unwrap();
2704        match component {
2705            Component::Separator(props) => {
2706                assert_eq!(props.orientation, None);
2707                // When orientation is None, frontend defaults to horizontal.
2708                // Explicit horizontal also round-trips correctly:
2709                let explicit = Component::Separator(SeparatorProps {
2710                    orientation: Some(Orientation::Horizontal),
2711                });
2712                let v = serde_json::to_value(&explicit).unwrap();
2713                assert_eq!(v["orientation"], "horizontal");
2714                let parsed: Component = serde_json::from_value(v).unwrap();
2715                assert_eq!(parsed, explicit);
2716            }
2717            _ => panic!("expected Separator"),
2718        }
2719    }
2720
2721    #[test]
2722    fn description_list_with_format() {
2723        let dl = Component::DescriptionList(DescriptionListProps {
2724            items: vec![
2725                DescriptionItem {
2726                    label: "Created".to_string(),
2727                    value: "2026-01-15".to_string(),
2728                    format: Some(ColumnFormat::Date),
2729                },
2730                DescriptionItem {
2731                    label: "Name".to_string(),
2732                    value: "Alice".to_string(),
2733                    format: None,
2734                },
2735            ],
2736            columns: Some(2),
2737        });
2738        let json = serde_json::to_value(&dl).unwrap();
2739        assert_eq!(json["type"], "DescriptionList");
2740        assert_eq!(json["columns"], 2);
2741        assert_eq!(json["items"][0]["format"], "date");
2742        assert!(json["items"][1].get("format").is_none());
2743        let parsed: Component = serde_json::from_value(json).unwrap();
2744        assert_eq!(parsed, dl);
2745    }
2746
2747    #[test]
2748    fn checkbox_with_error() {
2749        let checkbox = Component::Checkbox(CheckboxProps {
2750            field: "agree".to_string(),
2751            value: None,
2752            label: "I agree".to_string(),
2753            description: None,
2754            checked: None,
2755            data_path: None,
2756            required: Some(true),
2757            disabled: None,
2758            error: Some("You must agree".to_string()),
2759        });
2760        let json = serde_json::to_value(&checkbox).unwrap();
2761        assert_eq!(json["error"], "You must agree");
2762        assert!(json.get("description").is_none());
2763        assert!(json.get("checked").is_none());
2764        let parsed: Component = serde_json::from_value(json).unwrap();
2765        assert_eq!(parsed, checkbox);
2766    }
2767
2768    #[test]
2769    fn tabs_round_trips() {
2770        let tabs = Component::Tabs(TabsProps {
2771            default_tab: "general".to_string(),
2772            tabs: vec![
2773                Tab {
2774                    value: "general".to_string(),
2775                    label: "General".to_string(),
2776                    children: vec![ComponentNode {
2777                        key: "name-input".to_string(),
2778                        component: Component::Input(InputProps {
2779                            field: "name".to_string(),
2780                            label: "Name".to_string(),
2781                            input_type: InputType::Text,
2782                            placeholder: None,
2783                            required: None,
2784                            disabled: None,
2785                            error: None,
2786                            description: None,
2787                            default_value: None,
2788                            data_path: None,
2789                            step: None,
2790                            list: None,
2791                        }),
2792                        action: None,
2793                        visibility: None,
2794                    }],
2795                },
2796                Tab {
2797                    value: "security".to_string(),
2798                    label: "Security".to_string(),
2799                    children: vec![ComponentNode {
2800                        key: "password-input".to_string(),
2801                        component: Component::Input(InputProps {
2802                            field: "password".to_string(),
2803                            label: "Password".to_string(),
2804                            input_type: InputType::Password,
2805                            placeholder: None,
2806                            required: None,
2807                            disabled: None,
2808                            error: None,
2809                            description: None,
2810                            default_value: None,
2811                            data_path: None,
2812                            step: None,
2813                            list: None,
2814                        }),
2815                        action: None,
2816                        visibility: None,
2817                    }],
2818                },
2819            ],
2820        });
2821        let json = serde_json::to_string(&tabs).unwrap();
2822        let parsed: Component = serde_json::from_str(&json).unwrap();
2823        assert_eq!(parsed, tabs);
2824    }
2825
2826    #[test]
2827    fn breadcrumb_round_trips() {
2828        let breadcrumb = Component::Breadcrumb(BreadcrumbProps {
2829            items: vec![
2830                BreadcrumbItem {
2831                    label: "Home".to_string(),
2832                    url: Some("/".to_string()),
2833                },
2834                BreadcrumbItem {
2835                    label: "Users".to_string(),
2836                    url: Some("/users".to_string()),
2837                },
2838                BreadcrumbItem {
2839                    label: "Edit User".to_string(),
2840                    url: None,
2841                },
2842            ],
2843        });
2844        let json = serde_json::to_string(&breadcrumb).unwrap();
2845        let parsed: Component = serde_json::from_str(&json).unwrap();
2846        assert_eq!(parsed, breadcrumb);
2847
2848        // Verify last item has no URL serialized
2849        let value = serde_json::to_value(&breadcrumb).unwrap();
2850        assert!(value["items"][2].get("url").is_none());
2851    }
2852
2853    #[test]
2854    fn pagination_round_trips() {
2855        let pagination = Component::Pagination(PaginationProps {
2856            current_page: 3,
2857            per_page: 25,
2858            total: 150,
2859            base_url: None,
2860        });
2861        let json = serde_json::to_string(&pagination).unwrap();
2862        let parsed: Component = serde_json::from_str(&json).unwrap();
2863        assert_eq!(parsed, pagination);
2864    }
2865
2866    #[test]
2867    fn progress_round_trips() {
2868        let progress = Component::Progress(ProgressProps {
2869            value: 75,
2870            max: Some(100),
2871            label: Some("Uploading...".to_string()),
2872        });
2873        let json = serde_json::to_string(&progress).unwrap();
2874        let parsed: Component = serde_json::from_str(&json).unwrap();
2875        assert_eq!(parsed, progress);
2876
2877        let value = serde_json::to_value(&progress).unwrap();
2878        assert_eq!(value["value"], 75);
2879        assert_eq!(value["max"], 100);
2880        assert_eq!(value["label"], "Uploading...");
2881    }
2882
2883    #[test]
2884    fn avatar_with_fallback() {
2885        let avatar = Component::Avatar(AvatarProps {
2886            src: None,
2887            alt: "John Doe".to_string(),
2888            fallback: Some("JD".to_string()),
2889            size: Some(Size::Lg),
2890        });
2891        let json = serde_json::to_string(&avatar).unwrap();
2892        let parsed: Component = serde_json::from_str(&json).unwrap();
2893        assert_eq!(parsed, avatar);
2894
2895        let value = serde_json::to_value(&avatar).unwrap();
2896        assert!(value.get("src").is_none());
2897        assert_eq!(value["fallback"], "JD");
2898        assert_eq!(value["size"], "lg");
2899    }
2900
2901    #[test]
2902    fn skeleton_round_trips() {
2903        let skeleton = Component::Skeleton(SkeletonProps {
2904            width: Some("100%".to_string()),
2905            height: Some("40px".to_string()),
2906            rounded: Some(true),
2907        });
2908        let json = serde_json::to_string(&skeleton).unwrap();
2909        let parsed: Component = serde_json::from_str(&json).unwrap();
2910        assert_eq!(parsed, skeleton);
2911
2912        let value = serde_json::to_value(&skeleton).unwrap();
2913        assert_eq!(value["width"], "100%");
2914        assert_eq!(value["height"], "40px");
2915        assert_eq!(value["rounded"], true);
2916    }
2917
2918    #[test]
2919    fn tabs_deserializes_from_json() {
2920        let json = r#"{
2921            "type": "Tabs",
2922            "default_tab": "general",
2923            "tabs": [
2924                {
2925                    "value": "general",
2926                    "label": "General",
2927                    "children": [
2928                        {
2929                            "key": "name-input",
2930                            "type": "Input",
2931                            "field": "name",
2932                            "label": "Name"
2933                        }
2934                    ]
2935                },
2936                {
2937                    "value": "security",
2938                    "label": "Security"
2939                }
2940            ]
2941        }"#;
2942        let component: Component = serde_json::from_str(json).unwrap();
2943        match component {
2944            Component::Tabs(props) => {
2945                assert_eq!(props.default_tab, "general");
2946                assert_eq!(props.tabs.len(), 2);
2947                assert_eq!(props.tabs[0].value, "general");
2948                assert_eq!(props.tabs[0].children.len(), 1);
2949                assert_eq!(props.tabs[1].value, "security");
2950                assert!(props.tabs[1].children.is_empty());
2951            }
2952            _ => panic!("expected Tabs"),
2953        }
2954    }
2955
2956    #[test]
2957    fn input_data_path_round_trips() {
2958        let input = Component::Input(InputProps {
2959            field: "name".to_string(),
2960            label: "Name".to_string(),
2961            input_type: InputType::Text,
2962            placeholder: None,
2963            required: None,
2964            disabled: None,
2965            error: None,
2966            description: None,
2967            default_value: None,
2968            data_path: Some("/data/user/name".to_string()),
2969            step: None,
2970            list: None,
2971        });
2972        let json = serde_json::to_value(&input).unwrap();
2973        assert_eq!(json["data_path"], "/data/user/name");
2974        let parsed: Component = serde_json::from_value(json).unwrap();
2975        assert_eq!(parsed, input);
2976    }
2977
2978    #[test]
2979    fn select_data_path_round_trips() {
2980        let select = Component::Select(SelectProps {
2981            field: "role".to_string(),
2982            label: "Role".to_string(),
2983            options: vec![SelectOption {
2984                value: "admin".to_string(),
2985                label: "Admin".to_string(),
2986            }],
2987            placeholder: None,
2988            required: None,
2989            disabled: None,
2990            error: None,
2991            description: None,
2992            default_value: None,
2993            data_path: Some("/data/user/role".to_string()),
2994        });
2995        let json = serde_json::to_value(&select).unwrap();
2996        assert_eq!(json["data_path"], "/data/user/role");
2997        let parsed: Component = serde_json::from_value(json).unwrap();
2998        assert_eq!(parsed, select);
2999    }
3000
3001    #[test]
3002    fn checkbox_data_path_round_trips() {
3003        let checkbox = Component::Checkbox(CheckboxProps {
3004            field: "terms".to_string(),
3005            value: None,
3006            label: "Accept Terms".to_string(),
3007            description: None,
3008            checked: None,
3009            data_path: Some("/data/user/accepted_terms".to_string()),
3010            required: None,
3011            disabled: None,
3012            error: None,
3013        });
3014        let json = serde_json::to_value(&checkbox).unwrap();
3015        assert_eq!(json["data_path"], "/data/user/accepted_terms");
3016        let parsed: Component = serde_json::from_value(json).unwrap();
3017        assert_eq!(parsed, checkbox);
3018    }
3019
3020    #[test]
3021    fn switch_data_path_round_trips() {
3022        let switch = Component::Switch(SwitchProps {
3023            field: "notifications".to_string(),
3024            label: "Enable Notifications".to_string(),
3025            description: None,
3026            checked: None,
3027            data_path: Some("/data/user/notifications_enabled".to_string()),
3028            required: None,
3029            disabled: None,
3030            error: None,
3031            action: None,
3032            compact: false,
3033        });
3034        let json = serde_json::to_value(&switch).unwrap();
3035        assert_eq!(json["data_path"], "/data/user/notifications_enabled");
3036        let parsed: Component = serde_json::from_value(json).unwrap();
3037        assert_eq!(parsed, switch);
3038    }
3039
3040    // ─── Plugin variant tests ────────────────────────────────────────
3041
3042    #[test]
3043    fn unknown_type_deserializes_as_plugin() {
3044        let json = r#"{"type": "Map", "center": [40.7, -74.0], "zoom": 12}"#;
3045        let component: Component = serde_json::from_str(json).unwrap();
3046        match component {
3047            Component::Plugin(props) => {
3048                assert_eq!(props.plugin_type, "Map");
3049                assert_eq!(props.props["center"][0], 40.7);
3050                assert_eq!(props.props["center"][1], -74.0);
3051                assert_eq!(props.props["zoom"], 12);
3052                // "type" should be removed from props
3053                assert!(props.props.get("type").is_none());
3054            }
3055            _ => panic!("expected Plugin"),
3056        }
3057    }
3058
3059    #[test]
3060    fn plugin_round_trips() {
3061        let plugin = Component::Plugin(PluginProps {
3062            plugin_type: "Chart".to_string(),
3063            props: serde_json::json!({"data": [1, 2, 3], "style": "bar"}),
3064        });
3065        let json = serde_json::to_value(&plugin).unwrap();
3066        assert_eq!(json["type"], "Chart");
3067        assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
3068        assert_eq!(json["style"], "bar");
3069
3070        let parsed: Component = serde_json::from_value(json).unwrap();
3071        assert_eq!(parsed, plugin);
3072    }
3073
3074    #[test]
3075    fn plugin_serializes_with_type_field() {
3076        let plugin = Component::Plugin(PluginProps {
3077            plugin_type: "Map".to_string(),
3078            props: serde_json::json!({"lat": 51.5, "lng": -0.1}),
3079        });
3080        let json = serde_json::to_value(&plugin).unwrap();
3081        assert_eq!(json["type"], "Map");
3082        assert_eq!(json["lat"], 51.5);
3083        assert_eq!(json["lng"], -0.1);
3084    }
3085
3086    #[test]
3087    fn plugin_with_empty_props() {
3088        let json = r#"{"type": "CustomWidget"}"#;
3089        let component: Component = serde_json::from_str(json).unwrap();
3090        match component {
3091            Component::Plugin(props) => {
3092                assert_eq!(props.plugin_type, "CustomWidget");
3093                assert!(props.props.as_object().unwrap().is_empty());
3094            }
3095            _ => panic!("expected Plugin"),
3096        }
3097    }
3098
3099    #[test]
3100    fn plugin_in_component_node() {
3101        let node = ComponentNode {
3102            key: "map-1".to_string(),
3103            component: Component::Plugin(PluginProps {
3104                plugin_type: "Map".to_string(),
3105                props: serde_json::json!({"center": [0.0, 0.0]}),
3106            }),
3107            action: None,
3108            visibility: None,
3109        };
3110        let json = serde_json::to_string(&node).unwrap();
3111        let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
3112        assert_eq!(parsed, node);
3113
3114        let value = serde_json::to_value(&node).unwrap();
3115        assert_eq!(value["type"], "Map");
3116        assert_eq!(value["key"], "map-1");
3117    }
3118
3119    #[test]
3120    fn known_types_not_treated_as_plugin() {
3121        // All known type names must still deserialize to their specific variants.
3122        let known_types = [
3123            "Card",
3124            "Table",
3125            "Form",
3126            "Button",
3127            "Input",
3128            "Select",
3129            "Alert",
3130            "Badge",
3131            "Modal",
3132            "Text",
3133            "Checkbox",
3134            "Switch",
3135            "Separator",
3136            "DescriptionList",
3137            "Tabs",
3138            "Breadcrumb",
3139            "Pagination",
3140            "Progress",
3141            "Avatar",
3142            "Skeleton",
3143        ];
3144        for type_name in &known_types {
3145            // Construct minimal valid JSON for each type (using the
3146            // all_component_variants_serialize test data format).
3147            let json_str = match *type_name {
3148                "Card" => r#"{"type":"Card","title":"t"}"#,
3149                "Table" => r#"{"type":"Table","columns":[],"data_path":"/d"}"#,
3150                "Form" => r#"{"type":"Form","action":{"handler":"h","method":"POST"},"fields":[]}"#,
3151                "Button" => r#"{"type":"Button","label":"b"}"#,
3152                "Input" => r#"{"type":"Input","field":"f","label":"l"}"#,
3153                "Select" => r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3154                "Alert" => r#"{"type":"Alert","message":"m"}"#,
3155                "Badge" => r#"{"type":"Badge","label":"b"}"#,
3156                "Modal" => r#"{"type":"Modal","id":"modal-t","title":"t"}"#,
3157                "Text" => r#"{"type":"Text","content":"c"}"#,
3158                "Checkbox" => r#"{"type":"Checkbox","field":"f","label":"l"}"#,
3159                "Switch" => r#"{"type":"Switch","field":"f","label":"l"}"#,
3160                "Separator" => r#"{"type":"Separator"}"#,
3161                "DescriptionList" => r#"{"type":"DescriptionList","items":[]}"#,
3162                "Tabs" => r#"{"type":"Tabs","default_tab":"t","tabs":[]}"#,
3163                "Breadcrumb" => r#"{"type":"Breadcrumb","items":[]}"#,
3164                "Pagination" => r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3165                "Progress" => r#"{"type":"Progress","value":0}"#,
3166                "Avatar" => r#"{"type":"Avatar","alt":"a"}"#,
3167                "Skeleton" => r#"{"type":"Skeleton"}"#,
3168                _ => unreachable!(),
3169            };
3170            let component: Component = serde_json::from_str(json_str).unwrap();
3171            assert!(
3172                !matches!(component, Component::Plugin(_)),
3173                "type {type_name} should not deserialize as Plugin"
3174            );
3175        }
3176    }
3177
3178    // ── Serde round-trip tests for 6 new components ──────────────────────
3179
3180    #[test]
3181    fn test_stat_card_serde_round_trip() {
3182        let component = Component::StatCard(StatCardProps {
3183            label: "Orders".into(),
3184            value: "42".into(),
3185            icon: Some("package".into()),
3186            subtitle: Some("today".into()),
3187            sse_target: Some("orders_today".into()),
3188        });
3189        let json = serde_json::to_string(&component).unwrap();
3190        assert!(json.contains("\"type\":\"StatCard\""));
3191        assert!(json.contains("\"sse_target\":\"orders_today\""));
3192        let deserialized: Component = serde_json::from_str(&json).unwrap();
3193        assert_eq!(component, deserialized);
3194    }
3195
3196    #[test]
3197    fn test_checklist_serde_round_trip() {
3198        let component = Component::Checklist(ChecklistProps {
3199            title: "Getting Started".into(),
3200            items: vec![
3201                ChecklistItem {
3202                    label: "Install dependencies".into(),
3203                    checked: true,
3204                    href: None,
3205                },
3206                ChecklistItem {
3207                    label: "Read the docs".into(),
3208                    checked: false,
3209                    href: Some("/docs".into()),
3210                },
3211            ],
3212            dismissible: true,
3213            dismiss_label: Some("Dismiss".into()),
3214            data_key: Some("onboarding".into()),
3215        });
3216        let json = serde_json::to_string(&component).unwrap();
3217        assert!(json.contains("\"type\":\"Checklist\""));
3218        assert!(json.contains("\"data_key\":\"onboarding\""));
3219        let deserialized: Component = serde_json::from_str(&json).unwrap();
3220        assert_eq!(component, deserialized);
3221    }
3222
3223    #[test]
3224    fn test_toast_serde_round_trip() {
3225        let component = Component::Toast(ToastProps {
3226            message: "Operation completed".into(),
3227            variant: ToastVariant::Success,
3228            timeout: Some(10),
3229            dismissible: true,
3230        });
3231        let json = serde_json::to_string(&component).unwrap();
3232        assert!(json.contains("\"type\":\"Toast\""));
3233        assert!(json.contains("\"timeout\":10"));
3234        let deserialized: Component = serde_json::from_str(&json).unwrap();
3235        assert_eq!(component, deserialized);
3236    }
3237
3238    #[test]
3239    fn test_notification_dropdown_serde_round_trip() {
3240        let component = Component::NotificationDropdown(NotificationDropdownProps {
3241            notifications: vec![
3242                NotificationItem {
3243                    icon: Some("bell".into()),
3244                    text: "New message".into(),
3245                    timestamp: Some("2m ago".into()),
3246                    read: false,
3247                    action_url: Some("/messages/1".into()),
3248                },
3249                NotificationItem {
3250                    icon: None,
3251                    text: "Old notification".into(),
3252                    timestamp: None,
3253                    read: true,
3254                    action_url: None,
3255                },
3256            ],
3257            empty_text: Some("No notifications".into()),
3258        });
3259        let json = serde_json::to_string(&component).unwrap();
3260        assert!(json.contains("\"type\":\"NotificationDropdown\""));
3261        assert!(json.contains("\"empty_text\":\"No notifications\""));
3262        let deserialized: Component = serde_json::from_str(&json).unwrap();
3263        assert_eq!(component, deserialized);
3264    }
3265
3266    #[test]
3267    fn test_sidebar_serde_round_trip() {
3268        let component = Component::Sidebar(SidebarProps {
3269            fixed_top: vec![SidebarNavItem {
3270                label: "Dashboard".into(),
3271                href: "/dashboard".into(),
3272                icon: Some("home".into()),
3273                active: true,
3274            }],
3275            groups: vec![SidebarGroup {
3276                label: "Management".into(),
3277                collapsed: false,
3278                items: vec![SidebarNavItem {
3279                    label: "Users".into(),
3280                    href: "/users".into(),
3281                    icon: None,
3282                    active: false,
3283                }],
3284            }],
3285            fixed_bottom: vec![SidebarNavItem {
3286                label: "Settings".into(),
3287                href: "/settings".into(),
3288                icon: Some("gear".into()),
3289                active: false,
3290            }],
3291        });
3292        let json = serde_json::to_string(&component).unwrap();
3293        assert!(json.contains("\"type\":\"Sidebar\""));
3294        assert!(json.contains("\"fixed_top\""));
3295        let deserialized: Component = serde_json::from_str(&json).unwrap();
3296        assert_eq!(component, deserialized);
3297    }
3298
3299    #[test]
3300    fn test_header_serde_round_trip() {
3301        let component = Component::Header(HeaderProps {
3302            business_name: "Acme Corp".into(),
3303            notification_count: Some(5),
3304            user_name: Some("Jane Doe".into()),
3305            user_avatar: Some("/avatar.jpg".into()),
3306            logout_url: Some("/logout".into()),
3307        });
3308        let json = serde_json::to_string(&component).unwrap();
3309        assert!(json.contains("\"type\":\"Header\""));
3310        assert!(json.contains("\"business_name\":\"Acme Corp\""));
3311        assert!(json.contains("\"notification_count\":5"));
3312        let deserialized: Component = serde_json::from_str(&json).unwrap();
3313        assert_eq!(component, deserialized);
3314    }
3315
3316    // ── Convenience constructor tests ─────────────────────────────────────
3317
3318    #[test]
3319    fn test_stat_card_constructor() {
3320        let props = StatCardProps {
3321            label: "Revenue".into(),
3322            value: "$1,000".into(),
3323            icon: None,
3324            subtitle: None,
3325            sse_target: None,
3326        };
3327        let node = ComponentNode::stat_card("revenue-card", props.clone());
3328        assert_eq!(node.key, "revenue-card");
3329        assert!(node.action.is_none());
3330        assert!(node.visibility.is_none());
3331        assert_eq!(node.component, Component::StatCard(props));
3332    }
3333
3334    #[test]
3335    fn test_checklist_constructor() {
3336        let props = ChecklistProps {
3337            title: "Tasks".into(),
3338            items: vec![],
3339            dismissible: true,
3340            dismiss_label: None,
3341            data_key: None,
3342        };
3343        let node = ComponentNode::checklist("task-list", props.clone());
3344        assert_eq!(node.key, "task-list");
3345        assert!(node.action.is_none());
3346        assert!(node.visibility.is_none());
3347        assert_eq!(node.component, Component::Checklist(props));
3348    }
3349
3350    #[test]
3351    fn test_toast_constructor() {
3352        let props = ToastProps {
3353            message: "Done!".into(),
3354            variant: ToastVariant::Success,
3355            timeout: None,
3356            dismissible: true,
3357        };
3358        let node = ComponentNode::toast("success-toast", props.clone());
3359        assert_eq!(node.key, "success-toast");
3360        assert!(node.action.is_none());
3361        assert!(node.visibility.is_none());
3362        assert_eq!(node.component, Component::Toast(props));
3363    }
3364
3365    #[test]
3366    fn test_notification_dropdown_constructor() {
3367        let props = NotificationDropdownProps {
3368            notifications: vec![],
3369            empty_text: Some("All caught up!".into()),
3370        };
3371        let node = ComponentNode::notification_dropdown("notifs", props.clone());
3372        assert_eq!(node.key, "notifs");
3373        assert!(node.action.is_none());
3374        assert!(node.visibility.is_none());
3375        assert_eq!(node.component, Component::NotificationDropdown(props));
3376    }
3377
3378    #[test]
3379    fn test_sidebar_constructor() {
3380        let props = SidebarProps {
3381            fixed_top: vec![],
3382            groups: vec![],
3383            fixed_bottom: vec![],
3384        };
3385        let node = ComponentNode::sidebar("main-nav", props.clone());
3386        assert_eq!(node.key, "main-nav");
3387        assert!(node.action.is_none());
3388        assert!(node.visibility.is_none());
3389        assert_eq!(node.component, Component::Sidebar(props));
3390    }
3391
3392    #[test]
3393    fn test_header_constructor() {
3394        let props = HeaderProps {
3395            business_name: "MyApp".into(),
3396            notification_count: None,
3397            user_name: None,
3398            user_avatar: None,
3399            logout_url: None,
3400        };
3401        let node = ComponentNode::header("page-header", props.clone());
3402        assert_eq!(node.key, "page-header");
3403        assert!(node.action.is_none());
3404        assert!(node.visibility.is_none());
3405        assert_eq!(node.component, Component::Header(props));
3406    }
3407
3408    // ── Sub-type serde tests ─────────────────────────────────────────────
3409
3410    #[test]
3411    fn test_checklist_item_round_trip() {
3412        let checked_item = ChecklistItem {
3413            label: "Completed task".into(),
3414            checked: true,
3415            href: Some("/task/1".into()),
3416        };
3417        let json = serde_json::to_string(&checked_item).unwrap();
3418        let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3419        assert_eq!(parsed, checked_item);
3420
3421        let unchecked_item = ChecklistItem {
3422            label: "Pending task".into(),
3423            checked: false,
3424            href: None,
3425        };
3426        let json = serde_json::to_string(&unchecked_item).unwrap();
3427        let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3428        assert_eq!(parsed, unchecked_item);
3429        // href is None — should be omitted
3430        assert!(!json.contains("href"));
3431    }
3432
3433    #[test]
3434    fn test_sidebar_group_round_trip() {
3435        let expanded = SidebarGroup {
3436            label: "Main".into(),
3437            collapsed: false,
3438            items: vec![
3439                SidebarNavItem {
3440                    label: "Home".into(),
3441                    href: "/".into(),
3442                    icon: Some("home".into()),
3443                    active: true,
3444                },
3445                SidebarNavItem {
3446                    label: "About".into(),
3447                    href: "/about".into(),
3448                    icon: None,
3449                    active: false,
3450                },
3451            ],
3452        };
3453        let json = serde_json::to_string(&expanded).unwrap();
3454        let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3455        assert_eq!(parsed, expanded);
3456        assert_eq!(parsed.items.len(), 2);
3457
3458        let collapsed = SidebarGroup {
3459            label: "Advanced".into(),
3460            collapsed: true,
3461            items: vec![],
3462        };
3463        let json = serde_json::to_string(&collapsed).unwrap();
3464        let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3465        assert_eq!(parsed, collapsed);
3466        assert!(parsed.collapsed);
3467    }
3468
3469    #[test]
3470    fn test_notification_item_round_trip() {
3471        let unread = NotificationItem {
3472            icon: Some("mail".into()),
3473            text: "You have a new message".into(),
3474            timestamp: Some("5m ago".into()),
3475            read: false,
3476            action_url: Some("/messages/42".into()),
3477        };
3478        let json = serde_json::to_string(&unread).unwrap();
3479        let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3480        assert_eq!(parsed, unread);
3481        assert!(!parsed.read);
3482
3483        let read_notif = NotificationItem {
3484            icon: None,
3485            text: "Welcome to the platform".into(),
3486            timestamp: None,
3487            read: true,
3488            action_url: None,
3489        };
3490        let json = serde_json::to_string(&read_notif).unwrap();
3491        let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3492        assert_eq!(parsed, read_notif);
3493        assert!(parsed.read);
3494        // Optional fields with None should be omitted
3495        assert!(!json.contains("\"icon\""));
3496        assert!(!json.contains("\"action_url\""));
3497    }
3498
3499    // ── Edge case tests ──────────────────────────────────────────────────
3500
3501    #[test]
3502    fn test_stat_card_all_optionals_none() {
3503        let component = Component::StatCard(StatCardProps {
3504            label: "Count".into(),
3505            value: "0".into(),
3506            icon: None,
3507            subtitle: None,
3508            sse_target: None,
3509        });
3510        let json = serde_json::to_string(&component).unwrap();
3511        assert!(json.contains("\"type\":\"StatCard\""));
3512        assert!(!json.contains("\"icon\""));
3513        assert!(!json.contains("\"subtitle\""));
3514        assert!(!json.contains("\"sse_target\""));
3515        let deserialized: Component = serde_json::from_str(&json).unwrap();
3516        assert_eq!(component, deserialized);
3517    }
3518
3519    #[test]
3520    fn test_checklist_empty_items() {
3521        let component = Component::Checklist(ChecklistProps {
3522            title: "Empty List".into(),
3523            items: vec![],
3524            dismissible: true,
3525            dismiss_label: None,
3526            data_key: None,
3527        });
3528        let json = serde_json::to_string(&component).unwrap();
3529        assert!(json.contains("\"type\":\"Checklist\""));
3530        let deserialized: Component = serde_json::from_str(&json).unwrap();
3531        assert_eq!(component, deserialized);
3532        match &deserialized {
3533            Component::Checklist(props) => assert!(props.items.is_empty()),
3534            _ => panic!("expected Checklist"),
3535        }
3536    }
3537
3538    #[test]
3539    fn test_sidebar_empty_groups_and_fixed() {
3540        let component = Component::Sidebar(SidebarProps {
3541            fixed_top: vec![],
3542            groups: vec![],
3543            fixed_bottom: vec![],
3544        });
3545        let json = serde_json::to_string(&component).unwrap();
3546        assert!(json.contains("\"type\":\"Sidebar\""));
3547        // Empty vecs should be omitted (skip_serializing_if = "Vec::is_empty")
3548        assert!(!json.contains("\"fixed_top\""));
3549        assert!(!json.contains("\"groups\""));
3550        assert!(!json.contains("\"fixed_bottom\""));
3551        let deserialized: Component = serde_json::from_str(&json).unwrap();
3552        assert_eq!(component, deserialized);
3553    }
3554
3555    #[test]
3556    fn test_notification_dropdown_empty_uses_empty_text() {
3557        let component = Component::NotificationDropdown(NotificationDropdownProps {
3558            notifications: vec![],
3559            empty_text: Some("Nothing here!".into()),
3560        });
3561        let json = serde_json::to_string(&component).unwrap();
3562        assert!(json.contains("\"type\":\"NotificationDropdown\""));
3563        assert!(json.contains("\"empty_text\":\"Nothing here!\""));
3564        let deserialized: Component = serde_json::from_str(&json).unwrap();
3565        assert_eq!(component, deserialized);
3566    }
3567
3568    // ── Optional field skip_serializing tests ────────────────────────────
3569
3570    #[test]
3571    fn test_stat_card_omits_sse_target_when_none() {
3572        let component = Component::StatCard(StatCardProps {
3573            label: "Revenue".into(),
3574            value: "$500".into(),
3575            icon: None,
3576            subtitle: None,
3577            sse_target: None,
3578        });
3579        let json = serde_json::to_string(&component).unwrap();
3580        assert!(
3581            !json.contains("sse_target"),
3582            "sse_target must be omitted when None"
3583        );
3584    }
3585
3586    // ── Grid tests ────────────────────────────────────────────────────
3587
3588    #[test]
3589    fn grid_round_trips() {
3590        let grid = Component::Grid(GridProps {
3591            columns: 3,
3592            md_columns: None,
3593            lg_columns: None,
3594            gap: GapSize::Lg,
3595            scrollable: None,
3596            children: vec![ComponentNode::text(
3597                "t",
3598                TextProps {
3599                    content: "cell".into(),
3600                    element: TextElement::P,
3601                },
3602            )],
3603        });
3604        let json = serde_json::to_value(&grid).unwrap();
3605        assert_eq!(json["type"], "Grid");
3606        assert_eq!(json["columns"], 3);
3607        assert_eq!(json["gap"], "lg");
3608        let parsed: Component = serde_json::from_value(json).unwrap();
3609        assert_eq!(parsed, grid);
3610    }
3611
3612    #[test]
3613    fn grid_defaults() {
3614        let json = serde_json::json!({"type": "Grid"});
3615        let parsed: Component = serde_json::from_value(json).unwrap();
3616        match parsed {
3617            Component::Grid(props) => {
3618                assert_eq!(props.columns, 2);
3619                assert_eq!(props.gap, GapSize::Md);
3620                assert!(props.children.is_empty());
3621            }
3622            _ => panic!("expected Grid"),
3623        }
3624    }
3625
3626    // ── Collapsible tests ────────────────────────────────────────────
3627
3628    #[test]
3629    fn collapsible_round_trips() {
3630        let c = Component::Collapsible(CollapsibleProps {
3631            title: "Details".into(),
3632            expanded: true,
3633            children: vec![],
3634        });
3635        let json = serde_json::to_value(&c).unwrap();
3636        assert_eq!(json["type"], "Collapsible");
3637        assert_eq!(json["title"], "Details");
3638        assert_eq!(json["expanded"], true);
3639        let parsed: Component = serde_json::from_value(json).unwrap();
3640        assert_eq!(parsed, c);
3641    }
3642
3643    // ── EmptyState tests ─────────────────────────────────────────────
3644
3645    #[test]
3646    fn empty_state_round_trips() {
3647        let es = Component::EmptyState(EmptyStateProps {
3648            title: "No items".into(),
3649            description: Some("Create one".into()),
3650            action: Some(Action::get("items.create")),
3651            action_label: Some("New item".into()),
3652        });
3653        let json = serde_json::to_value(&es).unwrap();
3654        assert_eq!(json["type"], "EmptyState");
3655        assert_eq!(json["title"], "No items");
3656        let parsed: Component = serde_json::from_value(json).unwrap();
3657        assert_eq!(parsed, es);
3658    }
3659
3660    #[test]
3661    fn empty_state_minimal() {
3662        let json = serde_json::json!({"type": "EmptyState", "title": "Nothing"});
3663        let parsed: Component = serde_json::from_value(json).unwrap();
3664        match parsed {
3665            Component::EmptyState(props) => {
3666                assert_eq!(props.title, "Nothing");
3667                assert!(props.description.is_none());
3668                assert!(props.action.is_none());
3669                assert!(props.action_label.is_none());
3670            }
3671            _ => panic!("expected EmptyState"),
3672        }
3673    }
3674
3675    // ── FormSection tests ────────────────────────────────────────────
3676
3677    #[test]
3678    fn form_section_round_trips() {
3679        let fs = Component::FormSection(FormSectionProps {
3680            title: "Contact".into(),
3681            description: Some("Your details".into()),
3682            children: vec![],
3683            layout: None,
3684        });
3685        let json = serde_json::to_value(&fs).unwrap();
3686        assert_eq!(json["type"], "FormSection");
3687        assert_eq!(json["title"], "Contact");
3688        let parsed: Component = serde_json::from_value(json).unwrap();
3689        assert_eq!(parsed, fs);
3690    }
3691
3692    // ── Switch action tests ──────────────────────────────────────────
3693
3694    #[test]
3695    fn switch_with_action_round_trips() {
3696        let sw = Component::Switch(SwitchProps {
3697            field: "active".into(),
3698            label: "Active".into(),
3699            description: None,
3700            checked: Some(true),
3701            data_path: None,
3702            required: None,
3703            disabled: None,
3704            error: None,
3705            action: Some(Action::new("settings.toggle")),
3706            compact: false,
3707        });
3708        let json = serde_json::to_value(&sw).unwrap();
3709        assert!(json["action"].is_object());
3710        assert_eq!(json["action"]["handler"], "settings.toggle");
3711        let parsed: Component = serde_json::from_value(json).unwrap();
3712        assert_eq!(parsed, sw);
3713    }
3714
3715    #[test]
3716    fn switch_without_action_omits_field() {
3717        let sw = Component::Switch(SwitchProps {
3718            field: "f".into(),
3719            label: "l".into(),
3720            description: None,
3721            checked: None,
3722            data_path: None,
3723            required: None,
3724            disabled: None,
3725            error: None,
3726            action: None,
3727            compact: false,
3728        });
3729        let json = serde_json::to_string(&sw).unwrap();
3730        assert!(!json.contains("\"action\""));
3731    }
3732
3733    #[test]
3734    fn test_toast_omits_timeout_when_none() {
3735        let component = Component::Toast(ToastProps {
3736            message: "Hello".into(),
3737            variant: ToastVariant::Info,
3738            timeout: None,
3739            dismissible: false,
3740        });
3741        let json = serde_json::to_string(&component).unwrap();
3742        assert!(
3743            !json.contains("\"timeout\""),
3744            "timeout must be omitted when None"
3745        );
3746    }
3747
3748    #[test]
3749    fn page_header_round_trip_title_only() {
3750        let component = Component::PageHeader(PageHeaderProps {
3751            title: "Test Title".to_string(),
3752            breadcrumb: vec![],
3753            actions: vec![],
3754        });
3755        let json = serde_json::to_value(&component).unwrap();
3756        assert_eq!(json["type"], "PageHeader");
3757        assert_eq!(json["title"], "Test Title");
3758        // Empty vecs are omitted.
3759        assert!(json.get("breadcrumb").is_none());
3760        assert!(json.get("actions").is_none());
3761        let parsed: Component = serde_json::from_value(json).unwrap();
3762        assert_eq!(parsed, component);
3763    }
3764
3765    #[test]
3766    fn page_header_round_trip_with_breadcrumb_and_actions() {
3767        let component = Component::PageHeader(PageHeaderProps {
3768            title: "Users".to_string(),
3769            breadcrumb: vec![
3770                BreadcrumbItem {
3771                    label: "Home".to_string(),
3772                    url: Some("/".to_string()),
3773                },
3774                BreadcrumbItem {
3775                    label: "Users".to_string(),
3776                    url: None,
3777                },
3778            ],
3779            actions: vec![ComponentNode {
3780                key: "add-btn".to_string(),
3781                component: Component::Button(ButtonProps {
3782                    label: "Add User".to_string(),
3783                    variant: ButtonVariant::Default,
3784                    size: Size::Default,
3785                    disabled: None,
3786                    icon: None,
3787                    icon_position: None,
3788                    button_type: None,
3789                }),
3790                action: None,
3791                visibility: None,
3792            }],
3793        });
3794        let json = serde_json::to_string(&component).unwrap();
3795        let parsed: Component = serde_json::from_str(&json).unwrap();
3796        assert_eq!(parsed, component);
3797        // Verify type field present.
3798        let value = serde_json::to_value(&component).unwrap();
3799        assert_eq!(value["type"], "PageHeader");
3800        assert_eq!(value["title"], "Users");
3801        assert!(value["breadcrumb"].is_array());
3802        assert!(value["actions"].is_array());
3803    }
3804
3805    #[test]
3806    fn page_header_deserialize_from_json() {
3807        let json = r#"{"type":"PageHeader","title":"Test"}"#;
3808        let component: Component = serde_json::from_str(json).unwrap();
3809        match component {
3810            Component::PageHeader(props) => {
3811                assert_eq!(props.title, "Test");
3812                assert!(props.breadcrumb.is_empty());
3813                assert!(props.actions.is_empty());
3814            }
3815            _ => panic!("expected PageHeader"),
3816        }
3817    }
3818
3819    #[test]
3820    fn button_group_round_trip_empty() {
3821        let component = Component::ButtonGroup(ButtonGroupProps { buttons: vec![] });
3822        let json = serde_json::to_value(&component).unwrap();
3823        assert_eq!(json["type"], "ButtonGroup");
3824        // Empty vec omitted.
3825        assert!(json.get("buttons").is_none());
3826        let parsed: Component = serde_json::from_value(json).unwrap();
3827        assert_eq!(parsed, component);
3828    }
3829
3830    #[test]
3831    fn button_group_round_trip_with_buttons() {
3832        let component = Component::ButtonGroup(ButtonGroupProps {
3833            buttons: vec![
3834                ComponentNode {
3835                    key: "save".to_string(),
3836                    component: Component::Button(ButtonProps {
3837                        label: "Save".to_string(),
3838                        variant: ButtonVariant::Default,
3839                        size: Size::Default,
3840                        disabled: None,
3841                        icon: None,
3842                        icon_position: None,
3843                        button_type: None,
3844                    }),
3845                    action: None,
3846                    visibility: None,
3847                },
3848                ComponentNode {
3849                    key: "cancel".to_string(),
3850                    component: Component::Button(ButtonProps {
3851                        label: "Cancel".to_string(),
3852                        variant: ButtonVariant::Outline,
3853                        size: Size::Default,
3854                        disabled: None,
3855                        icon: None,
3856                        icon_position: None,
3857                        button_type: None,
3858                    }),
3859                    action: None,
3860                    visibility: None,
3861                },
3862            ],
3863        });
3864        let json = serde_json::to_string(&component).unwrap();
3865        let parsed: Component = serde_json::from_str(&json).unwrap();
3866        assert_eq!(parsed, component);
3867        let value = serde_json::to_value(&component).unwrap();
3868        assert_eq!(value["type"], "ButtonGroup");
3869        assert!(value["buttons"].is_array());
3870        assert_eq!(value["buttons"].as_array().unwrap().len(), 2);
3871    }
3872
3873    #[test]
3874    fn button_group_deserialize_from_json() {
3875        let json = r#"{"type":"ButtonGroup","buttons":[]}"#;
3876        let component: Component = serde_json::from_str(json).unwrap();
3877        match component {
3878            Component::ButtonGroup(props) => {
3879                assert!(props.buttons.is_empty());
3880            }
3881            _ => panic!("expected ButtonGroup"),
3882        }
3883    }
3884
3885    #[test]
3886    fn image_round_trips() {
3887        // URL variant (existing — stays green after refactor)
3888        let json = r#"{"type": "Image", "src": "/img/s.png", "alt": "Screenshot"}"#;
3889        let component: Component = serde_json::from_str(json).expect("URL variant");
3890        match component {
3891            Component::Image(props) => {
3892                assert!(
3893                    matches!(props.source, ImageSource::Url { .. }),
3894                    "URL JSON must deserialize to ImageSource::Url"
3895                );
3896                assert_eq!(props.alt, "Screenshot");
3897                assert!(props.aspect_ratio.is_none());
3898            }
3899            _ => panic!("expected Component::Image"),
3900        }
3901
3902        // InlineSvg variant (new — the phase-148 extension)
3903        let json_svg = r#"{"type": "Image", "svg": "<svg></svg>", "alt": "Chart"}"#;
3904        let component_svg: Component = serde_json::from_str(json_svg).expect("InlineSvg variant");
3905        match component_svg {
3906            Component::Image(props) => {
3907                assert!(
3908                    matches!(props.source, ImageSource::InlineSvg { .. }),
3909                    "SVG JSON must deserialize to ImageSource::InlineSvg"
3910                );
3911                assert_eq!(props.alt, "Chart");
3912            }
3913            _ => panic!("expected Component::Image"),
3914        }
3915
3916        // Neither src nor svg — must fail (guards D-10 no-source rejection)
3917        let json_neither = r#"{"type": "Image", "alt": "Bad"}"#;
3918        serde_json::from_str::<Component>(json_neither)
3919            .expect_err("input without src or svg must be rejected");
3920    }
3921
3922    #[test]
3923    fn all_known_types_round_trip() {
3924        let known_types: &[(&str, &str)] = &[
3925            ("Alert", r#"{"type":"Alert","message":"m"}"#),
3926            ("Avatar", r#"{"type":"Avatar","alt":"a"}"#),
3927            ("Badge", r#"{"type":"Badge","label":"b"}"#),
3928            ("Breadcrumb", r#"{"type":"Breadcrumb","items":[]}"#),
3929            ("Button", r#"{"type":"Button","label":"b"}"#),
3930            ("CalendarCell", r#"{"type":"CalendarCell","day":1}"#),
3931            ("Checkbox", r#"{"type":"Checkbox","field":"f","label":"l"}"#),
3932            ("Image", r#"{"type":"Image","src":"/img/s.png","alt":"a"}"#),
3933            ("Input", r#"{"type":"Input","field":"f","label":"l"}"#),
3934            (
3935                "Pagination",
3936                r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3937            ),
3938            ("Progress", r#"{"type":"Progress","value":50}"#),
3939            (
3940                "Select",
3941                r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3942            ),
3943            ("Separator", r#"{"type":"Separator"}"#),
3944            ("Skeleton", r#"{"type":"Skeleton"}"#),
3945            ("Text", r#"{"type":"Text","content":"c"}"#),
3946        ];
3947        for (type_name, json_str) in known_types {
3948            let component: Component = serde_json::from_str(json_str)
3949                .unwrap_or_else(|e| panic!("failed to parse {type_name}: {e}"));
3950            let serialized = serde_json::to_value(&component).unwrap();
3951            assert_eq!(
3952                serialized["type"], *type_name,
3953                "type mismatch for {type_name}"
3954            );
3955            let reparsed: Component = serde_json::from_value(serialized)
3956                .unwrap_or_else(|e| panic!("failed to reparse {type_name}: {e}"));
3957            assert_eq!(
3958                serde_json::to_value(&reparsed).unwrap()["type"],
3959                *type_name,
3960                "round-trip type mismatch for {type_name}"
3961            );
3962        }
3963
3964        // Phase 148: InlineSvg variant round-trip (sibling to the URL fixture row).
3965        // Wire-format discrimination: same "type":"Image", "svg" instead of "src".
3966        // Option (b): independent block inside the same test, because the fixture
3967        // iteration asserts serialized["type"] == tuple name ("Image" != "ImageInlineSvg").
3968        let svg_json = r#"{"type":"Image","svg":"<svg/>","alt":"chart"}"#;
3969        let parsed: Component =
3970            serde_json::from_str(svg_json).expect("InlineSvg JSON must deserialize");
3971        let serialized = serde_json::to_value(&parsed).expect("InlineSvg component must serialize");
3972        assert_eq!(
3973            serialized.get("type").and_then(|v| v.as_str()),
3974            Some("Image"),
3975            "InlineSvg variant must serialize with type=Image"
3976        );
3977        assert!(
3978            serialized.get("svg").is_some(),
3979            "InlineSvg serialization must carry the svg field"
3980        );
3981        assert!(
3982            serialized.get("src").is_none(),
3983            "InlineSvg serialization must NOT carry a src field"
3984        );
3985        let reparsed: Component = serde_json::from_value(serialized).expect("round-trip reparse");
3986        assert_eq!(parsed, reparsed, "round-trip must preserve equality");
3987    }
3988}
3989
3990#[cfg(test)]
3991mod key_value_editor_tests {
3992    use super::*;
3993    use serde_json::json;
3994
3995    #[test]
3996    fn key_value_editor_serde_roundtrip() {
3997        let original = Component::KeyValueEditor(KeyValueEditorProps {
3998            field: "metadata".to_string(),
3999            label: Some("Metadata".to_string()),
4000            suggested_keys: vec!["env".to_string(), "region".to_string()],
4001            allow_custom_keys: false,
4002            data_path: Some("/meta".to_string()),
4003            error: Some("required".to_string()),
4004        });
4005
4006        let serialized =
4007            serde_json::to_value(&original).expect("serialize KeyValueEditor component");
4008
4009        // Tagged serialization must inject "type": "KeyValueEditor".
4010        assert_eq!(
4011            serialized.get("type").and_then(|v| v.as_str()),
4012            Some("KeyValueEditor"),
4013            "serialized form must have type=KeyValueEditor: {serialized}"
4014        );
4015        assert_eq!(
4016            serialized.get("field").and_then(|v| v.as_str()),
4017            Some("metadata")
4018        );
4019        assert_eq!(
4020            serialized
4021                .get("allow_custom_keys")
4022                .and_then(|v| v.as_bool()),
4023            Some(false)
4024        );
4025
4026        // Round-trip: deserialize back into Component, assert structural equality.
4027        let deserialized: Component =
4028            serde_json::from_value(serialized).expect("deserialize KeyValueEditor component");
4029        match deserialized {
4030            Component::KeyValueEditor(ref p) => {
4031                assert_eq!(p.field, "metadata");
4032                assert_eq!(p.label.as_deref(), Some("Metadata"));
4033                assert_eq!(
4034                    p.suggested_keys,
4035                    vec!["env".to_string(), "region".to_string()]
4036                );
4037                assert!(!p.allow_custom_keys);
4038                assert_eq!(p.data_path.as_deref(), Some("/meta"));
4039                assert_eq!(p.error.as_deref(), Some("required"));
4040            }
4041            other => panic!("expected KeyValueEditor, got {other:?}"),
4042        }
4043        assert_eq!(original, deserialized, "PartialEq round-trip failed");
4044    }
4045
4046    #[test]
4047    fn key_value_editor_allow_custom_keys_defaults_to_true() {
4048        // Serde default: when allow_custom_keys is absent from JSON input, it must be true.
4049        let json_input = json!({
4050            "type": "KeyValueEditor",
4051            "field": "meta",
4052        });
4053        let parsed: Component =
4054            serde_json::from_value(json_input).expect("deserialize minimal KeyValueEditor");
4055        match parsed {
4056            Component::KeyValueEditor(p) => {
4057                assert!(
4058                    p.allow_custom_keys,
4059                    "allow_custom_keys default must be true"
4060                );
4061                assert!(
4062                    p.suggested_keys.is_empty(),
4063                    "suggested_keys default must be empty"
4064                );
4065                assert!(p.label.is_none());
4066                assert!(p.data_path.is_none());
4067                assert!(p.error.is_none());
4068            }
4069            other => panic!("expected KeyValueEditor, got {other:?}"),
4070        }
4071    }
4072}
4073
4074#[cfg(test)]
4075mod detail_form_tests {
4076    use super::*;
4077    use crate::action::{Action, HttpMethod};
4078    use serde_json::json;
4079
4080    // ── EditMode (D-01, D-02) ─────────────────────────────────────────────
4081
4082    #[test]
4083    fn edit_mode_default_is_view() {
4084        assert_eq!(EditMode::default(), EditMode::View);
4085    }
4086
4087    #[test]
4088    fn edit_mode_from_query_exact_edit() {
4089        assert_eq!(EditMode::from_query(Some("edit")), EditMode::Edit);
4090    }
4091
4092    #[test]
4093    fn edit_mode_from_query_case_insensitive_upper() {
4094        assert_eq!(EditMode::from_query(Some("EDIT")), EditMode::Edit);
4095    }
4096
4097    #[test]
4098    fn edit_mode_from_query_case_insensitive_mixed() {
4099        assert_eq!(EditMode::from_query(Some("eDiT")), EditMode::Edit);
4100    }
4101
4102    #[test]
4103    fn edit_mode_from_query_title_case() {
4104        assert_eq!(EditMode::from_query(Some("Edit")), EditMode::Edit);
4105    }
4106
4107    #[test]
4108    fn edit_mode_from_query_none_is_view() {
4109        assert_eq!(EditMode::from_query(None), EditMode::View);
4110    }
4111
4112    #[test]
4113    fn edit_mode_from_query_empty_is_view() {
4114        assert_eq!(EditMode::from_query(Some("")), EditMode::View);
4115    }
4116
4117    #[test]
4118    fn edit_mode_from_query_view_literal_is_view() {
4119        assert_eq!(EditMode::from_query(Some("view")), EditMode::View);
4120    }
4121
4122    #[test]
4123    fn edit_mode_from_query_unknown_is_view() {
4124        assert_eq!(EditMode::from_query(Some("anything-else")), EditMode::View);
4125    }
4126
4127    #[test]
4128    fn edit_mode_serializes_as_snake_case() {
4129        assert_eq!(
4130            serde_json::to_value(EditMode::Edit).expect("serialize Edit"),
4131            json!("edit")
4132        );
4133        assert_eq!(
4134            serde_json::to_value(EditMode::View).expect("serialize View"),
4135            json!("view")
4136        );
4137        let parsed_edit: EditMode =
4138            serde_json::from_value(json!("edit")).expect("deserialize 'edit'");
4139        assert_eq!(parsed_edit, EditMode::Edit);
4140        let parsed_view: EditMode =
4141            serde_json::from_value(json!("view")).expect("deserialize 'view'");
4142        assert_eq!(parsed_view, EditMode::View);
4143    }
4144
4145    // ── DetailFormProps serde (D-03, D-04, D-17, D-19) ────────────────────
4146
4147    fn sample_detail_form_props() -> DetailFormProps {
4148        DetailFormProps {
4149            mode: EditMode::Edit,
4150            action: Action {
4151                handler: "users.update".to_string(),
4152                url: Some("/users/1".to_string()),
4153                method: HttpMethod::Put,
4154                confirm: None,
4155                on_success: None,
4156                on_error: None,
4157                target: None,
4158            },
4159            fields: vec![
4160                DetailField {
4161                    label: "Name".to_string(),
4162                    value: "Ada".to_string(),
4163                    input: ComponentNode::input(
4164                        "name",
4165                        InputProps {
4166                            field: "name".to_string(),
4167                            label: "".to_string(),
4168                            input_type: InputType::Text,
4169                            placeholder: None,
4170                            required: None,
4171                            disabled: None,
4172                            error: None,
4173                            description: None,
4174                            default_value: None,
4175                            data_path: None,
4176                            step: None,
4177                            list: None,
4178                        },
4179                    ),
4180                },
4181                DetailField {
4182                    label: "Email".to_string(),
4183                    value: "ada@example.com".to_string(),
4184                    input: ComponentNode::input(
4185                        "email",
4186                        InputProps {
4187                            field: "email".to_string(),
4188                            label: "".to_string(),
4189                            input_type: InputType::Email,
4190                            placeholder: None,
4191                            required: None,
4192                            disabled: None,
4193                            error: None,
4194                            description: None,
4195                            default_value: None,
4196                            data_path: None,
4197                            step: None,
4198                            list: None,
4199                        },
4200                    ),
4201                },
4202            ],
4203            edit_url: "/users/1?mode=edit".to_string(),
4204            cancel_url: "/users/1".to_string(),
4205            edit_label: Some("Modifica".to_string()),
4206            save_label: Some("Salva".to_string()),
4207            cancel_label: Some("Annulla".to_string()),
4208            method: Some(HttpMethod::Put),
4209        }
4210    }
4211
4212    #[test]
4213    fn detail_form_props_serde_roundtrip() {
4214        let original = Component::DetailForm(sample_detail_form_props());
4215        let serialized = serde_json::to_value(&original).expect("serialize DetailForm component");
4216        assert_eq!(
4217            serialized.get("type").and_then(|v| v.as_str()),
4218            Some("DetailForm"),
4219            "serialized form must have type=DetailForm: {serialized}"
4220        );
4221        let deserialized: Component =
4222            serde_json::from_value(serialized).expect("deserialize DetailForm component");
4223        assert_eq!(original, deserialized, "PartialEq round-trip failed");
4224    }
4225
4226    #[test]
4227    fn detail_form_props_omits_optional_nones() {
4228        let props = DetailFormProps {
4229            mode: EditMode::View,
4230            action: Action {
4231                handler: "x".to_string(),
4232                url: None,
4233                method: HttpMethod::Post,
4234                confirm: None,
4235                on_success: None,
4236                on_error: None,
4237                target: None,
4238            },
4239            fields: Vec::new(),
4240            edit_url: "/x?mode=edit".to_string(),
4241            cancel_url: "/x".to_string(),
4242            edit_label: None,
4243            save_label: None,
4244            cancel_label: None,
4245            method: None,
4246        };
4247        let v = serde_json::to_value(&props).expect("serialize");
4248        assert!(
4249            v.get("edit_label").is_none(),
4250            "edit_label=None must be skipped, got: {v}"
4251        );
4252        assert!(
4253            v.get("save_label").is_none(),
4254            "save_label=None must be skipped"
4255        );
4256        assert!(
4257            v.get("cancel_label").is_none(),
4258            "cancel_label=None must be skipped"
4259        );
4260        assert!(v.get("method").is_none(), "method=None must be skipped");
4261    }
4262
4263    #[test]
4264    fn detail_form_props_defaults_mode_to_view() {
4265        let v = json!({
4266            "action": {"handler": "x", "method": "POST"},
4267            "fields": [],
4268            "edit_url": "/x?mode=edit",
4269            "cancel_url": "/x"
4270        });
4271        let props: DetailFormProps =
4272            serde_json::from_value(v).expect("deserialize DetailFormProps without mode");
4273        assert_eq!(
4274            props.mode,
4275            EditMode::View,
4276            "missing 'mode' must default to View"
4277        );
4278    }
4279
4280    // ── ComponentNode::detail_form factory (D-18) ─────────────────────────
4281
4282    #[test]
4283    fn component_node_detail_form_factory_shape() {
4284        let node = ComponentNode::detail_form("details", sample_detail_form_props());
4285        assert_eq!(node.key, "details");
4286        assert!(node.action.is_none());
4287        assert!(node.visibility.is_none());
4288        assert!(
4289            matches!(node.component, Component::DetailForm(_)),
4290            "expected Component::DetailForm variant"
4291        );
4292    }
4293}
4294
4295#[cfg(test)]
4296mod image_source_tests {
4297    use super::*;
4298    use serde_json::json;
4299
4300    #[test]
4301    fn image_source_url_roundtrip() {
4302        let parsed: ImageSource =
4303            serde_json::from_value(json!({"src": "/a.png"})).expect("Url variant");
4304        match parsed {
4305            ImageSource::Url { src } => assert_eq!(src, "/a.png"),
4306            _ => panic!("expected ImageSource::Url"),
4307        }
4308    }
4309
4310    #[test]
4311    fn image_source_inline_svg_roundtrip() {
4312        let parsed: ImageSource =
4313            serde_json::from_value(json!({"svg": "<svg/>"})).expect("InlineSvg variant");
4314        match parsed {
4315            ImageSource::InlineSvg { svg } => assert_eq!(svg, "<svg/>"),
4316            _ => panic!("expected ImageSource::InlineSvg"),
4317        }
4318    }
4319
4320    #[test]
4321    fn image_source_neither_rejected() {
4322        serde_json::from_value::<ImageSource>(json!({}))
4323            .expect_err("empty object (no src, no svg) must fail to deserialize");
4324    }
4325
4326    #[test]
4327    fn image_props_url_constructor() {
4328        let p = ImageProps::url("/a.png", "alt");
4329        assert!(matches!(p.source, ImageSource::Url { .. }));
4330        match &p.source {
4331            ImageSource::Url { src } => assert_eq!(src, "/a.png"),
4332            _ => unreachable!(),
4333        }
4334        assert_eq!(p.alt, "alt");
4335        assert!(p.aspect_ratio.is_none());
4336        assert!(p.placeholder_label.is_none());
4337    }
4338
4339    #[test]
4340    fn image_props_inline_svg_constructor() {
4341        let p = ImageProps::inline_svg("<svg/>", "chart");
4342        assert!(matches!(p.source, ImageSource::InlineSvg { .. }));
4343        match &p.source {
4344            ImageSource::InlineSvg { svg } => assert_eq!(svg, "<svg/>"),
4345            _ => unreachable!(),
4346        }
4347        assert_eq!(p.alt, "chart");
4348        assert!(p.aspect_ratio.is_none());
4349        assert!(p.placeholder_label.is_none());
4350    }
4351}
4352
4353#[cfg(test)]
4354mod rich_text_editor_tests {
4355    use super::*;
4356    use serde_json::json;
4357
4358    #[test]
4359    fn rich_text_editor_serde_roundtrip() {
4360        let original = Component::RichTextEditor(RichTextEditorProps {
4361            name: "body".to_string(),
4362            value: Some(r#"{"ops":[{"insert":"hello\n"}]}"#.to_string()),
4363            formats: vec!["bold".to_string(), "italic".to_string(), "link".to_string()],
4364            placeholder: Some("Type here...".to_string()),
4365            theme: "snow".to_string(),
4366            label: Some("Body".to_string()),
4367            error: Some("Required".to_string()),
4368            data_path: Some("/article/body".to_string()),
4369            required: Some(true),
4370        });
4371
4372        let serialized =
4373            serde_json::to_value(&original).expect("serialize RichTextEditor component");
4374
4375        assert_eq!(
4376            serialized.get("type").and_then(|v| v.as_str()),
4377            Some("RichTextEditor"),
4378            "tagged form must have type=RichTextEditor: {serialized}"
4379        );
4380        assert_eq!(
4381            serialized.get("name").and_then(|v| v.as_str()),
4382            Some("body")
4383        );
4384        assert_eq!(
4385            serialized.get("theme").and_then(|v| v.as_str()),
4386            Some("snow")
4387        );
4388
4389        let deserialized: Component =
4390            serde_json::from_value(serialized).expect("deserialize RichTextEditor component");
4391
4392        match deserialized {
4393            Component::RichTextEditor(ref p) => {
4394                assert_eq!(p.name, "body");
4395                assert_eq!(p.theme, "snow");
4396                assert_eq!(p.formats.len(), 3);
4397                assert_eq!(p.label.as_deref(), Some("Body"));
4398                assert_eq!(p.error.as_deref(), Some("Required"));
4399                assert_eq!(p.placeholder.as_deref(), Some("Type here..."));
4400                assert_eq!(p.data_path.as_deref(), Some("/article/body"));
4401                assert_eq!(p.required, Some(true));
4402            }
4403            other => panic!("expected RichTextEditor, got {other:?}"),
4404        }
4405        assert_eq!(original, deserialized, "PartialEq round-trip failed");
4406    }
4407
4408    #[test]
4409    fn rich_text_editor_theme_defaults_to_snow() {
4410        // Minimal JSON: only type and name; everything else falls back to defaults.
4411        let json_input = json!({
4412            "type": "RichTextEditor",
4413            "name": "body",
4414        });
4415        let parsed: Component =
4416            serde_json::from_value(json_input).expect("deserialize minimal RichTextEditor");
4417        match parsed {
4418            Component::RichTextEditor(p) => {
4419                assert_eq!(p.theme, "snow", "theme default must be \"snow\"");
4420                // Default formats per D-18: bold, italic, underline, list, header, link
4421                assert_eq!(
4422                    p.formats.len(),
4423                    6,
4424                    "default formats must have 6 entries: {:?}",
4425                    p.formats
4426                );
4427                for fmt in ["bold", "italic", "underline", "list", "header", "link"] {
4428                    assert!(
4429                        p.formats.iter().any(|f| f == fmt),
4430                        "default formats missing {fmt}: {:?}",
4431                        p.formats
4432                    );
4433                }
4434                assert!(p.value.is_none());
4435                assert!(p.placeholder.is_none());
4436                assert!(p.label.is_none());
4437                assert!(p.error.is_none());
4438                assert!(p.data_path.is_none());
4439                assert!(p.required.is_none());
4440            }
4441            other => panic!("expected RichTextEditor, got {other:?}"),
4442        }
4443    }
4444}