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/// Shared size enum for components (Button, Badge, Avatar, Input).
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum Size {
15    Xs,
16    Sm,
17    #[default]
18    Default,
19    Lg,
20}
21
22/// Icon placement relative to button label.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum IconPosition {
26    #[default]
27    Left,
28    Right,
29}
30
31/// Sort direction for table columns.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum SortDirection {
35    #[default]
36    Asc,
37    Desc,
38}
39
40/// Separator orientation.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum Orientation {
44    #[default]
45    Horizontal,
46    Vertical,
47}
48
49/// Button visual variants (aligned to shadcn/ui).
50#[derive(
51    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
52)]
53#[serde(rename_all = "snake_case")]
54#[strum(serialize_all = "snake_case")]
55pub enum ButtonVariant {
56    #[default]
57    Default,
58    Secondary,
59    Destructive,
60    Outline,
61    Ghost,
62    Link,
63}
64
65/// Input field types.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum InputType {
69    #[default]
70    Text,
71    Email,
72    Password,
73    Number,
74    Textarea,
75    Hidden,
76    Date,
77    Time,
78    Url,
79    Tel,
80    Search,
81    File,
82}
83
84/// Alert visual variants.
85#[derive(
86    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
87)]
88#[serde(rename_all = "snake_case")]
89#[strum(serialize_all = "snake_case")]
90pub enum AlertVariant {
91    #[default]
92    Info,
93    Success,
94    Warning,
95    Error,
96}
97
98/// Badge visual variants (aligned to shadcn/ui).
99#[derive(
100    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
101)]
102#[serde(rename_all = "snake_case")]
103#[strum(serialize_all = "snake_case")]
104pub enum BadgeVariant {
105    #[default]
106    Default,
107    Secondary,
108    Destructive,
109    Outline,
110}
111
112/// Text element types for semantic HTML rendering.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "snake_case")]
115pub enum TextElement {
116    #[default]
117    P,
118    H1,
119    H2,
120    H3,
121    Span,
122    Div,
123    Section,
124}
125
126/// Column display format for tables.
127///
128/// `Badge` cells expect the row value to be an object `{variant, label}` matching
129/// [`BadgeProps`]. Other variants are display hints layered over plain cell text.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131#[serde(rename_all = "snake_case")]
132pub enum ColumnFormat {
133    Date,
134    DateTime,
135    Currency,
136    Boolean,
137    Badge,
138}
139
140/// Table column definition.
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
142pub struct Column {
143    pub key: String,
144    pub label: String,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub format: Option<ColumnFormat>,
147}
148
149/// Select option (value + label pair).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151pub struct SelectOption {
152    pub value: String,
153    pub label: String,
154}
155
156/// Visual variant for Card chrome.
157///
158/// - `Bordered` (default): `border + bg-card + shadow-sm` with `p-4`.
159///   Dashboard cards in dense layouts.
160/// - `Elevated`: `bg-card + shadow-md` (no border) with `p-8`.
161///   Auth pages, error pages, standalone marketing cards.
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163#[serde(rename_all = "snake_case")]
164pub enum CardVariant {
165    #[default]
166    Bordered,
167    Elevated,
168}
169
170/// Props for Card component.
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
172pub struct CardProps {
173    pub title: String,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub description: Option<String>,
176    /// Optional muted secondary line rendered immediately below the title and
177    /// above the description. Pattern: name → role, customer → staff,
178    /// title → category. Visually `text-sm text-text-muted`.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub subtitle: Option<String>,
181    /// Optional small badge text rendered alongside the title. Visually a
182    /// Badge-styled pill inside the Card chrome — for status indicators,
183    /// counters, countdown labels, etc. Independent of the title hierarchy.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub badge: Option<String>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub max_width: Option<FormMaxWidth>,
188    /// IDs of footer elements (resolved against `Spec.elements`).
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub footer: Vec<String>,
191    #[serde(default)]
192    pub variant: CardVariant,
193}
194
195/// Props for Table component.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
197pub struct TableProps {
198    pub columns: Vec<Column>,
199    pub data_path: String,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub row_actions: Option<Vec<Action>>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub empty_message: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub sortable: Option<bool>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub sort_column: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub sort_direction: Option<SortDirection>,
210}
211
212/// Maximum width constraint for form containers.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
214#[serde(rename_all = "snake_case")]
215pub enum FormMaxWidth {
216    #[default]
217    Default,
218    Narrow,
219    Wide,
220}
221
222/// Props for Form component.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
224pub struct FormProps {
225    pub action: Action,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub method: Option<crate::action::HttpMethod>,
228    /// Form guard type. When set, the runtime JS disables the submit button
229    /// until the guard condition is met. Value: `"number-gt-0"` — at least
230    /// one number input must have value > 0.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub guard: Option<String>,
233    /// Optional max-width constraint for the form container.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub max_width: Option<FormMaxWidth>,
236    /// Optional HTML `id` attribute for the rendered `<form>`. Pair with a
237    /// Button's `form` prop to submit this form from a button placed outside
238    /// it (e.g. in a PageHeader actions slot).
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub id: Option<String>,
241    /// HTML form `enctype` attribute. Set to `"multipart/form-data"` for forms
242    /// carrying a file input. Without this, the browser default encoding
243    /// (`application/x-www-form-urlencoded`) is used and file inputs are sent
244    /// as plain text rather than a multipart body.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub enctype: Option<String>,
247}
248
249/// HTML button type attribute.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
251#[serde(rename_all = "snake_case")]
252pub enum ButtonType {
253    #[default]
254    Button,
255    Submit,
256}
257
258/// Props for Button component.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
260pub struct ButtonProps {
261    pub label: String,
262    #[serde(default)]
263    pub variant: ButtonVariant,
264    #[serde(default)]
265    pub size: Size,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub disabled: Option<bool>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub icon: Option<String>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub icon_position: Option<IconPosition>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub button_type: Option<ButtonType>,
274    /// HTML5 `form` attribute. Lets a submit button rendered outside its
275    /// target `<form>` (e.g. in a PageHeader actions slot) still submit
276    /// that form, by matching the form's `id`.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub form: Option<String>,
279}
280
281/// Props for Input component.
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
283pub struct InputProps {
284    /// Form field name for data binding.
285    pub field: String,
286    pub label: String,
287    #[serde(default)]
288    pub input_type: InputType,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub placeholder: Option<String>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub required: Option<bool>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub disabled: Option<bool>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub error: Option<String>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub description: Option<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub default_value: Option<String>,
301    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub data_path: Option<String>,
304    /// HTML step attribute for number inputs (e.g., "any", "0.01").
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub step: Option<String>,
307    /// HTML datalist id for autocomplete suggestions.
308    /// When set, renders `list="..."` on the input and a companion `<datalist>`
309    /// whose options come from a view data key matching this id.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub list: Option<String>,
312    /// HTML `accept` attribute for `input_type = "file"`. Comma-separated MIME
313    /// types or extensions (e.g. `"image/jpeg,image/png,image/webp"`). Browser-
314    /// side filter hint only — server-side MIME validation is the consumer's
315    /// responsibility (the spec layer does not enforce file content type).
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub accept: Option<String>,
318}
319
320/// Props for RichTextEditor leaf element — rendered by the Quill 2.0.3 plugin.
321///
322/// The plugin emits a container div (`<div data-ferro-quill ...>`) and a hidden
323/// input that receives the editor's HTML on every text-change event. The form
324/// handler receives standard `field=<html>` POST data on submit.
325///
326/// # Security
327/// The editor produces user-controlled HTML. Sanitization on submit is the
328/// consumer's responsibility — handle this in the form handler before
329/// persisting (e.g. via `ammonia`).
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
331pub struct RichTextEditorProps {
332    pub field: String,
333    pub label: String,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub placeholder: Option<String>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub default_value: Option<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub data_path: Option<String>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub error: Option<String>,
342}
343
344/// Props for Select component.
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
346pub struct SelectProps {
347    /// Form field name for data binding.
348    pub field: String,
349    pub label: String,
350    pub options: Vec<SelectOption>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub placeholder: Option<String>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub required: Option<bool>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub disabled: Option<bool>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub error: Option<String>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub description: Option<String>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub default_value: Option<String>,
363    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub data_path: Option<String>,
366}
367
368/// Props for Alert component.
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
370pub struct AlertProps {
371    pub message: String,
372    #[serde(default)]
373    pub variant: AlertVariant,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub title: Option<String>,
376}
377
378/// Props for Badge component.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
380pub struct BadgeProps {
381    pub label: String,
382    #[serde(default)]
383    pub variant: BadgeVariant,
384}
385
386/// Props for Modal component.
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
388pub struct ModalProps {
389    pub id: String,
390    pub title: String,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub description: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub trigger_label: Option<String>,
395    /// IDs of footer elements (resolved against `Spec.elements`).
396    #[serde(default, skip_serializing_if = "Vec::is_empty")]
397    pub footer: Vec<String>,
398}
399
400/// Props for Text component.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
402pub struct TextProps {
403    pub content: String,
404    #[serde(default)]
405    pub element: TextElement,
406}
407
408/// Props for Checkbox component.
409#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
410pub struct CheckboxProps {
411    /// Form field name for data binding.
412    pub field: String,
413    /// HTML value attribute. When set, the checkbox submits this value instead of "1".
414    /// Required for multi-value checkbox groups (same name, different values).
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub value: Option<String>,
417    pub label: String,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub description: Option<String>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub checked: Option<bool>,
422    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub data_path: Option<String>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub required: Option<bool>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub disabled: Option<bool>,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub error: Option<String>,
431}
432
433/// Props for CheckboxList component — multi-select checkbox group.
434///
435/// Each checked option submits as `field=value`. Options may be supplied
436/// statically via `options` or resolved at render time from `options_path`.
437/// Pre-selected values are read from `selected_path` (a `Vec<String>`).
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
439pub struct CheckboxListProps {
440    /// Shared form field name; each checkbox submits as `field=value`.
441    pub field: String,
442    /// Static options list. When empty and `options_path` is set, options are
443    /// resolved from the data at render time.
444    #[serde(default, skip_serializing_if = "Vec::is_empty")]
445    pub options: Vec<SelectOption>,
446    /// Data path to an array of `{value, label}` objects for data-driven options.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub options_path: Option<String>,
449    /// Data path to a `Vec<String>` of pre-selected values.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub selected_path: Option<String>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub label: Option<String>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub description: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub disabled: Option<bool>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub error: Option<String>,
460}
461
462/// Props for Switch component.
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
464pub struct SwitchProps {
465    /// Form field name for data binding.
466    pub field: String,
467    pub label: String,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub description: Option<String>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub checked: Option<bool>,
472    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub data_path: Option<String>,
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub required: Option<bool>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub disabled: Option<bool>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub error: Option<String>,
481    /// Auto-submit action. When set, the switch renders inside a minimal
482    /// form and submits on change.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub action: Option<Action>,
485    /// When true, applies `scale-75 origin-left` CSS to the switch container
486    /// for compact inline display (e.g. per-row settings toggles).
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub compact: Option<bool>,
489}
490
491/// Props for Separator component.
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
493pub struct SeparatorProps {
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub orientation: Option<Orientation>,
496}
497
498/// A single item in a description list.
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
500pub struct DescriptionItem {
501    pub label: String,
502    pub value: String,
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub format: Option<ColumnFormat>,
505}
506
507/// Props for DescriptionList component.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
509pub struct DescriptionListProps {
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub items: Vec<DescriptionItem>,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub columns: Option<u8>,
514    /// Optional data-path override of `items`. When set, the renderer
515    /// resolves the array at this path and decodes each entry as a
516    /// `DescriptionItem`.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub data_path: Option<String>,
519}
520
521/// A single tab within a Tabs component.
522#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
523pub struct Tab {
524    pub value: String,
525    pub label: String,
526    /// IDs of elements rendered inside this tab's panel.
527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
528    pub children: Vec<String>,
529}
530
531/// Props for Tabs component.
532#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
533pub struct TabsProps {
534    pub default_tab: String,
535    pub tabs: Vec<Tab>,
536}
537
538/// A single item in a breadcrumb trail.
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
540pub struct BreadcrumbItem {
541    pub label: String,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub url: Option<String>,
544}
545
546/// Props for Breadcrumb component.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
548pub struct BreadcrumbProps {
549    pub items: Vec<BreadcrumbItem>,
550}
551
552/// Props for Pagination component.
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
554pub struct PaginationProps {
555    pub current_page: u32,
556    pub per_page: u32,
557    pub total: u32,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub base_url: Option<String>,
560}
561
562/// Props for Progress component.
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
564pub struct ProgressProps {
565    /// Percentage value (0-100).
566    pub value: u8,
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub max: Option<u8>,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub label: Option<String>,
571}
572
573/// Props for Image component.
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
575pub struct ImageProps {
576    #[serde(default)]
577    pub src: String,
578    pub alt: String,
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub aspect_ratio: Option<String>,
581    /// Optional label shown in a skeleton placeholder that sits behind the
582    /// image. When the image fails to load (or is still being generated),
583    /// the `<img>` is hidden via `onerror` and the placeholder remains
584    /// visible, keeping the container at its aspect-ratio size.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub placeholder_label: Option<String>,
587    /// Server-rendered inline SVG string. When set, the SVG is emitted verbatim
588    /// inside a `<div aria-label="{alt}">` wrapper; no `<img>` tag is produced.
589    ///
590    /// # Safety
591    /// Content is NOT sanitized. The SVG string is emitted into the response
592    /// verbatim. Pass only server-constructed SVG (e.g. bar charts, QR codes).
593    /// Do NOT pass untrusted input. `alt` is required and is HTML-escaped.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub inline_svg: Option<String>,
596    /// Optional data-path override of `src`. When set, the renderer resolves
597    /// the value at this path against handler data and uses it as the
598    /// `<img src>`. Falls back to `src` when missing or non-string.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub data_path: Option<String>,
601}
602
603impl ImageProps {
604    /// Convenience constructor for inline-SVG images. `src` is set to the
605    /// empty string; the renderer takes the SVG path when `inline_svg` is `Some`.
606    ///
607    /// # Safety
608    /// `svg` is emitted verbatim. See [`ImageProps::inline_svg`] for the trust model.
609    pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
610        Self {
611            src: String::new(),
612            alt: alt.into(),
613            aspect_ratio: None,
614            placeholder_label: None,
615            inline_svg: Some(svg.into()),
616            data_path: None,
617        }
618    }
619}
620
621/// Props for Avatar component.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
623pub struct AvatarProps {
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub src: Option<String>,
626    pub alt: String,
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub fallback: Option<String>,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub size: Option<Size>,
631}
632
633/// Props for Skeleton loading placeholder.
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
635pub struct SkeletonProps {
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub width: Option<String>,
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub height: Option<String>,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub rounded: Option<bool>,
642}
643
644/// Props for the `RawHtml` component — server-injected HTML island.
645///
646/// # Safety
647/// `html` is emitted into the response VERBATIM with NO sanitization. The
648/// component exists to bridge server-rendered HTML fragments (e.g. a status
649/// pill, a link badge) into a v2 spec where a first-class component would
650/// be over-engineering.
651///
652/// Sanitization is the CONSUMER's responsibility — pass only server-
653/// constructed HTML, or run untrusted input through a sanitiser (e.g.
654/// `ammonia`) in the handler before embedding. This mirrors
655/// `RichTextEditorProps` discipline (see component.rs).
656///
657/// For richer widgets (interactive forms, charts, OAuth flows), use the
658/// first-class plugin system (`JsonUiPlugin`) instead — see
659/// `docs/src/json-ui/plugins.md`.
660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
661pub struct RawHtmlProps {
662    /// Server-constructed HTML emitted verbatim. NOT sanitized.
663    #[serde(default)]
664    pub html: String,
665}
666
667/// Props for the `StreamText` component — SSE token stream renderer.
668///
669/// Connects to `sse_url` via the browser `EventSource` API and appends arriving
670/// tokens as plain text nodes. The SSE endpoint MUST emit `event: done` on
671/// completion to prevent `EventSource` auto-reconnect.
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
673pub struct StreamTextProps {
674    /// URL of the server-sent-events endpoint that streams tokens.
675    /// Must emit `event: done` on completion.
676    #[serde(default)]
677    pub sse_url: String,
678    /// Text shown inside the content area before the first token arrives.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub placeholder: Option<String>,
681    /// Status text shown while the stream is open.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub loading_text: Option<String>,
684}
685
686/// Toast visual variants.
687#[derive(
688    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
689)]
690#[serde(rename_all = "snake_case")]
691#[strum(serialize_all = "snake_case")]
692pub enum ToastVariant {
693    #[default]
694    Info,
695    Success,
696    Warning,
697    Error,
698}
699
700/// A single item in a checklist.
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
702pub struct ChecklistItem {
703    pub label: String,
704    #[serde(default)]
705    pub checked: bool,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub href: Option<String>,
708}
709
710/// A single item in a notification dropdown.
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
712pub struct NotificationItem {
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub icon: Option<String>,
715    pub text: String,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub timestamp: Option<String>,
718    #[serde(default)]
719    pub read: bool,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub action_url: Option<String>,
722}
723
724/// A single navigation item in the sidebar.
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
726pub struct SidebarNavItem {
727    pub label: String,
728    pub href: String,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub icon: Option<String>,
731    #[serde(default)]
732    pub active: bool,
733    /// When true, the item renders as a muted, non-clickable `<span>`
734    /// instead of an `<a>` — useful for "coming soon" placeholders.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub disabled: Option<bool>,
737}
738
739/// A collapsible group in the sidebar.
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
741pub struct SidebarGroup {
742    pub label: String,
743    #[serde(default)]
744    pub collapsed: bool,
745    pub items: Vec<SidebarNavItem>,
746}
747
748/// Props for StatCard component (live-updatable metric card).
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
750pub struct StatCardProps {
751    pub label: String,
752    pub value: String,
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub icon: Option<String>,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub subtitle: Option<String>,
757    /// SSE target key for live updates; maps to `data-sse-target` on the value element.
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub sse_target: Option<String>,
760}
761
762/// Props for Checklist component.
763#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
764pub struct ChecklistProps {
765    pub title: String,
766    pub items: Vec<ChecklistItem>,
767    #[serde(default = "default_true")]
768    pub dismissible: bool,
769    #[serde(default, skip_serializing_if = "Option::is_none")]
770    pub dismiss_label: Option<String>,
771    /// Server-side state persistence key for this checklist.
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub data_key: Option<String>,
774}
775
776fn default_true() -> bool {
777    true
778}
779
780/// Props for Toast component (declarative notification intent).
781///
782/// The JS runtime reads data attributes from the rendered element to
783/// display the toast. Timeouts and dismissal are handled client-side.
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
785pub struct ToastProps {
786    pub message: String,
787    #[serde(default)]
788    pub variant: ToastVariant,
789    /// Seconds before auto-dismiss. Default 5.
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub timeout: Option<u32>,
792    #[serde(default = "default_true")]
793    pub dismissible: bool,
794}
795
796/// Props for NotificationDropdown component.
797#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
798pub struct NotificationDropdownProps {
799    pub notifications: Vec<NotificationItem>,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub empty_text: Option<String>,
802}
803
804/// Props for Sidebar component.
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
806pub struct SidebarProps {
807    #[serde(default, skip_serializing_if = "Vec::is_empty")]
808    pub fixed_top: Vec<SidebarNavItem>,
809    #[serde(default, skip_serializing_if = "Vec::is_empty")]
810    pub groups: Vec<SidebarGroup>,
811    #[serde(default, skip_serializing_if = "Vec::is_empty")]
812    pub fixed_bottom: Vec<SidebarNavItem>,
813}
814
815/// Props for Header component.
816#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
817pub struct HeaderProps {
818    pub business_name: String,
819    /// Unread notification count for badge display.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub notification_count: Option<u32>,
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub user_name: Option<String>,
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub user_avatar: Option<String>,
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub logout_url: Option<String>,
828}
829
830/// Gap size for Grid layout.
831#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
832#[serde(rename_all = "snake_case")]
833pub enum GapSize {
834    None,
835    Sm,
836    #[default]
837    Md,
838    Lg,
839    Xl,
840}
841
842/// Props for Grid component — multi-column layout.
843#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
844pub struct GridProps {
845    /// Number of columns (1-12) at base (mobile) viewport.
846    #[serde(default = "default_grid_columns")]
847    pub columns: u8,
848    /// Number of columns at md breakpoint (768px+). When set, creates a responsive grid.
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub md_columns: Option<u8>,
851    /// Number of columns at lg breakpoint (1024px+). Optional; falls back to md.
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub lg_columns: Option<u8>,
854    /// Gap between grid items.
855    #[serde(default)]
856    pub gap: GapSize,
857    /// Enables horizontal scroll mode. Children get `min-w-[280px]` and the grid
858    /// uses `grid-flow-col` auto-cols layout for Trello-like horizontal scrolling.
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub scrollable: Option<bool>,
861}
862
863fn default_grid_columns() -> u8 {
864    2
865}
866
867/// Props for Collapsible section — expandable `<details>`/`<summary>`.
868#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
869pub struct CollapsibleProps {
870    pub title: String,
871    #[serde(default)]
872    pub expanded: bool,
873}
874
875/// Props for EmptyState component — standardized empty view.
876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
877pub struct EmptyStateProps {
878    pub title: String,
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub description: Option<String>,
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub action: Option<Action>,
883    #[serde(default, skip_serializing_if = "Option::is_none")]
884    pub action_label: Option<String>,
885}
886
887/// Layout variant for form sections.
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
889#[serde(rename_all = "snake_case")]
890pub enum FormSectionLayout {
891    #[default]
892    Stacked,
893    TwoColumn,
894}
895
896/// Props for FormSection component — visual grouping within forms.
897#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
898pub struct FormSectionProps {
899    pub title: String,
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub description: Option<String>,
902    /// Optional layout variant. Defaults to stacked (single column).
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub layout: Option<FormSectionLayout>,
905}
906
907/// Props for PageHeader component -- page title with optional breadcrumb and action buttons.
908#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
909pub struct PageHeaderProps {
910    pub title: String,
911    #[serde(default, skip_serializing_if = "Vec::is_empty")]
912    pub breadcrumb: Vec<BreadcrumbItem>,
913    /// IDs of action button elements rendered to the right of the title.
914    #[serde(
915        default,
916        deserialize_with = "deserialize_actions_lax",
917        skip_serializing_if = "Vec::is_empty"
918    )]
919    pub actions: Vec<String>,
920}
921
922/// Props for ButtonGroup component -- horizontal button row with consistent gap.
923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
924pub struct ButtonGroupProps {
925    /// Gap between buttons. Defaults to small spacing.
926    #[serde(default)]
927    pub gap: GapSize,
928}
929
930/// Props for DetailPage component -- opinionated resource-detail skeleton.
931///
932/// Renders a PageHeader (title + breadcrumb + actions), an info Card
933/// wrapping the `info` slot IDs (typically a Badge plus a DescriptionList),
934/// and `Element.children` as stacked sections below the card (tabs,
935/// related-resource lists, action panels). Centralizes the visual contract
936/// every dashboard detail page follows so per-page rebuilds cannot drift
937/// from the canonical shape.
938#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
939pub struct DetailPageProps {
940    pub title: String,
941    #[serde(default, skip_serializing_if = "Vec::is_empty")]
942    pub breadcrumb: Vec<BreadcrumbItem>,
943    /// IDs of action button elements rendered to the right of the title.
944    #[serde(
945        default,
946        deserialize_with = "deserialize_actions_lax",
947        skip_serializing_if = "Vec::is_empty"
948    )]
949    pub actions: Vec<String>,
950    /// IDs of elements rendered inside the info Card
951    /// (typically a Badge and a DescriptionList). Omit to skip the card.
952    #[serde(default, skip_serializing_if = "Vec::is_empty")]
953    pub info: Vec<String>,
954}
955
956/// A single action item in a dropdown menu.
957#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
958pub struct DropdownMenuAction {
959    pub label: String,
960    pub action: Action,
961    #[serde(default)]
962    pub destructive: bool,
963    /// When set, this item is only emitted in a DataTable row when the row's
964    /// `visible_if` field is truthy (true / non-zero number / non-empty string /
965    /// non-empty array or object). An absent or falsy field hides the item —
966    /// fail-closed so a typo in the view spec cannot leak an action onto every
967    /// row. Outside DataTable contexts (e.g. standalone `DropdownMenu` element)
968    /// the field is ignored.
969    #[serde(default, skip_serializing_if = "Option::is_none")]
970    pub visible_if: Option<String>,
971}
972
973/// Props for DropdownMenu component — trigger button with absolutely-positioned action panel.
974#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
975pub struct DropdownMenuProps {
976    pub menu_id: String,
977    pub trigger_label: String,
978    pub items: Vec<DropdownMenuAction>,
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub trigger_variant: Option<ButtonVariant>,
981}
982
983/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
984/// mobile card fallback, and empty state.
985#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
986pub struct DataTableProps {
987    pub columns: Vec<Column>,
988    pub data_path: String,
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub row_actions: Option<Vec<DropdownMenuAction>>,
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub empty_message: Option<String>,
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub row_key: Option<String>,
995    /// URL pattern for row click navigation. Use `{row_key}` as placeholder.
996    #[serde(default, skip_serializing_if = "Option::is_none")]
997    pub row_href: Option<String>,
998}
999
1000/// Props for MediaCardGrid — a responsive card grid backed by a data array.
1001/// Mirrors DataTable's row_key/row_actions/data_path contract but renders
1002/// cards with an optional screenshot image instead of table rows.
1003#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1004pub struct MediaCardGridProps {
1005    pub data_path: String,
1006    /// Key in each row object whose value becomes the card title.
1007    pub title_key: String,
1008    /// Key for the subtitle/URL line below the title.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub description_key: Option<String>,
1011    /// Key for the screenshot image URL. No image rendered when absent or empty.
1012    #[serde(default, skip_serializing_if = "Option::is_none")]
1013    pub image_key: Option<String>,
1014    /// Key for the URL the image links to (opens in new tab).
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub image_href_key: Option<String>,
1017    /// CSS aspect-ratio value for the image (default "4/5").
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub image_aspect_ratio: Option<String>,
1020    /// CSS object-position for the cropped image: "top" | "center" | "bottom"
1021    /// (or any valid object-position value). Default "center".
1022    #[serde(default, skip_serializing_if = "Option::is_none")]
1023    pub image_position: Option<String>,
1024    /// Key for the footer badge label text.
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub badge_key: Option<String>,
1027    /// Key for the badge variant string: "outline" | "destructive" | "default".
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub badge_variant_key: Option<String>,
1030    /// Key used for {row_key} substitution in row_action URLs.
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub row_key: Option<String>,
1033    #[serde(default, skip_serializing_if = "Option::is_none")]
1034    pub row_actions: Option<Vec<DropdownMenuAction>>,
1035    #[serde(default, skip_serializing_if = "Option::is_none")]
1036    pub empty_message: Option<String>,
1037    /// Number of columns in the grid (default 3).
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub columns: Option<u8>,
1040}
1041
1042/// Props for a single column in a KanbanBoard.
1043#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1044pub struct KanbanColumnProps {
1045    pub id: String,
1046    pub title: String,
1047    pub count: u32,
1048    /// IDs of elements rendered inside this column.
1049    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1050    pub children: Vec<String>,
1051}
1052
1053/// Props for KanbanBoard component — horizontal scrollable columns on desktop, tab-based on mobile.
1054#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1055pub struct KanbanBoardProps {
1056    /// Static columns. Used when `data_path` is None.
1057    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1058    pub columns: Vec<KanbanColumnProps>,
1059    /// Optional data-path override of `columns`. When set, the renderer
1060    /// resolves the array at this path against handler data and decodes
1061    /// each entry as a `KanbanColumnProps`. Takes precedence over
1062    /// `columns` when both are set.
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub data_path: Option<String>,
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub mobile_default_column: Option<String>,
1067    /// Placeholder text shown inside columns whose `children` list is empty.
1068    /// When `None`, columns with no children render no placeholder (back-compat).
1069    /// Provide a short, neutral message — e.g. "Nessun ordine", "Nothing here".
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub empty_label: Option<String>,
1072}
1073
1074/// Props for a calendar day cell.
1075///
1076/// Renders a single day in a month grid with today highlight,
1077/// out-of-month muting, and event count indicator.
1078#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1079pub struct CalendarCellProps {
1080    pub day: u8,
1081    #[serde(default)]
1082    pub is_today: bool,
1083    #[serde(default)]
1084    pub is_current_month: bool,
1085    #[serde(default)]
1086    pub event_count: u32,
1087    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
1088    /// When non-empty, colored dots are rendered instead of plain primary dots.
1089    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1090    pub dot_colors: Vec<String>,
1091}
1092
1093/// Visual variant for action cards.
1094#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1095#[serde(rename_all = "snake_case")]
1096pub enum ActionCardVariant {
1097    #[default]
1098    Default,
1099    Setup,
1100    Danger,
1101}
1102
1103/// Props for a horizontal action card with variant-colored left border.
1104///
1105/// Renders icon + title + description + chevron in a clickable row.
1106/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
1107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1108pub struct ActionCardProps {
1109    pub title: String,
1110    pub description: String,
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub icon: Option<String>,
1113    #[serde(default)]
1114    pub variant: ActionCardVariant,
1115    /// Optional navigation URL. When set, the card renders as an `<a>` element.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub href: Option<String>,
1118}
1119
1120/// Props for a touch-friendly product tile with quantity controls.
1121///
1122/// Renders product name, price, and +/- buttons that drive a hidden input
1123/// via JS. Used for POS-style product selection during order creation.
1124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1125pub struct ProductTileProps {
1126    pub product_id: String,
1127    pub name: String,
1128    pub price: String,
1129    pub field: String,
1130    #[serde(default, skip_serializing_if = "Option::is_none")]
1131    pub default_quantity: Option<u32>,
1132}
1133
1134/// Lax deserializer for PageHeader.actions. Per D-19/F6:
1135/// Accepts: missing field (via #[serde(default)]), null, [], empty string "",
1136/// and array of strings. Rejects: non-empty strings, arrays of non-strings.
1137/// This loosens the wire-format contract for actions only — other Vec<String>
1138/// ID-slot fields (e.g. CardProps.footer) remain strict.
1139fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1140    d: D,
1141) -> Result<Vec<String>, D::Error> {
1142    use serde::de::Error;
1143    let v = serde_json::Value::deserialize(d)?;
1144    match v {
1145        serde_json::Value::Null => Ok(Vec::new()),
1146        serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1147        serde_json::Value::Array(arr) => arr
1148            .into_iter()
1149            .map(|item| {
1150                item.as_str()
1151                    .map(String::from)
1152                    .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1153            })
1154            .collect(),
1155        other => Err(D::Error::custom(format!(
1156            "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1157        ))),
1158    }
1159}
1160
1161#[cfg(test)]
1162mod schema_smoke_tests {
1163    //! Runtime `schema_for!` smoke tests per D-32.
1164    //!
1165    //! Each test asserts that the generated JSON Schema for the given Props
1166    //! struct is a non-empty JSON object with a populated `properties` field.
1167    //! This proves the `JsonSchema` derive executes without panic on every
1168    //! surviving Props struct — a compile-time `#[derive(JsonSchema)]` alone
1169    //! does not prove the generated code runs.
1170    //!
1171    //! One `#[test]` per type for clear failure localization.
1172
1173    use super::*;
1174
1175    fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1176        let schema = schemars::schema_for!(T);
1177        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1178        assert!(
1179            value.is_object(),
1180            "{type_label}: schema must be a JSON object"
1181        );
1182        let props = value
1183            .get("properties")
1184            .and_then(|p| p.as_object())
1185            .map(|o| !o.is_empty())
1186            .unwrap_or(false);
1187        assert!(
1188            props,
1189            "{type_label}: schema must have a non-empty `properties` field"
1190        );
1191    }
1192
1193    #[test]
1194    fn schema_for_card_props_generates() {
1195        assert_schema_nonempty_object::<CardProps>("CardProps");
1196    }
1197
1198    #[test]
1199    fn schema_for_table_props_generates() {
1200        assert_schema_nonempty_object::<TableProps>("TableProps");
1201    }
1202
1203    #[test]
1204    fn schema_for_form_props_generates() {
1205        assert_schema_nonempty_object::<FormProps>("FormProps");
1206    }
1207
1208    #[test]
1209    fn schema_for_button_props_generates() {
1210        assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1211    }
1212
1213    #[test]
1214    fn schema_for_input_props_generates() {
1215        assert_schema_nonempty_object::<InputProps>("InputProps");
1216    }
1217
1218    #[test]
1219    fn schema_for_select_props_generates() {
1220        assert_schema_nonempty_object::<SelectProps>("SelectProps");
1221    }
1222
1223    #[test]
1224    fn schema_for_alert_props_generates() {
1225        assert_schema_nonempty_object::<AlertProps>("AlertProps");
1226    }
1227
1228    #[test]
1229    fn schema_for_badge_props_generates() {
1230        assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1231    }
1232
1233    #[test]
1234    fn schema_for_modal_props_generates() {
1235        assert_schema_nonempty_object::<ModalProps>("ModalProps");
1236    }
1237
1238    #[test]
1239    fn schema_for_text_props_generates() {
1240        assert_schema_nonempty_object::<TextProps>("TextProps");
1241    }
1242
1243    #[test]
1244    fn schema_for_checkbox_props_generates() {
1245        assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1246    }
1247
1248    #[test]
1249    fn schema_for_switch_props_generates() {
1250        assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1251    }
1252
1253    #[test]
1254    fn schema_for_separator_props_generates() {
1255        assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1256    }
1257
1258    #[test]
1259    fn schema_for_description_list_props_generates() {
1260        assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1261    }
1262
1263    #[test]
1264    fn schema_for_tab_generates() {
1265        assert_schema_nonempty_object::<Tab>("Tab");
1266    }
1267
1268    #[test]
1269    fn schema_for_tabs_props_generates() {
1270        assert_schema_nonempty_object::<TabsProps>("TabsProps");
1271    }
1272
1273    #[test]
1274    fn schema_for_breadcrumb_props_generates() {
1275        assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1276    }
1277
1278    #[test]
1279    fn schema_for_pagination_props_generates() {
1280        assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1281    }
1282
1283    #[test]
1284    fn schema_for_progress_props_generates() {
1285        assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1286    }
1287
1288    #[test]
1289    fn schema_for_image_props_generates() {
1290        assert_schema_nonempty_object::<ImageProps>("ImageProps");
1291    }
1292
1293    #[test]
1294    fn image_inline_svg_factory_roundtrips_via_serde() {
1295        let p = ImageProps::inline_svg("<svg/>", "alt");
1296        let json = serde_json::to_value(&p).expect("serialization must not fail");
1297        let parsed: ImageProps =
1298            serde_json::from_value(json).expect("deserialization must not fail");
1299        assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1300        assert_eq!(parsed.alt, "alt");
1301        assert_eq!(parsed.src, "");
1302    }
1303
1304    #[test]
1305    fn schema_for_avatar_props_generates() {
1306        assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1307    }
1308
1309    #[test]
1310    fn schema_for_skeleton_props_generates() {
1311        assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1312    }
1313
1314    #[test]
1315    fn schema_for_stat_card_props_generates() {
1316        assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1317    }
1318
1319    #[test]
1320    fn schema_for_checklist_props_generates() {
1321        assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1322    }
1323
1324    #[test]
1325    fn schema_for_toast_props_generates() {
1326        assert_schema_nonempty_object::<ToastProps>("ToastProps");
1327    }
1328
1329    #[test]
1330    fn schema_for_notification_dropdown_props_generates() {
1331        assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1332    }
1333
1334    #[test]
1335    fn schema_for_sidebar_props_generates() {
1336        assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1337    }
1338
1339    #[test]
1340    fn schema_for_header_props_generates() {
1341        assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1342    }
1343
1344    #[test]
1345    fn schema_for_grid_props_generates() {
1346        assert_schema_nonempty_object::<GridProps>("GridProps");
1347    }
1348
1349    #[test]
1350    fn schema_for_collapsible_props_generates() {
1351        assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1352    }
1353
1354    #[test]
1355    fn schema_for_empty_state_props_generates() {
1356        assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1357    }
1358
1359    #[test]
1360    fn schema_for_form_section_props_generates() {
1361        assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1362    }
1363
1364    #[test]
1365    fn schema_for_page_header_props_generates() {
1366        assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1367    }
1368
1369    #[test]
1370    fn schema_for_button_group_props_generates() {
1371        assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1372    }
1373
1374    #[test]
1375    fn schema_for_dropdown_menu_action_generates() {
1376        assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1377    }
1378
1379    #[test]
1380    fn schema_for_dropdown_menu_props_generates() {
1381        assert_schema_nonempty_object::<DropdownMenuProps>("DropdownMenuProps");
1382    }
1383
1384    #[test]
1385    fn schema_for_data_table_props_generates() {
1386        assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1387    }
1388
1389    #[test]
1390    fn schema_for_kanban_column_props_generates() {
1391        assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1392    }
1393
1394    #[test]
1395    fn schema_for_kanban_board_props_generates() {
1396        assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1397    }
1398
1399    #[test]
1400    fn schema_for_calendar_cell_props_generates() {
1401        assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1402    }
1403
1404    #[test]
1405    fn schema_for_action_card_props_generates() {
1406        assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1407    }
1408
1409    #[test]
1410    fn schema_for_product_tile_props_generates() {
1411        assert_schema_nonempty_object::<ProductTileProps>("ProductTileProps");
1412    }
1413
1414    #[test]
1415    fn card_props_round_trips_footer() {
1416        let original = CardProps {
1417            title: "Hero".to_string(),
1418            description: None,
1419            subtitle: None,
1420            badge: None,
1421            max_width: None,
1422            footer: vec!["btn1".to_string(), "btn2".to_string()],
1423            variant: CardVariant::Bordered,
1424        };
1425        let json = serde_json::to_string(&original).unwrap();
1426        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1427        assert_eq!(original.footer, parsed.footer);
1428    }
1429
1430    #[test]
1431    fn tab_round_trips_children() {
1432        let original = Tab {
1433            value: "overview".to_string(),
1434            label: "Overview".to_string(),
1435            children: vec!["panel1".to_string()],
1436        };
1437        let json = serde_json::to_string(&original).unwrap();
1438        let parsed: Tab = serde_json::from_str(&json).unwrap();
1439        assert_eq!(original.children, parsed.children);
1440    }
1441
1442    #[test]
1443    fn card_props_omits_empty_footer_in_json() {
1444        let card = CardProps {
1445            title: "Card".to_string(),
1446            description: None,
1447            subtitle: None,
1448            badge: None,
1449            max_width: None,
1450            footer: Vec::new(),
1451            variant: CardVariant::Bordered,
1452        };
1453        let json = serde_json::to_string(&card).unwrap();
1454        assert!(
1455            !json.contains("\"footer\""),
1456            "empty footer must be skipped, got: {json}"
1457        );
1458    }
1459
1460    #[test]
1461    fn card_props_round_trips_badge() {
1462        let original = CardProps {
1463            title: "Hero".to_string(),
1464            description: None,
1465            subtitle: None,
1466            badge: Some("Scade tra 9m".to_string()),
1467            max_width: None,
1468            footer: Vec::new(),
1469            variant: CardVariant::Bordered,
1470        };
1471        let json = serde_json::to_string(&original).unwrap();
1472        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1473        assert_eq!(original.badge, parsed.badge);
1474    }
1475
1476    #[test]
1477    fn card_props_omits_empty_badge_in_json() {
1478        let card = CardProps {
1479            title: "Card".to_string(),
1480            description: None,
1481            subtitle: None,
1482            badge: None,
1483            max_width: None,
1484            footer: Vec::new(),
1485            variant: CardVariant::Bordered,
1486        };
1487        let json = serde_json::to_string(&card).unwrap();
1488        assert!(
1489            !json.contains("\"badge\""),
1490            "empty badge must be skipped, got: {json}"
1491        );
1492    }
1493
1494    #[test]
1495    fn card_props_round_trips_subtitle() {
1496        let original = CardProps {
1497            title: "Hero".to_string(),
1498            description: None,
1499            subtitle: Some("Marco Rossi".to_string()),
1500            badge: None,
1501            max_width: None,
1502            footer: Vec::new(),
1503            variant: CardVariant::Bordered,
1504        };
1505        let json = serde_json::to_string(&original).unwrap();
1506        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1507        assert_eq!(original.subtitle, parsed.subtitle);
1508    }
1509
1510    #[test]
1511    fn card_props_omits_empty_subtitle_in_json() {
1512        let card = CardProps {
1513            title: "Card".to_string(),
1514            description: None,
1515            subtitle: None,
1516            badge: None,
1517            max_width: None,
1518            footer: Vec::new(),
1519            variant: CardVariant::Bordered,
1520        };
1521        let json = serde_json::to_string(&card).unwrap();
1522        assert!(
1523            !json.contains("\"subtitle\""),
1524            "empty subtitle must be skipped, got: {json}"
1525        );
1526    }
1527
1528    #[test]
1529    fn card_props_schema_includes_badge() {
1530        let schema = schemars::schema_for!(CardProps);
1531        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1532        let props = value
1533            .get("properties")
1534            .and_then(|p| p.as_object())
1535            .expect("schema has a properties object");
1536        assert!(
1537            props.contains_key("badge"),
1538            "CardProps schema must expose a `badge` property; got keys: {:?}",
1539            props.keys().collect::<Vec<_>>()
1540        );
1541        // `badge: Option<String>` — schemars 1.x emits either {"type": ["string","null"]}
1542        // or a {"type":"string"} entry inside a oneOf/anyOf branch. We only assert
1543        // presence + that the rendered schema mentions a string somewhere under
1544        // the badge entry, which is robust to either encoding.
1545        let badge_schema = props.get("badge").expect("badge entry");
1546        let badge_json = badge_schema.to_string();
1547        assert!(
1548            badge_json.contains("\"string\""),
1549            "badge schema entry must mention string type; got: {badge_json}"
1550        );
1551    }
1552
1553    #[test]
1554    fn card_props_schema_includes_subtitle() {
1555        let schema = schemars::schema_for!(CardProps);
1556        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1557        let props = value
1558            .get("properties")
1559            .and_then(|p| p.as_object())
1560            .expect("schema has a properties object");
1561        assert!(
1562            props.contains_key("subtitle"),
1563            "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1564            props.keys().collect::<Vec<_>>()
1565        );
1566        // Same robustness note as `card_props_schema_includes_badge` —
1567        // `subtitle: Option<String>` may surface as type-union or oneOf depending
1568        // on the schemars version. Assert string is mentioned in the rendered
1569        // entry rather than locking down the exact null encoding.
1570        let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1571        let subtitle_json = subtitle_schema.to_string();
1572        assert!(
1573            subtitle_json.contains("\"string\""),
1574            "subtitle schema entry must mention string type; got: {subtitle_json}"
1575        );
1576    }
1577
1578    #[test]
1579    fn schema_for_checkbox_list_props_generates() {
1580        assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
1581    }
1582
1583    #[test]
1584    fn checkbox_list_props_serde_roundtrip() {
1585        let json = serde_json::json!({
1586            "field": "services",
1587            "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
1588            "selected_path": "/preselected"
1589        });
1590        let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
1591        assert_eq!(parsed.field, "services");
1592        assert_eq!(parsed.options.len(), 2);
1593        assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
1594        let reserialized = serde_json::to_value(&parsed).expect("encode");
1595        // None/empty fields are omitted by serde.
1596        assert!(reserialized.get("label").is_none());
1597        assert!(reserialized.get("disabled").is_none());
1598    }
1599
1600    #[test]
1601    fn schema_for_rich_text_editor_props_generates() {
1602        assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
1603    }
1604
1605    #[test]
1606    fn rich_text_editor_props_serde_roundtrip() {
1607        let json = serde_json::json!({
1608            "field": "body",
1609            "label": "Body"
1610        });
1611        let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
1612        assert_eq!(parsed.field, "body");
1613        assert_eq!(parsed.label, "Body");
1614        assert!(parsed.placeholder.is_none());
1615        assert!(parsed.default_value.is_none());
1616        assert!(parsed.data_path.is_none());
1617        assert!(parsed.error.is_none());
1618        let reserialized = serde_json::to_value(&parsed).expect("encode");
1619        // Optional None fields are omitted.
1620        assert!(reserialized.get("placeholder").is_none());
1621        assert!(reserialized.get("error").is_none());
1622    }
1623}
1624
1625#[cfg(test)]
1626mod strum_tests {
1627    use super::*;
1628
1629    /// Assert AsRef<str> matches serde JSON wire format for every variant of
1630    /// AlertVariant, BadgeVariant, ButtonVariant, and ToastVariant.
1631    /// Threat T-162-08-01: strum and serde must agree on every snake_case string.
1632    #[test]
1633    fn variant_enums_strum_matches_serde_wire_format() {
1634        fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
1635            for v in variants {
1636                let json = serde_json::to_string(v).expect("serialize");
1637                let json_stripped = json.trim_matches('"');
1638                assert_eq!(
1639                    v.as_ref(),
1640                    json_stripped,
1641                    "strum AsRefStr drifted from serde for {label} variant"
1642                );
1643            }
1644        }
1645        check(
1646            &[
1647                AlertVariant::Info,
1648                AlertVariant::Success,
1649                AlertVariant::Warning,
1650                AlertVariant::Error,
1651            ],
1652            "AlertVariant",
1653        );
1654        check(
1655            &[
1656                BadgeVariant::Default,
1657                BadgeVariant::Secondary,
1658                BadgeVariant::Destructive,
1659                BadgeVariant::Outline,
1660            ],
1661            "BadgeVariant",
1662        );
1663        check(
1664            &[
1665                ButtonVariant::Default,
1666                ButtonVariant::Secondary,
1667                ButtonVariant::Destructive,
1668                ButtonVariant::Outline,
1669                ButtonVariant::Ghost,
1670                ButtonVariant::Link,
1671            ],
1672            "ButtonVariant",
1673        );
1674        check(
1675            &[
1676                ToastVariant::Info,
1677                ToastVariant::Success,
1678                ToastVariant::Warning,
1679                ToastVariant::Error,
1680            ],
1681            "ToastVariant",
1682        );
1683    }
1684
1685    #[test]
1686    fn alert_variant_as_ref_str_matches_wire_format() {
1687        assert_eq!(AlertVariant::Success.as_ref(), "success");
1688        assert_eq!(AlertVariant::Warning.as_ref(), "warning");
1689        assert_eq!(AlertVariant::Info.as_ref(), "info");
1690        assert_eq!(AlertVariant::Error.as_ref(), "error");
1691    }
1692}
1693
1694#[cfg(test)]
1695mod card_variant_tests {
1696    use super::*;
1697
1698    #[test]
1699    fn card_variant_default_is_bordered() {
1700        assert_eq!(CardVariant::default(), CardVariant::Bordered);
1701    }
1702
1703    #[test]
1704    fn card_variant_serializes_snake_case() {
1705        assert_eq!(
1706            serde_json::to_value(CardVariant::Bordered).unwrap(),
1707            serde_json::json!("bordered")
1708        );
1709        assert_eq!(
1710            serde_json::to_value(CardVariant::Elevated).unwrap(),
1711            serde_json::json!("elevated")
1712        );
1713    }
1714
1715    #[test]
1716    fn card_variant_deserializes_snake_case() {
1717        assert_eq!(
1718            serde_json::from_value::<CardVariant>(serde_json::json!("bordered")).unwrap(),
1719            CardVariant::Bordered
1720        );
1721        assert_eq!(
1722            serde_json::from_value::<CardVariant>(serde_json::json!("elevated")).unwrap(),
1723            CardVariant::Elevated
1724        );
1725    }
1726
1727    #[test]
1728    fn card_props_without_variant_defaults_to_bordered() {
1729        let v = serde_json::json!({"title": "x"});
1730        let p: CardProps = serde_json::from_value(v).unwrap();
1731        assert_eq!(p.variant, CardVariant::Bordered);
1732    }
1733
1734    #[test]
1735    fn card_props_with_elevated_variant() {
1736        let v = serde_json::json!({"title": "x", "variant": "elevated"});
1737        let p: CardProps = serde_json::from_value(v).unwrap();
1738        assert_eq!(p.variant, CardVariant::Elevated);
1739    }
1740
1741    #[test]
1742    fn card_props_roundtrip_preserves_variant() {
1743        let p = CardProps {
1744            title: "x".into(),
1745            description: None,
1746            subtitle: None,
1747            badge: None,
1748            max_width: None,
1749            footer: vec![],
1750            variant: CardVariant::Elevated,
1751        };
1752        let j = serde_json::to_value(&p).unwrap();
1753        let back: CardProps = serde_json::from_value(j).unwrap();
1754        assert_eq!(back.variant, CardVariant::Elevated);
1755    }
1756}
1757
1758#[cfg(test)]
1759mod kanban_board_props_tests {
1760    use super::*;
1761
1762    #[test]
1763    fn kanban_board_props_serde_static_columns() {
1764        let v = serde_json::json!({
1765            "columns": [{"title": "To Do", "items": [], "id": "todo", "count": 0}]
1766        });
1767        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1768        assert_eq!(p.columns.len(), 1);
1769        assert!(p.data_path.is_none());
1770    }
1771
1772    #[test]
1773    fn kanban_board_props_serde_data_path() {
1774        let v = serde_json::json!({"data_path": "/columns"});
1775        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1776        assert!(p.columns.is_empty());
1777        assert_eq!(p.data_path.as_deref(), Some("/columns"));
1778    }
1779
1780    #[test]
1781    fn kanban_board_props_serde_neither() {
1782        let v = serde_json::json!({});
1783        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1784        assert!(p.columns.is_empty());
1785        assert!(p.data_path.is_none());
1786    }
1787
1788    #[test]
1789    fn kanban_board_props_empty_columns_skipped_on_serialize() {
1790        let p = KanbanBoardProps {
1791            columns: vec![],
1792            data_path: Some("/x".into()),
1793            mobile_default_column: None,
1794            empty_label: None,
1795        };
1796        let j = serde_json::to_value(&p).unwrap();
1797        assert!(
1798            j.get("columns").is_none(),
1799            "empty columns must be skipped, got: {j}"
1800        );
1801        assert_eq!(j.get("data_path").and_then(|v| v.as_str()), Some("/x"));
1802    }
1803}
1804
1805#[cfg(test)]
1806mod page_header_actions_tests {
1807    use super::*;
1808
1809    #[test]
1810    fn page_header_actions_missing_field() {
1811        let v = serde_json::json!({"title": "X"});
1812        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1813        assert!(p.actions.is_empty());
1814    }
1815
1816    #[test]
1817    fn page_header_actions_null() {
1818        let v = serde_json::json!({"title": "X", "actions": null});
1819        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1820        assert!(p.actions.is_empty());
1821    }
1822
1823    #[test]
1824    fn page_header_actions_empty_string() {
1825        let v = serde_json::json!({"title": "X", "actions": ""});
1826        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1827        assert!(p.actions.is_empty());
1828    }
1829
1830    #[test]
1831    fn page_header_actions_empty_array() {
1832        let v = serde_json::json!({"title": "X", "actions": []});
1833        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1834        assert!(p.actions.is_empty());
1835    }
1836
1837    #[test]
1838    fn page_header_actions_non_empty_array() {
1839        let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
1840        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1841        assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
1842    }
1843
1844    #[test]
1845    fn page_header_actions_non_empty_string_rejected() {
1846        let v = serde_json::json!({"title": "X", "actions": "not-empty"});
1847        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
1848        assert!(result.is_err(), "non-empty string must be rejected");
1849    }
1850
1851    #[test]
1852    fn page_header_actions_non_string_array_rejected() {
1853        let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
1854        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
1855        assert!(result.is_err(), "array of non-strings must be rejected");
1856    }
1857}