Skip to main content

ferro_json_ui/
component.rs

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