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