Skip to main content

ferro_json_ui/
render.rs

1//! HTML render engine for JSON-UI views.
2//!
3//! Walks a `JsonUiView` component tree and produces an HTML fragment using
4//! Tailwind CSS utility classes. All 26 built-in component types plus plugin
5//! components are supported. Plugin components are dispatched to the plugin
6//! registry; their CSS/JS assets are collected and returned separately.
7
8use std::collections::HashSet;
9
10use serde_json::Value;
11
12use crate::action::HttpMethod;
13use crate::component::{
14    ActionCardProps, ActionCardVariant, AlertProps, AlertVariant, AvatarProps, BadgeProps,
15    BadgeVariant, BreadcrumbProps, ButtonGroupProps, ButtonProps, ButtonType, ButtonVariant,
16    CalendarCellProps, CardProps, CheckboxProps, ChecklistProps, CollapsibleProps, Component,
17    ComponentNode, DataTableProps, DescriptionListProps, DetailFormProps, DropdownMenuAction,
18    DropdownMenuProps, EditMode, EmptyStateProps, FormMaxWidth, FormProps, FormSectionLayout,
19    FormSectionProps, GapSize, GridProps, HeaderProps, IconPosition, ImageProps, ImageSource,
20    InputProps, InputType, KanbanBoardProps, KeyValueEditorProps, ModalProps,
21    NotificationDropdownProps, Orientation, PageHeaderProps, PaginationProps, PluginProps,
22    ProductTileProps, ProgressProps, SelectProps, SeparatorProps, SidebarProps, Size,
23    SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextElement, TextProps,
24    ToastProps, ToastVariant,
25};
26use crate::data::{resolve_path, resolve_path_string};
27use crate::plugin::{collect_plugin_assets, Asset};
28use crate::view::JsonUiView;
29
30/// Render a JSON-UI view to an HTML fragment.
31///
32/// Walks the component tree and produces a `<div>` containing all rendered
33/// components. This is a fragment, not a full page -- the framework wrapper
34/// handles `<html>`, `<head>`, and `<body>`.
35///
36/// The `data` parameter is used to resolve `data_path` references on form
37/// fields and table components.
38pub fn render_to_html(view: &JsonUiView, data: &Value) -> String {
39    let mut html = String::from(
40        "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
41    );
42    for node in &view.components {
43        html.push_str(&render_node(node, data));
44    }
45    html.push_str("</div>");
46    html
47}
48
49/// Result of rendering a view with plugin support.
50///
51/// Contains the rendered HTML fragment plus CSS and JS tags collected
52/// from plugins used on the page.
53pub struct RenderResult {
54    /// The rendered HTML fragment (same as `render_to_html` output).
55    pub html: String,
56    /// CSS `<link>` tags to inject into `<head>`.
57    pub css_head: String,
58    /// JS `<script>` tags and init scripts to inject before `</body>`.
59    pub scripts: String,
60}
61
62/// Render a JSON-UI view to HTML and collect plugin assets.
63///
64/// Scans the component tree for plugin components, renders everything to
65/// HTML (including plugin components via the registry), then collects and
66/// deduplicates CSS/JS assets from the plugins used on the page.
67pub fn render_to_html_with_plugins(view: &JsonUiView, data: &Value) -> RenderResult {
68    let html = render_to_html(view, data);
69
70    let plugin_types = collect_plugin_types(view);
71    if plugin_types.is_empty() {
72        return RenderResult {
73            html,
74            css_head: String::new(),
75            scripts: String::new(),
76        };
77    }
78
79    let type_names: Vec<String> = plugin_types.into_iter().collect();
80    let assets = collect_plugin_assets(&type_names);
81
82    let css_head = render_css_tags(&assets.css);
83    let scripts = render_js_tags(&assets.js, &assets.init_scripts);
84
85    RenderResult {
86        html,
87        css_head,
88        scripts,
89    }
90}
91
92/// Walk the component tree and collect unique plugin type names.
93pub(crate) fn collect_plugin_types(view: &JsonUiView) -> HashSet<String> {
94    let mut types = HashSet::new();
95    for node in &view.components {
96        collect_plugin_types_node(node, &mut types);
97    }
98    types
99}
100
101/// Recursively collect plugin type names from a component node.
102fn collect_plugin_types_node(node: &ComponentNode, types: &mut HashSet<String>) {
103    match &node.component {
104        Component::Plugin(props) => {
105            types.insert(props.plugin_type.clone());
106        }
107        Component::Card(props) => {
108            for child in &props.children {
109                collect_plugin_types_node(child, types);
110            }
111            for child in &props.footer {
112                collect_plugin_types_node(child, types);
113            }
114        }
115        Component::Form(props) => {
116            for field in &props.fields {
117                collect_plugin_types_node(field, types);
118            }
119        }
120        Component::DetailForm(props) => {
121            for field in &props.fields {
122                collect_plugin_types_node(&field.input, types);
123            }
124        }
125        Component::Modal(props) => {
126            for child in &props.children {
127                collect_plugin_types_node(child, types);
128            }
129            for child in &props.footer {
130                collect_plugin_types_node(child, types);
131            }
132        }
133        Component::Tabs(props) => {
134            for tab in &props.tabs {
135                for child in &tab.children {
136                    collect_plugin_types_node(child, types);
137                }
138            }
139        }
140        Component::Grid(props) => {
141            for child in &props.children {
142                collect_plugin_types_node(child, types);
143            }
144        }
145        Component::Collapsible(props) => {
146            for child in &props.children {
147                collect_plugin_types_node(child, types);
148            }
149        }
150        Component::FormSection(props) => {
151            for child in &props.children {
152                collect_plugin_types_node(child, types);
153            }
154        }
155        Component::PageHeader(props) => {
156            for child in &props.actions {
157                collect_plugin_types_node(child, types);
158            }
159        }
160        Component::ButtonGroup(props) => {
161            for child in &props.buttons {
162                collect_plugin_types_node(child, types);
163            }
164        }
165        // Leaf components have no children to recurse into.
166        Component::Table(_)
167        | Component::Button(_)
168        | Component::Input(_)
169        | Component::Select(_)
170        | Component::Alert(_)
171        | Component::Badge(_)
172        | Component::Text(_)
173        | Component::Checkbox(_)
174        | Component::Switch(_)
175        | Component::Separator(_)
176        | Component::DescriptionList(_)
177        | Component::Breadcrumb(_)
178        | Component::Pagination(_)
179        | Component::Progress(_)
180        | Component::Avatar(_)
181        | Component::Skeleton(_)
182        | Component::StatCard(_)
183        | Component::Checklist(_)
184        | Component::Toast(_)
185        | Component::NotificationDropdown(_)
186        | Component::Sidebar(_)
187        | Component::Header(_)
188        | Component::EmptyState(_)
189        | Component::DropdownMenu(_)
190        | Component::CalendarCell(_)
191        | Component::ActionCard(_)
192        | Component::ProductTile(_)
193        | Component::DataTable(_)
194        | Component::Image(_)
195        | Component::KeyValueEditor(_) => {}
196        Component::KanbanBoard(props) => {
197            for col in &props.columns {
198                for child in &col.children {
199                    collect_plugin_types_node(child, types);
200                }
201            }
202        }
203    }
204}
205
206/// Render CSS assets as `<link>` tags.
207fn render_css_tags(assets: &[Asset]) -> String {
208    let mut out = String::new();
209    for asset in assets {
210        out.push_str("<link rel=\"stylesheet\" href=\"");
211        out.push_str(&html_escape(&asset.url));
212        out.push('"');
213        if let Some(ref integrity) = asset.integrity {
214            out.push_str(" integrity=\"");
215            out.push_str(&html_escape(integrity));
216            out.push('"');
217        }
218        if let Some(ref co) = asset.crossorigin {
219            out.push_str(" crossorigin=\"");
220            out.push_str(&html_escape(co));
221            out.push('"');
222        }
223        out.push('>');
224    }
225    out
226}
227
228/// Render JS assets as `<script>` tags followed by inline init scripts.
229fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
230    let mut out = String::new();
231    for asset in assets {
232        out.push_str("<script src=\"");
233        out.push_str(&html_escape(&asset.url));
234        out.push('"');
235        if let Some(ref integrity) = asset.integrity {
236            out.push_str(" integrity=\"");
237            out.push_str(&html_escape(integrity));
238            out.push('"');
239        }
240        if let Some(ref co) = asset.crossorigin {
241            out.push_str(" crossorigin=\"");
242            out.push_str(&html_escape(co));
243            out.push('"');
244        }
245        out.push_str("></script>");
246    }
247    if !init_scripts.is_empty() {
248        out.push_str("<script>");
249        for script in init_scripts {
250            out.push_str(script);
251        }
252        out.push_str("</script>");
253    }
254    out
255}
256
257/// Render a single component node, optionally wrapping in `<a>` for GET actions.
258fn render_node(node: &ComponentNode, data: &Value) -> String {
259    let component_html = render_component(&node.component, data);
260
261    // Wrap in <a> if the node has a GET action with a resolved URL.
262    if let Some(ref action) = node.action {
263        if action.method == HttpMethod::Get {
264            if let Some(ref url) = action.url {
265                let target_attr = match action.target.as_deref() {
266                    Some(t) => {
267                        format!(" target=\"{}\" rel=\"noopener noreferrer\"", html_escape(t))
268                    }
269                    None => String::new(),
270                };
271                // Block-level components (like Image) need the wrapping
272                // anchor to fill the available width so their aspect-ratio
273                // container doesn't collapse. Inline style overrides the
274                // `[&>a]:w-auto` rule used by Card/Form for action buttons.
275                let style_attr = if matches!(node.component, Component::Image(_)) {
276                    " style=\"width:100%\""
277                } else {
278                    ""
279                };
280                return format!(
281                    "<a href=\"{}\" class=\"block\"{}{}>{}</a>",
282                    html_escape(url),
283                    style_attr,
284                    target_attr,
285                    component_html
286                );
287            }
288        }
289    }
290
291    component_html
292}
293
294/// Dispatch to the appropriate per-component renderer.
295fn render_component(component: &Component, data: &Value) -> String {
296    match component {
297        Component::Text(props) => render_text(props),
298        Component::Button(props) => render_button(props),
299        Component::Badge(props) => render_badge(props),
300        Component::Alert(props) => render_alert(props),
301        Component::Separator(props) => render_separator(props),
302        Component::Progress(props) => render_progress(props),
303        Component::Avatar(props) => render_avatar(props),
304        Component::Skeleton(props) => render_skeleton(props),
305        Component::Breadcrumb(props) => render_breadcrumb(props),
306        Component::Pagination(props) => render_pagination(props),
307        Component::DescriptionList(props) => render_description_list(props),
308
309        // Container components.
310        Component::Card(props) => render_card(props, data),
311        Component::Form(props) => render_form(props, data),
312        Component::DetailForm(props) => render_detail_form(props, data),
313        Component::Modal(props) => render_modal(props, data),
314        Component::Tabs(props) => render_tabs(props, data),
315        Component::Table(props) => render_table(props, data),
316
317        // Form field components.
318        Component::Input(props) => render_input(props, data),
319        Component::Select(props) => render_select(props, data),
320        Component::Checkbox(props) => render_checkbox(props, data),
321        Component::Switch(props) => render_switch(props, data),
322        Component::KeyValueEditor(props) => render_key_value_editor(props, data),
323
324        // Layout components.
325        Component::Grid(props) => render_grid(props, data),
326        Component::Collapsible(props) => render_collapsible(props, data),
327        Component::FormSection(props) => render_form_section(props, data),
328
329        // Standalone components.
330        Component::EmptyState(props) => render_empty_state(props),
331        Component::DropdownMenu(props) => render_dropdown_menu(props),
332
333        // Dashboard components.
334        Component::StatCard(props) => render_stat_card(props),
335        Component::Checklist(props) => render_checklist(props),
336        Component::Toast(props) => render_toast(props),
337        Component::NotificationDropdown(props) => render_notification_dropdown(props),
338        Component::Sidebar(props) => render_sidebar(props),
339        Component::Header(props) => render_header(props),
340
341        // Page layout components.
342        Component::PageHeader(props) => render_page_header(props, data),
343        Component::ButtonGroup(props) => render_button_group(props, data),
344
345        // Standalone leaf components.
346        Component::CalendarCell(props) => render_calendar_cell(props),
347        Component::ActionCard(props) => render_action_card(props),
348        Component::ProductTile(props) => render_product_tile(props),
349        Component::DataTable(props) => render_data_table(props, data),
350
351        // Container components (responsive).
352        Component::KanbanBoard(props) => render_kanban_board(props, data),
353
354        // Image component.
355        Component::Image(props) => render_image(props),
356
357        // Plugin components (rendered via plugin registry).
358        Component::Plugin(props) => render_plugin(props, data),
359    }
360}
361
362// ── CalendarCell renderer ───────────────────────────────────────────────
363
364fn render_calendar_cell(props: &CalendarCellProps) -> String {
365    let opacity = if props.is_current_month {
366        ""
367    } else {
368        " opacity-40"
369    };
370    let hover = if props.is_current_month {
371        " hover:bg-surface/60 transition-colors cursor-pointer"
372    } else {
373        " cursor-pointer"
374    };
375
376    let mut html = format!(
377        "<div class=\"flex flex-col min-h-[5rem] p-2 border border-border -mt-px -ml-px{opacity}{hover}\">",
378    );
379
380    // Day number — top-left, today gets a small circle
381    if props.is_today {
382        html.push_str(&format!(
383            "<span class=\"w-7 h-7 flex items-center justify-center text-sm font-semibold bg-primary text-primary-foreground rounded-full\">{}</span>",
384            props.day
385        ));
386    } else {
387        html.push_str(&format!(
388            "<span class=\"text-sm text-text\">{}</span>",
389            props.day
390        ));
391    }
392
393    // Event indicators. When dot_colors is provided, render one colored dot
394    // per entry (up to 3). Otherwise fall back to plain primary dots based on
395    // event_count. For >3 events, show a count label.
396    let total = if !props.dot_colors.is_empty() {
397        props.dot_colors.len() as u32
398    } else {
399        props.event_count
400    };
401    if total > 0 && total <= 3 {
402        html.push_str("<div class=\"flex gap-1 mt-auto pt-1\">");
403        if props.dot_colors.is_empty() {
404            for _ in 0..total {
405                html.push_str("<span class=\"w-1.5 h-1.5 rounded-full bg-primary\"></span>");
406            }
407        } else {
408            for color in props.dot_colors.iter().take(3) {
409                html.push_str(&format!(
410                    "<span class=\"w-1.5 h-1.5 rounded-full {}\"></span>",
411                    html_escape(color)
412                ));
413            }
414        }
415        html.push_str("</div>");
416    } else if total > 3 {
417        html.push_str(&format!(
418            "<span class=\"mt-auto pt-1 text-xs font-medium text-primary\">{total} prenot.</span>"
419        ));
420    }
421
422    html.push_str("</div>");
423    html
424}
425
426// ── ActionCard renderer ────────────────────────────────────────────────
427
428fn render_action_card(props: &ActionCardProps) -> String {
429    let border_class = match props.variant {
430        ActionCardVariant::Default => "border-l-primary",
431        ActionCardVariant::Setup => "border-l-warning",
432        ActionCardVariant::Danger => "border-l-destructive",
433    };
434
435    // A11Y-04: use <a> when href is set, <div> otherwise
436    let (open_tag, close_tag) = if let Some(ref href) = props.href {
437        (
438            format!(
439                "<a href=\"{}\" aria-label=\"{}\" class=\"rounded-lg border-l-4 {} border border-border bg-card shadow-sm p-4 flex items-center gap-4 hover:bg-surface transition-colors duration-150 no-underline\">",
440                html_escape(href),
441                html_escape(&props.title),
442                border_class,
443            ),
444            "</a>".to_string(),
445        )
446    } else {
447        (
448            format!(
449                "<div class=\"rounded-lg border-l-4 {border_class} border border-border bg-card shadow-sm p-4 flex items-center gap-4 cursor-pointer hover:bg-surface transition-colors duration-150\">"
450            ),
451            "</div>".to_string(),
452        )
453    };
454
455    let mut html = open_tag;
456
457    // Optional icon. Raw HTML passthrough — must be developer-supplied SVG only.
458    // This field is set at schema-authoring time and is not reachable from user data.
459    if let Some(ref icon) = props.icon {
460        html.push_str(&format!(
461            "<div class=\"w-10 h-10 flex-shrink-0 rounded-md bg-surface flex items-center justify-center text-text-muted\">{icon}</div>",
462        ));
463    }
464
465    // Text block.
466    html.push_str(&format!(
467        "<div class=\"flex-1 min-w-0\"><p class=\"text-sm font-semibold text-text\">{}</p><p class=\"text-sm text-text-muted mt-0.5\">{}</p></div>",
468        html_escape(&props.title),
469        html_escape(&props.description)
470    ));
471
472    // Chevron.
473    html.push_str("<span class=\"text-text-muted flex-shrink-0 text-lg\">&rsaquo;</span>");
474
475    html.push_str(&close_tag);
476    html
477}
478
479// ── ProductTile renderer ────────────────────────────────────────────────
480
481fn render_product_tile(props: &ProductTileProps) -> String {
482    let name = html_escape(&props.name);
483    let price = html_escape(&props.price);
484    let field = html_escape(&props.field);
485    let qty = props.default_quantity.unwrap_or(0);
486
487    format!(
488        "<div class=\"rounded-lg border border-border bg-card p-4 flex flex-col gap-3 touch-manipulation\">\
489         <div class=\"flex items-start justify-between gap-2\">\
490         <span class=\"text-sm font-semibold text-text\">{name}</span>\
491         <span class=\"text-sm font-semibold text-text-muted\">{price}</span>\
492         </div>\
493         <div class=\"flex items-center justify-between gap-2\">\
494         <button type=\"button\" data-qty-dec=\"{field}\" \
495         class=\"min-h-[44px] min-w-[44px] flex items-center justify-center rounded-md border border-border bg-surface text-text text-lg font-semibold hover:bg-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
496         aria-label=\"Diminuisci quantit\u{00E0} {name}\">\u{2212}</button>\
497         <span data-qty-display=\"{field}\" class=\"text-sm font-semibold text-text min-w-[2ch] text-center\">{qty}</span>\
498         <button type=\"button\" data-qty-inc=\"{field}\" \
499         class=\"min-h-[44px] min-w-[44px] flex items-center justify-center rounded-md border border-border bg-surface text-text text-lg font-semibold hover:bg-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
500         aria-label=\"Aumenta quantit\u{00E0} {name}\">+</button>\
501         </div>\
502         <input type=\"hidden\" name=\"{field}\" data-qty-input=\"{field}\" value=\"{qty}\">\
503         </div>"
504    )
505}
506
507// ── KanbanBoard renderer ────────────────────────────────────────────────
508
509fn render_kanban_board(props: &KanbanBoardProps, data: &Value) -> String {
510    if props.columns.is_empty() {
511        return String::new();
512    }
513
514    let default_id = props
515        .mobile_default_column
516        .as_deref()
517        .unwrap_or_else(|| &props.columns[0].id);
518
519    let mut html = String::new();
520
521    // ── Desktop view: horizontal scrollable columns ──────────────────
522    html.push_str("<div class=\"hidden md:block overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">");
523    html.push_str("<div class=\"flex gap-4\" style=\"min-width: min-content;\">");
524
525    for col in &props.columns {
526        html.push_str("<div class=\"min-w-[260px] flex-1 flex-shrink-0 rounded-lg border border-border bg-card/50 p-3\">");
527        html.push_str("<div class=\"flex items-center justify-between mb-3\">");
528        html.push_str(&format!(
529            "<h3 class=\"text-sm font-semibold text-text\">{}</h3>",
530            html_escape(&col.title),
531        ));
532        let badge_class = if col.count > 0 {
533            "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold bg-primary text-primary-foreground"
534        } else {
535            "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-text-muted bg-surface"
536        };
537        html.push_str(&format!(
538            "<span class=\"{}\">{}</span>",
539            badge_class, col.count,
540        ));
541        html.push_str("</div>");
542        html.push_str("<div class=\"space-y-2\">");
543        for child in &col.children {
544            html.push_str("<div data-kanban-card class=\"cursor-pointer\">");
545            html.push_str(&render_node(child, data));
546            html.push_str("</div>");
547        }
548        html.push_str("</div>");
549        html.push_str("</div>");
550    }
551
552    html.push_str("</div>");
553    html.push_str("</div>");
554
555    // ── Mobile view: tab-based column switching ──────────────────────
556    html.push_str("<div class=\"block md:hidden\" data-tabs>");
557    html.push_str("<div class=\"flex border-b border-border mb-4\">");
558
559    for col in &props.columns {
560        let is_default = col.id == default_id;
561        let (border, text) = if is_default {
562            ("border-primary", "text-primary font-semibold")
563        } else {
564            ("border-transparent", "text-text-muted hover:text-text")
565        };
566        html.push_str(&format!(
567            "<button type=\"button\" data-tab=\"{}\" class=\"flex-1 px-3 py-2 text-sm border-b-2 {} {}\" aria-selected=\"{}\">{} <span class=\"ml-1 text-xs text-text-muted\">({})</span></button>",
568            html_escape(&col.id),
569            border,
570            text,
571            is_default,
572            html_escape(&col.title),
573            col.count,
574        ));
575    }
576
577    html.push_str("</div>");
578
579    for col in &props.columns {
580        let is_default = col.id == default_id;
581        let hidden = if is_default { "" } else { " hidden" };
582        html.push_str(&format!(
583            "<div data-tab-panel=\"{}\" class=\"space-y-3{hidden}\">",
584            html_escape(&col.id),
585        ));
586        for child in &col.children {
587            html.push_str("<div data-kanban-card class=\"cursor-pointer\">");
588            html.push_str(&render_node(child, data));
589            html.push_str("</div>");
590        }
591        html.push_str("</div>");
592    }
593
594    html.push_str("</div>");
595
596    html
597}
598
599// ── DropdownMenu renderer ───────────────────────────────────────────────
600
601fn render_dropdown_menu(props: &DropdownMenuProps) -> String {
602    let mut html = String::from("<div class=\"relative\">");
603
604    // Trigger button — kebab icon (⋮) with label as aria-label for accessibility
605    let trigger_icon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"5\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/></svg>";
606    html.push_str(&format!(
607        "<button type=\"button\" data-dropdown-toggle=\"{}\" aria-label=\"{}\" \
608         class=\"inline-flex items-center justify-center rounded-md p-1.5 \
609         text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 \
610         focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary \
611         focus-visible:ring-offset-2\">{}</button>",
612        html_escape(&props.menu_id),
613        html_escape(&props.trigger_label),
614        trigger_icon,
615    ));
616
617    // Panel (hidden by default)
618    html.push_str(&format!(
619        "<div data-dropdown=\"{}\" \
620         class=\"absolute right-0 z-50 mt-1 w-48 rounded-md border border-border bg-card shadow-md hidden\">",
621        html_escape(&props.menu_id),
622    ));
623
624    for item in &props.items {
625        let url = item.action.url.as_deref().unwrap_or("#");
626        let base_class = if item.destructive {
627            "block w-full text-left px-4 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors duration-150"
628        } else {
629            "block w-full text-left px-4 py-2 text-sm text-text hover:bg-surface transition-colors duration-150"
630        };
631
632        // Confirm dialog data attributes
633        let confirm_attrs = if let Some(ref confirm) = item.action.confirm {
634            let mut attrs = format!(" data-confirm-title=\"{}\"", html_escape(&confirm.title));
635            if let Some(ref message) = confirm.message {
636                attrs.push_str(&format!(
637                    " data-confirm-message=\"{}\"",
638                    html_escape(message)
639                ));
640            }
641            attrs
642        } else {
643            String::new()
644        };
645
646        let onclick = if item.action.confirm.is_some() {
647            " onclick=\"return confirm(this.dataset.confirmMessage || this.dataset.confirmTitle)\""
648        } else {
649            ""
650        };
651
652        match item.action.method {
653            HttpMethod::Get => {
654                html.push_str(&format!(
655                    "<a href=\"{}\" class=\"{}\"{}{}>{}</a>",
656                    html_escape(url),
657                    base_class,
658                    confirm_attrs,
659                    onclick,
660                    html_escape(&item.label),
661                ));
662            }
663            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
664                let (form_method, needs_spoofing) = match item.action.method {
665                    HttpMethod::Post => ("post", false),
666                    HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
667                    _ => unreachable!(),
668                };
669                html.push_str(&format!(
670                    "<form action=\"{}\" method=\"{}\">",
671                    html_escape(url),
672                    form_method,
673                ));
674                if needs_spoofing {
675                    let method_value = match item.action.method {
676                        HttpMethod::Put => "PUT",
677                        HttpMethod::Patch => "PATCH",
678                        HttpMethod::Delete => "DELETE",
679                        _ => unreachable!(),
680                    };
681                    html.push_str(&format!(
682                        "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
683                    ));
684                }
685                html.push_str(&format!(
686                    "<button type=\"submit\" class=\"{}\"{}{}>{}</button>",
687                    base_class,
688                    confirm_attrs,
689                    onclick,
690                    html_escape(&item.label),
691                ));
692                html.push_str("</form>");
693            }
694        }
695    }
696
697    html.push_str("</div>"); // close panel
698    html.push_str("</div>"); // close wrapper
699    html
700}
701
702// ── Plugin component renderer ───────────────────────────────────────────
703
704fn render_plugin(props: &PluginProps, data: &Value) -> String {
705    crate::plugin::with_plugin(&props.plugin_type, |plugin| {
706        plugin.render(&props.props, data)
707    })
708    .unwrap_or_else(|| {
709        format!(
710            "<div class=\"p-4 bg-destructive/10 text-destructive rounded-md\">Unknown plugin component: {}</div>",
711            html_escape(&props.plugin_type)
712        )
713    })
714}
715
716// ── Page layout component renderers ─────────────────────────────────────
717
718fn render_page_header(props: &PageHeaderProps, data: &Value) -> String {
719    let mut html =
720        String::from("<div class=\"flex flex-wrap items-center justify-between gap-3 pb-4\">");
721
722    // Title block — breadcrumb and title fused into one inline flow
723    html.push_str("<div class=\"flex items-center gap-2 min-w-0\">");
724
725    if !props.breadcrumb.is_empty() {
726        for item in &props.breadcrumb {
727            if let Some(ref url) = item.url {
728                html.push_str(&format!(
729                    "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text whitespace-nowrap\">{}</a>",
730                    html_escape(url),
731                    html_escape(&item.label)
732                ));
733            } else {
734                html.push_str(&format!(
735                    "<span class=\"text-sm text-text-muted whitespace-nowrap\">{}</span>",
736                    html_escape(&item.label)
737                ));
738            }
739            // Chevron separator between breadcrumb and title
740            html.push_str(
741                "<span aria-hidden=\"true\" class=\"text-text-muted flex-shrink-0\">\
742                 <svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\
743                 <path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>\
744                 </svg></span>"
745            );
746        }
747    }
748
749    html.push_str(&format!(
750        "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text truncate\">{}</h2>",
751        html_escape(&props.title)
752    ));
753    html.push_str("</div>");
754
755    // Actions (optional)
756    if !props.actions.is_empty() {
757        html.push_str("<div class=\"flex flex-wrap items-center gap-2\">");
758        for action in &props.actions {
759            html.push_str(&render_node(action, data));
760        }
761        html.push_str("</div>");
762    }
763
764    html.push_str("</div>");
765    html
766}
767
768fn render_button_group(props: &ButtonGroupProps, data: &Value) -> String {
769    let mut html = String::from("<div class=\"flex items-center gap-2 flex-wrap\">");
770    for button in &props.buttons {
771        html.push_str(&render_node(button, data));
772    }
773    html.push_str("</div>");
774    html
775}
776
777// ── Container component renderers ───────────────────────────────────────
778
779fn render_card(props: &CardProps, data: &Value) -> String {
780    let mut html = String::from(
781        "<div class=\"rounded-lg border border-border bg-card shadow-sm overflow-visible\"><div class=\"p-4\">",
782    );
783    // FIXME: workaround — conditional rendering to avoid ghost spacing when title/description are absent
784    let has_header = !props.title.is_empty() || props.description.is_some();
785    if !props.title.is_empty() {
786        html.push_str(&format!(
787            "<h3 class=\"text-base font-semibold leading-snug text-text\">{}</h3>",
788            html_escape(&props.title)
789        ));
790    }
791    if let Some(ref desc) = props.description {
792        let p_class = if props.title.is_empty() {
793            "text-sm text-text-muted"
794        } else {
795            "mt-1 text-sm text-text-muted"
796        };
797        html.push_str(&format!(
798            "<p class=\"{p_class}\">{}</p>",
799            html_escape(desc),
800            p_class = p_class,
801        ));
802    }
803    if !props.children.is_empty() {
804        let children_class = if has_header {
805            "mt-3 flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible"
806        } else {
807            "flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible"
808        };
809        html.push_str(&format!("<div class=\"{children_class}\">"));
810        for child in &props.children {
811            html.push_str(&render_node(child, data));
812        }
813        html.push_str("</div>");
814    }
815    html.push_str("</div>"); // close p-6
816    if !props.footer.is_empty() {
817        html.push_str("<div class=\"border-t border-border px-6 py-4 flex items-center justify-between gap-2\">");
818        for child in &props.footer {
819            html.push_str(&render_node(child, data));
820        }
821        html.push_str("</div>");
822    }
823    html.push_str("</div>"); // close outer card
824
825    // Narrow/Wide must occupy a full row in flex-wrap parents so siblings
826    // stack vertically. The outer `w-full` guarantees the flex slot is full
827    // width even though `max-w-*` visually constrains the content.
828    match props.max_width.as_ref().unwrap_or(&FormMaxWidth::Default) {
829        FormMaxWidth::Default => {}
830        FormMaxWidth::Narrow => {
831            html = format!(
832                "<div class=\"w-full\"><div class=\"max-w-2xl mx-auto\">{html}</div></div>"
833            );
834        }
835        FormMaxWidth::Wide => {
836            html = format!(
837                "<div class=\"w-full\"><div class=\"max-w-4xl mx-auto\">{html}</div></div>"
838            );
839        }
840    }
841
842    html
843}
844
845fn render_modal(props: &ModalProps, data: &Value) -> String {
846    let trigger = props.trigger_label.as_deref().unwrap_or("Open");
847    let mut html = String::new();
848    // Trigger button (sibling of dialog, not inside it)
849    html.push_str(&format!(
850        "<button type=\"button\" class=\"inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium cursor-pointer\" data-modal-open=\"{}\">{}</button>",
851        html_escape(&props.id),
852        html_escape(trigger)
853    ));
854    // Native <dialog> element — focus trap and Escape key are built-in
855    html.push_str(&format!(
856        "<dialog id=\"{}\" aria-modal=\"true\" aria-labelledby=\"{}-title\" class=\"bg-card rounded-lg shadow-lg max-w-lg w-full mx-4 p-6 backdrop:bg-black/50\">",
857        html_escape(&props.id),
858        html_escape(&props.id)
859    ));
860    // Header row: title + close button
861    html.push_str("<div class=\"flex items-center justify-between mb-4\">");
862    html.push_str(&format!(
863        "<h3 id=\"{}-title\" class=\"text-lg font-semibold leading-snug text-text\">{}</h3>",
864        html_escape(&props.id),
865        html_escape(&props.title)
866    ));
867    html.push_str(
868        "<button type=\"button\" data-modal-close aria-label=\"Chiudi\" class=\"text-text-muted hover:text-text p-2 rounded transition-colors duration-150\">\u{00d7}</button>",
869    );
870    html.push_str("</div>");
871    if let Some(ref desc) = props.description {
872        html.push_str(&format!(
873            "<p class=\"text-sm text-text-muted mb-4\">{}</p>",
874            html_escape(desc)
875        ));
876    }
877    html.push_str(
878        "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
879    );
880    for child in &props.children {
881        html.push_str(&render_node(child, data));
882    }
883    html.push_str("</div>");
884    if !props.footer.is_empty() {
885        html.push_str("<div class=\"mt-6 flex items-center justify-end gap-2\">");
886        for child in &props.footer {
887            html.push_str(&render_node(child, data));
888        }
889        html.push_str("</div>");
890    }
891    html.push_str("</dialog>");
892    html
893}
894
895fn render_tabs(props: &TabsProps, data: &Value) -> String {
896    // Auto-hide tab bar when only one tab.
897    if props.tabs.len() == 1 {
898        let tab = &props.tabs[0];
899        let mut html = String::from(
900            "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
901        );
902        for child in &tab.children {
903            html.push_str(&render_node(child, data));
904        }
905        html.push_str("</div>");
906        return html;
907    }
908
909    // Determine rendering strategy per tab:
910    // - Tabs with children render their panel and switch client-side via JS.
911    // - Empty tabs (server-driven) render as links with ?tab= for full reload.
912    let has_any_content = props.tabs.iter().any(|t| !t.children.is_empty());
913
914    let mut html = String::from("<div data-tabs>");
915    html.push_str("<div class=\"border-b border-border\">");
916    html.push_str("<nav class=\"flex -mb-px space-x-4\" role=\"tablist\">");
917
918    for tab in &props.tabs {
919        let is_active = tab.value == props.default_tab;
920        let border = if is_active {
921            "border-primary"
922        } else {
923            "border-transparent"
924        };
925        let text = if is_active {
926            "text-primary font-semibold"
927        } else {
928            "text-text-muted hover:text-text"
929        };
930
931        if has_any_content && (is_active || !tab.children.is_empty()) {
932            // Client-side tab trigger
933            html.push_str(&format!(
934                "<button type=\"button\" role=\"tab\" id=\"tab-btn-{}\" aria-controls=\"tab-panel-{}\" data-tab=\"{}\" \
935                 class=\"border-b-2 {} {} px-3 py-2 text-sm font-medium cursor-pointer transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
936                 aria-selected=\"{}\">{}</button>",
937                html_escape(&tab.value),
938                html_escape(&tab.value),
939                html_escape(&tab.value),
940                border,
941                text,
942                is_active,
943                html_escape(&tab.label),
944            ));
945        } else {
946            // Server-driven tab: link with ?tab= query param
947            html.push_str(&format!(
948                "<a href=\"?tab={}\" role=\"tab\" id=\"tab-btn-{}\" aria-controls=\"tab-panel-{}\" \
949                 class=\"border-b-2 {} {} px-3 py-2 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
950                 aria-selected=\"{}\">{}</a>",
951                html_escape(&tab.value),
952                html_escape(&tab.value),
953                html_escape(&tab.value),
954                border,
955                text,
956                is_active,
957                html_escape(&tab.label),
958            ));
959        }
960    }
961
962    html.push_str("</nav></div>");
963
964    // Render all tab panels — inactive panels are hidden via CSS.
965    for tab in &props.tabs {
966        if tab.children.is_empty() && tab.value != props.default_tab {
967            continue;
968        }
969        let hidden = if tab.value != props.default_tab {
970            " hidden"
971        } else {
972            ""
973        };
974        html.push_str(&format!(
975            "<div role=\"tabpanel\" id=\"tab-panel-{}\" aria-labelledby=\"tab-btn-{}\" data-tab-panel=\"{}\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto{}\">",
976            html_escape(&tab.value),
977            html_escape(&tab.value),
978            html_escape(&tab.value),
979            hidden,
980        ));
981        for child in &tab.children {
982            html.push_str(&render_node(child, data));
983        }
984        html.push_str("</div>");
985    }
986
987    html.push_str("</div>");
988    html
989}
990
991fn render_form(props: &FormProps, data: &Value) -> String {
992    // Determine the effective HTTP method.
993    let effective_method = props
994        .method
995        .as_ref()
996        .unwrap_or(&props.action.method)
997        .clone();
998
999    // For PUT/PATCH/DELETE, use POST with method spoofing.
1000    let (form_method, needs_spoofing) = match effective_method {
1001        HttpMethod::Get => ("get", false),
1002        HttpMethod::Post => ("post", false),
1003        HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
1004    };
1005
1006    let action_url = props.action.url.as_deref().unwrap_or("#");
1007    let mut html = match &props.guard {
1008        Some(g) => format!(
1009            "<form action=\"{}\" method=\"{}\" data-form-guard=\"{}\" class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
1010            html_escape(action_url),
1011            form_method,
1012            html_escape(g)
1013        ),
1014        None => format!(
1015            "<form action=\"{}\" method=\"{}\" class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
1016            html_escape(action_url),
1017            form_method
1018        ),
1019    };
1020
1021    if needs_spoofing {
1022        let method_value = match effective_method {
1023            HttpMethod::Put => "PUT",
1024            HttpMethod::Patch => "PATCH",
1025            HttpMethod::Delete => "DELETE",
1026            _ => unreachable!(),
1027        };
1028        html.push_str(&format!(
1029            "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1030        ));
1031    }
1032
1033    for field in &props.fields {
1034        html.push_str(&render_node(field, data));
1035    }
1036    html.push_str("</form>");
1037
1038    // FIX-02: wrap in max-width container when specified. Outer `w-full`
1039    // ensures the flex slot spans the full row so siblings stack instead of
1040    // packing horizontally when the flex-wrap parent sees two narrow items.
1041    let html = match props.max_width.as_ref().unwrap_or(&FormMaxWidth::Default) {
1042        FormMaxWidth::Default => html,
1043        FormMaxWidth::Narrow => {
1044            format!("<div class=\"w-full\"><div class=\"max-w-2xl mx-auto\">{html}</div></div>")
1045        }
1046        FormMaxWidth::Wide => {
1047            format!("<div class=\"w-full\"><div class=\"max-w-4xl mx-auto\">{html}</div></div>")
1048        }
1049    };
1050    html
1051}
1052
1053/// Renders a DetailForm — a description-list-style block that toggles between
1054/// View and Edit modes via its `mode` prop.
1055///
1056/// **Structural coherence (147-UI-SPEC §5).** The `<dl>` scaffold, every `<dt>`,
1057/// and every `<dd>` wrapper is byte-for-byte identical across modes. Only the
1058/// content inside each `<dd>` differs: View emits `html_escape(field.value)`,
1059/// Edit renders the inner `ComponentNode` via `render_node`. Edit additionally
1060/// wraps the scaffold in a `<form>` with method spoofing for PUT/PATCH/DELETE.
1061///
1062/// **Option A authoring rule (147-UI-SPEC §9).** When `DetailField.input` is an
1063/// Input / Select / Textarea / Checkbox / Switch, the caller MUST set its
1064/// `label` prop to the empty string `""` — the `<dt>` provides the visible
1065/// label. `render_input` then emits `<label></label>` (a zero-content label)
1066/// which is semantically inert and visually invisible. This renderer does NOT
1067/// mutate caller-supplied props. Accessibility is preserved in Edit mode by
1068/// wrapping each rendered input inside a `role="group"` labeling element whose
1069/// `aria-label` is derived from the field's `<dt>` text, so screen readers
1070/// retain the field name even though the inner `<label>` is empty.
1071///
1072/// **html_escape discipline.** Every dynamic string (labels, values, edit_url,
1073/// cancel_url, action URL, button labels) is emitted through `html_escape` to
1074/// prevent XSS via attribute-breaking characters (T-147-02). The method-spoofed
1075/// `value="PUT|PATCH|DELETE"` hidden input is a fixed literal drawn from a
1076/// match over `HttpMethod` variants — caller-supplied strings cannot reach it
1077/// (T-147-01).
1078fn render_detail_form(props: &DetailFormProps, data: &Value) -> String {
1079    // 1. Build the shared <dl> body — identical in both modes except for
1080    //    <dd> content. Per §5 of 147-UI-SPEC, the <dl> opening tag, every
1081    //    <dt>, and every <dd> wrapper must be byte-for-byte identical across
1082    //    modes.
1083    let mut dl = String::from("<dl class=\"grid grid-cols-1 gap-4\">");
1084    for field in &props.fields {
1085        dl.push_str("<div>");
1086        dl.push_str(&format!(
1087            "<dt class=\"text-sm font-medium text-text-muted\">{}</dt>",
1088            html_escape(&field.label)
1089        ));
1090        match props.mode {
1091            EditMode::View => {
1092                dl.push_str(&format!(
1093                    "<dd class=\"mt-1 text-sm text-text\">{}</dd>",
1094                    html_escape(&field.value)
1095                ));
1096            }
1097            EditMode::Edit => {
1098                // Wrap the rendered input in a role="group" labeling element
1099                // whose aria-label is derived from the <dt> text — honors the
1100                // §11 accessibility contract without mutating caller props
1101                // (per §9 Option A). The outer <dd> tag and classes stay
1102                // identical to View mode (structural coherence, §5).
1103                dl.push_str(&format!(
1104                    "<dd class=\"mt-1 text-sm text-text\"><span role=\"group\" aria-label=\"{}\">{}</span></dd>",
1105                    html_escape(&field.label),
1106                    render_node(&field.input, data)
1107                ));
1108            }
1109        }
1110        dl.push_str("</div>");
1111    }
1112    dl.push_str("</dl>");
1113
1114    // 2. Button class strings (distilled from render_button, 147-RESEARCH.md
1115    //    §Button Variant Class Strings). No new classes beyond what
1116    //    render_form / render_button already emit (§14.2 of UI-SPEC).
1117    let btn_base = "inline-flex items-center justify-center rounded-md font-medium \
1118                    transition-colors duration-150 motion-reduce:transition-none \
1119                    focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary \
1120                    focus-visible:ring-offset-2 px-4 py-2 text-sm";
1121    let btn_primary = "bg-primary text-primary-foreground hover:bg-primary/90";
1122    let btn_outline = "border border-border bg-background text-text hover:bg-surface";
1123
1124    // 3. Action bar — positioned identically in both modes (right-aligned,
1125    //    flex gap-2, mt-6 separation from the <dl>); only the button set
1126    //    differs per §5.
1127    let edit_label = props.edit_label.as_deref().unwrap_or("Modifica");
1128    let save_label = props.save_label.as_deref().unwrap_or("Salva");
1129    let cancel_label = props.cancel_label.as_deref().unwrap_or("Annulla");
1130
1131    let action_bar = match props.mode {
1132        EditMode::View => format!(
1133            "<div class=\"flex gap-2 justify-start mt-6\">\
1134                 <a href=\"{url}\" class=\"{base} {outline}\">{label}</a>\
1135             </div>",
1136            url = html_escape(&props.edit_url),
1137            base = btn_base,
1138            outline = btn_outline,
1139            label = html_escape(edit_label),
1140        ),
1141        EditMode::Edit => format!(
1142            "<div class=\"flex gap-2 justify-start mt-6\">\
1143                 <button type=\"submit\" class=\"{base} {primary}\">{save_label}</button>\
1144                 <a href=\"{cancel_url}\" class=\"{base} {outline}\">{cancel_label}</a>\
1145             </div>",
1146            cancel_url = html_escape(&props.cancel_url),
1147            cancel_label = html_escape(cancel_label),
1148            save_label = html_escape(save_label),
1149            base = btn_base,
1150            outline = btn_outline,
1151            primary = btn_primary,
1152        ),
1153    };
1154
1155    // 4. Assemble final output.
1156    match props.mode {
1157        EditMode::View => format!("<div>{dl}{action_bar}</div>"),
1158        EditMode::Edit => {
1159            // Method-spoofing block — lifted verbatim from render_form above
1160            // (see render_form at render.rs:971-1011). T-147-01: the hidden
1161            // `_method` value is one of the fixed HttpMethod variant literals;
1162            // caller-supplied strings cannot reach this position.
1163            let effective_method = props
1164                .method
1165                .as_ref()
1166                .unwrap_or(&props.action.method)
1167                .clone();
1168            let (form_method, needs_spoofing) = match effective_method {
1169                HttpMethod::Get => ("get", false),
1170                HttpMethod::Post => ("post", false),
1171                HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
1172            };
1173            let action_url = props.action.url.as_deref().unwrap_or("#");
1174            let mut html = format!(
1175                "<form action=\"{}\" method=\"{}\" class=\"space-y-4\">",
1176                html_escape(action_url),
1177                form_method
1178            );
1179            if needs_spoofing {
1180                let method_value = match effective_method {
1181                    HttpMethod::Put => "PUT",
1182                    HttpMethod::Patch => "PATCH",
1183                    HttpMethod::Delete => "DELETE",
1184                    _ => unreachable!(),
1185                };
1186                html.push_str(&format!(
1187                    "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1188                ));
1189            }
1190            html.push_str(&dl);
1191            html.push_str(&action_bar);
1192            html.push_str("</form>");
1193            html
1194        }
1195    }
1196}
1197
1198fn render_table(props: &TableProps, data: &Value) -> String {
1199    let mut html = String::from(
1200        "<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-border\">",
1201    );
1202
1203    // Header.
1204    html.push_str("<thead class=\"bg-surface\"><tr>");
1205    for col in &props.columns {
1206        html.push_str(&format!(
1207            "<th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted\">{}</th>",
1208            html_escape(&col.label)
1209        ));
1210    }
1211    if props.row_actions.is_some() {
1212        html.push_str("<th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-text-muted\">Azioni</th>");
1213    }
1214    html.push_str("</tr></thead>");
1215
1216    // Body.
1217    html.push_str("<tbody class=\"divide-y divide-border bg-background\">");
1218
1219    let rows = resolve_path(data, &props.data_path);
1220    let row_array = rows.and_then(|v| v.as_array());
1221
1222    if let Some(items) = row_array {
1223        if items.is_empty() {
1224            if let Some(ref msg) = props.empty_message {
1225                let col_count =
1226                    props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
1227                html.push_str(&format!(
1228                    "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1229                    col_count,
1230                    html_escape(msg)
1231                ));
1232            }
1233        } else {
1234            for row in items {
1235                html.push_str("<tr class=\"hover:bg-surface\">");
1236                for col in &props.columns {
1237                    let cell_value = row.get(&col.key);
1238                    let cell_text = match cell_value {
1239                        Some(Value::String(s)) => s.clone(),
1240                        Some(Value::Number(n)) => n.to_string(),
1241                        Some(Value::Bool(b)) => b.to_string(),
1242                        Some(Value::Null) | None => String::new(),
1243                        Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1244                            serde_json::to_string(v).unwrap_or_default()
1245                        }
1246                    };
1247                    html.push_str(&format!(
1248                        "<td class=\"px-6 py-4 text-sm text-text whitespace-nowrap\">{}</td>",
1249                        html_escape(&cell_text)
1250                    ));
1251                }
1252                if let Some(ref actions) = props.row_actions {
1253                    html.push_str("<td class=\"px-6 py-4 text-right text-sm space-x-2\">");
1254                    for action in actions {
1255                        let url = action.url.as_deref().unwrap_or("#");
1256                        let label = action
1257                            .handler
1258                            .split('.')
1259                            .next_back()
1260                            .unwrap_or(&action.handler);
1261                        html.push_str(&format!(
1262                            "<a href=\"{}\" class=\"text-primary hover:text-primary/80\">{}</a>",
1263                            html_escape(url),
1264                            html_escape(label)
1265                        ));
1266                    }
1267                    html.push_str("</td>");
1268                }
1269                html.push_str("</tr>");
1270            }
1271        }
1272    } else if let Some(ref msg) = props.empty_message {
1273        let col_count = props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
1274        html.push_str(&format!(
1275            "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1276            col_count,
1277            html_escape(msg)
1278        ));
1279    }
1280
1281    html.push_str("</tbody></table></div>");
1282    html
1283}
1284
1285fn render_data_table(props: &DataTableProps, data: &Value) -> String {
1286    let rows = resolve_path(data, &props.data_path);
1287    let row_array = rows.and_then(|v| v.as_array().cloned());
1288    let items = row_array.unwrap_or_default();
1289    let has_actions = props.row_actions.is_some();
1290    let col_count = props.columns.len() + if has_actions { 1 } else { 0 };
1291    let empty_msg = props
1292        .empty_message
1293        .as_deref()
1294        .unwrap_or("Nessun elemento trovato");
1295
1296    let mut html = String::new();
1297
1298    // --- Desktop table (hidden on mobile) ---
1299    html.push_str(
1300        "<div class=\"hidden md:block rounded-lg border border-border overflow-visible\">",
1301    );
1302
1303    if items.is_empty() {
1304        html.push_str("<table class=\"w-full\"><tbody>");
1305        html.push_str(&format!(
1306            "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1307            col_count,
1308            html_escape(empty_msg)
1309        ));
1310        html.push_str("</tbody></table>");
1311    } else {
1312        html.push_str("<table class=\"w-full\">");
1313
1314        // Header
1315        html.push_str("<thead><tr class=\"bg-surface\">");
1316        for col in &props.columns {
1317            html.push_str(&format!(
1318                "<th class=\"px-6 py-4 text-left text-xs font-semibold uppercase tracking-wider text-text-muted\">{}</th>",
1319                html_escape(&col.label)
1320            ));
1321        }
1322        if has_actions {
1323            html.push_str(
1324                "<th class=\"px-6 py-4 text-right text-xs font-semibold uppercase tracking-wider text-text-muted\">Azioni</th>"
1325            );
1326        }
1327        html.push_str("</tr></thead>");
1328
1329        // Body
1330        html.push_str("<tbody>");
1331        for (index, row) in items.iter().enumerate() {
1332            let row_key_value = if let Some(ref rk) = props.row_key {
1333                row.get(rk)
1334                    .and_then(|v| match v {
1335                        Value::String(s) => Some(s.clone()),
1336                        Value::Number(n) => Some(n.to_string()),
1337                        _ => None,
1338                    })
1339                    .unwrap_or_else(|| index.to_string())
1340            } else {
1341                index.to_string()
1342            };
1343            let href = props
1344                .row_href
1345                .as_ref()
1346                .map(|p| p.replace("{row_key}", &row_key_value));
1347            if let Some(ref url) = href {
1348                html.push_str(&format!(
1349                    "<tr class=\"even:bg-surface hover:bg-surface/80 transition-colors duration-150 border-t border-border cursor-pointer\" onclick=\"window.location='{}'\">",
1350                    html_escape(url)
1351                ));
1352            } else {
1353                html.push_str(
1354                    "<tr class=\"even:bg-surface hover:bg-surface/80 transition-colors duration-150 border-t border-border\">"
1355                );
1356            }
1357            for col in &props.columns {
1358                let cell_value = row.get(&col.key);
1359                let cell_text = match cell_value {
1360                    Some(Value::String(s)) => s.clone(),
1361                    Some(Value::Number(n)) => n.to_string(),
1362                    Some(Value::Bool(b)) => b.to_string(),
1363                    Some(Value::Null) | None => String::new(),
1364                    Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1365                        serde_json::to_string(v).unwrap_or_default()
1366                    }
1367                };
1368                html.push_str(&format!(
1369                    "<td class=\"px-6 py-4 text-sm text-text\">{}</td>",
1370                    html_escape(&cell_text)
1371                ));
1372            }
1373            if let Some(ref actions) = props.row_actions {
1374                let row_key_value = if let Some(ref rk) = props.row_key {
1375                    row.get(rk)
1376                        .and_then(|v| match v {
1377                            Value::String(s) => Some(s.clone()),
1378                            Value::Number(n) => Some(n.to_string()),
1379                            _ => None,
1380                        })
1381                        .unwrap_or_else(|| index.to_string())
1382                } else {
1383                    index.to_string()
1384                };
1385                let templated_items: Vec<DropdownMenuAction> = actions
1386                    .iter()
1387                    .map(|a| {
1388                        let mut cloned = a.clone();
1389                        // Resolve URL from handler if url is None, then apply row_key template
1390                        let base_url = cloned
1391                            .action
1392                            .url
1393                            .clone()
1394                            .or_else(|| Some(cloned.action.handler.clone()));
1395                        if let Some(url) = base_url {
1396                            cloned.action.url = Some(url.replace("{row_key}", &row_key_value));
1397                        }
1398                        cloned
1399                    })
1400                    .collect();
1401                let dropdown_props = DropdownMenuProps {
1402                    menu_id: format!("dt-{row_key_value}"),
1403                    trigger_label: "\u{22EE}".to_string(),
1404                    items: templated_items,
1405                    trigger_variant: None,
1406                };
1407                html.push_str("<td class=\"px-6 py-4 text-right\">");
1408                html.push_str(&render_dropdown_menu(&dropdown_props));
1409                html.push_str("</td>");
1410            }
1411            html.push_str("</tr>");
1412        }
1413        html.push_str("</tbody></table>");
1414    }
1415    html.push_str("</div>");
1416
1417    // --- Mobile cards (visible on mobile) ---
1418    html.push_str("<div class=\"block md:hidden space-y-3\">");
1419    if items.is_empty() {
1420        html.push_str(&format!(
1421            "<div class=\"text-center text-sm text-text-muted py-8\">{}</div>",
1422            html_escape(empty_msg)
1423        ));
1424    } else {
1425        for (index, row) in items.iter().enumerate() {
1426            let row_key_value = if let Some(ref rk) = props.row_key {
1427                row.get(rk)
1428                    .and_then(|v| match v {
1429                        Value::String(s) => Some(s.clone()),
1430                        Value::Number(n) => Some(n.to_string()),
1431                        _ => None,
1432                    })
1433                    .unwrap_or_else(|| index.to_string())
1434            } else {
1435                index.to_string()
1436            };
1437            let mobile_href = props
1438                .row_href
1439                .as_ref()
1440                .map(|p| p.replace("{row_key}", &row_key_value));
1441            let has_actions = props.row_actions.is_some();
1442            // When both row_href and row_actions are present, wrapping actions inside <a>
1443            // produces nested anchors which the browser ejects from the parent, breaking
1444            // layout. Use an outer <div> so the link covers only the data rows.
1445            let use_outer_wrapper = mobile_href.is_some() && has_actions;
1446            if use_outer_wrapper {
1447                html.push_str(
1448                    "<div class=\"rounded-lg border border-border bg-card overflow-visible\">",
1449                );
1450                html.push_str(&format!(
1451                    "<a href=\"{}\" class=\"block p-4 space-y-2 hover:bg-surface transition-colors\">",
1452                    html_escape(mobile_href.as_ref().unwrap())
1453                ));
1454            } else if let Some(ref url) = mobile_href {
1455                html.push_str(&format!(
1456                    "<a href=\"{}\" class=\"block rounded-lg border border-border bg-card p-4 space-y-2 hover:bg-surface transition-colors\">",
1457                    html_escape(url)
1458                ));
1459            } else {
1460                html.push_str(
1461                    "<div class=\"rounded-lg border border-border bg-card p-4 space-y-2\">",
1462                );
1463            }
1464            for col in &props.columns {
1465                let cell_value = row.get(&col.key);
1466                let cell_text = match cell_value {
1467                    Some(Value::String(s)) => s.clone(),
1468                    Some(Value::Number(n)) => n.to_string(),
1469                    Some(Value::Bool(b)) => b.to_string(),
1470                    Some(Value::Null) | None => String::new(),
1471                    Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1472                        serde_json::to_string(v).unwrap_or_default()
1473                    }
1474                };
1475                html.push_str(&format!(
1476                    "<div class=\"flex justify-between\"><span class=\"text-xs font-semibold text-text-muted uppercase\">{}</span><span class=\"text-sm text-text\">{}</span></div>",
1477                    html_escape(&col.label),
1478                    html_escape(&cell_text)
1479                ));
1480            }
1481            if use_outer_wrapper {
1482                html.push_str("</a>");
1483            }
1484            if let Some(ref actions) = props.row_actions {
1485                let row_key_value = if let Some(ref rk) = props.row_key {
1486                    row.get(rk)
1487                        .and_then(|v| match v {
1488                            Value::String(s) => Some(s.clone()),
1489                            Value::Number(n) => Some(n.to_string()),
1490                            _ => None,
1491                        })
1492                        .unwrap_or_else(|| index.to_string())
1493                } else {
1494                    index.to_string()
1495                };
1496                let templated_items: Vec<DropdownMenuAction> = actions
1497                    .iter()
1498                    .map(|a| {
1499                        let mut cloned = a.clone();
1500                        let base_url = cloned
1501                            .action
1502                            .url
1503                            .clone()
1504                            .or_else(|| Some(cloned.action.handler.clone()));
1505                        if let Some(url) = base_url {
1506                            cloned.action.url = Some(url.replace("{row_key}", &row_key_value));
1507                        }
1508                        cloned
1509                    })
1510                    .collect();
1511                let dropdown_props = DropdownMenuProps {
1512                    menu_id: format!("dt-m-{row_key_value}"),
1513                    trigger_label: "\u{22EE}".to_string(),
1514                    items: templated_items,
1515                    trigger_variant: None,
1516                };
1517                html.push_str(
1518                    "<div class=\"px-4 pb-3 pt-2 border-t border-border flex justify-end\">",
1519                );
1520                html.push_str(&render_dropdown_menu(&dropdown_props));
1521                html.push_str("</div>");
1522            }
1523            if use_outer_wrapper {
1524                html.push_str("</div>");
1525            } else if mobile_href.is_some() {
1526                html.push_str("</a>");
1527            } else {
1528                html.push_str("</div>");
1529            }
1530        }
1531    }
1532    html.push_str("</div>");
1533
1534    html
1535}
1536
1537// ── Form field component renderers ──────────────────────────────────────
1538
1539fn render_input(props: &InputProps, data: &Value) -> String {
1540    // Resolve the effective value: default_value wins, else data_path, else empty.
1541    let resolved_value = if let Some(ref dv) = props.default_value {
1542        Some(dv.clone())
1543    } else if let Some(ref dp) = props.data_path {
1544        resolve_path_string(data, dp)
1545    } else {
1546        None
1547    };
1548
1549    // A11Y-07: Hidden inputs emit no label or wrapper div.
1550    if matches!(props.input_type, InputType::Hidden) {
1551        let val = resolved_value.as_deref().unwrap_or("");
1552        return format!(
1553            "<input type=\"hidden\" id=\"{}\" name=\"{}\" value=\"{}\">",
1554            html_escape(&props.field),
1555            html_escape(&props.field),
1556            html_escape(val)
1557        );
1558    }
1559
1560    let has_error = props.error.is_some();
1561    let border_class = if has_error {
1562        "border-destructive"
1563    } else {
1564        "border-border"
1565    };
1566    let focus_ring_class = if has_error {
1567        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
1568    } else {
1569        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
1570    };
1571
1572    let mut html = String::from("<div class=\"space-y-1\">");
1573    html.push_str(&format!(
1574        "<label class=\"block text-sm font-medium text-text\" for=\"{}\">{}</label>",
1575        html_escape(&props.field),
1576        html_escape(&props.label)
1577    ));
1578
1579    match props.input_type {
1580        InputType::Hidden => unreachable!("handled by early return above"),
1581        InputType::Textarea => {
1582            let val = resolved_value.as_deref().unwrap_or("");
1583            html.push_str(&format!(
1584                "<textarea id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1585                html_escape(&props.field),
1586                html_escape(&props.field),
1587                border_class,
1588                focus_ring_class
1589            ));
1590            if let Some(ref placeholder) = props.placeholder {
1591                html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
1592            }
1593            if props.required == Some(true) {
1594                html.push_str(" required");
1595            }
1596            if props.disabled == Some(true) {
1597                html.push_str(" disabled");
1598            }
1599            // FIX-03 / A11Y-08: inline validation ARIA for textarea
1600            if has_error {
1601                html.push_str(&format!(
1602                    " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1603                    html_escape(&props.field)
1604                ));
1605            }
1606            html.push_str(&format!(">{}</textarea>", html_escape(val)));
1607        }
1608        _ => {
1609            let input_type = match props.input_type {
1610                InputType::Text => "text",
1611                InputType::Email => "email",
1612                InputType::Password => "password",
1613                InputType::Number => "number",
1614                InputType::Date => "date",
1615                InputType::Time => "time",
1616                InputType::Url => "url",
1617                InputType::Tel => "tel",
1618                InputType::Search => "search",
1619                InputType::Textarea | InputType::Hidden => unreachable!(),
1620            };
1621            html.push_str(&format!(
1622                "<input type=\"{}\" id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1623                input_type,
1624                html_escape(&props.field),
1625                html_escape(&props.field),
1626                border_class,
1627                focus_ring_class
1628            ));
1629            if let Some(ref placeholder) = props.placeholder {
1630                html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
1631            }
1632            if let Some(ref val) = resolved_value {
1633                html.push_str(&format!(" value=\"{}\"", html_escape(val)));
1634            }
1635            if let Some(ref step) = props.step {
1636                html.push_str(&format!(" step=\"{}\"", html_escape(step)));
1637            }
1638            if let Some(ref list_id) = props.list {
1639                html.push_str(&format!(" list=\"{}\"", html_escape(list_id)));
1640            }
1641            if props.required == Some(true) {
1642                html.push_str(" required");
1643            }
1644            if props.disabled == Some(true) {
1645                html.push_str(" disabled");
1646            }
1647            // FIX-03 / A11Y-08: inline validation ARIA for standard inputs
1648            if has_error {
1649                html.push_str(&format!(
1650                    " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1651                    html_escape(&props.field)
1652                ));
1653            }
1654            html.push('>');
1655            if let Some(ref list_id) = props.list {
1656                if let Some(arr) = data.get(list_id).and_then(|v| v.as_array()) {
1657                    html.push_str(&format!("<datalist id=\"{}\">", html_escape(list_id)));
1658                    for opt in arr {
1659                        if let Some(s) = opt.as_str() {
1660                            html.push_str(&format!("<option value=\"{}\">", html_escape(s)));
1661                        }
1662                    }
1663                    html.push_str("</datalist>");
1664                }
1665            }
1666        }
1667    }
1668
1669    if let Some(ref desc) = props.description {
1670        html.push_str(&format!(
1671            "<p class=\"text-sm text-text-muted\">{}</p>",
1672            html_escape(desc)
1673        ));
1674    }
1675
1676    if let Some(ref error) = props.error {
1677        // FIX-03 / A11Y-08: id on error paragraph links to aria-describedby on input
1678        html.push_str(&format!(
1679            "<p id=\"err-{}\" class=\"text-sm text-destructive\">{}</p>",
1680            html_escape(&props.field),
1681            html_escape(error)
1682        ));
1683    }
1684    html.push_str("</div>");
1685    html
1686}
1687
1688fn render_select(props: &SelectProps, data: &Value) -> String {
1689    // Resolve the effective selected value.
1690    let selected_value = if let Some(ref dv) = props.default_value {
1691        Some(dv.clone())
1692    } else if let Some(ref dp) = props.data_path {
1693        resolve_path_string(data, dp)
1694    } else {
1695        None
1696    };
1697
1698    let has_error = props.error.is_some();
1699    let border_class = if has_error {
1700        "border-destructive"
1701    } else {
1702        "border-border"
1703    };
1704    let focus_ring_class = if has_error {
1705        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
1706    } else {
1707        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
1708    };
1709
1710    let mut html = String::from("<div class=\"space-y-1\">");
1711    html.push_str(&format!(
1712        "<label class=\"block text-sm font-medium text-text\" for=\"{}\">{}</label>",
1713        html_escape(&props.field),
1714        html_escape(&props.label)
1715    ));
1716
1717    html.push_str("<div class=\"relative\">");
1718    html.push_str(&format!(
1719        "<select id=\"{}\" name=\"{}\" class=\"block w-full appearance-none bg-background rounded-md border {} pr-10 px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1720        html_escape(&props.field),
1721        html_escape(&props.field),
1722        border_class,
1723        focus_ring_class
1724    ));
1725    if props.required == Some(true) {
1726        html.push_str(" required");
1727    }
1728    if props.disabled == Some(true) {
1729        html.push_str(" disabled");
1730    }
1731    // FIX-03: inline validation ARIA for select
1732    if has_error {
1733        html.push_str(&format!(
1734            " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1735            html_escape(&props.field)
1736        ));
1737    }
1738    html.push('>');
1739
1740    if let Some(ref placeholder) = props.placeholder {
1741        html.push_str(&format!(
1742            "<option value=\"\">{}</option>",
1743            html_escape(placeholder)
1744        ));
1745    }
1746
1747    for opt in &props.options {
1748        let is_selected = selected_value.as_deref() == Some(&opt.value);
1749        let selected_attr = if is_selected { " selected" } else { "" };
1750        html.push_str(&format!(
1751            "<option value=\"{}\"{}>{}</option>",
1752            html_escape(&opt.value),
1753            selected_attr,
1754            html_escape(&opt.label)
1755        ));
1756    }
1757
1758    html.push_str("</select>");
1759    html.push_str(concat!(
1760        "<span class=\"pointer-events-none absolute inset-y-0 right-3 flex items-center\" aria-hidden=\"true\">",
1761        "<svg class=\"h-4 w-4 text-text-muted\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1762        "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" clip-rule=\"evenodd\"/>",
1763        "</svg></span>"
1764    ));
1765    html.push_str("</div>");
1766
1767    if let Some(ref desc) = props.description {
1768        html.push_str(&format!(
1769            "<p class=\"text-sm text-text-muted\">{}</p>",
1770            html_escape(desc)
1771        ));
1772    }
1773
1774    if let Some(ref error) = props.error {
1775        // FIX-03: id on error paragraph links to aria-describedby on select
1776        html.push_str(&format!(
1777            "<p id=\"err-{}\" class=\"text-sm text-destructive\">{}</p>",
1778            html_escape(&props.field),
1779            html_escape(error)
1780        ));
1781    }
1782    html.push_str("</div>");
1783    html
1784}
1785
1786fn render_checkbox(props: &CheckboxProps, data: &Value) -> String {
1787    // Resolve checked state: explicit `checked` prop wins, else data_path truthy.
1788    let is_checked = if let Some(c) = props.checked {
1789        c
1790    } else if let Some(ref dp) = props.data_path {
1791        resolve_path(data, dp)
1792            .map(|v| match v {
1793                Value::Bool(b) => *b,
1794                Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
1795                Value::String(s) => !s.is_empty() && s != "false" && s != "0",
1796                Value::Null => false,
1797                _ => true,
1798            })
1799            .unwrap_or(false)
1800    } else {
1801        false
1802    };
1803
1804    let value_attr = props.value.as_deref().unwrap_or("1");
1805    // When value is set, use it as part of the id to make each checkbox unique.
1806    let checkbox_id = match &props.value {
1807        Some(v) => format!("{}_{}", props.field, v),
1808        None => props.field.clone(),
1809    };
1810
1811    let mut html = String::from("<div class=\"space-y-1\">");
1812    html.push_str("<div class=\"flex items-center gap-2\">");
1813    html.push_str(&format!(
1814        "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"{}\" class=\"h-4 w-4 rounded-sm border-border text-primary transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\"",
1815        html_escape(&checkbox_id),
1816        html_escape(&props.field),
1817        html_escape(value_attr)
1818    ));
1819    if is_checked {
1820        html.push_str(" checked");
1821    }
1822    if props.required == Some(true) {
1823        html.push_str(" required");
1824    }
1825    if props.disabled == Some(true) {
1826        html.push_str(" disabled");
1827    }
1828    html.push('>');
1829    html.push_str(&format!(
1830        "<label class=\"text-sm font-medium text-text\" for=\"{}\">{}</label>",
1831        html_escape(&checkbox_id),
1832        html_escape(&props.label)
1833    ));
1834    html.push_str("</div>");
1835
1836    if let Some(ref desc) = props.description {
1837        html.push_str(&format!(
1838            "<p class=\"ml-6 text-sm text-text-muted\">{}</p>",
1839            html_escape(desc)
1840        ));
1841    }
1842
1843    if let Some(ref error) = props.error {
1844        html.push_str(&format!(
1845            "<p class=\"ml-6 text-sm text-destructive\">{}</p>",
1846            html_escape(error)
1847        ));
1848    }
1849    html.push_str("</div>");
1850    html
1851}
1852
1853fn render_switch(props: &SwitchProps, data: &Value) -> String {
1854    // Resolve checked state: explicit `checked` prop wins, else data_path truthy.
1855    let is_checked = if let Some(c) = props.checked {
1856        c
1857    } else if let Some(ref dp) = props.data_path {
1858        resolve_path(data, dp)
1859            .map(|v| match v {
1860                Value::Bool(b) => *b,
1861                Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
1862                Value::String(s) => !s.is_empty() && s != "false" && s != "0",
1863                Value::Null => false,
1864                _ => true,
1865            })
1866            .unwrap_or(false)
1867    } else {
1868        false
1869    };
1870
1871    let auto_submit = props.action.is_some();
1872    let onchange = if auto_submit {
1873        " onchange=\"this.closest('form').submit()\""
1874    } else {
1875        ""
1876    };
1877
1878    let mut html = String::new();
1879
1880    // Wrap in a minimal form when an action is provided.
1881    if let Some(ref action) = props.action {
1882        let action_url = action.url.as_deref().unwrap_or("#");
1883        let (form_method, needs_spoofing) = match action.method {
1884            HttpMethod::Get => ("get", false),
1885            HttpMethod::Post => ("post", false),
1886            HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
1887        };
1888        html.push_str(&format!(
1889            "<form action=\"{}\" method=\"{}\">",
1890            html_escape(action_url),
1891            form_method
1892        ));
1893        if needs_spoofing {
1894            let method_value = match action.method {
1895                HttpMethod::Put => "PUT",
1896                HttpMethod::Patch => "PATCH",
1897                HttpMethod::Delete => "DELETE",
1898                _ => unreachable!(),
1899            };
1900            html.push_str(&format!(
1901                "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1902            ));
1903        }
1904    }
1905
1906    html.push_str("<div class=\"space-y-1\">");
1907    let row_class = if props.compact {
1908        "flex items-center gap-3"
1909    } else {
1910        "flex items-center justify-between"
1911    };
1912    html.push_str(&format!("<div class=\"{row_class}\">"));
1913
1914    if props.compact {
1915        // Compact: toggle first, then label.
1916        html.push_str("<label class=\"relative inline-flex items-center cursor-pointer\">");
1917    } else {
1918        // Standard: label left, toggle right.
1919        html.push_str("<div>");
1920        html.push_str(&format!(
1921            "<label class=\"text-sm font-medium text-text\" for=\"{}\">{}</label>",
1922            html_escape(&props.field),
1923            html_escape(&props.label)
1924        ));
1925        if let Some(ref desc) = props.description {
1926            html.push_str(&format!(
1927                "<p class=\"text-sm text-text-muted\">{}</p>",
1928                html_escape(desc)
1929            ));
1930        }
1931        html.push_str("</div>");
1932        html.push_str("<label class=\"relative inline-flex items-center cursor-pointer\">");
1933    }
1934    let aria_checked = if is_checked { "true" } else { "false" };
1935    html.push_str(&format!(
1936        "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" role=\"switch\" aria-checked=\"{}\" class=\"sr-only peer\"{}",
1937        html_escape(&props.field),
1938        html_escape(&props.field),
1939        aria_checked,
1940        onchange,
1941    ));
1942    if is_checked {
1943        html.push_str(" checked");
1944    }
1945    if props.required == Some(true) {
1946        html.push_str(" required");
1947    }
1948    if props.disabled == Some(true) {
1949        html.push_str(" disabled");
1950    }
1951    html.push('>');
1952    html.push_str("<div class=\"w-11 h-6 bg-border rounded-full peer peer-checked:bg-primary peer-focus:ring-2 peer-focus:ring-primary/30 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-background after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full\"></div>");
1953    html.push_str("</label>");
1954    if props.compact {
1955        html.push_str(&format!(
1956            "<label class=\"text-sm font-medium text-text cursor-pointer\" for=\"{}\">{}</label>",
1957            html_escape(&props.field),
1958            html_escape(&props.label)
1959        ));
1960    }
1961    html.push_str("</div>");
1962
1963    if let Some(ref error) = props.error {
1964        html.push_str(&format!(
1965            "<p class=\"text-sm text-destructive\">{}</p>",
1966            html_escape(error)
1967        ));
1968    }
1969    html.push_str("</div>");
1970
1971    if props.action.is_some() {
1972        html.push_str("</form>");
1973    }
1974
1975    html
1976}
1977
1978/// Renders a dynamic key/value editor backed by a hidden JSON field.
1979///
1980/// Emits a `<div data-kv-editor>` wrapper containing a row list, a row
1981/// template, an optional `<datalist>`, an "Add row" button, and a hidden
1982/// input whose `value` is the JSON object `{"key": "value", ...}`.
1983/// The runtime module `key_value_editor` wires add/delete/input events and
1984/// keeps the hidden field in sync on every mutation.
1985///
1986/// `data_path` must resolve to a JSON object; each entry seeds one row.
1987/// Non-string leaf values are `serde_json::to_string`-encoded so complex
1988/// seed values still round-trip. When `data_path` is absent or does not
1989/// resolve to an object, the editor renders with zero rows and `{}` in
1990/// the hidden field.
1991fn render_key_value_editor(props: &KeyValueEditorProps, data: &Value) -> String {
1992    // 1. Resolve initial entries from data_path. Use resolve_path (not
1993    //    resolve_path_string) so we can iterate over the object map.
1994    let initial_entries: Vec<(String, String)> = if let Some(ref dp) = props.data_path {
1995        resolve_path(data, dp)
1996            .and_then(|v| v.as_object())
1997            .map(|obj| {
1998                obj.iter()
1999                    .map(|(k, v)| {
2000                        let val_str = match v {
2001                            Value::String(s) => s.clone(),
2002                            Value::Null => String::new(),
2003                            other => serde_json::to_string(other).unwrap_or_default(),
2004                        };
2005                        (k.clone(), val_str)
2006                    })
2007                    .collect()
2008            })
2009            .unwrap_or_default()
2010    } else {
2011        Vec::new()
2012    };
2013
2014    // 2. Build the initial JSON payload for the hidden field.
2015    let initial_json = if initial_entries.is_empty() {
2016        "{}".to_string()
2017    } else {
2018        let obj: serde_json::Map<String, Value> = initial_entries
2019            .iter()
2020            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
2021            .collect();
2022        serde_json::to_string(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
2023    };
2024
2025    // 3. Error state classes (mirrors render_input).
2026    let has_error = props.error.is_some();
2027    let border_class = if has_error {
2028        "border-destructive"
2029    } else {
2030        "border-border"
2031    };
2032    let focus_ring_class = if has_error {
2033        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
2034    } else {
2035        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
2036    };
2037
2038    // 4. ARIA attributes applied to key and value inputs when an error exists.
2039    let field_escaped = html_escape(&props.field);
2040    let aria_attrs = if has_error {
2041        format!(r#" aria-invalid="true" aria-describedby="err-{field_escaped}""#)
2042    } else {
2043        String::new()
2044    };
2045
2046    // Shared input class fragment (matches render_input field styling).
2047    let input_base = format!(
2048        "block w-full rounded-md border {border_class} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none {focus_ring_class}",
2049    );
2050
2051    // Shared select class fragment (compact — no chevron decoration).
2052    let select_base = format!(
2053        "block w-full appearance-none bg-background rounded-md border {border_class} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none {focus_ring_class}",
2054    );
2055
2056    // Inline delete SVG — 16x16, currentColor, aria-hidden.
2057    let delete_svg = r##"<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/></svg>"##;
2058
2059    // Inline plus SVG for "Add row".
2060    let add_svg = r##"<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"/></svg>"##;
2061
2062    // Reusable builder for a single row (used for both prefilled rows and the template row).
2063    // `is_template` suppresses aria-invalid/aria-describedby: those attributes describe
2064    // a specific input's validity, not cloned rows whose values are not yet invalid.
2065    let render_row = |key_value: Option<(&str, &str)>, is_template: bool| -> String {
2066        let (key_attr_value, value_attr_value) = match key_value {
2067            Some((k, v)) => (html_escape(k), html_escape(v)),
2068            None => (String::new(), String::new()),
2069        };
2070        let row_aria = if is_template { "" } else { &aria_attrs[..] };
2071
2072        // Key cell: either <input type="text"> + datalist OR <select>.
2073        let key_cell = if props.allow_custom_keys {
2074            let list_attr = if !props.suggested_keys.is_empty() {
2075                format!(r#" list="{field_escaped}-suggestions""#)
2076            } else {
2077                String::new()
2078            };
2079            format!(
2080                r#"<input type="text" class="{input_base}" placeholder="Key"{list_attr} data-kv-key{row_aria} value="{key_attr_value}">"#
2081            )
2082        } else {
2083            let mut s = format!(r#"<select class="{select_base}" data-kv-key{row_aria}>"#);
2084            s.push_str(r#"<option value="">Select key</option>"#);
2085            for k in &props.suggested_keys {
2086                let k_escaped = html_escape(k);
2087                let selected = if k_escaped == key_attr_value {
2088                    " selected"
2089                } else {
2090                    ""
2091                };
2092                s.push_str(&format!(
2093                    r#"<option value="{k_escaped}"{selected}>{k_escaped}</option>"#
2094                ));
2095            }
2096            s.push_str("</select>");
2097            s
2098        };
2099
2100        let value_cell = format!(
2101            r#"<input type="text" class="{input_base}" placeholder="Value" data-kv-value{row_aria} value="{value_attr_value}">"#
2102        );
2103
2104        let delete_cell = format!(
2105            r#"<button type="button" class="flex items-center justify-center min-w-[32px] min-h-[32px] rounded text-text-muted hover:text-destructive transition-colors duration-150" aria-label="Remove row" data-kv-delete>{delete_svg}</button>"#
2106        );
2107
2108        format!(
2109            r#"<div class="grid grid-cols-[1fr_1fr_auto] gap-2 items-center" data-kv-row>{key_cell}{value_cell}{delete_cell}</div>"#
2110        )
2111    };
2112
2113    // 5. Assemble the outer wrapper.
2114    let mut html = String::with_capacity(1024);
2115    html.push_str(&format!(
2116        r#"<div class="space-y-1" data-kv-editor data-kv-field="{field_escaped}">"#
2117    ));
2118
2119    // 6. Optional label.
2120    if let Some(ref label) = props.label {
2121        let label_escaped = html_escape(label);
2122        html.push_str(&format!(
2123            r#"<label class="block text-sm font-medium text-text" for="{field_escaped}">{label_escaped}</label>"#
2124        ));
2125    }
2126
2127    // 7. Rows container.
2128    html.push_str(r#"<div class="space-y-2" data-kv-rows>"#);
2129    for (k, v) in &initial_entries {
2130        html.push_str(&render_row(Some((k.as_str(), v.as_str())), false));
2131    }
2132    html.push_str("</div>");
2133
2134    // 8. Row template (always present, used by JS to clone new rows).
2135    // Template rows are never aria-invalid — they carry no user values yet.
2136    html.push_str("<template data-kv-row-template>");
2137    html.push_str(&render_row(None, true));
2138    html.push_str("</template>");
2139
2140    // 9. Datalist — only when allow_custom_keys AND suggested_keys non-empty.
2141    if props.allow_custom_keys && !props.suggested_keys.is_empty() {
2142        html.push_str(&format!(r#"<datalist id="{field_escaped}-suggestions">"#));
2143        for key in &props.suggested_keys {
2144            let key_escaped = html_escape(key);
2145            html.push_str(&format!(r#"<option value="{key_escaped}">"#));
2146        }
2147        html.push_str("</datalist>");
2148    }
2149
2150    // 10. Add-row button.
2151    html.push_str(&format!(
2152        r#"<button type="button" class="mt-1 inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors duration-150" data-kv-add>{add_svg}Add row</button>"#
2153    ));
2154
2155    // 11. Hidden field carrying the serialized JSON.
2156    let initial_json_escaped = html_escape(&initial_json);
2157    html.push_str(&format!(
2158        r#"<input type="hidden" id="{field_escaped}" name="{field_escaped}" value="{initial_json_escaped}">"#
2159    ));
2160
2161    // 12. Optional error paragraph.
2162    if let Some(ref error) = props.error {
2163        let error_escaped = html_escape(error);
2164        html.push_str(&format!(
2165            r#"<p id="err-{field_escaped}" class="text-sm text-destructive">{error_escaped}</p>"#
2166        ));
2167    }
2168
2169    html.push_str("</div>");
2170    html
2171}
2172
2173// ── Leaf component renderers ────────────────────────────────────────────
2174
2175fn render_text(props: &TextProps) -> String {
2176    let content = html_escape(&props.content);
2177    match props.element {
2178        TextElement::P => format!("<p class=\"text-base leading-relaxed text-text\">{content}</p>"),
2179        TextElement::H1 => format!("<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">{content}</h1>"),
2180        TextElement::H2 => {
2181            format!("<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">{content}</h2>")
2182        }
2183        TextElement::H3 => {
2184            format!("<h3 class=\"text-xl font-semibold leading-snug text-text\">{content}</h3>")
2185        }
2186        TextElement::Span => format!("<span class=\"text-base text-text\">{content}</span>"),
2187        TextElement::Div => format!("<div class=\"text-base leading-relaxed text-text\">{content}</div>"),
2188        TextElement::Section => {
2189            format!("<section class=\"text-base leading-relaxed text-text\">{content}</section>")
2190        }
2191    }
2192}
2193
2194fn render_button(props: &ButtonProps) -> String {
2195    let base = "inline-flex items-center justify-center rounded-md font-medium transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
2196
2197    let variant_classes = match props.variant {
2198        ButtonVariant::Default => "bg-primary text-primary-foreground hover:bg-primary/90",
2199        ButtonVariant::Secondary => "bg-secondary text-secondary-foreground hover:bg-secondary/90",
2200        ButtonVariant::Destructive => {
2201            "bg-destructive text-primary-foreground hover:bg-destructive/90"
2202        }
2203        ButtonVariant::Outline => "border border-border bg-background text-text hover:bg-surface",
2204        ButtonVariant::Ghost => "text-text hover:bg-surface",
2205        ButtonVariant::Link => "text-primary underline hover:text-primary/80",
2206    };
2207
2208    let is_ghost = matches!(props.variant, ButtonVariant::Ghost);
2209    let size_classes = match props.size {
2210        Size::Xs => {
2211            if is_ghost {
2212                "py-1 text-xs"
2213            } else {
2214                "px-2 py-1 text-xs"
2215            }
2216        }
2217        Size::Sm => {
2218            if is_ghost {
2219                "py-1.5 text-sm"
2220            } else {
2221                "px-3 py-1.5 text-sm"
2222            }
2223        }
2224        Size::Default => {
2225            if is_ghost {
2226                "py-2 text-sm"
2227            } else {
2228                "px-4 py-2 text-sm"
2229            }
2230        }
2231        Size::Lg => {
2232            if is_ghost {
2233                "py-3 text-base"
2234            } else {
2235                "px-6 py-3 text-base"
2236            }
2237        }
2238    };
2239
2240    let disabled_classes = if props.disabled == Some(true) {
2241        " opacity-50 cursor-not-allowed"
2242    } else {
2243        ""
2244    };
2245
2246    let disabled_attr = if props.disabled == Some(true) {
2247        " disabled"
2248    } else {
2249        ""
2250    };
2251
2252    let label = html_escape(&props.label);
2253
2254    // Build icon + label content.
2255    let content = if let Some(ref icon) = props.icon {
2256        let icon_span = format!(
2257            "<span class=\"icon\" data-icon=\"{}\">{}</span>",
2258            html_escape(icon),
2259            html_escape(icon)
2260        );
2261        let position = props.icon_position.as_ref().cloned().unwrap_or_default();
2262        match position {
2263            IconPosition::Left => format!("{icon_span} {label}"),
2264            IconPosition::Right => format!("{label} {icon_span}"),
2265        }
2266    } else {
2267        label
2268    };
2269
2270    // When button_type is None, omit the attribute so browsers apply the HTML
2271    // default (submit inside a form, no-op elsewhere). This preserves behavior
2272    // for forms that rely on the default submit button.
2273    let type_attr = match props.button_type.as_ref() {
2274        Some(ButtonType::Button) => " type=\"button\"",
2275        Some(ButtonType::Submit) => " type=\"submit\"",
2276        None => "",
2277    };
2278
2279    format!(
2280        "<button{type_attr} class=\"{base} {variant_classes} {size_classes}{disabled_classes}\"{disabled_attr}>{content}</button>"
2281    )
2282}
2283
2284fn render_badge(props: &BadgeProps) -> String {
2285    let base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium !w-fit";
2286    let variant_classes = match props.variant {
2287        BadgeVariant::Default => "bg-primary/10 text-primary",
2288        BadgeVariant::Secondary => "bg-secondary/10 text-secondary-foreground",
2289        BadgeVariant::Destructive => "bg-destructive/10 text-destructive",
2290        BadgeVariant::Outline => "border border-border text-text",
2291    };
2292    format!(
2293        "<span class=\"{} {}\">{}</span>",
2294        base,
2295        variant_classes,
2296        html_escape(&props.label)
2297    )
2298}
2299
2300// ── CMP-01: Alert SVG icons ──────────────────────────────────────────────
2301
2302const ICON_INFO: &str = concat!(
2303    "<span aria-hidden=\"true\" class=\"shrink-0\">",
2304    "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2305    "<path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z\" clip-rule=\"evenodd\"/>",
2306    "</svg></span>"
2307);
2308
2309const ICON_SUCCESS: &str = concat!(
2310    "<span aria-hidden=\"true\" class=\"shrink-0\">",
2311    "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2312    "<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z\" clip-rule=\"evenodd\"/>",
2313    "</svg></span>"
2314);
2315
2316const ICON_WARNING: &str = concat!(
2317    "<span aria-hidden=\"true\" class=\"shrink-0\">",
2318    "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2319    "<path fill-rule=\"evenodd\" d=\"M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z\" clip-rule=\"evenodd\"/>",
2320    "</svg></span>"
2321);
2322
2323const ICON_ERROR: &str = concat!(
2324    "<span aria-hidden=\"true\" class=\"shrink-0\">",
2325    "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2326    "<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\" clip-rule=\"evenodd\"/>",
2327    "</svg></span>"
2328);
2329
2330fn render_alert(props: &AlertProps) -> String {
2331    let variant_classes = match props.variant {
2332        AlertVariant::Info => "bg-primary/10 border-primary text-primary",
2333        AlertVariant::Success => "bg-success/10 border-success text-success",
2334        AlertVariant::Warning => "bg-warning/10 border-warning text-warning",
2335        AlertVariant::Error => "bg-destructive/10 border-destructive text-destructive",
2336    };
2337    let icon = match props.variant {
2338        AlertVariant::Info => ICON_INFO,
2339        AlertVariant::Success => ICON_SUCCESS,
2340        AlertVariant::Warning => ICON_WARNING,
2341        AlertVariant::Error => ICON_ERROR,
2342    };
2343    let mut html = format!(
2344        "<div role=\"alert\" class=\"rounded-md border p-4 flex items-start gap-3 {variant_classes}\">"
2345    );
2346    html.push_str(icon);
2347    html.push_str("<div>");
2348    if let Some(ref title) = props.title {
2349        html.push_str(&format!(
2350            "<h4 class=\"font-semibold mb-1\">{}</h4>",
2351            html_escape(title)
2352        ));
2353    }
2354    html.push_str(&format!("<p>{}</p>", html_escape(&props.message)));
2355    html.push_str("</div>");
2356    html.push_str("</div>");
2357    html
2358}
2359
2360fn render_separator(props: &SeparatorProps) -> String {
2361    let orientation = props.orientation.as_ref().cloned().unwrap_or_default();
2362    match orientation {
2363        Orientation::Horizontal => "<hr class=\"my-4 border-border\">".to_string(),
2364        Orientation::Vertical => "<div class=\"mx-4 h-full w-px bg-border\"></div>".to_string(),
2365    }
2366}
2367
2368fn render_progress(props: &ProgressProps) -> String {
2369    let max = props.max.unwrap_or(100) as f64;
2370    let pct = if max > 0.0 {
2371        ((props.value as f64 * 100.0 / max).round() as u8).min(100)
2372    } else {
2373        0
2374    };
2375
2376    let mut html = String::from("<div class=\"w-full\">");
2377    if let Some(ref label) = props.label {
2378        html.push_str(&format!(
2379            "<div class=\"mb-1 text-sm text-text-muted\">{}</div>",
2380            html_escape(label)
2381        ));
2382    }
2383    html.push_str(&format!(
2384        "<div class=\"w-full rounded-full bg-border h-2.5\"><div class=\"rounded-full bg-primary h-2.5\" style=\"width: {pct}%\"></div></div>"
2385    ));
2386    html.push_str("</div>");
2387    html
2388}
2389
2390fn render_avatar(props: &AvatarProps) -> String {
2391    let size = props.size.as_ref().cloned().unwrap_or_default();
2392    let size_classes = match size {
2393        Size::Xs => "h-6 w-6 text-xs",
2394        Size::Sm => "h-8 w-8 text-sm",
2395        Size::Default => "h-10 w-10 text-sm",
2396        Size::Lg => "h-12 w-12 text-base",
2397    };
2398
2399    if let Some(ref src) = props.src {
2400        format!(
2401            "<img src=\"{}\" alt=\"{}\" class=\"rounded-full object-cover {}\">",
2402            html_escape(src),
2403            html_escape(&props.alt),
2404            size_classes
2405        )
2406    } else {
2407        let fallback_text = props.fallback.as_deref().unwrap_or_else(|| {
2408            // Use first characters of alt as fallback.
2409            &props.alt
2410        });
2411        // Take first two chars for initials.
2412        let initials: String = fallback_text.chars().take(2).collect();
2413        format!(
2414            "<span class=\"inline-flex items-center justify-center rounded-full bg-card text-text-muted {}\">{}</span>",
2415            size_classes,
2416            html_escape(&initials)
2417        )
2418    }
2419}
2420
2421fn render_image(props: &ImageProps) -> String {
2422    let container_style = match &props.aspect_ratio {
2423        Some(ratio) => format!(" style=\"aspect-ratio: {}\"", html_escape(ratio)),
2424        None => String::new(),
2425    };
2426
2427    match &props.source {
2428        ImageSource::Url { src } => {
2429            // Placeholder sits behind the image in the same box. When the `<img>`
2430            // fails to load, `onerror` hides it so the placeholder remains visible.
2431            let placeholder = match &props.placeholder_label {
2432                Some(label) => format!(
2433                    "<div class=\"absolute inset-0 flex items-center justify-center \
2434                     rounded-md bg-surface text-xs text-text-muted\">{}</div>",
2435                    html_escape(label)
2436                ),
2437                None => {
2438                    String::from("<div class=\"absolute inset-0 rounded-md bg-surface\"></div>")
2439                }
2440            };
2441            format!(
2442                "<div class=\"relative w-full\"{container_style}>\
2443                    {placeholder}\
2444                    <img src=\"{src}\" alt=\"{alt}\" \
2445                         class=\"relative w-full h-full rounded-md object-cover object-top\" \
2446                         loading=\"lazy\" onerror=\"this.style.display='none'\">\
2447                 </div>",
2448                src = html_escape(src),
2449                alt = html_escape(&props.alt),
2450            )
2451        }
2452        ImageSource::InlineSvg { svg } => {
2453            // SAFETY: `svg` is emitted verbatim WITHOUT html_escape. This is the one
2454            // deliberate html_escape bypass in render_image (and in ferro-json-ui).
2455            // Intended for server-constructed SVG (charts, sparklines, icons) — NOT
2456            // for user-supplied strings. `alt` IS html_escape'd below (attacker-
2457            // controllable via form data etc.), so the asymmetry is scoped.
2458            // See ImageSource::InlineSvg rustdoc and 148-CONTEXT.md D-06.
2459            format!(
2460                "<div class=\"relative w-full\"{container_style}>\
2461                    <div role=\"img\" aria-label=\"{alt}\">{svg}</div>\
2462                 </div>",
2463                alt = html_escape(&props.alt),
2464            )
2465        }
2466    }
2467}
2468
2469// ── CMP-02: Skeleton shimmer animation ──────────────────────────────────
2470
2471const SHIMMER_CSS: &str = concat!(
2472    "<style>",
2473    "@keyframes ferro-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}",
2474    ".ferro-shimmer{",
2475    "background:linear-gradient(90deg,var(--color-card,#f1f5f9) 25%,var(--color-border,#e2e8f0) 50%,var(--color-card,#f1f5f9) 75%);",
2476    "background-size:200% 100%;",
2477    "animation:ferro-shimmer 1.5s ease-in-out infinite;",
2478    "}",
2479    "</style>"
2480);
2481
2482fn render_skeleton(props: &SkeletonProps) -> String {
2483    let width = props.width.as_deref().unwrap_or("100%");
2484    let height = props.height.as_deref().unwrap_or("1rem");
2485    let rounded = if props.rounded == Some(true) {
2486        "rounded-full"
2487    } else {
2488        "rounded-md"
2489    };
2490    format!("{SHIMMER_CSS}<div class=\"ferro-shimmer {rounded}\" style=\"width: {width}; height: {height}\"></div>")
2491}
2492
2493// ── CMP-03: Breadcrumb SVG chevron separator ─────────────────────────────
2494
2495const BREADCRUMB_SEP: &str = concat!(
2496    "<span aria-hidden=\"true\" class=\"text-text-muted\">",
2497    "<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2498    "<path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>",
2499    "</svg></span>"
2500);
2501
2502fn render_breadcrumb(props: &BreadcrumbProps) -> String {
2503    let mut html =
2504        String::from("<nav class=\"flex items-center space-x-2 text-sm text-text-muted\">");
2505    let len = props.items.len();
2506    for (i, item) in props.items.iter().enumerate() {
2507        let is_last = i == len - 1;
2508        if is_last {
2509            html.push_str(&format!(
2510                "<span class=\"text-text font-medium\">{}</span>",
2511                html_escape(&item.label)
2512            ));
2513        } else if let Some(ref url) = item.url {
2514            html.push_str(&format!(
2515                "<a href=\"{}\" class=\"hover:text-text transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">{}</a>",
2516                html_escape(url),
2517                html_escape(&item.label)
2518            ));
2519        } else {
2520            html.push_str(&format!("<span>{}</span>", html_escape(&item.label)));
2521        }
2522        if !is_last {
2523            html.push_str(BREADCRUMB_SEP);
2524        }
2525    }
2526    html.push_str("</nav>");
2527    html
2528}
2529
2530fn render_pagination(props: &PaginationProps) -> String {
2531    if props.total == 0 || props.per_page == 0 {
2532        return String::new();
2533    }
2534
2535    let total_pages = props.total.div_ceil(props.per_page);
2536    if total_pages <= 1 {
2537        return String::new();
2538    }
2539
2540    let base_url = props.base_url.as_deref().unwrap_or("?");
2541    let current = props.current_page;
2542
2543    let mut html = String::from("<nav class=\"flex items-center space-x-1\">");
2544
2545    // Previous button.
2546    if current > 1 {
2547        html.push_str(&format!(
2548            "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">&laquo;</a>",
2549            html_escape(base_url),
2550            current - 1
2551        ));
2552    }
2553
2554    // Page numbers — show up to 7 with ellipsis.
2555    let pages = compute_page_range(current, total_pages);
2556    let mut prev_page = 0u32;
2557    for page in pages {
2558        if prev_page > 0 && page > prev_page + 1 {
2559            html.push_str("<span class=\"px-2 text-text-muted\">&hellip;</span>");
2560        }
2561        if page == current {
2562            html.push_str(&format!(
2563                "<span class=\"px-3 py-1 rounded-md bg-primary text-primary-foreground\">{page}</span>"
2564            ));
2565        } else {
2566            html.push_str(&format!(
2567                "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">{}</a>",
2568                html_escape(base_url),
2569                page,
2570                page
2571            ));
2572        }
2573        prev_page = page;
2574    }
2575
2576    // Next button.
2577    if current < total_pages {
2578        html.push_str(&format!(
2579            "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">&raquo;</a>",
2580            html_escape(base_url),
2581            current + 1
2582        ));
2583    }
2584
2585    html.push_str("</nav>");
2586    html
2587}
2588
2589/// Compute which page numbers to display (up to 7 entries).
2590fn compute_page_range(current: u32, total: u32) -> Vec<u32> {
2591    if total <= 7 {
2592        return (1..=total).collect();
2593    }
2594    let mut pages = Vec::new();
2595    pages.push(1);
2596    let start = current.saturating_sub(1).max(2);
2597    let end = (current + 1).min(total - 1);
2598    for p in start..=end {
2599        if !pages.contains(&p) {
2600            pages.push(p);
2601        }
2602    }
2603    if !pages.contains(&total) {
2604        pages.push(total);
2605    }
2606    pages.sort();
2607    pages.dedup();
2608    pages
2609}
2610
2611fn render_description_list(props: &DescriptionListProps) -> String {
2612    let columns = props.columns.unwrap_or(1);
2613    let mut html = format!("<dl class=\"grid grid-cols-{columns} gap-4\">");
2614    for item in &props.items {
2615        html.push_str(&format!(
2616            "<div><dt class=\"text-sm font-medium text-text-muted\">{}</dt><dd class=\"mt-1 text-sm text-text\">{}</dd></div>",
2617            html_escape(&item.label),
2618            html_escape(&item.value)
2619        ));
2620    }
2621    html.push_str("</dl>");
2622    html
2623}
2624
2625// ── Layout component renderers ───────────────────────────────────────────
2626
2627fn render_grid(props: &GridProps, data: &Value) -> String {
2628    let gap = match props.gap {
2629        GapSize::None => "gap-0",
2630        GapSize::Sm => "gap-2",
2631        GapSize::Md => "gap-4",
2632        GapSize::Lg => "gap-6",
2633        GapSize::Xl => "gap-8",
2634    };
2635
2636    if props.scrollable == Some(true) {
2637        let mut html = format!("<div class=\"overflow-x-auto\"><div class=\"grid grid-flow-col auto-cols-[minmax(280px,1fr)] {gap}\">");
2638        for child in &props.children {
2639            html.push_str(&render_node(child, data));
2640        }
2641        html.push_str("</div></div>");
2642        return html;
2643    }
2644
2645    let cols = props.columns.clamp(1, 12);
2646    let mut col_classes = format!("grid-cols-{cols}");
2647    if let Some(md) = props.md_columns {
2648        col_classes.push_str(&format!(" md:grid-cols-{}", md.clamp(1, 12)));
2649    }
2650    if let Some(lg) = props.lg_columns {
2651        col_classes.push_str(&format!(" lg:grid-cols-{}", lg.clamp(1, 12)));
2652    }
2653    let mut html = format!("<div class=\"grid w-full {col_classes} {gap}\">");
2654    for child in &props.children {
2655        html.push_str(&render_node(child, data));
2656    }
2657    html.push_str("</div>");
2658    html
2659}
2660
2661// ── CMP-06: Collapsible SVG chevron ─────────────────────────────────────
2662
2663const CHEVRON_DOWN: &str = concat!(
2664    "<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2665    "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" clip-rule=\"evenodd\"/>",
2666    "</svg>"
2667);
2668
2669fn render_collapsible(props: &CollapsibleProps, data: &Value) -> String {
2670    let mut html =
2671        String::from("<details class=\"group rounded-lg border border-border overflow-hidden\"");
2672    if props.expanded {
2673        html.push_str(" open");
2674    }
2675    html.push('>');
2676    let aria_expanded = if props.expanded { "true" } else { "false" };
2677    html.push_str(&format!(
2678        "<summary class=\"flex items-center justify-between cursor-pointer px-4 py-3 text-sm font-medium text-text bg-surface hover:bg-card\" aria-expanded=\"{}\">{}<span class=\"text-text-muted group-open:rotate-180 transition-transform\">{CHEVRON_DOWN}</span></summary>",
2679        aria_expanded,
2680        html_escape(&props.title)
2681    ));
2682    html.push_str("<div class=\"px-4 py-3 bg-card flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
2683    for child in &props.children {
2684        html.push_str(&render_node(child, data));
2685    }
2686    html.push_str("</div></details>");
2687    html
2688}
2689
2690fn render_empty_state(props: &EmptyStateProps) -> String {
2691    let mut html = String::from(
2692        "<div class=\"flex flex-col items-center justify-center py-8 px-6 text-center\">",
2693    );
2694    html.push_str(&format!(
2695        "<p class=\"text-sm text-text-muted\">{}</p>",
2696        html_escape(&props.title)
2697    ));
2698    if let Some(ref desc) = props.description {
2699        html.push_str(&format!(
2700            "<p class=\"mt-1 text-sm text-text-muted\">{}</p>",
2701            html_escape(desc)
2702        ));
2703    }
2704    if let Some(ref action) = props.action {
2705        let label = props.action_label.as_deref().unwrap_or("Action");
2706        let url = action.url.as_deref().unwrap_or("#");
2707        html.push_str(&format!(
2708            "<a href=\"{}\" class=\"mt-4 inline-flex items-center justify-center rounded-md \
2709             border border-border bg-card text-text px-4 py-2 text-sm font-medium \
2710             hover:bg-surface transition-colors\">{}</a>",
2711            html_escape(url),
2712            html_escape(label)
2713        ));
2714    }
2715    html.push_str("</div>");
2716    html
2717}
2718
2719fn render_form_section(props: &FormSectionProps, data: &Value) -> String {
2720    let is_two_column = matches!(props.layout.as_ref(), Some(FormSectionLayout::TwoColumn));
2721
2722    if is_two_column {
2723        // FIX-05: two-column layout — description left (2 cols), controls right (3 cols)
2724        let mut html = String::from("<fieldset class=\"md:grid md:grid-cols-5 md:gap-8\">");
2725        html.push_str(&format!(
2726            "<div class=\"md:col-span-2\"><legend class=\"text-base font-semibold text-text\">{}</legend>",
2727            html_escape(&props.title)
2728        ));
2729        if let Some(ref desc) = props.description {
2730            html.push_str(&format!(
2731                "<p class=\"text-sm text-text-muted mt-1\">{}</p>",
2732                html_escape(desc)
2733            ));
2734        }
2735        html.push_str("</div>");
2736        html.push_str("<div class=\"md:col-span-3 space-y-4 mt-4 md:mt-0\">");
2737        for child in &props.children {
2738            html.push_str(&render_node(child, data));
2739        }
2740        html.push_str("</div></fieldset>");
2741        html
2742    } else {
2743        // Stacked (default) behavior
2744        let mut html = String::from(
2745            "<fieldset class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
2746        );
2747        html.push_str(&format!(
2748            "<legend class=\"text-base font-semibold text-text\">{}</legend>",
2749            html_escape(&props.title)
2750        ));
2751        if let Some(ref desc) = props.description {
2752            html.push_str(&format!(
2753                "<p class=\"text-sm text-text-muted\">{}</p>",
2754                html_escape(desc)
2755            ));
2756        }
2757        html.push_str("<div class=\"space-y-4\">");
2758        for child in &props.children {
2759            html.push_str(&render_node(child, data));
2760        }
2761        html.push_str("</div></fieldset>");
2762        html
2763    }
2764}
2765
2766// ── Dashboard component renderers ───────────────────────────────────────
2767
2768fn render_stat_card(props: &StatCardProps) -> String {
2769    let mut html =
2770        String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
2771    if let Some(ref icon) = props.icon {
2772        html.push_str(&format!(
2773            "<span class=\"inline-block mb-2 w-6 h-6\">{icon}</span>"
2774        ));
2775        // raw
2776    }
2777    html.push_str(&format!(
2778        "<p class=\"text-sm text-text-muted\">{}</p>",
2779        html_escape(&props.label)
2780    ));
2781    if let Some(ref sse) = props.sse_target {
2782        html.push_str(&format!(
2783            "<p class=\"text-2xl font-bold text-text\" data-sse-target=\"{}\" data-live-value>{}</p>",
2784            html_escape(sse),
2785            html_escape(&props.value)
2786        ));
2787    } else {
2788        html.push_str(&format!(
2789            "<p class=\"text-2xl font-bold text-text\">{}</p>",
2790            html_escape(&props.value)
2791        ));
2792    }
2793    if let Some(ref subtitle) = props.subtitle {
2794        html.push_str(&format!(
2795            "<p class=\"text-xs text-text-muted mt-1\">{}</p>",
2796            html_escape(subtitle)
2797        ));
2798    }
2799    html.push_str("</div>");
2800    html
2801}
2802
2803fn render_checklist(props: &ChecklistProps) -> String {
2804    let mut html =
2805        String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
2806    html.push_str("<div class=\"flex items-center justify-between mb-3\">");
2807    html.push_str(&format!(
2808        "<h3 class=\"text-sm font-semibold leading-snug text-text\">{}</h3>",
2809        html_escape(&props.title)
2810    ));
2811    if props.dismissible {
2812        let dismiss_label = props.dismiss_label.as_deref().unwrap_or("Dismiss");
2813        html.push_str(&format!(
2814            "<button type=\"button\" class=\"text-xs font-medium text-text hover:text-primary\" data-dismissible>{}</button>",
2815            html_escape(dismiss_label)
2816        ));
2817    }
2818    html.push_str("</div>");
2819    if let Some(ref key) = props.data_key {
2820        html.push_str(&format!(
2821            "<div data-checklist-key=\"{}\">",
2822            html_escape(key)
2823        ));
2824    } else {
2825        html.push_str("<div>");
2826    }
2827    if props.dismissible {
2828        html.push_str("<ul data-dismissible class=\"space-y-2\">");
2829    } else {
2830        html.push_str("<ul class=\"space-y-2\">");
2831    }
2832    for item in &props.items {
2833        html.push_str("<li class=\"flex items-center gap-2\">");
2834        if item.checked {
2835            html.push_str("<input type=\"checkbox\" checked class=\"h-4 w-4 rounded-sm border-border text-primary\">");
2836        } else {
2837            html.push_str(
2838                "<input type=\"checkbox\" class=\"h-4 w-4 rounded-sm border-border text-primary\">",
2839            );
2840        }
2841        let label_class = if item.checked {
2842            "text-sm line-through text-text-muted"
2843        } else {
2844            "text-sm text-text"
2845        };
2846        if let Some(ref href) = item.href {
2847            html.push_str(&format!(
2848                "<a href=\"{}\" class=\"{}\">{}</a>",
2849                html_escape(href),
2850                label_class,
2851                html_escape(&item.label)
2852            ));
2853        } else {
2854            html.push_str(&format!(
2855                "<span class=\"{}\">{}</span>",
2856                label_class,
2857                html_escape(&item.label)
2858            ));
2859        }
2860        html.push_str("</li>");
2861    }
2862    html.push_str("</ul></div></div>");
2863    html
2864}
2865
2866fn render_toast(props: &ToastProps) -> String {
2867    let variant_classes = match props.variant {
2868        ToastVariant::Info => "bg-primary/10 border-primary text-primary",
2869        ToastVariant::Success => "bg-success/10 border-success text-success",
2870        ToastVariant::Warning => "bg-warning/10 border-warning text-warning",
2871        ToastVariant::Error => "bg-destructive/10 border-destructive text-destructive",
2872    };
2873    let variant_str = match props.variant {
2874        ToastVariant::Info => "info",
2875        ToastVariant::Success => "success",
2876        ToastVariant::Warning => "warning",
2877        ToastVariant::Error => "error",
2878    };
2879    let timeout = props.timeout.unwrap_or(5);
2880    let mut html = format!(
2881        "<div class=\"fixed top-4 right-4 z-50 rounded-md border p-4 shadow-lg {variant_classes}\" data-toast-variant=\"{variant_str}\" data-toast-timeout=\"{timeout}\"",
2882    );
2883    if props.dismissible {
2884        html.push_str(" data-toast-dismissible");
2885    }
2886    html.push('>');
2887    html.push_str("<div class=\"flex items-start gap-3\">");
2888    html.push_str(&format!(
2889        "<p class=\"text-sm\">{}</p>",
2890        html_escape(&props.message)
2891    ));
2892    if props.dismissible {
2893        html.push_str(
2894            "<button type=\"button\" class=\"ml-auto text-current opacity-70 hover:opacity-100\">&times;</button>",
2895        );
2896    }
2897    html.push_str("</div></div>");
2898    html
2899}
2900
2901// ── CMP-05: Bell SVG icon ────────────────────────────────────────────────
2902
2903const BELL_SVG: &str = concat!(
2904    "<svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">",
2905    "<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" ",
2906    "d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/>",
2907    "</svg>"
2908);
2909
2910fn render_notification_dropdown(props: &NotificationDropdownProps) -> String {
2911    let unread_count = props.notifications.iter().filter(|n| !n.read).count();
2912    let mut html = String::from("<div class=\"relative\" data-notification-dropdown>");
2913    // Bell icon button with badge.
2914    html.push_str(&format!(
2915        "<button type=\"button\" class=\"relative p-2 text-text-muted hover:text-text\" data-notification-count=\"{unread_count}\">"
2916    ));
2917    html.push_str(BELL_SVG);
2918    if unread_count > 0 {
2919        html.push_str(&format!(
2920            "<span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{unread_count}</span>"
2921        ));
2922    }
2923    html.push_str("</button>");
2924    // Dropdown panel.
2925    html.push_str(
2926        "<div class=\"hidden absolute right-0 mt-2 w-80 bg-card rounded-lg shadow-lg border border-border z-50\" data-notification-panel>",
2927    );
2928    if props.notifications.is_empty() {
2929        let empty = props.empty_text.as_deref().unwrap_or("No notifications");
2930        html.push_str(&format!(
2931            "<p class=\"p-4 text-sm text-text-muted\">{}</p>",
2932            html_escape(empty)
2933        ));
2934    } else {
2935        html.push_str("<ul class=\"divide-y divide-border\">");
2936        for item in &props.notifications {
2937            html.push_str("<li class=\"flex items-start gap-3 p-3\">");
2938            if let Some(ref icon) = item.icon {
2939                html.push_str(&format!(
2940                    "<span class=\"text-lg shrink-0\">{}</span>",
2941                    html_escape(icon)
2942                ));
2943            }
2944            html.push_str("<div class=\"flex-1 min-w-0\">");
2945            if let Some(ref url) = item.action_url {
2946                html.push_str(&format!(
2947                    "<a href=\"{}\" class=\"text-sm text-text hover:underline\">{}</a>",
2948                    html_escape(url),
2949                    html_escape(&item.text)
2950                ));
2951            } else {
2952                html.push_str(&format!(
2953                    "<p class=\"text-sm text-text\">{}</p>",
2954                    html_escape(&item.text)
2955                ));
2956            }
2957            if let Some(ref ts) = item.timestamp {
2958                html.push_str(&format!(
2959                    "<p class=\"text-xs text-text-muted mt-0.5\">{}</p>",
2960                    html_escape(ts)
2961                ));
2962            }
2963            html.push_str("</div>");
2964            if !item.read {
2965                html.push_str(
2966                    "<span class=\"h-2 w-2 mt-1 shrink-0 rounded-full bg-primary\"></span>",
2967                );
2968            }
2969            html.push_str("</li>");
2970        }
2971        html.push_str("</ul>");
2972    }
2973    html.push_str("</div></div>");
2974    html
2975}
2976
2977fn render_sidebar(props: &SidebarProps) -> String {
2978    let mut html =
2979        String::from("<aside class=\"flex flex-col h-full bg-background border-r border-border\">");
2980    // Fixed top items.
2981    if !props.fixed_top.is_empty() {
2982        html.push_str("<nav class=\"p-4 space-y-1\">");
2983        for item in &props.fixed_top {
2984            html.push_str(&render_sidebar_nav_item(item));
2985        }
2986        html.push_str("</nav>");
2987    }
2988    // Groups.
2989    if !props.groups.is_empty() {
2990        html.push_str("<div class=\"flex-1 overflow-y-auto p-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
2991        for group in &props.groups {
2992            html.push_str("<div data-sidebar-group");
2993            if group.collapsed {
2994                html.push_str(" data-collapsed");
2995            }
2996            html.push('>');
2997            html.push_str(&format!(
2998                "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wider\">{}</p>",
2999                html_escape(&group.label)
3000            ));
3001            html.push_str("<nav class=\"space-y-1\">");
3002            for item in &group.items {
3003                html.push_str(&render_sidebar_nav_item(item));
3004            }
3005            html.push_str("</nav></div>");
3006        }
3007        html.push_str("</div>");
3008    }
3009    // Fixed bottom items.
3010    if !props.fixed_bottom.is_empty() {
3011        html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
3012        for item in &props.fixed_bottom {
3013            html.push_str(&render_sidebar_nav_item(item));
3014        }
3015        html.push_str("</nav>");
3016    }
3017    html.push_str("</aside>");
3018    html
3019}
3020
3021fn render_sidebar_nav_item(item: &crate::component::SidebarNavItem) -> String {
3022    let classes = if item.active {
3023        "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
3024    } else {
3025        "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
3026    };
3027    let mut html = format!(
3028        "<a href=\"{}\" class=\"{}\">",
3029        html_escape(&item.href),
3030        classes
3031    );
3032    if let Some(ref icon) = item.icon {
3033        html.push_str(&format!(
3034            "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" // raw SVG
3035        ));
3036    }
3037    html.push_str(&format!("{}</a>", html_escape(&item.label)));
3038    html
3039}
3040
3041fn render_header(props: &HeaderProps) -> String {
3042    let mut html = String::from(
3043        "<header class=\"relative flex items-center justify-between px-6 py-4 bg-background border-b border-border\">",
3044    );
3045    // Left spacer keeps justify-between layout intact.
3046    html.push_str("<div></div>");
3047    // Business name — absolutely centered relative to header, independent of
3048    // surrounding elements.
3049    html.push_str(&format!(
3050        "<span class=\"absolute left-1/2 -translate-x-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
3051        html_escape(&props.business_name)
3052    ));
3053    html.push_str("<div class=\"flex items-center gap-4\">");
3054    // Notification bell with count badge.
3055    if let Some(count) = props.notification_count {
3056        if count > 0 {
3057            html.push_str(&format!(
3058                "<div class=\"relative\"><span class=\"text-text-muted\">{BELL_SVG}</span><span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\" data-notification-count=\"{count}\">{count}</span></div>"
3059            ));
3060        } else {
3061            html.push_str(&format!(
3062                "<span class=\"text-text-muted\" data-notification-count=\"{count}\">{BELL_SVG}</span>"
3063            ));
3064        }
3065    }
3066    // User section.
3067    html.push_str("<div class=\"flex items-center gap-2\">");
3068    if let Some(ref avatar) = props.user_avatar {
3069        html.push_str(&format!(
3070            "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
3071            html_escape(avatar)
3072        ));
3073    } else if let Some(ref name) = props.user_name {
3074        let initials: String = name
3075            .split_whitespace()
3076            .filter_map(|w| w.chars().next())
3077            .take(2)
3078            .collect();
3079        html.push_str(&format!(
3080            "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full bg-card text-text-muted text-sm font-medium\">{}</span>",
3081            html_escape(&initials)
3082        ));
3083        html.push_str(&format!(
3084            "<span class=\"text-sm text-text\">{}</span>",
3085            html_escape(name)
3086        ));
3087    }
3088    if let Some(ref logout) = props.logout_url {
3089        html.push_str(&format!(
3090            "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text\">Logout</a>",
3091            html_escape(logout)
3092        ));
3093    }
3094    html.push_str("</div></div></header>");
3095    html
3096}
3097
3098// ── HTML escaping ───────────────────────────────────────────────────────
3099
3100/// Escape special HTML characters to prevent XSS.
3101pub(crate) fn html_escape(s: &str) -> String {
3102    let mut escaped = String::with_capacity(s.len());
3103    for c in s.chars() {
3104        match c {
3105            '&' => escaped.push_str("&amp;"),
3106            '<' => escaped.push_str("&lt;"),
3107            '>' => escaped.push_str("&gt;"),
3108            '"' => escaped.push_str("&quot;"),
3109            '\'' => escaped.push_str("&#x27;"),
3110            _ => escaped.push(c),
3111        }
3112    }
3113    escaped
3114}
3115
3116#[cfg(test)]
3117mod tests {
3118    use super::*;
3119    use crate::action::{Action, HttpMethod};
3120    use crate::component::*;
3121    use serde_json::json;
3122
3123    // ── Helpers ─────────────────────────────────────────────────────────
3124
3125    fn text_node(key: &str, content: &str, element: TextElement) -> ComponentNode {
3126        ComponentNode {
3127            key: key.to_string(),
3128            component: Component::Text(TextProps {
3129                content: content.to_string(),
3130                element,
3131            }),
3132            action: None,
3133            visibility: None,
3134        }
3135    }
3136
3137    fn button_node(key: &str, label: &str, variant: ButtonVariant, size: Size) -> ComponentNode {
3138        ComponentNode {
3139            key: key.to_string(),
3140            component: Component::Button(ButtonProps {
3141                label: label.to_string(),
3142                variant,
3143                size,
3144                disabled: None,
3145                icon: None,
3146                icon_position: None,
3147                button_type: None,
3148            }),
3149            action: None,
3150            visibility: None,
3151        }
3152    }
3153
3154    fn make_action(handler: &str, method: HttpMethod) -> Action {
3155        Action {
3156            handler: handler.to_string(),
3157            url: None,
3158            method,
3159            confirm: None,
3160            on_success: None,
3161            on_error: None,
3162            target: None,
3163        }
3164    }
3165
3166    fn make_action_with_url(handler: &str, method: HttpMethod, url: &str) -> Action {
3167        Action {
3168            handler: handler.to_string(),
3169            url: Some(url.to_string()),
3170            method,
3171            confirm: None,
3172            on_success: None,
3173            on_error: None,
3174            target: None,
3175        }
3176    }
3177
3178    // ── 1. render_to_html produces wrapper div ──────────────────────────
3179
3180    #[test]
3181    fn render_empty_view_produces_wrapper_div() {
3182        let view = JsonUiView::new();
3183        let html = render_to_html(&view, &json!({}));
3184        assert_eq!(html, "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\"></div>");
3185    }
3186
3187    #[test]
3188    fn render_view_with_component_wraps_in_div() {
3189        let view = JsonUiView::new().component(text_node("t", "Hello", TextElement::P));
3190        let html = render_to_html(&view, &json!({}));
3191        assert!(html.starts_with(
3192            "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">"
3193        ));
3194        assert!(html.ends_with("</div>"));
3195        assert!(html.contains("<p class=\"text-base leading-relaxed text-text\">Hello</p>"));
3196    }
3197
3198    // ── 2. Text variants ────────────────────────────────────────────────
3199
3200    #[test]
3201    fn text_p_variant() {
3202        let view = JsonUiView::new().component(text_node("t", "Paragraph", TextElement::P));
3203        let html = render_to_html(&view, &json!({}));
3204        assert!(html.contains("<p class=\"text-base leading-relaxed text-text\">Paragraph</p>"));
3205    }
3206
3207    #[test]
3208    fn text_h1_variant() {
3209        let view = JsonUiView::new().component(text_node("t", "Title", TextElement::H1));
3210        let html = render_to_html(&view, &json!({}));
3211        assert!(html.contains(
3212            "<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">Title</h1>"
3213        ));
3214    }
3215
3216    #[test]
3217    fn text_h2_variant() {
3218        let view = JsonUiView::new().component(text_node("t", "Subtitle", TextElement::H2));
3219        let html = render_to_html(&view, &json!({}));
3220        assert!(html.contains(
3221            "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">Subtitle</h2>"
3222        ));
3223    }
3224
3225    #[test]
3226    fn text_h3_variant() {
3227        let view = JsonUiView::new().component(text_node("t", "Section", TextElement::H3));
3228        let html = render_to_html(&view, &json!({}));
3229        assert!(html
3230            .contains("<h3 class=\"text-xl font-semibold leading-snug text-text\">Section</h3>"));
3231    }
3232
3233    #[test]
3234    fn text_span_variant() {
3235        let view = JsonUiView::new().component(text_node("t", "Inline", TextElement::Span));
3236        let html = render_to_html(&view, &json!({}));
3237        assert!(html.contains("<span class=\"text-base text-text\">Inline</span>"));
3238    }
3239
3240    // ── 3. Button variants ──────────────────────────────────────────────
3241
3242    #[test]
3243    fn button_default_variant() {
3244        let view = JsonUiView::new().component(button_node(
3245            "b",
3246            "Click",
3247            ButtonVariant::Default,
3248            Size::Default,
3249        ));
3250        let html = render_to_html(&view, &json!({}));
3251        assert!(html.contains("bg-primary text-primary-foreground hover:bg-primary/90"));
3252        assert!(html.contains(">Click</button>"));
3253    }
3254
3255    #[test]
3256    fn button_secondary_variant() {
3257        let view = JsonUiView::new().component(button_node(
3258            "b",
3259            "Click",
3260            ButtonVariant::Secondary,
3261            Size::Default,
3262        ));
3263        let html = render_to_html(&view, &json!({}));
3264        assert!(html.contains("bg-secondary text-secondary-foreground hover:bg-secondary/90"));
3265    }
3266
3267    #[test]
3268    fn button_destructive_variant() {
3269        let view = JsonUiView::new().component(button_node(
3270            "b",
3271            "Delete",
3272            ButtonVariant::Destructive,
3273            Size::Default,
3274        ));
3275        let html = render_to_html(&view, &json!({}));
3276        assert!(html.contains("bg-destructive text-primary-foreground hover:bg-destructive/90"));
3277    }
3278
3279    #[test]
3280    fn button_outline_variant() {
3281        let view = JsonUiView::new().component(button_node(
3282            "b",
3283            "Click",
3284            ButtonVariant::Outline,
3285            Size::Default,
3286        ));
3287        let html = render_to_html(&view, &json!({}));
3288        assert!(html.contains("border border-border bg-background text-text hover:bg-surface"));
3289    }
3290
3291    #[test]
3292    fn button_ghost_variant() {
3293        let view = JsonUiView::new().component(button_node(
3294            "b",
3295            "Click",
3296            ButtonVariant::Ghost,
3297            Size::Default,
3298        ));
3299        let html = render_to_html(&view, &json!({}));
3300        assert!(html.contains("text-text hover:bg-surface"));
3301        assert!(html.contains("py-2 text-sm"));
3302        assert!(!html.contains("px-4"));
3303    }
3304
3305    #[test]
3306    fn button_ghost_omits_horizontal_padding_across_sizes() {
3307        for size in [Size::Xs, Size::Sm, Size::Default, Size::Lg] {
3308            let view =
3309                JsonUiView::new().component(button_node("b", "G", ButtonVariant::Ghost, size));
3310            let html = render_to_html(&view, &json!({}));
3311            assert!(!html.contains("px-2"), "ghost must not set px-2");
3312            assert!(!html.contains("px-3"), "ghost must not set px-3");
3313            assert!(!html.contains("px-4"), "ghost must not set px-4");
3314            assert!(!html.contains("px-6"), "ghost must not set px-6");
3315        }
3316    }
3317
3318    #[test]
3319    fn button_link_variant() {
3320        let view = JsonUiView::new().component(button_node(
3321            "b",
3322            "Click",
3323            ButtonVariant::Link,
3324            Size::Default,
3325        ));
3326        let html = render_to_html(&view, &json!({}));
3327        assert!(html.contains("text-primary underline hover:text-primary/80"));
3328    }
3329
3330    #[test]
3331    fn button_disabled_state() {
3332        let view = JsonUiView::new().component(ComponentNode {
3333            key: "b".to_string(),
3334            component: Component::Button(ButtonProps {
3335                label: "Disabled".to_string(),
3336                variant: ButtonVariant::Default,
3337                size: Size::Default,
3338                disabled: Some(true),
3339                icon: None,
3340                icon_position: None,
3341                button_type: None,
3342            }),
3343            action: None,
3344            visibility: None,
3345        });
3346        let html = render_to_html(&view, &json!({}));
3347        assert!(html.contains("opacity-50 cursor-not-allowed"));
3348        assert!(html.contains(" disabled"));
3349    }
3350
3351    #[test]
3352    fn button_with_icon_left() {
3353        let view = JsonUiView::new().component(ComponentNode {
3354            key: "b".to_string(),
3355            component: Component::Button(ButtonProps {
3356                label: "Save".to_string(),
3357                variant: ButtonVariant::Default,
3358                size: Size::Default,
3359                disabled: None,
3360                icon: Some("save".to_string()),
3361                icon_position: Some(IconPosition::Left),
3362                button_type: None,
3363            }),
3364            action: None,
3365            visibility: None,
3366        });
3367        let html = render_to_html(&view, &json!({}));
3368        assert!(html.contains("data-icon=\"save\""));
3369        // Icon span comes before label.
3370        let icon_pos = html.find("data-icon").unwrap();
3371        let label_pos = html.find("Save").unwrap();
3372        assert!(icon_pos < label_pos);
3373    }
3374
3375    #[test]
3376    fn button_with_icon_right() {
3377        let view = JsonUiView::new().component(ComponentNode {
3378            key: "b".to_string(),
3379            component: Component::Button(ButtonProps {
3380                label: "Next".to_string(),
3381                variant: ButtonVariant::Default,
3382                size: Size::Default,
3383                disabled: None,
3384                icon: Some("arrow-right".to_string()),
3385                icon_position: Some(IconPosition::Right),
3386                button_type: None,
3387            }),
3388            action: None,
3389            visibility: None,
3390        });
3391        let html = render_to_html(&view, &json!({}));
3392        assert!(html.contains("data-icon=\"arrow-right\""));
3393        // Label comes before icon span.
3394        let label_pos = html.find("Next").unwrap();
3395        let icon_pos = html.find("data-icon").unwrap();
3396        assert!(label_pos < icon_pos);
3397    }
3398
3399    // ── 4. Button sizes ─────────────────────────────────────────────────
3400
3401    #[test]
3402    fn button_size_xs() {
3403        let view =
3404            JsonUiView::new().component(button_node("b", "X", ButtonVariant::Default, Size::Xs));
3405        let html = render_to_html(&view, &json!({}));
3406        assert!(html.contains("px-2 py-1 text-xs"));
3407    }
3408
3409    #[test]
3410    fn button_size_sm() {
3411        let view =
3412            JsonUiView::new().component(button_node("b", "S", ButtonVariant::Default, Size::Sm));
3413        let html = render_to_html(&view, &json!({}));
3414        assert!(html.contains("px-3 py-1.5 text-sm"));
3415    }
3416
3417    #[test]
3418    fn button_size_default() {
3419        let view = JsonUiView::new().component(button_node(
3420            "b",
3421            "D",
3422            ButtonVariant::Default,
3423            Size::Default,
3424        ));
3425        let html = render_to_html(&view, &json!({}));
3426        assert!(html.contains("px-4 py-2 text-sm"));
3427    }
3428
3429    #[test]
3430    fn button_size_lg() {
3431        let view =
3432            JsonUiView::new().component(button_node("b", "L", ButtonVariant::Default, Size::Lg));
3433        let html = render_to_html(&view, &json!({}));
3434        assert!(html.contains("px-6 py-3 text-base"));
3435    }
3436
3437    // ── 5. Badge variants ───────────────────────────────────────────────
3438
3439    #[test]
3440    fn badge_default_variant() {
3441        let view = JsonUiView::new().component(ComponentNode {
3442            key: "bg".to_string(),
3443            component: Component::Badge(BadgeProps {
3444                label: "New".to_string(),
3445                variant: BadgeVariant::Default,
3446            }),
3447            action: None,
3448            visibility: None,
3449        });
3450        let html = render_to_html(&view, &json!({}));
3451        assert!(html.contains("bg-primary/10 text-primary"));
3452        assert!(html.contains(">New</span>"));
3453    }
3454
3455    #[test]
3456    fn badge_secondary_variant() {
3457        let view = JsonUiView::new().component(ComponentNode {
3458            key: "bg".to_string(),
3459            component: Component::Badge(BadgeProps {
3460                label: "Draft".to_string(),
3461                variant: BadgeVariant::Secondary,
3462            }),
3463            action: None,
3464            visibility: None,
3465        });
3466        let html = render_to_html(&view, &json!({}));
3467        assert!(html.contains("bg-secondary/10 text-secondary-foreground"));
3468    }
3469
3470    #[test]
3471    fn badge_destructive_variant() {
3472        let view = JsonUiView::new().component(ComponentNode {
3473            key: "bg".to_string(),
3474            component: Component::Badge(BadgeProps {
3475                label: "Deleted".to_string(),
3476                variant: BadgeVariant::Destructive,
3477            }),
3478            action: None,
3479            visibility: None,
3480        });
3481        let html = render_to_html(&view, &json!({}));
3482        assert!(html.contains("bg-destructive/10 text-destructive"));
3483    }
3484
3485    #[test]
3486    fn badge_outline_variant() {
3487        let view = JsonUiView::new().component(ComponentNode {
3488            key: "bg".to_string(),
3489            component: Component::Badge(BadgeProps {
3490                label: "Info".to_string(),
3491                variant: BadgeVariant::Outline,
3492            }),
3493            action: None,
3494            visibility: None,
3495        });
3496        let html = render_to_html(&view, &json!({}));
3497        assert!(html.contains("border border-border text-text"));
3498    }
3499
3500    #[test]
3501    fn badge_has_base_classes() {
3502        let view = JsonUiView::new().component(ComponentNode {
3503            key: "bg".to_string(),
3504            component: Component::Badge(BadgeProps {
3505                label: "Test".to_string(),
3506                variant: BadgeVariant::Default,
3507            }),
3508            action: None,
3509            visibility: None,
3510        });
3511        let html = render_to_html(&view, &json!({}));
3512        assert!(html
3513            .contains("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"));
3514    }
3515
3516    // ── 6. Alert variants ───────────────────────────────────────────────
3517
3518    #[test]
3519    fn alert_info_variant() {
3520        let view = JsonUiView::new().component(ComponentNode {
3521            key: "a".to_string(),
3522            component: Component::Alert(AlertProps {
3523                message: "Info message".to_string(),
3524                variant: AlertVariant::Info,
3525                title: None,
3526            }),
3527            action: None,
3528            visibility: None,
3529        });
3530        let html = render_to_html(&view, &json!({}));
3531        assert!(html.contains("bg-primary/10 border-primary text-primary"));
3532        assert!(html.contains("role=\"alert\""));
3533        assert!(html.contains("<p>Info message</p>"));
3534    }
3535
3536    #[test]
3537    fn alert_success_variant() {
3538        let view = JsonUiView::new().component(ComponentNode {
3539            key: "a".to_string(),
3540            component: Component::Alert(AlertProps {
3541                message: "Done".to_string(),
3542                variant: AlertVariant::Success,
3543                title: None,
3544            }),
3545            action: None,
3546            visibility: None,
3547        });
3548        let html = render_to_html(&view, &json!({}));
3549        assert!(html.contains("bg-success/10 border-success text-success"));
3550    }
3551
3552    #[test]
3553    fn alert_warning_variant() {
3554        let view = JsonUiView::new().component(ComponentNode {
3555            key: "a".to_string(),
3556            component: Component::Alert(AlertProps {
3557                message: "Careful".to_string(),
3558                variant: AlertVariant::Warning,
3559                title: None,
3560            }),
3561            action: None,
3562            visibility: None,
3563        });
3564        let html = render_to_html(&view, &json!({}));
3565        assert!(html.contains("bg-warning/10 border-warning text-warning"));
3566    }
3567
3568    #[test]
3569    fn alert_error_variant() {
3570        let view = JsonUiView::new().component(ComponentNode {
3571            key: "a".to_string(),
3572            component: Component::Alert(AlertProps {
3573                message: "Failed".to_string(),
3574                variant: AlertVariant::Error,
3575                title: None,
3576            }),
3577            action: None,
3578            visibility: None,
3579        });
3580        let html = render_to_html(&view, &json!({}));
3581        assert!(html.contains("bg-destructive/10 border-destructive text-destructive"));
3582    }
3583
3584    #[test]
3585    fn alert_with_title() {
3586        let view = JsonUiView::new().component(ComponentNode {
3587            key: "a".to_string(),
3588            component: Component::Alert(AlertProps {
3589                message: "Details here".to_string(),
3590                variant: AlertVariant::Warning,
3591                title: Some("Warning".to_string()),
3592            }),
3593            action: None,
3594            visibility: None,
3595        });
3596        let html = render_to_html(&view, &json!({}));
3597        assert!(html.contains("<h4 class=\"font-semibold mb-1\">Warning</h4>"));
3598        assert!(html.contains("<p>Details here</p>"));
3599    }
3600
3601    #[test]
3602    fn alert_without_title() {
3603        let view = JsonUiView::new().component(ComponentNode {
3604            key: "a".to_string(),
3605            component: Component::Alert(AlertProps {
3606                message: "No title".to_string(),
3607                variant: AlertVariant::Info,
3608                title: None,
3609            }),
3610            action: None,
3611            visibility: None,
3612        });
3613        let html = render_to_html(&view, &json!({}));
3614        assert!(!html.contains("<h4"));
3615    }
3616
3617    // ── 7. Separator orientations ───────────────────────────────────────
3618
3619    #[test]
3620    fn separator_horizontal() {
3621        let view = JsonUiView::new().component(ComponentNode {
3622            key: "s".to_string(),
3623            component: Component::Separator(SeparatorProps {
3624                orientation: Some(Orientation::Horizontal),
3625            }),
3626            action: None,
3627            visibility: None,
3628        });
3629        let html = render_to_html(&view, &json!({}));
3630        assert!(html.contains("<hr class=\"my-4 border-border\">"));
3631    }
3632
3633    #[test]
3634    fn separator_vertical() {
3635        let view = JsonUiView::new().component(ComponentNode {
3636            key: "s".to_string(),
3637            component: Component::Separator(SeparatorProps {
3638                orientation: Some(Orientation::Vertical),
3639            }),
3640            action: None,
3641            visibility: None,
3642        });
3643        let html = render_to_html(&view, &json!({}));
3644        assert!(html.contains("<div class=\"mx-4 h-full w-px bg-border\"></div>"));
3645    }
3646
3647    #[test]
3648    fn separator_default_is_horizontal() {
3649        let view = JsonUiView::new().component(ComponentNode {
3650            key: "s".to_string(),
3651            component: Component::Separator(SeparatorProps { orientation: None }),
3652            action: None,
3653            visibility: None,
3654        });
3655        let html = render_to_html(&view, &json!({}));
3656        assert!(html.contains("<hr"));
3657    }
3658
3659    // ── 8. Progress ─────────────────────────────────────────────────────
3660
3661    #[test]
3662    fn progress_renders_bar() {
3663        let view = JsonUiView::new().component(ComponentNode {
3664            key: "p".to_string(),
3665            component: Component::Progress(ProgressProps {
3666                value: 50,
3667                max: None,
3668                label: None,
3669            }),
3670            action: None,
3671            visibility: None,
3672        });
3673        let html = render_to_html(&view, &json!({}));
3674        assert!(html.contains("style=\"width: 50%\""));
3675        assert!(html.contains("bg-primary h-2.5"));
3676    }
3677
3678    #[test]
3679    fn progress_with_label() {
3680        let view = JsonUiView::new().component(ComponentNode {
3681            key: "p".to_string(),
3682            component: Component::Progress(ProgressProps {
3683                value: 75,
3684                max: None,
3685                label: Some("Uploading...".to_string()),
3686            }),
3687            action: None,
3688            visibility: None,
3689        });
3690        let html = render_to_html(&view, &json!({}));
3691        assert!(html.contains("Uploading..."));
3692        assert!(html.contains("text-sm text-text-muted"));
3693    }
3694
3695    #[test]
3696    fn progress_with_custom_max() {
3697        let view = JsonUiView::new().component(ComponentNode {
3698            key: "p".to_string(),
3699            component: Component::Progress(ProgressProps {
3700                value: 25,
3701                max: Some(50),
3702                label: None,
3703            }),
3704            action: None,
3705            visibility: None,
3706        });
3707        let html = render_to_html(&view, &json!({}));
3708        // 25/50 = 50%
3709        assert!(html.contains("style=\"width: 50%\""));
3710    }
3711
3712    // ── 9. Avatar ───────────────────────────────────────────────────────
3713
3714    #[test]
3715    fn avatar_with_src() {
3716        let view = JsonUiView::new().component(ComponentNode {
3717            key: "av".to_string(),
3718            component: Component::Avatar(AvatarProps {
3719                src: Some("/img/user.jpg".to_string()),
3720                alt: "User".to_string(),
3721                fallback: None,
3722                size: None,
3723            }),
3724            action: None,
3725            visibility: None,
3726        });
3727        let html = render_to_html(&view, &json!({}));
3728        assert!(html.contains("<img"));
3729        assert!(html.contains("src=\"/img/user.jpg\""));
3730        assert!(html.contains("alt=\"User\""));
3731        assert!(html.contains("rounded-full object-cover"));
3732    }
3733
3734    #[test]
3735    fn avatar_without_src_uses_fallback() {
3736        let view = JsonUiView::new().component(ComponentNode {
3737            key: "av".to_string(),
3738            component: Component::Avatar(AvatarProps {
3739                src: None,
3740                alt: "John Doe".to_string(),
3741                fallback: Some("JD".to_string()),
3742                size: None,
3743            }),
3744            action: None,
3745            visibility: None,
3746        });
3747        let html = render_to_html(&view, &json!({}));
3748        assert!(!html.contains("<img"));
3749        assert!(html.contains("<span"));
3750        assert!(html.contains("bg-card text-text-muted"));
3751        assert!(html.contains(">JD</span>"));
3752    }
3753
3754    #[test]
3755    fn avatar_without_src_or_fallback_uses_alt_initials() {
3756        let view = JsonUiView::new().component(ComponentNode {
3757            key: "av".to_string(),
3758            component: Component::Avatar(AvatarProps {
3759                src: None,
3760                alt: "Alice".to_string(),
3761                fallback: None,
3762                size: Some(Size::Lg),
3763            }),
3764            action: None,
3765            visibility: None,
3766        });
3767        let html = render_to_html(&view, &json!({}));
3768        assert!(html.contains(">Al</span>"));
3769        assert!(html.contains("h-12 w-12 text-base"));
3770    }
3771
3772    // ── Image ────────────────────────────────────────────────────────────
3773
3774    #[test]
3775    fn image_with_aspect_ratio() {
3776        let view = JsonUiView::new().component(ComponentNode {
3777            key: "img".to_string(),
3778            component: Component::Image(ImageProps {
3779                source: ImageSource::Url {
3780                    src: "/img/page.png".to_string(),
3781                },
3782                alt: "Page".to_string(),
3783                aspect_ratio: Some("16/9".to_string()),
3784                placeholder_label: None,
3785            }),
3786            action: None,
3787            visibility: None,
3788        });
3789        let html = render_to_html(&view, &json!({}));
3790        assert!(html.contains("<img"));
3791        assert!(html.contains("src=\"/img/page.png\""));
3792        assert!(html.contains("alt=\"Page\""));
3793        assert!(html.contains("w-full h-full rounded-md object-cover"));
3794        assert!(html.contains("style=\"aspect-ratio: 16/9\""));
3795        assert!(html.contains("loading=\"lazy\""));
3796    }
3797
3798    #[test]
3799    fn image_without_aspect_ratio_omits_style() {
3800        let view = JsonUiView::new().component(ComponentNode {
3801            key: "img".to_string(),
3802            component: Component::Image(ImageProps::url("/img/page.png", "Page")),
3803            action: None,
3804            visibility: None,
3805        });
3806        let html = render_to_html(&view, &json!({}));
3807        assert!(!html.contains("style="));
3808        assert!(html.contains("loading=\"lazy\""));
3809    }
3810
3811    #[test]
3812    fn image_xss_src_escaped() {
3813        let view = JsonUiView::new().component(ComponentNode {
3814            key: "img".to_string(),
3815            component: Component::Image(ImageProps::url("x\" onerror=\"alert(1)", "Test")),
3816            action: None,
3817            visibility: None,
3818        });
3819        let html = render_to_html(&view, &json!({}));
3820        assert!(html.contains("src=\"x&quot; onerror=&quot;alert(1)\""));
3821    }
3822
3823    #[test]
3824    fn inline_svg_renders_div_role_img() {
3825        let view = JsonUiView::new().component(ComponentNode::image(
3826            "chart",
3827            ImageProps::inline_svg("<svg><rect/></svg>", "Revenue chart"),
3828        ));
3829        let html = render_to_html(&view, &json!({}));
3830        assert!(
3831            html.contains("<div role=\"img\""),
3832            "InlineSvg branch must emit <div role=\"img\">: {html}"
3833        );
3834        assert!(
3835            html.contains("aria-label=\"Revenue chart\""),
3836            "InlineSvg branch must emit aria-label from alt: {html}"
3837        );
3838        assert!(
3839            html.contains("<svg><rect/></svg>"),
3840            "InlineSvg branch must emit the svg body verbatim: {html}"
3841        );
3842        assert!(
3843            !html.contains("<img"),
3844            "InlineSvg branch must NOT emit an <img> tag: {html}"
3845        );
3846    }
3847
3848    // LOAD-BEARING: This test documents the deliberate html_escape bypass on the
3849    // svg body. If this test ever fails by producing the &lt;script&gt; escaped
3850    // form, the Plan 02 implementation has regressed the contract defined at
3851    // 148-CONTEXT.md D-06. The asymmetry (svg body unescaped, alt attribute
3852    // escaped) is intentional and scoped — the rustdoc on
3853    // ImageSource::InlineSvg and ImageProps::inline_svg states the bypass; this
3854    // test executes it. Reviewers: do NOT "fix" this test by escaping the svg
3855    // body — that would silently revert the phase-148 design decision.
3856    #[test]
3857    fn inline_svg_with_script_passes_through() {
3858        let svg = "<svg><script>alert(1)</script></svg>";
3859        let view = JsonUiView::new().component(ComponentNode::image(
3860            "chart",
3861            ImageProps::inline_svg(svg, "Chart"),
3862        ));
3863        let html = render_to_html(&view, &json!({}));
3864        assert!(
3865            html.contains("<script>alert(1)</script>"),
3866            "svg body must pass through VERBATIM without escaping — see D-06: {html}"
3867        );
3868        assert!(
3869            !html.contains("&lt;script&gt;"),
3870            "svg body must NOT be html_escape'd — see D-06: {html}"
3871        );
3872    }
3873
3874    #[test]
3875    fn inline_svg_alt_xss_escaped() {
3876        // alt IS escaped on both source variants (D-06) — mirrors
3877        // image_xss_src_escaped's injection-string pattern for symmetry.
3878        let view = JsonUiView::new().component(ComponentNode::image(
3879            "chart",
3880            ImageProps::inline_svg("<svg/>", "\" onload=\"alert(1)"),
3881        ));
3882        let html = render_to_html(&view, &json!({}));
3883        assert!(
3884            html.contains("aria-label=\"&quot; onload=&quot;alert(1)\""),
3885            "alt MUST be html_escape'd on the InlineSvg branch: {html}"
3886        );
3887    }
3888
3889    // ── 10. Skeleton ────────────────────────────────────────────────────
3890
3891    #[test]
3892    fn skeleton_default() {
3893        let view = JsonUiView::new().component(ComponentNode {
3894            key: "sk".to_string(),
3895            component: Component::Skeleton(SkeletonProps {
3896                width: None,
3897                height: None,
3898                rounded: None,
3899            }),
3900            action: None,
3901            visibility: None,
3902        });
3903        let html = render_to_html(&view, &json!({}));
3904        assert!(html.contains("ferro-shimmer"));
3905        assert!(html.contains("rounded-md"));
3906        assert!(html.contains("width: 100%"));
3907        assert!(html.contains("height: 1rem"));
3908    }
3909
3910    #[test]
3911    fn skeleton_custom_dimensions() {
3912        let view = JsonUiView::new().component(ComponentNode {
3913            key: "sk".to_string(),
3914            component: Component::Skeleton(SkeletonProps {
3915                width: Some("200px".to_string()),
3916                height: Some("40px".to_string()),
3917                rounded: Some(true),
3918            }),
3919            action: None,
3920            visibility: None,
3921        });
3922        let html = render_to_html(&view, &json!({}));
3923        assert!(html.contains("rounded-full"));
3924        assert!(html.contains("width: 200px"));
3925        assert!(html.contains("height: 40px"));
3926    }
3927
3928    // ── 11. Breadcrumb ──────────────────────────────────────────────────
3929
3930    #[test]
3931    fn breadcrumb_items_with_links() {
3932        let view = JsonUiView::new().component(ComponentNode {
3933            key: "bc".to_string(),
3934            component: Component::Breadcrumb(BreadcrumbProps {
3935                items: vec![
3936                    BreadcrumbItem {
3937                        label: "Home".to_string(),
3938                        url: Some("/".to_string()),
3939                    },
3940                    BreadcrumbItem {
3941                        label: "Users".to_string(),
3942                        url: Some("/users".to_string()),
3943                    },
3944                    BreadcrumbItem {
3945                        label: "Edit".to_string(),
3946                        url: None,
3947                    },
3948                ],
3949            }),
3950            action: None,
3951            visibility: None,
3952        });
3953        let html = render_to_html(&view, &json!({}));
3954        assert!(html.contains("<nav"));
3955        assert!(
3956            html.contains("<a href=\"/\""),
3957            "breadcrumb Home link should exist"
3958        );
3959        assert!(
3960            html.contains(">Home</a>"),
3961            "breadcrumb Home label should exist"
3962        );
3963        assert!(
3964            html.contains("<a href=\"/users\""),
3965            "breadcrumb Users link should exist"
3966        );
3967        assert!(
3968            html.contains(">Users</a>"),
3969            "breadcrumb Users label should exist"
3970        );
3971        // Last item is plain span, not a link.
3972        assert!(html.contains("<span class=\"text-text font-medium\">Edit</span>"));
3973        // Separators between items — SVG chevrons.
3974        assert!(html.contains("<svg"));
3975    }
3976
3977    #[test]
3978    fn breadcrumb_single_item() {
3979        let view = JsonUiView::new().component(ComponentNode {
3980            key: "bc".to_string(),
3981            component: Component::Breadcrumb(BreadcrumbProps {
3982                items: vec![BreadcrumbItem {
3983                    label: "Home".to_string(),
3984                    url: Some("/".to_string()),
3985                }],
3986            }),
3987            action: None,
3988            visibility: None,
3989        });
3990        let html = render_to_html(&view, &json!({}));
3991        // Single item is the last item, rendered as font-medium span.
3992        assert!(html.contains("<span class=\"text-text font-medium\">Home</span>"));
3993        // No separator.
3994        assert!(!html.contains("<span>/</span>"));
3995    }
3996
3997    // ── 12. Pagination ──────────────────────────────────────────────────
3998
3999    #[test]
4000    fn pagination_renders_page_links() {
4001        let view = JsonUiView::new().component(ComponentNode {
4002            key: "pg".to_string(),
4003            component: Component::Pagination(PaginationProps {
4004                current_page: 2,
4005                per_page: 10,
4006                total: 50,
4007                base_url: None,
4008            }),
4009            action: None,
4010            visibility: None,
4011        });
4012        let html = render_to_html(&view, &json!({}));
4013        assert!(html.contains("<nav"));
4014        // Current page has active class.
4015        assert!(html.contains("bg-primary text-primary-foreground\">2</span>"));
4016        // Other pages are links.
4017        assert!(html.contains("?page=1"));
4018        assert!(html.contains("?page=3"));
4019    }
4020
4021    #[test]
4022    fn pagination_single_page_produces_no_output() {
4023        let view = JsonUiView::new().component(ComponentNode {
4024            key: "pg".to_string(),
4025            component: Component::Pagination(PaginationProps {
4026                current_page: 1,
4027                per_page: 10,
4028                total: 5,
4029                base_url: None,
4030            }),
4031            action: None,
4032            visibility: None,
4033        });
4034        let html = render_to_html(&view, &json!({}));
4035        // Single page: no nav rendered.
4036        assert!(!html.contains("<nav"));
4037    }
4038
4039    #[test]
4040    fn pagination_prev_and_next_buttons() {
4041        let view = JsonUiView::new().component(ComponentNode {
4042            key: "pg".to_string(),
4043            component: Component::Pagination(PaginationProps {
4044                current_page: 3,
4045                per_page: 10,
4046                total: 100,
4047                base_url: None,
4048            }),
4049            action: None,
4050            visibility: None,
4051        });
4052        let html = render_to_html(&view, &json!({}));
4053        // Prev button.
4054        assert!(html.contains("?page=2"));
4055        // Next button.
4056        assert!(html.contains("?page=4"));
4057    }
4058
4059    #[test]
4060    fn pagination_no_prev_on_first_page() {
4061        let view = JsonUiView::new().component(ComponentNode {
4062            key: "pg".to_string(),
4063            component: Component::Pagination(PaginationProps {
4064                current_page: 1,
4065                per_page: 10,
4066                total: 30,
4067                base_url: None,
4068            }),
4069            action: None,
4070            visibility: None,
4071        });
4072        let html = render_to_html(&view, &json!({}));
4073        // Should not have prev link (&laquo;).
4074        assert!(!html.contains("&laquo;"));
4075        // Should have next link.
4076        assert!(html.contains("&raquo;"));
4077    }
4078
4079    #[test]
4080    fn pagination_custom_base_url() {
4081        let view = JsonUiView::new().component(ComponentNode {
4082            key: "pg".to_string(),
4083            component: Component::Pagination(PaginationProps {
4084                current_page: 1,
4085                per_page: 10,
4086                total: 30,
4087                base_url: Some("/users?sort=name&".to_string()),
4088            }),
4089            action: None,
4090            visibility: None,
4091        });
4092        let html = render_to_html(&view, &json!({}));
4093        assert!(html.contains("/users?sort=name&amp;page=2"));
4094    }
4095
4096    // ── 13. DescriptionList ─────────────────────────────────────────────
4097
4098    #[test]
4099    fn description_list_renders_dl_dt_dd() {
4100        let view = JsonUiView::new().component(ComponentNode {
4101            key: "dl".to_string(),
4102            component: Component::DescriptionList(DescriptionListProps {
4103                items: vec![
4104                    DescriptionItem {
4105                        label: "Name".to_string(),
4106                        value: "Alice".to_string(),
4107                        format: None,
4108                    },
4109                    DescriptionItem {
4110                        label: "Email".to_string(),
4111                        value: "alice@example.com".to_string(),
4112                        format: None,
4113                    },
4114                ],
4115                columns: None,
4116            }),
4117            action: None,
4118            visibility: None,
4119        });
4120        let html = render_to_html(&view, &json!({}));
4121        assert!(html.contains("<dl"));
4122        assert!(html.contains("grid-cols-1"));
4123        assert!(html.contains("<dt class=\"text-sm font-medium text-text-muted\">Name</dt>"));
4124        assert!(html.contains("<dd class=\"mt-1 text-sm text-text\">Alice</dd>"));
4125        assert!(html.contains("<dt class=\"text-sm font-medium text-text-muted\">Email</dt>"));
4126    }
4127
4128    #[test]
4129    fn description_list_with_columns() {
4130        let view = JsonUiView::new().component(ComponentNode {
4131            key: "dl".to_string(),
4132            component: Component::DescriptionList(DescriptionListProps {
4133                items: vec![DescriptionItem {
4134                    label: "Status".to_string(),
4135                    value: "Active".to_string(),
4136                    format: None,
4137                }],
4138                columns: Some(3),
4139            }),
4140            action: None,
4141            visibility: None,
4142        });
4143        let html = render_to_html(&view, &json!({}));
4144        assert!(html.contains("grid-cols-3"));
4145    }
4146
4147    // ── 14. XSS prevention ──────────────────────────────────────────────
4148
4149    #[test]
4150    fn xss_script_tags_escaped_in_text() {
4151        let view = JsonUiView::new().component(text_node(
4152            "t",
4153            "<script>alert('xss')</script>",
4154            TextElement::P,
4155        ));
4156        let html = render_to_html(&view, &json!({}));
4157        assert!(!html.contains("<script>"));
4158        assert!(html.contains("&lt;script&gt;"));
4159        assert!(html.contains("&#x27;"));
4160    }
4161
4162    #[test]
4163    fn xss_quotes_escaped_in_attributes() {
4164        let view = JsonUiView::new().component(ComponentNode {
4165            key: "av".to_string(),
4166            component: Component::Avatar(AvatarProps {
4167                src: Some("x\" onload=\"alert(1)".to_string()),
4168                alt: "Test".to_string(),
4169                fallback: None,
4170                size: None,
4171            }),
4172            action: None,
4173            visibility: None,
4174        });
4175        let html = render_to_html(&view, &json!({}));
4176        // Quotes are escaped so the attacker cannot break out of the attribute.
4177        assert!(html.contains("&quot;"));
4178        // The src attribute value stays intact within quotes (no breakout).
4179        assert!(html.contains("src=\"x&quot; onload=&quot;alert(1)\""));
4180    }
4181
4182    #[test]
4183    fn xss_in_button_label() {
4184        let view = JsonUiView::new().component(ComponentNode {
4185            key: "b".to_string(),
4186            component: Component::Button(ButtonProps {
4187                label: "<img src=x onerror=alert(1)>".to_string(),
4188                variant: ButtonVariant::Default,
4189                size: Size::Default,
4190                disabled: None,
4191                icon: None,
4192                icon_position: None,
4193                button_type: None,
4194            }),
4195            action: None,
4196            visibility: None,
4197        });
4198        let html = render_to_html(&view, &json!({}));
4199        assert!(!html.contains("<img"));
4200        assert!(html.contains("&lt;img"));
4201    }
4202
4203    #[test]
4204    fn xss_ampersand_in_content() {
4205        let view = JsonUiView::new().component(text_node("t", "Tom & Jerry", TextElement::P));
4206        let html = render_to_html(&view, &json!({}));
4207        assert!(html.contains("Tom &amp; Jerry"));
4208    }
4209
4210    #[test]
4211    fn html_escape_function_covers_all_chars() {
4212        let result = html_escape("&<>\"'normal");
4213        assert_eq!(result, "&amp;&lt;&gt;&quot;&#x27;normal");
4214    }
4215
4216    // ── 15. Action wrapping ─────────────────────────────────────────────
4217
4218    #[test]
4219    fn get_action_wraps_in_anchor() {
4220        let view = JsonUiView::new().component(ComponentNode {
4221            key: "b".to_string(),
4222            component: Component::Button(ButtonProps {
4223                label: "View".to_string(),
4224                variant: ButtonVariant::Default,
4225                size: Size::Default,
4226                disabled: None,
4227                icon: None,
4228                icon_position: None,
4229                button_type: None,
4230            }),
4231            action: Some(make_action_with_url(
4232                "users.show",
4233                HttpMethod::Get,
4234                "/users/1",
4235            )),
4236            visibility: None,
4237        });
4238        let html = render_to_html(&view, &json!({}));
4239        assert!(html.contains("<a href=\"/users/1\" class=\"block\">"));
4240        assert!(html.contains("</a>"));
4241        assert!(html.contains("<button"));
4242    }
4243
4244    #[test]
4245    fn post_action_does_not_wrap_in_anchor() {
4246        let view = JsonUiView::new().component(ComponentNode {
4247            key: "b".to_string(),
4248            component: Component::Button(ButtonProps {
4249                label: "Submit".to_string(),
4250                variant: ButtonVariant::Default,
4251                size: Size::Default,
4252                disabled: None,
4253                icon: None,
4254                icon_position: None,
4255                button_type: None,
4256            }),
4257            action: Some(make_action_with_url(
4258                "users.store",
4259                HttpMethod::Post,
4260                "/users",
4261            )),
4262            visibility: None,
4263        });
4264        let html = render_to_html(&view, &json!({}));
4265        assert!(!html.contains("<a href="));
4266        assert!(html.contains("<button"));
4267    }
4268
4269    #[test]
4270    fn get_action_without_url_does_not_wrap() {
4271        let view = JsonUiView::new().component(ComponentNode {
4272            key: "b".to_string(),
4273            component: Component::Button(ButtonProps {
4274                label: "View".to_string(),
4275                variant: ButtonVariant::Default,
4276                size: Size::Default,
4277                disabled: None,
4278                icon: None,
4279                icon_position: None,
4280                button_type: None,
4281            }),
4282            action: Some(make_action("users.show", HttpMethod::Get)),
4283            visibility: None,
4284        });
4285        let html = render_to_html(&view, &json!({}));
4286        assert!(!html.contains("<a href="));
4287    }
4288
4289    #[test]
4290    fn delete_action_does_not_wrap_in_anchor() {
4291        let view = JsonUiView::new().component(ComponentNode {
4292            key: "b".to_string(),
4293            component: Component::Button(ButtonProps {
4294                label: "Delete".to_string(),
4295                variant: ButtonVariant::Destructive,
4296                size: Size::Default,
4297                disabled: None,
4298                icon: None,
4299                icon_position: None,
4300                button_type: None,
4301            }),
4302            action: Some(make_action_with_url(
4303                "users.destroy",
4304                HttpMethod::Delete,
4305                "/users/1",
4306            )),
4307            visibility: None,
4308        });
4309        let html = render_to_html(&view, &json!({}));
4310        assert!(!html.contains("<a href="));
4311    }
4312
4313    #[test]
4314    fn action_url_is_html_escaped() {
4315        let view = JsonUiView::new().component(ComponentNode {
4316            key: "b".to_string(),
4317            component: Component::Button(ButtonProps {
4318                label: "View".to_string(),
4319                variant: ButtonVariant::Default,
4320                size: Size::Default,
4321                disabled: None,
4322                icon: None,
4323                icon_position: None,
4324                button_type: None,
4325            }),
4326            action: Some(make_action_with_url(
4327                "users.show",
4328                HttpMethod::Get,
4329                "/users?id=1&name=test",
4330            )),
4331            visibility: None,
4332        });
4333        let html = render_to_html(&view, &json!({}));
4334        assert!(html.contains("href=\"/users?id=1&amp;name=test\""));
4335    }
4336
4337    // ── 16. Card ───────────────────────────────────────────────────────
4338
4339    #[test]
4340    fn card_renders_title_and_description() {
4341        let view = JsonUiView::new().component(ComponentNode {
4342            key: "c".to_string(),
4343            component: Component::Card(CardProps {
4344                title: "My Card".to_string(),
4345                description: Some("A description".to_string()),
4346                children: vec![],
4347                footer: vec![],
4348                max_width: None,
4349            }),
4350            action: None,
4351            visibility: None,
4352        });
4353        let html = render_to_html(&view, &json!({}));
4354        assert!(html.contains("rounded-lg border border-border bg-card shadow-sm overflow-visible"));
4355        assert!(html
4356            .contains("<h3 class=\"text-base font-semibold leading-snug text-text\">My Card</h3>"));
4357        assert!(html.contains("<p class=\"mt-1 text-sm text-text-muted\">A description</p>"));
4358    }
4359
4360    #[test]
4361    fn card_renders_children_recursively() {
4362        let view = JsonUiView::new().component(ComponentNode {
4363            key: "c".to_string(),
4364            component: Component::Card(CardProps {
4365                title: "Card".to_string(),
4366                description: None,
4367                children: vec![text_node("t", "Child content", TextElement::P)],
4368                footer: vec![],
4369                max_width: None,
4370            }),
4371            action: None,
4372            visibility: None,
4373        });
4374        let html = render_to_html(&view, &json!({}));
4375        assert!(
4376            html.contains("mt-3 flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible")
4377        );
4378        assert!(html.contains("Child content"));
4379    }
4380
4381    #[test]
4382    fn card_renders_footer() {
4383        let view = JsonUiView::new().component(ComponentNode {
4384            key: "c".to_string(),
4385            component: Component::Card(CardProps {
4386                title: "Card".to_string(),
4387                description: None,
4388                children: vec![],
4389                max_width: None,
4390                footer: vec![button_node(
4391                    "btn",
4392                    "Save",
4393                    ButtonVariant::Default,
4394                    Size::Default,
4395                )],
4396            }),
4397            action: None,
4398            visibility: None,
4399        });
4400        let html = render_to_html(&view, &json!({}));
4401        assert!(html
4402            .contains("border-t border-border px-6 py-4 flex items-center justify-between gap-2"));
4403        assert!(html.contains(">Save</button>"));
4404    }
4405
4406    // ── 17. Modal ──────────────────────────────────────────────────────
4407
4408    #[test]
4409    fn modal_renders_dialog_element() {
4410        let view = JsonUiView::new().component(ComponentNode {
4411            key: "m".to_string(),
4412            component: Component::Modal(ModalProps {
4413                id: "modal-confirm".to_string(),
4414                title: "Confirm".to_string(),
4415                description: Some("Are you sure?".to_string()),
4416                children: vec![text_node("t", "Body text", TextElement::P)],
4417                footer: vec![button_node(
4418                    "ok",
4419                    "OK",
4420                    ButtonVariant::Default,
4421                    Size::Default,
4422                )],
4423                trigger_label: Some("Open Modal".to_string()),
4424            }),
4425            action: None,
4426            visibility: None,
4427        });
4428        let html = render_to_html(&view, &json!({}));
4429        assert!(html.contains("<dialog"), "uses dialog element");
4430        assert!(html.contains("aria-modal=\"true\""), "has aria-modal");
4431        assert!(
4432            html.contains("data-modal-open=\"modal-confirm\""),
4433            "trigger has data-modal-open"
4434        );
4435        assert!(html.contains("data-modal-close"), "has close button");
4436        assert!(html.contains("Confirm"), "shows title");
4437        assert!(html.contains("Are you sure?"), "shows description");
4438        assert!(html.contains("Body text"), "shows children");
4439        assert!(html.contains(">OK</button>"), "shows footer");
4440        assert!(!html.contains("<details"), "no details element");
4441        assert!(!html.contains("<summary"), "no summary element");
4442    }
4443
4444    #[test]
4445    fn modal_default_trigger_label() {
4446        let view = JsonUiView::new().component(ComponentNode {
4447            key: "m".to_string(),
4448            component: Component::Modal(ModalProps {
4449                id: "modal-dialog".to_string(),
4450                title: "Dialog".to_string(),
4451                description: None,
4452                children: vec![],
4453                footer: vec![],
4454                trigger_label: None,
4455            }),
4456            action: None,
4457            visibility: None,
4458        });
4459        let html = render_to_html(&view, &json!({}));
4460        assert!(html.contains("Open"), "default trigger label");
4461        assert!(html.contains("<dialog"), "uses dialog element");
4462    }
4463
4464    // ── 18. Tabs ───────────────────────────────────────────────────────
4465
4466    #[test]
4467    fn tabs_renders_only_default_tab_content() {
4468        let view = JsonUiView::new().component(ComponentNode {
4469            key: "tabs".to_string(),
4470            component: Component::Tabs(TabsProps {
4471                default_tab: "general".to_string(),
4472                tabs: vec![
4473                    Tab {
4474                        value: "general".to_string(),
4475                        label: "General".to_string(),
4476                        children: vec![text_node("t1", "General content", TextElement::P)],
4477                    },
4478                    Tab {
4479                        value: "security".to_string(),
4480                        label: "Security".to_string(),
4481                        children: vec![text_node("t2", "Security content", TextElement::P)],
4482                    },
4483                ],
4484            }),
4485            action: None,
4486            visibility: None,
4487        });
4488        let html = render_to_html(&view, &json!({}));
4489        // Active tab styling.
4490        assert!(html.contains("border-b-2 border-primary text-primary"));
4491        assert!(html.contains(">General</button>"));
4492        // Inactive tab styling.
4493        assert!(html.contains("border-transparent text-text-muted"));
4494        assert!(html.contains(">Security</button>"));
4495        // Default tab panel visible, inactive panel hidden.
4496        assert!(html.contains("General content"));
4497        assert!(html.contains("Security content"));
4498        // Active panel has no hidden class; inactive panel is hidden.
4499        assert!(html.contains("data-tab-panel=\"general\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\""));
4500        assert!(html.contains("data-tab-panel=\"security\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto hidden\""));
4501    }
4502
4503    // ── 19. Form ───────────────────────────────────────────────────────
4504
4505    #[test]
4506    fn form_renders_action_url_and_method() {
4507        let view = JsonUiView::new().component(ComponentNode {
4508            key: "f".to_string(),
4509            component: Component::Form(FormProps {
4510                action: Action {
4511                    handler: "users.store".to_string(),
4512                    url: Some("/users".to_string()),
4513                    method: HttpMethod::Post,
4514                    confirm: None,
4515                    on_success: None,
4516                    on_error: None,
4517                    target: None,
4518                },
4519                fields: vec![],
4520                method: None,
4521                guard: None,
4522                max_width: None,
4523            }),
4524            action: None,
4525            visibility: None,
4526        });
4527        let html = render_to_html(&view, &json!({}));
4528        assert!(html.contains("action=\"/users\""));
4529        assert!(html.contains("method=\"post\""));
4530        assert!(html.contains(
4531            "class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\""
4532        ));
4533    }
4534
4535    #[test]
4536    fn form_method_spoofing_for_delete() {
4537        let view = JsonUiView::new().component(ComponentNode {
4538            key: "f".to_string(),
4539            component: Component::Form(FormProps {
4540                action: Action {
4541                    handler: "users.destroy".to_string(),
4542                    url: Some("/users/1".to_string()),
4543                    method: HttpMethod::Delete,
4544                    confirm: None,
4545                    on_success: None,
4546                    on_error: None,
4547                    target: None,
4548                },
4549                fields: vec![],
4550                method: None,
4551                guard: None,
4552                max_width: None,
4553            }),
4554            action: None,
4555            visibility: None,
4556        });
4557        let html = render_to_html(&view, &json!({}));
4558        assert!(html.contains("method=\"post\""));
4559        assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"DELETE\">"));
4560    }
4561
4562    #[test]
4563    fn form_method_spoofing_for_put() {
4564        let view = JsonUiView::new().component(ComponentNode {
4565            key: "f".to_string(),
4566            component: Component::Form(FormProps {
4567                action: Action {
4568                    handler: "users.update".to_string(),
4569                    url: Some("/users/1".to_string()),
4570                    method: HttpMethod::Put,
4571                    confirm: None,
4572                    on_success: None,
4573                    on_error: None,
4574                    target: None,
4575                },
4576                fields: vec![],
4577                method: Some(HttpMethod::Put),
4578                guard: None,
4579                max_width: None,
4580            }),
4581            action: None,
4582            visibility: None,
4583        });
4584        let html = render_to_html(&view, &json!({}));
4585        assert!(html.contains("method=\"post\""));
4586        assert!(html.contains("name=\"_method\" value=\"PUT\""));
4587    }
4588
4589    #[test]
4590    fn form_get_method_no_spoofing() {
4591        let view = JsonUiView::new().component(ComponentNode {
4592            key: "f".to_string(),
4593            component: Component::Form(FormProps {
4594                action: Action {
4595                    handler: "users.index".to_string(),
4596                    url: Some("/users".to_string()),
4597                    method: HttpMethod::Get,
4598                    confirm: None,
4599                    on_success: None,
4600                    on_error: None,
4601                    target: None,
4602                },
4603                fields: vec![],
4604                method: None,
4605                guard: None,
4606                max_width: None,
4607            }),
4608            action: None,
4609            visibility: None,
4610        });
4611        let html = render_to_html(&view, &json!({}));
4612        assert!(html.contains("method=\"get\""));
4613        assert!(!html.contains("_method"));
4614    }
4615
4616    // ── 20. Input ──────────────────────────────────────────────────────
4617
4618    #[test]
4619    fn input_renders_label_and_field() {
4620        let view = JsonUiView::new().component(ComponentNode {
4621            key: "i".to_string(),
4622            component: Component::Input(InputProps {
4623                field: "email".to_string(),
4624                label: "Email".to_string(),
4625                input_type: InputType::Email,
4626                placeholder: Some("user@example.com".to_string()),
4627                required: Some(true),
4628                disabled: None,
4629                error: None,
4630                description: Some("Your work email".to_string()),
4631                default_value: None,
4632                data_path: None,
4633                step: None,
4634                list: None,
4635            }),
4636            action: None,
4637            visibility: None,
4638        });
4639        let html = render_to_html(&view, &json!({}));
4640        assert!(html.contains("for=\"email\""));
4641        assert!(html.contains(">Email</label>"));
4642        assert!(html.contains("Your work email"));
4643        assert!(html.contains("type=\"email\""));
4644        assert!(html.contains("id=\"email\""));
4645        assert!(html.contains("name=\"email\""));
4646        assert!(html.contains("placeholder=\"user@example.com\""));
4647        assert!(html.contains(" required"));
4648        assert!(html.contains("border-border"));
4649    }
4650
4651    #[test]
4652    fn input_renders_error_with_red_border() {
4653        let view = JsonUiView::new().component(ComponentNode {
4654            key: "i".to_string(),
4655            component: Component::Input(InputProps {
4656                field: "name".to_string(),
4657                label: "Name".to_string(),
4658                input_type: InputType::Text,
4659                placeholder: None,
4660                required: None,
4661                disabled: None,
4662                error: Some("Name is required".to_string()),
4663                description: None,
4664                default_value: None,
4665                data_path: None,
4666                step: None,
4667                list: None,
4668            }),
4669            action: None,
4670            visibility: None,
4671        });
4672        let html = render_to_html(&view, &json!({}));
4673        assert!(html.contains("border-destructive"));
4674        assert!(html.contains("text-destructive") && html.contains("Name is required"));
4675        assert!(
4676            html.contains("ring-destructive"),
4677            "error input should have destructive ring"
4678        );
4679    }
4680
4681    #[test]
4682    fn input_resolves_data_path_for_value() {
4683        let data = json!({"user": {"name": "Alice"}});
4684        let view = JsonUiView::new().component(ComponentNode {
4685            key: "i".to_string(),
4686            component: Component::Input(InputProps {
4687                field: "name".to_string(),
4688                label: "Name".to_string(),
4689                input_type: InputType::Text,
4690                placeholder: None,
4691                required: None,
4692                disabled: None,
4693                error: None,
4694                description: None,
4695                default_value: None,
4696                data_path: Some("/user/name".to_string()),
4697                step: None,
4698                list: None,
4699            }),
4700            action: None,
4701            visibility: None,
4702        });
4703        let html = render_to_html(&view, &data);
4704        assert!(html.contains("value=\"Alice\""));
4705    }
4706
4707    #[test]
4708    fn input_default_value_overrides_data_path() {
4709        let data = json!({"user": {"name": "Alice"}});
4710        let view = JsonUiView::new().component(ComponentNode {
4711            key: "i".to_string(),
4712            component: Component::Input(InputProps {
4713                field: "name".to_string(),
4714                label: "Name".to_string(),
4715                input_type: InputType::Text,
4716                placeholder: None,
4717                required: None,
4718                disabled: None,
4719                error: None,
4720                description: None,
4721                default_value: Some("Bob".to_string()),
4722                data_path: Some("/user/name".to_string()),
4723                step: None,
4724                list: None,
4725            }),
4726            action: None,
4727            visibility: None,
4728        });
4729        let html = render_to_html(&view, &data);
4730        assert!(html.contains("value=\"Bob\""));
4731        assert!(!html.contains("Alice"));
4732    }
4733
4734    #[test]
4735    fn input_textarea_renders_textarea_element() {
4736        let view = JsonUiView::new().component(ComponentNode {
4737            key: "i".to_string(),
4738            component: Component::Input(InputProps {
4739                field: "bio".to_string(),
4740                label: "Bio".to_string(),
4741                input_type: InputType::Textarea,
4742                placeholder: Some("Tell us about yourself".to_string()),
4743                required: None,
4744                disabled: None,
4745                error: None,
4746                description: None,
4747                default_value: Some("Hello world".to_string()),
4748                data_path: None,
4749                step: None,
4750                list: None,
4751            }),
4752            action: None,
4753            visibility: None,
4754        });
4755        let html = render_to_html(&view, &json!({}));
4756        assert!(html.contains("<textarea"));
4757        assert!(html.contains(">Hello world</textarea>"));
4758        assert!(html.contains("placeholder=\"Tell us about yourself\""));
4759    }
4760
4761    #[test]
4762    fn input_hidden_renders_hidden_field() {
4763        let view = JsonUiView::new().component(ComponentNode {
4764            key: "i".to_string(),
4765            component: Component::Input(InputProps {
4766                field: "token".to_string(),
4767                label: "Token".to_string(),
4768                input_type: InputType::Hidden,
4769                placeholder: None,
4770                required: None,
4771                disabled: None,
4772                error: None,
4773                description: None,
4774                default_value: Some("abc123".to_string()),
4775                data_path: None,
4776                step: None,
4777                list: None,
4778            }),
4779            action: None,
4780            visibility: None,
4781        });
4782        let html = render_to_html(&view, &json!({}));
4783        assert!(html.contains("type=\"hidden\""));
4784        assert!(html.contains("value=\"abc123\""));
4785    }
4786
4787    #[test]
4788    fn input_renders_datalist() {
4789        let props = InputProps {
4790            field: "category".to_string(),
4791            label: "Category".to_string(),
4792            input_type: InputType::Text,
4793            placeholder: None,
4794            required: None,
4795            disabled: None,
4796            error: None,
4797            description: None,
4798            default_value: None,
4799            data_path: None,
4800            step: None,
4801            list: Some("cat-suggestions".to_string()),
4802        };
4803        let data = serde_json::json!({
4804            "cat-suggestions": ["Pizza", "Pasta", "Bevande"]
4805        });
4806        let html = render_input(&props, &data);
4807        assert!(
4808            html.contains("list=\"cat-suggestions\""),
4809            "input should have list attribute"
4810        );
4811        assert!(
4812            html.contains("<datalist id=\"cat-suggestions\">"),
4813            "should render datalist element"
4814        );
4815        assert!(
4816            html.contains("<option value=\"Pizza\">"),
4817            "should render option for Pizza"
4818        );
4819        assert!(
4820            html.contains("<option value=\"Pasta\">"),
4821            "should render option for Pasta"
4822        );
4823        assert!(
4824            html.contains("<option value=\"Bevande\">"),
4825            "should render option for Bevande"
4826        );
4827        assert!(html.contains("</datalist>"), "should close datalist");
4828    }
4829
4830    #[test]
4831    fn input_no_datalist_without_data() {
4832        let props = InputProps {
4833            field: "category".to_string(),
4834            label: "Category".to_string(),
4835            input_type: InputType::Text,
4836            placeholder: None,
4837            required: None,
4838            disabled: None,
4839            error: None,
4840            description: None,
4841            default_value: None,
4842            data_path: None,
4843            step: None,
4844            list: Some("missing-key".to_string()),
4845        };
4846        let data = serde_json::json!({});
4847        let html = render_input(&props, &data);
4848        assert!(
4849            html.contains("list=\"missing-key\""),
4850            "input should still have list attribute"
4851        );
4852        assert!(
4853            !html.contains("<datalist"),
4854            "should NOT render datalist when data key missing"
4855        );
4856    }
4857
4858    // ── 21. Select ─────────────────────────────────────────────────────
4859
4860    #[test]
4861    fn select_renders_options_with_selected() {
4862        let view = JsonUiView::new().component(ComponentNode {
4863            key: "s".to_string(),
4864            component: Component::Select(SelectProps {
4865                field: "role".to_string(),
4866                label: "Role".to_string(),
4867                options: vec![
4868                    SelectOption {
4869                        value: "admin".to_string(),
4870                        label: "Admin".to_string(),
4871                    },
4872                    SelectOption {
4873                        value: "user".to_string(),
4874                        label: "User".to_string(),
4875                    },
4876                ],
4877                placeholder: Some("Select a role".to_string()),
4878                required: Some(true),
4879                disabled: None,
4880                error: None,
4881                description: None,
4882                default_value: Some("admin".to_string()),
4883                data_path: None,
4884            }),
4885            action: None,
4886            visibility: None,
4887        });
4888        let html = render_to_html(&view, &json!({}));
4889        assert!(html.contains("for=\"role\""));
4890        assert!(html.contains("id=\"role\""));
4891        assert!(html.contains("name=\"role\""));
4892        assert!(html.contains("<option value=\"\">Select a role</option>"));
4893        assert!(html.contains("<option value=\"admin\" selected>Admin</option>"));
4894        assert!(html.contains("<option value=\"user\">User</option>"));
4895        assert!(html.contains(" required"));
4896    }
4897
4898    #[test]
4899    fn select_resolves_data_path_for_selected() {
4900        let data = json!({"user": {"role": "user"}});
4901        let view = JsonUiView::new().component(ComponentNode {
4902            key: "s".to_string(),
4903            component: Component::Select(SelectProps {
4904                field: "role".to_string(),
4905                label: "Role".to_string(),
4906                options: vec![
4907                    SelectOption {
4908                        value: "admin".to_string(),
4909                        label: "Admin".to_string(),
4910                    },
4911                    SelectOption {
4912                        value: "user".to_string(),
4913                        label: "User".to_string(),
4914                    },
4915                ],
4916                placeholder: None,
4917                required: None,
4918                disabled: None,
4919                error: None,
4920                description: None,
4921                default_value: None,
4922                data_path: Some("/user/role".to_string()),
4923            }),
4924            action: None,
4925            visibility: None,
4926        });
4927        let html = render_to_html(&view, &data);
4928        assert!(html.contains("<option value=\"user\" selected>User</option>"));
4929        assert!(!html.contains("<option value=\"admin\" selected>"));
4930    }
4931
4932    #[test]
4933    fn select_renders_error() {
4934        let view = JsonUiView::new().component(ComponentNode {
4935            key: "s".to_string(),
4936            component: Component::Select(SelectProps {
4937                field: "role".to_string(),
4938                label: "Role".to_string(),
4939                options: vec![],
4940                placeholder: None,
4941                required: None,
4942                disabled: None,
4943                error: Some("Role is required".to_string()),
4944                description: None,
4945                default_value: None,
4946                data_path: None,
4947            }),
4948            action: None,
4949            visibility: None,
4950        });
4951        let html = render_to_html(&view, &json!({}));
4952        assert!(html.contains("border-destructive"));
4953        assert!(html.contains("Role is required"));
4954        assert!(
4955            html.contains("ring-destructive"),
4956            "error select should have destructive ring"
4957        );
4958    }
4959
4960    // ── 22. Checkbox ───────────────────────────────────────────────────
4961
4962    #[test]
4963    fn checkbox_renders_checked_state() {
4964        let view = JsonUiView::new().component(ComponentNode {
4965            key: "cb".to_string(),
4966            component: Component::Checkbox(CheckboxProps {
4967                field: "terms".to_string(),
4968                value: None,
4969                label: "Accept Terms".to_string(),
4970                description: Some("You must accept".to_string()),
4971                checked: Some(true),
4972                data_path: None,
4973                required: Some(true),
4974                disabled: None,
4975                error: None,
4976            }),
4977            action: None,
4978            visibility: None,
4979        });
4980        let html = render_to_html(&view, &json!({}));
4981        assert!(html.contains("type=\"checkbox\""));
4982        assert!(html.contains("id=\"terms\""));
4983        assert!(html.contains("name=\"terms\""));
4984        assert!(html.contains("value=\"1\""));
4985        assert!(html.contains(" checked"));
4986        assert!(html.contains(" required"));
4987        assert!(html.contains("for=\"terms\""));
4988        assert!(html.contains(">Accept Terms</label>"));
4989        assert!(html.contains("ml-6 text-sm text-text-muted"));
4990        assert!(html.contains("You must accept"));
4991    }
4992
4993    #[test]
4994    fn checkbox_resolves_data_path_for_checked() {
4995        let data = json!({"user": {"accepted": true}});
4996        let view = JsonUiView::new().component(ComponentNode {
4997            key: "cb".to_string(),
4998            component: Component::Checkbox(CheckboxProps {
4999                field: "accepted".to_string(),
5000                value: None,
5001                label: "Accepted".to_string(),
5002                description: None,
5003                checked: None,
5004                data_path: Some("/user/accepted".to_string()),
5005                required: None,
5006                disabled: None,
5007                error: None,
5008            }),
5009            action: None,
5010            visibility: None,
5011        });
5012        let html = render_to_html(&view, &data);
5013        assert!(html.contains(" checked"));
5014    }
5015
5016    #[test]
5017    fn checkbox_renders_error() {
5018        let view = JsonUiView::new().component(ComponentNode {
5019            key: "cb".to_string(),
5020            component: Component::Checkbox(CheckboxProps {
5021                field: "terms".to_string(),
5022                value: None,
5023                label: "Terms".to_string(),
5024                description: None,
5025                checked: None,
5026                data_path: None,
5027                required: None,
5028                disabled: None,
5029                error: Some("Must accept".to_string()),
5030            }),
5031            action: None,
5032            visibility: None,
5033        });
5034        let html = render_to_html(&view, &json!({}));
5035        assert!(html.contains("ml-6 text-sm text-destructive"));
5036        assert!(html.contains("Must accept"));
5037    }
5038
5039    // ── 23. Switch ─────────────────────────────────────────────────────
5040
5041    #[test]
5042    fn switch_renders_toggle_structure() {
5043        let view = JsonUiView::new().component(ComponentNode {
5044            key: "sw".to_string(),
5045            component: Component::Switch(SwitchProps {
5046                field: "notifications".to_string(),
5047                label: "Notifications".to_string(),
5048                description: Some("Get email updates".to_string()),
5049                checked: Some(true),
5050                data_path: None,
5051                required: None,
5052                disabled: None,
5053                error: None,
5054                action: None,
5055                compact: false,
5056            }),
5057            action: None,
5058            visibility: None,
5059        });
5060        let html = render_to_html(&view, &json!({}));
5061        assert!(html.contains("sr-only peer"));
5062        assert!(html.contains("id=\"notifications\""));
5063        assert!(html.contains("name=\"notifications\""));
5064        assert!(html.contains("value=\"1\""));
5065        assert!(html.contains(" checked"));
5066        assert!(html.contains("peer-checked:bg-primary"));
5067        assert!(html.contains("for=\"notifications\""));
5068        assert!(html.contains(">Notifications</label>"));
5069        assert!(html.contains("Get email updates"));
5070    }
5071
5072    #[test]
5073    fn switch_renders_error() {
5074        let view = JsonUiView::new().component(ComponentNode {
5075            key: "sw".to_string(),
5076            component: Component::Switch(SwitchProps {
5077                field: "agree".to_string(),
5078                label: "Agree".to_string(),
5079                description: None,
5080                checked: None,
5081                data_path: None,
5082                required: None,
5083                disabled: None,
5084                error: Some("Required".to_string()),
5085                action: None,
5086                compact: false,
5087            }),
5088            action: None,
5089            visibility: None,
5090        });
5091        let html = render_to_html(&view, &json!({}));
5092        assert!(html.contains("text-sm text-destructive"));
5093        assert!(html.contains("Required"));
5094    }
5095
5096    // ── 24. Table ──────────────────────────────────────────────────────
5097
5098    #[test]
5099    fn table_renders_headers_and_data_rows() {
5100        let data = json!({
5101            "users": [
5102                {"name": "Alice", "email": "alice@example.com"},
5103                {"name": "Bob", "email": "bob@example.com"}
5104            ]
5105        });
5106        let view = JsonUiView::new().component(ComponentNode {
5107            key: "t".to_string(),
5108            component: Component::Table(TableProps {
5109                columns: vec![
5110                    Column {
5111                        key: "name".to_string(),
5112                        label: "Name".to_string(),
5113                        format: None,
5114                    },
5115                    Column {
5116                        key: "email".to_string(),
5117                        label: "Email".to_string(),
5118                        format: None,
5119                    },
5120                ],
5121                data_path: "/users".to_string(),
5122                row_actions: None,
5123                empty_message: Some("No users".to_string()),
5124                sortable: None,
5125                sort_column: None,
5126                sort_direction: None,
5127            }),
5128            action: None,
5129            visibility: None,
5130        });
5131        let html = render_to_html(&view, &data);
5132        // Headers.
5133        assert!(html.contains("tracking-wider text-text-muted\">Name</th>"));
5134        assert!(html.contains("tracking-wider text-text-muted\">Email</th>"));
5135        // Data rows.
5136        assert!(html.contains(">Alice</td>"));
5137        assert!(html.contains(">alice@example.com</td>"));
5138        assert!(html.contains(">Bob</td>"));
5139        assert!(html.contains(">bob@example.com</td>"));
5140        // Wrapped in overflow container.
5141        assert!(html.contains("overflow-x-auto"));
5142    }
5143
5144    #[test]
5145    fn table_renders_empty_message() {
5146        let data = json!({"users": []});
5147        let view = JsonUiView::new().component(ComponentNode {
5148            key: "t".to_string(),
5149            component: Component::Table(TableProps {
5150                columns: vec![Column {
5151                    key: "name".to_string(),
5152                    label: "Name".to_string(),
5153                    format: None,
5154                }],
5155                data_path: "/users".to_string(),
5156                row_actions: None,
5157                empty_message: Some("No users found".to_string()),
5158                sortable: None,
5159                sort_column: None,
5160                sort_direction: None,
5161            }),
5162            action: None,
5163            visibility: None,
5164        });
5165        let html = render_to_html(&view, &data);
5166        assert!(html.contains("No users found"));
5167        assert!(html.contains("text-center text-sm text-text-muted"));
5168    }
5169
5170    #[test]
5171    fn table_renders_empty_message_when_path_missing() {
5172        let data = json!({});
5173        let view = JsonUiView::new().component(ComponentNode {
5174            key: "t".to_string(),
5175            component: Component::Table(TableProps {
5176                columns: vec![Column {
5177                    key: "name".to_string(),
5178                    label: "Name".to_string(),
5179                    format: None,
5180                }],
5181                data_path: "/users".to_string(),
5182                row_actions: None,
5183                empty_message: Some("No data".to_string()),
5184                sortable: None,
5185                sort_column: None,
5186                sort_direction: None,
5187            }),
5188            action: None,
5189            visibility: None,
5190        });
5191        let html = render_to_html(&view, &data);
5192        assert!(html.contains("No data"));
5193    }
5194
5195    #[test]
5196    fn table_renders_row_actions() {
5197        let data = json!({"items": [{"name": "Item 1"}]});
5198        let view = JsonUiView::new().component(ComponentNode {
5199            key: "t".to_string(),
5200            component: Component::Table(TableProps {
5201                columns: vec![Column {
5202                    key: "name".to_string(),
5203                    label: "Name".to_string(),
5204                    format: None,
5205                }],
5206                data_path: "/items".to_string(),
5207                row_actions: Some(vec![
5208                    make_action_with_url("items.edit", HttpMethod::Get, "/items/1/edit"),
5209                    make_action_with_url("items.destroy", HttpMethod::Delete, "/items/1"),
5210                ]),
5211                empty_message: None,
5212                sortable: None,
5213                sort_column: None,
5214                sort_direction: None,
5215            }),
5216            action: None,
5217            visibility: None,
5218        });
5219        let html = render_to_html(&view, &data);
5220        // Actions header.
5221        assert!(html.contains(">Azioni</th>"));
5222        // Action links.
5223        assert!(html.contains("href=\"/items/1/edit\""));
5224        assert!(html.contains(">edit</a>"));
5225        assert!(html.contains("href=\"/items/1\""));
5226        assert!(html.contains(">destroy</a>"));
5227    }
5228
5229    #[test]
5230    fn table_handles_numeric_and_bool_cells() {
5231        let data = json!({"rows": [{"count": 42, "active": true}]});
5232        let view = JsonUiView::new().component(ComponentNode {
5233            key: "t".to_string(),
5234            component: Component::Table(TableProps {
5235                columns: vec![
5236                    Column {
5237                        key: "count".to_string(),
5238                        label: "Count".to_string(),
5239                        format: None,
5240                    },
5241                    Column {
5242                        key: "active".to_string(),
5243                        label: "Active".to_string(),
5244                        format: None,
5245                    },
5246                ],
5247                data_path: "/rows".to_string(),
5248                row_actions: None,
5249                empty_message: None,
5250                sortable: None,
5251                sort_column: None,
5252                sort_direction: None,
5253            }),
5254            action: None,
5255            visibility: None,
5256        });
5257        let html = render_to_html(&view, &data);
5258        assert!(html.contains(">42</td>"));
5259        assert!(html.contains(">true</td>"));
5260    }
5261
5262    // ── Plugin rendering tests ────────────────────────────────────────
5263
5264    #[test]
5265    fn plugin_renders_error_div_when_not_registered() {
5266        let view = JsonUiView::new().component(ComponentNode {
5267            key: "map-1".to_string(),
5268            component: Component::Plugin(PluginProps {
5269                plugin_type: "UnknownPluginXyz".to_string(),
5270                props: json!({"lat": 0}),
5271            }),
5272            action: None,
5273            visibility: None,
5274        });
5275        let html = render_to_html(&view, &json!({}));
5276        assert!(html.contains("Unknown plugin component: UnknownPluginXyz"));
5277        assert!(html.contains("bg-destructive/10"));
5278    }
5279
5280    #[test]
5281    fn collect_plugin_types_finds_top_level_plugins() {
5282        let view = JsonUiView::new()
5283            .component(ComponentNode {
5284                key: "map".to_string(),
5285                component: Component::Plugin(PluginProps {
5286                    plugin_type: "Map".to_string(),
5287                    props: json!({}),
5288                }),
5289                action: None,
5290                visibility: None,
5291            })
5292            .component(ComponentNode {
5293                key: "text".to_string(),
5294                component: Component::Text(TextProps {
5295                    content: "Hello".to_string(),
5296                    element: TextElement::P,
5297                }),
5298                action: None,
5299                visibility: None,
5300            });
5301        let types = collect_plugin_types(&view);
5302        assert_eq!(types.len(), 1);
5303        assert!(types.contains("Map"));
5304    }
5305
5306    #[test]
5307    fn collect_plugin_types_finds_nested_in_card() {
5308        let view = JsonUiView::new().component(ComponentNode {
5309            key: "card".to_string(),
5310            component: Component::Card(CardProps {
5311                title: "Test".to_string(),
5312                description: None,
5313                children: vec![ComponentNode {
5314                    key: "chart".to_string(),
5315                    component: Component::Plugin(PluginProps {
5316                        plugin_type: "Chart".to_string(),
5317                        props: json!({}),
5318                    }),
5319                    action: None,
5320                    visibility: None,
5321                }],
5322                footer: vec![],
5323                max_width: None,
5324            }),
5325            action: None,
5326            visibility: None,
5327        });
5328        let types = collect_plugin_types(&view);
5329        assert!(types.contains("Chart"));
5330    }
5331
5332    #[test]
5333    fn collect_plugin_types_deduplicates() {
5334        let view = JsonUiView::new()
5335            .component(ComponentNode {
5336                key: "map1".to_string(),
5337                component: Component::Plugin(PluginProps {
5338                    plugin_type: "Map".to_string(),
5339                    props: json!({}),
5340                }),
5341                action: None,
5342                visibility: None,
5343            })
5344            .component(ComponentNode {
5345                key: "map2".to_string(),
5346                component: Component::Plugin(PluginProps {
5347                    plugin_type: "Map".to_string(),
5348                    props: json!({"zoom": 5}),
5349                }),
5350                action: None,
5351                visibility: None,
5352            });
5353        let types = collect_plugin_types(&view);
5354        assert_eq!(types.len(), 1);
5355    }
5356
5357    #[test]
5358    fn collect_plugin_types_empty_for_builtin_only() {
5359        let view = JsonUiView::new().component(ComponentNode {
5360            key: "text".to_string(),
5361            component: Component::Text(TextProps {
5362                content: "Hello".to_string(),
5363                element: TextElement::P,
5364            }),
5365            action: None,
5366            visibility: None,
5367        });
5368        let types = collect_plugin_types(&view);
5369        assert!(types.is_empty());
5370    }
5371
5372    #[test]
5373    fn render_to_html_with_plugins_returns_empty_assets_for_builtin_only() {
5374        let view = JsonUiView::new().component(ComponentNode {
5375            key: "text".to_string(),
5376            component: Component::Text(TextProps {
5377                content: "Hello".to_string(),
5378                element: TextElement::P,
5379            }),
5380            action: None,
5381            visibility: None,
5382        });
5383        let result = render_to_html_with_plugins(&view, &json!({}));
5384        assert!(result.css_head.is_empty());
5385        assert!(result.scripts.is_empty());
5386        assert!(result.html.contains("Hello"));
5387    }
5388
5389    #[test]
5390    fn render_css_tags_generates_link_elements() {
5391        let assets = vec![Asset::new("https://cdn.example.com/style.css")
5392            .integrity("sha256-abc")
5393            .crossorigin("")];
5394        let tags = render_css_tags(&assets);
5395        assert!(tags.contains("rel=\"stylesheet\""));
5396        assert!(tags.contains("href=\"https://cdn.example.com/style.css\""));
5397        assert!(tags.contains("integrity=\"sha256-abc\""));
5398        assert!(tags.contains("crossorigin=\"\""));
5399    }
5400
5401    #[test]
5402    fn render_js_tags_generates_script_elements() {
5403        let assets = vec![Asset::new("https://cdn.example.com/lib.js")];
5404        let init = vec!["initLib();".to_string()];
5405        let tags = render_js_tags(&assets, &init);
5406        assert!(tags.contains("src=\"https://cdn.example.com/lib.js\""));
5407        assert!(tags.contains("<script>initLib();</script>"));
5408    }
5409
5410    // ── StatCard ─────────────────────────────────────────────────────────
5411
5412    #[test]
5413    fn stat_card_renders_label_and_value() {
5414        let view = JsonUiView::new().component(ComponentNode::stat_card(
5415            "rev",
5416            StatCardProps {
5417                label: "Revenue".to_string(),
5418                value: "$1,234".to_string(),
5419                icon: None,
5420                subtitle: None,
5421                sse_target: None,
5422            },
5423        ));
5424        let html = render_to_html(&view, &json!({}));
5425        assert!(html.contains("Revenue"));
5426        assert!(html.contains("$1,234"));
5427        assert!(html.contains("bg-card rounded-lg shadow-sm"));
5428    }
5429
5430    #[test]
5431    fn stat_card_renders_icon_and_subtitle() {
5432        let view = JsonUiView::new().component(ComponentNode::stat_card(
5433            "users",
5434            StatCardProps {
5435                label: "Users".to_string(),
5436                value: "42".to_string(),
5437                icon: Some("👤".to_string()),
5438                subtitle: Some("active today".to_string()),
5439                sse_target: None,
5440            },
5441        ));
5442        let html = render_to_html(&view, &json!({}));
5443        assert!(html.contains("👤"));
5444        assert!(html.contains("active today"));
5445    }
5446
5447    #[test]
5448    fn stat_card_renders_svg_icon_without_escaping() {
5449        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>"#;
5450        let view = JsonUiView::new().component(ComponentNode::stat_card(
5451            "svg-icon",
5452            StatCardProps {
5453                label: "Test".to_string(),
5454                value: "0".to_string(),
5455                icon: Some(svg.to_string()),
5456                subtitle: None,
5457                sse_target: None,
5458            },
5459        ));
5460        let html = render_to_html(&view, &json!({}));
5461        assert!(
5462            html.contains("<svg"),
5463            "SVG should render as markup, not escaped text"
5464        );
5465        assert!(!html.contains("&lt;svg"), "SVG should NOT be HTML-escaped");
5466    }
5467
5468    #[test]
5469    fn stat_card_renders_sse_target_data_attributes() {
5470        let view = JsonUiView::new().component(ComponentNode::stat_card(
5471            "live",
5472            StatCardProps {
5473                label: "Live count".to_string(),
5474                value: "100".to_string(),
5475                icon: None,
5476                subtitle: None,
5477                sse_target: Some("visitor_count".to_string()),
5478            },
5479        ));
5480        let html = render_to_html(&view, &json!({}));
5481        assert!(html.contains("data-sse-target=\"visitor_count\""));
5482        assert!(html.contains("data-live-value"));
5483    }
5484
5485    #[test]
5486    fn stat_card_no_sse_target_omits_data_attributes() {
5487        let view = JsonUiView::new().component(ComponentNode::stat_card(
5488            "static",
5489            StatCardProps {
5490                label: "Label".to_string(),
5491                value: "99".to_string(),
5492                icon: None,
5493                subtitle: None,
5494                sse_target: None,
5495            },
5496        ));
5497        let html = render_to_html(&view, &json!({}));
5498        assert!(!html.contains("data-sse-target"));
5499        assert!(!html.contains("data-live-value"));
5500    }
5501
5502    // ── Checklist ────────────────────────────────────────────────────────
5503
5504    #[test]
5505    fn checklist_renders_title_and_items() {
5506        let view = JsonUiView::new().component(ComponentNode::checklist(
5507            "tasks",
5508            ChecklistProps {
5509                title: "Setup Tasks".to_string(),
5510                items: vec![
5511                    ChecklistItem {
5512                        label: "Create account".to_string(),
5513                        checked: true,
5514                        href: None,
5515                    },
5516                    ChecklistItem {
5517                        label: "Add team member".to_string(),
5518                        checked: false,
5519                        href: None,
5520                    },
5521                ],
5522                dismissible: true,
5523                dismiss_label: None,
5524                data_key: None,
5525            },
5526        ));
5527        let html = render_to_html(&view, &json!({}));
5528        assert!(html.contains("Setup Tasks"));
5529        assert!(html.contains("Create account"));
5530        assert!(html.contains("Add team member"));
5531    }
5532
5533    #[test]
5534    fn checklist_checked_item_has_strikethrough() {
5535        let view = JsonUiView::new().component(ComponentNode::checklist(
5536            "tasks",
5537            ChecklistProps {
5538                title: "Tasks".to_string(),
5539                items: vec![ChecklistItem {
5540                    label: "Done".to_string(),
5541                    checked: true,
5542                    href: None,
5543                }],
5544                dismissible: false,
5545                dismiss_label: None,
5546                data_key: None,
5547            },
5548        ));
5549        let html = render_to_html(&view, &json!({}));
5550        assert!(html.contains("line-through"));
5551        assert!(html.contains("checked"));
5552    }
5553
5554    #[test]
5555    fn checklist_dismissible_renders_dismiss_button() {
5556        let view = JsonUiView::new().component(ComponentNode::checklist(
5557            "tasks",
5558            ChecklistProps {
5559                title: "Tasks".to_string(),
5560                items: vec![],
5561                dismissible: true,
5562                dismiss_label: Some("Close".to_string()),
5563                data_key: None,
5564            },
5565        ));
5566        let html = render_to_html(&view, &json!({}));
5567        assert!(html.contains("Close"));
5568        assert!(html.contains("data-dismissible"));
5569        assert!(html.contains("font-medium"));
5570        assert!(html.contains("text-text"));
5571        assert!(html.contains("hover:text-primary"));
5572    }
5573
5574    #[test]
5575    fn checklist_data_key_added_to_container() {
5576        let view = JsonUiView::new().component(ComponentNode::checklist(
5577            "tasks",
5578            ChecklistProps {
5579                title: "Tasks".to_string(),
5580                items: vec![],
5581                dismissible: false,
5582                dismiss_label: None,
5583                data_key: Some("onboarding_checklist".to_string()),
5584            },
5585        ));
5586        let html = render_to_html(&view, &json!({}));
5587        assert!(html.contains("data-checklist-key=\"onboarding_checklist\""));
5588    }
5589
5590    #[test]
5591    fn checklist_item_with_href_renders_link() {
5592        let view = JsonUiView::new().component(ComponentNode::checklist(
5593            "tasks",
5594            ChecklistProps {
5595                title: "Tasks".to_string(),
5596                items: vec![ChecklistItem {
5597                    label: "Visit docs".to_string(),
5598                    checked: false,
5599                    href: Some("/docs".to_string()),
5600                }],
5601                dismissible: false,
5602                dismiss_label: None,
5603                data_key: None,
5604            },
5605        ));
5606        let html = render_to_html(&view, &json!({}));
5607        assert!(html.contains("href=\"/docs\""));
5608        assert!(html.contains("Visit docs"));
5609    }
5610
5611    // ── Toast ────────────────────────────────────────────────────────────
5612
5613    #[test]
5614    fn toast_renders_message_and_variant() {
5615        let view = JsonUiView::new().component(ComponentNode::toast(
5616            "t",
5617            ToastProps {
5618                message: "Saved successfully!".to_string(),
5619                variant: ToastVariant::Success,
5620                timeout: None,
5621                dismissible: true,
5622            },
5623        ));
5624        let html = render_to_html(&view, &json!({}));
5625        assert!(html.contains("Saved successfully!"));
5626        assert!(html.contains("data-toast-variant=\"success\""));
5627    }
5628
5629    #[test]
5630    fn toast_renders_timeout_attribute() {
5631        let view = JsonUiView::new().component(ComponentNode::toast(
5632            "t",
5633            ToastProps {
5634                message: "Warning!".to_string(),
5635                variant: ToastVariant::Warning,
5636                timeout: Some(10),
5637                dismissible: false,
5638            },
5639        ));
5640        let html = render_to_html(&view, &json!({}));
5641        assert!(html.contains("data-toast-timeout=\"10\""));
5642        assert!(!html.contains("data-toast-dismissible"));
5643    }
5644
5645    #[test]
5646    fn toast_default_timeout_is_five_seconds() {
5647        let view = JsonUiView::new().component(ComponentNode::toast(
5648            "t",
5649            ToastProps {
5650                message: "Hello".to_string(),
5651                variant: ToastVariant::Info,
5652                timeout: None,
5653                dismissible: false,
5654            },
5655        ));
5656        let html = render_to_html(&view, &json!({}));
5657        assert!(html.contains("data-toast-timeout=\"5\""));
5658    }
5659
5660    #[test]
5661    fn toast_dismissible_renders_dismiss_button() {
5662        let view = JsonUiView::new().component(ComponentNode::toast(
5663            "t",
5664            ToastProps {
5665                message: "Error occurred".to_string(),
5666                variant: ToastVariant::Error,
5667                timeout: None,
5668                dismissible: true,
5669            },
5670        ));
5671        let html = render_to_html(&view, &json!({}));
5672        assert!(html.contains("data-toast-dismissible"));
5673        assert!(html.contains("&times;"));
5674    }
5675
5676    #[test]
5677    fn toast_info_variant_uses_blue_classes() {
5678        let view = JsonUiView::new().component(ComponentNode::toast(
5679            "t",
5680            ToastProps {
5681                message: "Info".to_string(),
5682                variant: ToastVariant::Info,
5683                timeout: None,
5684                dismissible: false,
5685            },
5686        ));
5687        let html = render_to_html(&view, &json!({}));
5688        assert!(html.contains("bg-primary/10"));
5689        assert!(html.contains("data-toast-variant=\"info\""));
5690    }
5691
5692    #[test]
5693    fn toast_has_fixed_position_classes() {
5694        let view = JsonUiView::new().component(ComponentNode::toast(
5695            "t",
5696            ToastProps {
5697                message: "msg".to_string(),
5698                variant: ToastVariant::Info,
5699                timeout: None,
5700                dismissible: false,
5701            },
5702        ));
5703        let html = render_to_html(&view, &json!({}));
5704        assert!(html.contains("fixed top-4 right-4 z-50"));
5705    }
5706
5707    // ── NotificationDropdown ─────────────────────────────────────────────
5708
5709    #[test]
5710    fn notification_dropdown_renders_bell_icon() {
5711        let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5712            "notifs",
5713            NotificationDropdownProps {
5714                notifications: vec![],
5715                empty_text: None,
5716            },
5717        ));
5718        let html = render_to_html(&view, &json!({}));
5719        assert!(html.contains("data-notification-dropdown"));
5720        assert!(html.contains("data-notification-count=\"0\""));
5721    }
5722
5723    #[test]
5724    fn notification_dropdown_shows_unread_count_badge() {
5725        let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5726            "notifs",
5727            NotificationDropdownProps {
5728                notifications: vec![
5729                    NotificationItem {
5730                        icon: None,
5731                        text: "New message".to_string(),
5732                        timestamp: None,
5733                        read: false,
5734                        action_url: None,
5735                    },
5736                    NotificationItem {
5737                        icon: None,
5738                        text: "Old message".to_string(),
5739                        timestamp: None,
5740                        read: true,
5741                        action_url: None,
5742                    },
5743                ],
5744                empty_text: None,
5745            },
5746        ));
5747        let html = render_to_html(&view, &json!({}));
5748        assert!(html.contains("data-notification-count=\"1\""));
5749        assert!(html.contains("New message"));
5750        assert!(html.contains("Old message"));
5751    }
5752
5753    #[test]
5754    fn notification_dropdown_shows_empty_text_when_no_notifications() {
5755        let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5756            "notifs",
5757            NotificationDropdownProps {
5758                notifications: vec![],
5759                empty_text: Some("All caught up!".to_string()),
5760            },
5761        ));
5762        let html = render_to_html(&view, &json!({}));
5763        assert!(html.contains("All caught up!"));
5764    }
5765
5766    #[test]
5767    fn notification_dropdown_unread_indicator_for_unread_items() {
5768        let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5769            "notifs",
5770            NotificationDropdownProps {
5771                notifications: vec![NotificationItem {
5772                    icon: None,
5773                    text: "Unread".to_string(),
5774                    timestamp: None,
5775                    read: false,
5776                    action_url: None,
5777                }],
5778                empty_text: None,
5779            },
5780        ));
5781        let html = render_to_html(&view, &json!({}));
5782        assert!(html.contains("bg-primary"));
5783    }
5784
5785    // ── Sidebar ──────────────────────────────────────────────────────────
5786
5787    #[test]
5788    fn sidebar_renders_aside_element() {
5789        let view = JsonUiView::new().component(ComponentNode::sidebar(
5790            "nav",
5791            SidebarProps {
5792                fixed_top: vec![],
5793                groups: vec![],
5794                fixed_bottom: vec![],
5795            },
5796        ));
5797        let html = render_to_html(&view, &json!({}));
5798        assert!(html.contains("<aside"));
5799        assert!(html.contains("</aside>"));
5800    }
5801
5802    #[test]
5803    fn sidebar_renders_fixed_top_items() {
5804        let view = JsonUiView::new().component(ComponentNode::sidebar(
5805            "nav",
5806            SidebarProps {
5807                fixed_top: vec![SidebarNavItem {
5808                    label: "Dashboard".to_string(),
5809                    href: "/dashboard".to_string(),
5810                    icon: None,
5811                    active: true,
5812                }],
5813                groups: vec![],
5814                fixed_bottom: vec![],
5815            },
5816        ));
5817        let html = render_to_html(&view, &json!({}));
5818        assert!(html.contains("href=\"/dashboard\""));
5819        assert!(html.contains("Dashboard"));
5820        assert!(html.contains("bg-card text-primary"));
5821    }
5822
5823    #[test]
5824    fn sidebar_renders_groups_with_data_attribute() {
5825        let view = JsonUiView::new().component(ComponentNode::sidebar(
5826            "nav",
5827            SidebarProps {
5828                fixed_top: vec![],
5829                groups: vec![SidebarGroup {
5830                    label: "Management".to_string(),
5831                    collapsed: false,
5832                    items: vec![SidebarNavItem {
5833                        label: "Users".to_string(),
5834                        href: "/users".to_string(),
5835                        icon: None,
5836                        active: false,
5837                    }],
5838                }],
5839                fixed_bottom: vec![],
5840            },
5841        ));
5842        let html = render_to_html(&view, &json!({}));
5843        assert!(html.contains("data-sidebar-group"));
5844        assert!(html.contains("Management"));
5845        assert!(html.contains("Users"));
5846        assert!(!html.contains("data-collapsed"));
5847    }
5848
5849    #[test]
5850    fn sidebar_collapsed_group_has_data_collapsed() {
5851        let view = JsonUiView::new().component(ComponentNode::sidebar(
5852            "nav",
5853            SidebarProps {
5854                fixed_top: vec![],
5855                groups: vec![SidebarGroup {
5856                    label: "Advanced".to_string(),
5857                    collapsed: true,
5858                    items: vec![],
5859                }],
5860                fixed_bottom: vec![],
5861            },
5862        ));
5863        let html = render_to_html(&view, &json!({}));
5864        assert!(html.contains("data-collapsed"));
5865    }
5866
5867    #[test]
5868    fn sidebar_inactive_item_uses_gray_classes() {
5869        let view = JsonUiView::new().component(ComponentNode::sidebar(
5870            "nav",
5871            SidebarProps {
5872                fixed_top: vec![SidebarNavItem {
5873                    label: "Settings".to_string(),
5874                    href: "/settings".to_string(),
5875                    icon: None,
5876                    active: false,
5877                }],
5878                groups: vec![],
5879                fixed_bottom: vec![],
5880            },
5881        ));
5882        let html = render_to_html(&view, &json!({}));
5883        assert!(html.contains("text-text-muted"));
5884        assert!(!html.contains("text-primary"));
5885    }
5886
5887    // ── Header ───────────────────────────────────────────────────────────
5888
5889    #[test]
5890    fn header_renders_business_name() {
5891        let view = JsonUiView::new().component(ComponentNode::header(
5892            "hdr",
5893            HeaderProps {
5894                business_name: "Acme Corp".to_string(),
5895                notification_count: None,
5896                user_name: None,
5897                user_avatar: None,
5898                logout_url: None,
5899            },
5900        ));
5901        let html = render_to_html(&view, &json!({}));
5902        assert!(html.contains("<header"));
5903        assert!(html.contains("Acme Corp"));
5904    }
5905
5906    #[test]
5907    fn header_renders_notification_count_badge() {
5908        let view = JsonUiView::new().component(ComponentNode::header(
5909            "hdr",
5910            HeaderProps {
5911                business_name: "Acme".to_string(),
5912                notification_count: Some(3),
5913                user_name: None,
5914                user_avatar: None,
5915                logout_url: None,
5916            },
5917        ));
5918        let html = render_to_html(&view, &json!({}));
5919        assert!(html.contains("data-notification-count=\"3\""));
5920    }
5921
5922    #[test]
5923    fn header_no_badge_when_count_is_zero() {
5924        let view = JsonUiView::new().component(ComponentNode::header(
5925            "hdr",
5926            HeaderProps {
5927                business_name: "Acme".to_string(),
5928                notification_count: Some(0),
5929                user_name: None,
5930                user_avatar: None,
5931                logout_url: None,
5932            },
5933        ));
5934        let html = render_to_html(&view, &json!({}));
5935        assert!(html.contains("data-notification-count=\"0\""));
5936        // No red badge when count is zero.
5937        assert!(!html.contains("bg-destructive"));
5938    }
5939
5940    #[test]
5941    fn header_renders_user_name_initials() {
5942        let view = JsonUiView::new().component(ComponentNode::header(
5943            "hdr",
5944            HeaderProps {
5945                business_name: "Acme".to_string(),
5946                notification_count: None,
5947                user_name: Some("John Doe".to_string()),
5948                user_avatar: None,
5949                logout_url: None,
5950            },
5951        ));
5952        let html = render_to_html(&view, &json!({}));
5953        assert!(html.contains("JD"));
5954        assert!(html.contains("John Doe"));
5955    }
5956
5957    #[test]
5958    fn header_renders_avatar_image_when_provided() {
5959        let view = JsonUiView::new().component(ComponentNode::header(
5960            "hdr",
5961            HeaderProps {
5962                business_name: "Acme".to_string(),
5963                notification_count: None,
5964                user_name: None,
5965                user_avatar: Some("/avatar.jpg".to_string()),
5966                logout_url: None,
5967            },
5968        ));
5969        let html = render_to_html(&view, &json!({}));
5970        assert!(html.contains("src=\"/avatar.jpg\""));
5971        assert!(html.contains("rounded-full"));
5972    }
5973
5974    #[test]
5975    fn header_renders_logout_link() {
5976        let view = JsonUiView::new().component(ComponentNode::header(
5977            "hdr",
5978            HeaderProps {
5979                business_name: "Acme".to_string(),
5980                notification_count: None,
5981                user_name: None,
5982                user_avatar: None,
5983                logout_url: Some("/logout".to_string()),
5984            },
5985        ));
5986        let html = render_to_html(&view, &json!({}));
5987        assert!(html.contains("href=\"/logout\""));
5988        assert!(html.contains("Logout"));
5989    }
5990
5991    #[test]
5992    fn header_escapes_business_name_xss() {
5993        let view = JsonUiView::new().component(ComponentNode::header(
5994            "hdr",
5995            HeaderProps {
5996                business_name: "<script>alert(1)</script>".to_string(),
5997                notification_count: None,
5998                user_name: None,
5999                user_avatar: None,
6000                logout_url: None,
6001            },
6002        ));
6003        let html = render_to_html(&view, &json!({}));
6004        assert!(!html.contains("<script>"));
6005        assert!(html.contains("&lt;script&gt;"));
6006    }
6007
6008    // ── Edge case integration tests ───────────────────────────────────────
6009
6010    #[test]
6011    fn test_render_deeply_nested_components() {
6012        // Card -> Card -> Text (three levels deep)
6013        let inner_card = ComponentNode::card(
6014            "inner-card",
6015            CardProps {
6016                title: "Inner Card".to_string(),
6017                description: None,
6018                children: vec![ComponentNode {
6019                    key: "inner-text".to_string(),
6020                    component: Component::Text(TextProps {
6021                        content: "Deep content".to_string(),
6022                        element: TextElement::P,
6023                    }),
6024                    action: None,
6025                    visibility: None,
6026                }],
6027                footer: vec![],
6028                max_width: None,
6029            },
6030        );
6031        let outer_card = ComponentNode::card(
6032            "outer-card",
6033            CardProps {
6034                title: "Outer Card".to_string(),
6035                description: None,
6036                children: vec![inner_card],
6037                footer: vec![],
6038                max_width: None,
6039            },
6040        );
6041        let view = JsonUiView::new().component(outer_card);
6042        let html = render_to_html(&view, &json!({}));
6043
6044        assert!(
6045            html.contains("Outer Card"),
6046            "outer card title should be rendered"
6047        );
6048        assert!(
6049            html.contains("Inner Card"),
6050            "inner card title should be rendered"
6051        );
6052        assert!(
6053            html.contains("Deep content"),
6054            "nested text content should be rendered"
6055        );
6056    }
6057
6058    #[test]
6059    fn test_render_empty_view() {
6060        let view = JsonUiView::new();
6061        let html = render_to_html(&view, &json!({}));
6062        assert_eq!(html, "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\"></div>", "empty view renders empty div");
6063    }
6064
6065    #[test]
6066    fn test_render_component_with_visibility_and_action() {
6067        use crate::action::{Action, HttpMethod};
6068        use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
6069
6070        // A ComponentNode with GET action + URL wraps in <a href="...">.
6071        let node = ComponentNode {
6072            key: "admin-link".to_string(),
6073            component: Component::Button(ButtonProps {
6074                label: "View Reports".to_string(),
6075                variant: ButtonVariant::Default,
6076                size: Size::Default,
6077                disabled: None,
6078                icon: None,
6079                icon_position: None,
6080                button_type: None,
6081            }),
6082            action: Some(Action {
6083                handler: "reports.index".to_string(),
6084                url: Some("/reports".to_string()),
6085                method: HttpMethod::Get,
6086                confirm: None,
6087                on_success: None,
6088                on_error: None,
6089                target: None,
6090            }),
6091            visibility: Some(Visibility::Condition(VisibilityCondition {
6092                path: "/auth/user/role".to_string(),
6093                operator: VisibilityOperator::Eq,
6094                value: Some(serde_json::Value::String("admin".to_string())),
6095            })),
6096        };
6097        let view = JsonUiView::new().component(node);
6098        let html = render_to_html(&view, &json!({}));
6099
6100        // GET action with URL wraps the component in <a href="...">
6101        assert!(
6102            html.contains("View Reports"),
6103            "button label should be rendered"
6104        );
6105        assert!(
6106            html.contains("href=\"/reports\""),
6107            "GET action with URL should produce anchor href"
6108        );
6109        assert!(
6110            html.contains("<a "),
6111            "GET action should wrap component in anchor tag"
6112        );
6113    }
6114
6115    // ── Grid ──────────────────────────────────────────────────────────
6116
6117    #[test]
6118    fn grid_renders_columns_and_gap() {
6119        let view = JsonUiView::new().component(ComponentNode::grid(
6120            "g",
6121            crate::component::GridProps {
6122                columns: 4,
6123                md_columns: None,
6124                lg_columns: None,
6125                gap: crate::component::GapSize::Lg,
6126                scrollable: None,
6127                children: vec![text_node("c1", "Cell 1", TextElement::P)],
6128            },
6129        ));
6130        let html = render_to_html(&view, &json!({}));
6131        assert!(html.contains("grid w-full grid-cols-4 gap-6"));
6132        assert!(html.contains("Cell 1"));
6133    }
6134
6135    #[test]
6136    fn grid_clamps_columns() {
6137        let view = JsonUiView::new().component(ComponentNode::grid(
6138            "g",
6139            crate::component::GridProps {
6140                columns: 20,
6141                md_columns: None,
6142                lg_columns: None,
6143                gap: crate::component::GapSize::default(),
6144                scrollable: None,
6145                children: vec![],
6146            },
6147        ));
6148        let html = render_to_html(&view, &json!({}));
6149        assert!(html.contains("grid-cols-12"));
6150    }
6151
6152    #[test]
6153    fn grid_responsive_md_columns() {
6154        let view = JsonUiView::new().component(ComponentNode::grid(
6155            "g",
6156            crate::component::GridProps {
6157                columns: 1,
6158                md_columns: Some(3),
6159                lg_columns: None,
6160                gap: crate::component::GapSize::Md,
6161                scrollable: None,
6162                children: vec![text_node("c1", "Cell 1", TextElement::P)],
6163            },
6164        ));
6165        let html = render_to_html(&view, &json!({}));
6166        assert!(html.contains("grid-cols-1 md:grid-cols-3"));
6167    }
6168
6169    #[test]
6170    fn grid_scrollable_renders_overflow() {
6171        let view = JsonUiView::new().component(ComponentNode::grid(
6172            "g",
6173            crate::component::GridProps {
6174                columns: 3,
6175                md_columns: None,
6176                lg_columns: None,
6177                gap: crate::component::GapSize::Md,
6178                scrollable: Some(true),
6179                children: vec![
6180                    text_node("c1", "Col 1", TextElement::P),
6181                    text_node("c2", "Col 2", TextElement::P),
6182                ],
6183            },
6184        ));
6185        let html = render_to_html(&view, &json!({}));
6186        assert!(
6187            html.contains("overflow-x-auto"),
6188            "should wrap with overflow-x-auto"
6189        );
6190        assert!(
6191            html.contains("grid-flow-col"),
6192            "should use grid-flow-col for scrollable"
6193        );
6194        assert!(
6195            html.contains("auto-cols-[minmax(280px,1fr)]"),
6196            "should use auto-cols"
6197        );
6198        assert!(html.contains("Col 1"));
6199        assert!(html.contains("Col 2"));
6200    }
6201
6202    #[test]
6203    fn grid_non_scrollable_unchanged() {
6204        let view = JsonUiView::new().component(ComponentNode::grid(
6205            "g",
6206            crate::component::GridProps {
6207                columns: 3,
6208                md_columns: None,
6209                lg_columns: None,
6210                gap: crate::component::GapSize::Md,
6211                scrollable: None,
6212                children: vec![text_node("c1", "Cell 1", TextElement::P)],
6213            },
6214        ));
6215        let html = render_to_html(&view, &json!({}));
6216        assert!(
6217            html.contains("grid w-full grid-cols-3 gap-4"),
6218            "non-scrollable should use grid-cols-N"
6219        );
6220        assert!(
6221            !html.contains("overflow-x-auto"),
6222            "non-scrollable should not have overflow-x-auto"
6223        );
6224        assert!(
6225            !html.contains("grid-flow-col"),
6226            "non-scrollable should not have grid-flow-col"
6227        );
6228    }
6229
6230    #[test]
6231    fn form_guard_renders_data_attribute() {
6232        let view = JsonUiView::new().component(ComponentNode {
6233            key: "f".to_string(),
6234            component: Component::Form(crate::component::FormProps {
6235                action: Action {
6236                    handler: "orders.create".to_string(),
6237                    url: Some("/orders".to_string()),
6238                    method: HttpMethod::Post,
6239                    confirm: None,
6240                    on_success: None,
6241                    on_error: None,
6242                    target: None,
6243                },
6244                fields: vec![],
6245                method: None,
6246                guard: Some("number-gt-0".to_string()),
6247                max_width: None,
6248            }),
6249            action: None,
6250            visibility: None,
6251        });
6252        let html = render_to_html(&view, &json!({}));
6253        assert!(
6254            html.contains("data-form-guard=\"number-gt-0\""),
6255            "form with guard should render data-form-guard attribute"
6256        );
6257    }
6258
6259    #[test]
6260    fn form_without_guard_unchanged() {
6261        let view = JsonUiView::new().component(ComponentNode {
6262            key: "f".to_string(),
6263            component: Component::Form(crate::component::FormProps {
6264                action: Action {
6265                    handler: "orders.create".to_string(),
6266                    url: Some("/orders".to_string()),
6267                    method: HttpMethod::Post,
6268                    confirm: None,
6269                    on_success: None,
6270                    on_error: None,
6271                    target: None,
6272                },
6273                fields: vec![],
6274                method: None,
6275                guard: None,
6276                max_width: None,
6277            }),
6278            action: None,
6279            visibility: None,
6280        });
6281        let html = render_to_html(&view, &json!({}));
6282        assert!(
6283            !html.contains("data-form-guard"),
6284            "form without guard should not render data-form-guard attribute"
6285        );
6286    }
6287
6288    // ── Collapsible ───────────────────────────────────────────────────
6289
6290    #[test]
6291    fn collapsible_renders_details_summary() {
6292        let view = JsonUiView::new().component(ComponentNode::collapsible(
6293            "c",
6294            crate::component::CollapsibleProps {
6295                title: "More info".into(),
6296                expanded: false,
6297                children: vec![text_node("t", "Hidden text", TextElement::P)],
6298            },
6299        ));
6300        let html = render_to_html(&view, &json!({}));
6301        assert!(html.contains("<details"));
6302        assert!(!html.contains(" open"));
6303        assert!(html.contains("<summary"));
6304        assert!(html.contains("More info"));
6305        assert!(html.contains("Hidden text"));
6306    }
6307
6308    #[test]
6309    fn collapsible_expanded_has_open() {
6310        let view = JsonUiView::new().component(ComponentNode::collapsible(
6311            "c",
6312            crate::component::CollapsibleProps {
6313                title: "Open".into(),
6314                expanded: true,
6315                children: vec![],
6316            },
6317        ));
6318        let html = render_to_html(&view, &json!({}));
6319        assert!(html.contains("<details") && html.contains(" open"));
6320    }
6321
6322    // ── EmptyState ────────────────────────────────────────────────────
6323
6324    #[test]
6325    fn empty_state_renders_title_and_description() {
6326        let view = JsonUiView::new().component(ComponentNode::empty_state(
6327            "es",
6328            crate::component::EmptyStateProps {
6329                title: "No orders".into(),
6330                description: Some("Create your first order".into()),
6331                action: None,
6332                action_label: None,
6333            },
6334        ));
6335        let html = render_to_html(&view, &json!({}));
6336        assert!(html.contains("No orders"));
6337        assert!(html.contains("Create your first order"));
6338        assert!(!html.contains("<a "));
6339    }
6340
6341    #[test]
6342    fn empty_state_renders_action_link() {
6343        let view = JsonUiView::new().component(ComponentNode::empty_state(
6344            "es",
6345            crate::component::EmptyStateProps {
6346                title: "Empty".into(),
6347                description: None,
6348                action: Some(Action {
6349                    handler: "orders.new".into(),
6350                    url: Some("/orders/new".into()),
6351                    method: HttpMethod::Get,
6352                    confirm: None,
6353                    on_success: None,
6354                    on_error: None,
6355                    target: None,
6356                }),
6357                action_label: Some("New order".into()),
6358            },
6359        ));
6360        let html = render_to_html(&view, &json!({}));
6361        assert!(html.contains("href=\"/orders/new\""));
6362        assert!(html.contains("New order"));
6363    }
6364
6365    // ── FormSection ───────────────────────────────────────────────────
6366
6367    #[test]
6368    fn form_section_renders_fieldset() {
6369        let view = JsonUiView::new().component(ComponentNode::form_section(
6370            "fs",
6371            crate::component::FormSectionProps {
6372                title: "Contact".into(),
6373                description: Some("Enter details".into()),
6374                children: vec![text_node("n", "Name field", TextElement::P)],
6375                layout: None,
6376            },
6377        ));
6378        let html = render_to_html(&view, &json!({}));
6379        assert!(html.contains("<fieldset"));
6380        assert!(html.contains("<legend"));
6381        assert!(html.contains("Contact"));
6382        assert!(html.contains("Enter details"));
6383        assert!(html.contains("Name field"));
6384    }
6385
6386    // ── Switch auto-submit ────────────────────────────────────────────
6387
6388    #[test]
6389    fn switch_with_action_renders_form() {
6390        let view = JsonUiView::new().component(ComponentNode::switch(
6391            "sw",
6392            SwitchProps {
6393                field: "active".into(),
6394                label: "Active".into(),
6395                description: None,
6396                checked: Some(true),
6397                data_path: None,
6398                required: None,
6399                disabled: None,
6400                error: None,
6401                action: Some(Action {
6402                    handler: "settings.toggle".into(),
6403                    url: Some("/settings/toggle".into()),
6404                    method: HttpMethod::Post,
6405                    confirm: None,
6406                    on_success: None,
6407                    on_error: None,
6408                    target: None,
6409                }),
6410                compact: false,
6411            },
6412        ));
6413        let html = render_to_html(&view, &json!({}));
6414        assert!(html.contains("<form action=\"/settings/toggle\" method=\"post\">"));
6415        assert!(html.contains("onchange=\"this.closest('form').submit()\""));
6416        assert!(html.contains("</form>"));
6417    }
6418
6419    #[test]
6420    fn switch_without_action_no_form() {
6421        let view = JsonUiView::new().component(ComponentNode::switch(
6422            "sw",
6423            SwitchProps {
6424                field: "f".into(),
6425                label: "L".into(),
6426                description: None,
6427                checked: None,
6428                data_path: None,
6429                required: None,
6430                disabled: None,
6431                error: None,
6432                action: None,
6433                compact: false,
6434            },
6435        ));
6436        let html = render_to_html(&view, &json!({}));
6437        assert!(!html.contains("<form"));
6438        assert!(!html.contains("onchange"));
6439    }
6440
6441    // ── PageHeader ──────────────────────────────────────────────────────
6442
6443    #[test]
6444    fn test_render_page_header_title_only() {
6445        let view = JsonUiView::new().component(ComponentNode {
6446            key: "ph".to_string(),
6447            component: Component::PageHeader(PageHeaderProps {
6448                title: "My Page".to_string(),
6449                breadcrumb: vec![],
6450                actions: vec![],
6451            }),
6452            action: None,
6453            visibility: None,
6454        });
6455        let html = render_to_html(&view, &json!({}));
6456        assert!(html.contains("pb-4"), "flex container with pb-4");
6457        assert!(html.contains(
6458            "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text truncate\">My Page</h2>"
6459        ));
6460        assert!(!html.contains("<nav"), "no breadcrumb nav when empty");
6461        assert!(!html.contains("flex-shrink-0"), "no actions div when empty");
6462    }
6463
6464    #[test]
6465    fn test_render_page_header_with_breadcrumb() {
6466        let view = JsonUiView::new().component(ComponentNode {
6467            key: "ph".to_string(),
6468            component: Component::PageHeader(PageHeaderProps {
6469                title: "Users".to_string(),
6470                breadcrumb: vec![
6471                    BreadcrumbItem {
6472                        label: "Home".to_string(),
6473                        url: Some("/".to_string()),
6474                    },
6475                    BreadcrumbItem {
6476                        label: "Users".to_string(),
6477                        url: None,
6478                    },
6479                ],
6480                actions: vec![],
6481            }),
6482            action: None,
6483            visibility: None,
6484        });
6485        let html = render_to_html(&view, &json!({}));
6486        assert!(html.contains("<a href=\"/\" class=\"text-sm text-text-muted hover:text-text whitespace-nowrap\">Home</a>"));
6487        assert!(
6488            html.contains("<span class=\"text-sm text-text-muted whitespace-nowrap\">Users</span>")
6489        );
6490        assert!(
6491            html.contains("<svg"),
6492            "SVG chevron separator between breadcrumb items"
6493        );
6494    }
6495
6496    #[test]
6497    fn test_render_page_header_with_actions() {
6498        let view = JsonUiView::new().component(ComponentNode {
6499            key: "ph".to_string(),
6500            component: Component::PageHeader(PageHeaderProps {
6501                title: "Dashboard".to_string(),
6502                breadcrumb: vec![],
6503                actions: vec![ComponentNode {
6504                    key: "add-btn".to_string(),
6505                    component: Component::Button(ButtonProps {
6506                        label: "Add New".to_string(),
6507                        variant: ButtonVariant::Default,
6508                        size: Size::Default,
6509                        disabled: None,
6510                        icon: None,
6511                        icon_position: None,
6512                        button_type: None,
6513                    }),
6514                    action: None,
6515                    visibility: None,
6516                }],
6517            }),
6518            action: None,
6519            visibility: None,
6520        });
6521        let html = render_to_html(&view, &json!({}));
6522        assert!(
6523            html.contains("flex flex-wrap items-center gap-2"),
6524            "actions wrapper with flex"
6525        );
6526        assert!(
6527            html.contains(">Add New</button>"),
6528            "action button rendered inside"
6529        );
6530    }
6531
6532    // ── ButtonGroup ─────────────────────────────────────────────────────
6533
6534    #[test]
6535    fn test_render_button_group() {
6536        let view = JsonUiView::new().component(ComponentNode {
6537            key: "bg".to_string(),
6538            component: Component::ButtonGroup(ButtonGroupProps {
6539                buttons: vec![
6540                    ComponentNode {
6541                        key: "save".to_string(),
6542                        component: Component::Button(ButtonProps {
6543                            label: "Save".to_string(),
6544                            variant: ButtonVariant::Default,
6545                            size: Size::Default,
6546                            disabled: None,
6547                            icon: None,
6548                            icon_position: None,
6549                            button_type: None,
6550                        }),
6551                        action: None,
6552                        visibility: None,
6553                    },
6554                    ComponentNode {
6555                        key: "cancel".to_string(),
6556                        component: Component::Button(ButtonProps {
6557                            label: "Cancel".to_string(),
6558                            variant: ButtonVariant::Outline,
6559                            size: Size::Default,
6560                            disabled: None,
6561                            icon: None,
6562                            icon_position: None,
6563                            button_type: None,
6564                        }),
6565                        action: None,
6566                        visibility: None,
6567                    },
6568                ],
6569            }),
6570            action: None,
6571            visibility: None,
6572        });
6573        let html = render_to_html(&view, &json!({}));
6574        assert!(
6575            html.contains("flex items-center gap-2 flex-wrap"),
6576            "horizontal flex container"
6577        );
6578        assert!(html.contains(">Save</button>"));
6579        assert!(html.contains(">Cancel</button>"));
6580    }
6581
6582    #[test]
6583    fn test_render_button_group_empty() {
6584        let view = JsonUiView::new().component(ComponentNode {
6585            key: "bg".to_string(),
6586            component: Component::ButtonGroup(ButtonGroupProps { buttons: vec![] }),
6587            action: None,
6588            visibility: None,
6589        });
6590        let html = render_to_html(&view, &json!({}));
6591        assert!(html.contains("<div class=\"flex items-center gap-2 flex-wrap\"></div>"));
6592    }
6593
6594    // ── Select appearance-none fix ──────────────────────────────────────
6595
6596    #[test]
6597    fn test_render_select_appearance_none() {
6598        let view = JsonUiView::new().component(ComponentNode {
6599            key: "sel".to_string(),
6600            component: Component::Select(SelectProps {
6601                field: "role".to_string(),
6602                label: "Role".to_string(),
6603                options: vec![SelectOption {
6604                    value: "admin".to_string(),
6605                    label: "Admin".to_string(),
6606                }],
6607                placeholder: None,
6608                required: None,
6609                disabled: None,
6610                error: None,
6611                description: None,
6612                default_value: None,
6613                data_path: None,
6614            }),
6615            action: None,
6616            visibility: None,
6617        });
6618        let html = render_to_html(&view, &json!({}));
6619        assert!(
6620            html.contains("appearance-none"),
6621            "select must have appearance-none class"
6622        );
6623        assert!(
6624            html.contains("bg-background"),
6625            "select must have bg-background class"
6626        );
6627    }
6628
6629    // ── Single-tab auto-hide ────────────────────────────────────────────
6630
6631    #[test]
6632    fn test_render_tabs_single_tab() {
6633        let view = JsonUiView::new().component(ComponentNode {
6634            key: "tabs".to_string(),
6635            component: Component::Tabs(TabsProps {
6636                default_tab: "only".to_string(),
6637                tabs: vec![Tab {
6638                    value: "only".to_string(),
6639                    label: "Only Tab".to_string(),
6640                    children: vec![ComponentNode {
6641                        key: "txt".to_string(),
6642                        component: Component::Text(TextProps {
6643                            content: "Content here".to_string(),
6644                            element: TextElement::P,
6645                        }),
6646                        action: None,
6647                        visibility: None,
6648                    }],
6649                }],
6650            }),
6651            action: None,
6652            visibility: None,
6653        });
6654        let html = render_to_html(&view, &json!({}));
6655        assert!(
6656            !html.contains("data-tabs"),
6657            "no data-tabs wrapper for single tab"
6658        );
6659        assert!(
6660            !html.contains("role=\"tablist\""),
6661            "no tab nav for single tab"
6662        );
6663        assert!(html.contains("Content here"), "tab content still rendered");
6664    }
6665
6666    #[test]
6667    fn test_render_tabs_multi_still_works() {
6668        let view = JsonUiView::new().component(ComponentNode {
6669            key: "tabs".to_string(),
6670            component: Component::Tabs(TabsProps {
6671                default_tab: "tab1".to_string(),
6672                tabs: vec![
6673                    Tab {
6674                        value: "tab1".to_string(),
6675                        label: "Tab One".to_string(),
6676                        children: vec![ComponentNode {
6677                            key: "t1".to_string(),
6678                            component: Component::Text(TextProps {
6679                                content: "Tab 1 content".to_string(),
6680                                element: TextElement::P,
6681                            }),
6682                            action: None,
6683                            visibility: None,
6684                        }],
6685                    },
6686                    Tab {
6687                        value: "tab2".to_string(),
6688                        label: "Tab Two".to_string(),
6689                        children: vec![ComponentNode {
6690                            key: "t2".to_string(),
6691                            component: Component::Text(TextProps {
6692                                content: "Tab 2 content".to_string(),
6693                                element: TextElement::P,
6694                            }),
6695                            action: None,
6696                            visibility: None,
6697                        }],
6698                    },
6699                ],
6700            }),
6701            action: None,
6702            visibility: None,
6703        });
6704        let html = render_to_html(&view, &json!({}));
6705        assert!(
6706            html.contains("data-tabs"),
6707            "multi-tab still has data-tabs wrapper"
6708        );
6709        assert!(
6710            html.contains("role=\"tablist\""),
6711            "multi-tab still has nav with role=tablist"
6712        );
6713        assert!(html.contains("Tab One"), "tab label rendered");
6714        assert!(html.contains("Tab Two"), "tab label rendered");
6715    }
6716
6717    // ── Structural test helpers ───────────────────────────────────────────
6718
6719    /// Check that rendered HTML contains an element with a specific CSS class.
6720    /// Checks class as: sole class, first class, middle class, or last class.
6721    /// More resilient than full class string matching — survives class additions.
6722    fn has_class(html: &str, class: &str) -> bool {
6723        html.contains(&format!("class=\"{class}\""))
6724            || html.contains(&format!("class=\"{class} "))
6725            || html.contains(&format!(" {class}\""))
6726            || html.contains(&format!(" {class} "))
6727    }
6728
6729    /// Assert HTML contains a specific element tag and content string.
6730    fn assert_element(html: &str, tag: &str, content: &str) {
6731        assert!(
6732            html.contains(&format!("<{tag} ")) || html.contains(&format!("<{tag}>")),
6733            "expected <{tag}> element in HTML"
6734        );
6735        assert!(
6736            html.contains(content),
6737            "expected content '{content}' in HTML"
6738        );
6739    }
6740
6741    // ── Structural tests — survive cosmetic class additions ───────────────
6742    //
6743    // These tests verify element type, text content, and semantic token classes
6744    // without matching the full class attribute string. Adding classes like
6745    // `leading-tight` or `bg-card` will not break these tests.
6746    //
6747    // The existing full-string tests above remain as documentation of current
6748    // class output and will be updated in Phases 103-107 as classes change.
6749
6750    mod structural_tests {
6751        use super::*;
6752        use serde_json::json;
6753
6754        // ── Text H1 (Phase 104 adds leading-tight tracking-tight) ─────────
6755
6756        #[test]
6757        fn h1_structural_element_and_semantic_class() {
6758            let view = JsonUiView::new().component(text_node("t", "Page Title", TextElement::H1));
6759            let html = render_to_html(&view, &json!({}));
6760            assert_element(&html, "h1", "Page Title");
6761            assert!(
6762                has_class(&html, "text-text"),
6763                "h1 should have text-text class"
6764            );
6765        }
6766
6767        // ── Text H2 (Phase 104 adds leading-tight tracking-tight) ─────────
6768
6769        #[test]
6770        fn h2_structural_element_and_semantic_class() {
6771            let view =
6772                JsonUiView::new().component(text_node("t", "Section Title", TextElement::H2));
6773            let html = render_to_html(&view, &json!({}));
6774            assert_element(&html, "h2", "Section Title");
6775            assert!(
6776                has_class(&html, "text-text"),
6777                "h2 should have text-text class"
6778            );
6779        }
6780
6781        // ── Text H3 (Phase 104 adds leading-snug) ─────────────────────────
6782
6783        #[test]
6784        fn h3_structural_element_and_semantic_class() {
6785            let view = JsonUiView::new().component(text_node("t", "Subsection", TextElement::H3));
6786            let html = render_to_html(&view, &json!({}));
6787            assert_element(&html, "h3", "Subsection");
6788            assert!(
6789                has_class(&html, "text-text"),
6790                "h3 should have text-text class"
6791            );
6792        }
6793
6794        // ── Text P (Phase 104 adds leading-relaxed) ───────────────────────
6795
6796        #[test]
6797        fn p_structural_element_and_semantic_class() {
6798            let view = JsonUiView::new().component(text_node("t", "Body text", TextElement::P));
6799            let html = render_to_html(&view, &json!({}));
6800            assert_element(&html, "p", "Body text");
6801            assert!(
6802                has_class(&html, "text-text"),
6803                "p should have text-text class"
6804            );
6805        }
6806
6807        // ── Card (Phase 103 adds bg-card) ─────────────────────────────────
6808
6809        #[test]
6810        fn card_structural_title_and_description() {
6811            let view = JsonUiView::new().component(ComponentNode {
6812                key: "c".to_string(),
6813                component: Component::Card(CardProps {
6814                    title: "Card Title".to_string(),
6815                    description: Some("Card description".to_string()),
6816                    children: vec![],
6817                    footer: vec![],
6818                    max_width: None,
6819                }),
6820                action: None,
6821                visibility: None,
6822            });
6823            let html = render_to_html(&view, &json!({}));
6824            assert!(html.contains("<div"), "card should render a div container");
6825            assert!(html.contains("Card Title"), "card title should be present");
6826            assert!(
6827                html.contains("Card description"),
6828                "card description should be present"
6829            );
6830            assert!(
6831                has_class(&html, "border-border"),
6832                "card container should have border-border"
6833            );
6834        }
6835
6836        // ── Alert (Phase 107 adds SVG icon) ──────────────────────────────
6837
6838        #[test]
6839        fn alert_structural_container_and_message() {
6840            let view = JsonUiView::new().component(ComponentNode {
6841                key: "a".to_string(),
6842                component: Component::Alert(AlertProps {
6843                    message: "Something went wrong".to_string(),
6844                    variant: AlertVariant::Warning,
6845                    title: None,
6846                }),
6847                action: None,
6848                visibility: None,
6849            });
6850            let html = render_to_html(&view, &json!({}));
6851            assert!(
6852                html.contains("role=\"alert\""),
6853                "alert should have role=alert"
6854            );
6855            assert!(
6856                html.contains("Something went wrong"),
6857                "alert message should be present"
6858            );
6859            assert!(
6860                has_class(&html, "text-warning"),
6861                "warning alert should have text-warning class"
6862            );
6863        }
6864
6865        // ── Input (Phase 105 adds transitions, disabled states) ───────────
6866
6867        #[test]
6868        fn input_structural_element_and_type() {
6869            let view = JsonUiView::new().component(ComponentNode {
6870                key: "i".to_string(),
6871                component: Component::Input(InputProps {
6872                    field: "username".to_string(),
6873                    label: "Username".to_string(),
6874                    input_type: InputType::Text,
6875                    placeholder: None,
6876                    required: None,
6877                    disabled: None,
6878                    error: None,
6879                    description: None,
6880                    default_value: None,
6881                    data_path: None,
6882                    step: None,
6883                    list: None,
6884                }),
6885                action: None,
6886                visibility: None,
6887            });
6888            let html = render_to_html(&view, &json!({}));
6889            assert!(
6890                html.contains("<input"),
6891                "input should render an <input element"
6892            );
6893            assert!(html.contains("type=\"text\""), "input type should be text");
6894            assert!(
6895                html.contains("name=\"username\""),
6896                "input name should match field"
6897            );
6898        }
6899
6900        // ── Select (Phase 105 adds custom arrow styling) ──────────────────
6901
6902        #[test]
6903        fn select_structural_element_and_options() {
6904            let view = JsonUiView::new().component(ComponentNode {
6905                key: "s".to_string(),
6906                component: Component::Select(SelectProps {
6907                    field: "status".to_string(),
6908                    label: "Status".to_string(),
6909                    options: vec![
6910                        SelectOption {
6911                            value: "active".to_string(),
6912                            label: "Active".to_string(),
6913                        },
6914                        SelectOption {
6915                            value: "inactive".to_string(),
6916                            label: "Inactive".to_string(),
6917                        },
6918                    ],
6919                    placeholder: None,
6920                    required: None,
6921                    disabled: None,
6922                    error: None,
6923                    description: None,
6924                    default_value: None,
6925                    data_path: None,
6926                }),
6927                action: None,
6928                visibility: None,
6929            });
6930            let html = render_to_html(&view, &json!({}));
6931            assert!(
6932                html.contains("<select"),
6933                "select should render a <select element"
6934            );
6935            assert!(
6936                html.contains("Active"),
6937                "select should render option labels"
6938            );
6939            assert!(
6940                html.contains("Inactive"),
6941                "select should render all options"
6942            );
6943        }
6944
6945        // ── Table (Phase 106 adds hover:bg-surface to rows) ───────────────
6946
6947        #[test]
6948        fn table_structural_headers_and_body() {
6949            let data = json!({
6950                "items": [{"name": "Widget", "price": "9.99"}]
6951            });
6952            let view = JsonUiView::new().component(ComponentNode {
6953                key: "t".to_string(),
6954                component: Component::Table(TableProps {
6955                    columns: vec![
6956                        Column {
6957                            key: "name".to_string(),
6958                            label: "Name".to_string(),
6959                            format: None,
6960                        },
6961                        Column {
6962                            key: "price".to_string(),
6963                            label: "Price".to_string(),
6964                            format: None,
6965                        },
6966                    ],
6967                    data_path: "/items".to_string(),
6968                    row_actions: None,
6969                    empty_message: None,
6970                    sortable: None,
6971                    sort_column: None,
6972                    sort_direction: None,
6973                }),
6974                action: None,
6975                visibility: None,
6976            });
6977            let html = render_to_html(&view, &data);
6978            assert!(
6979                html.contains("<table"),
6980                "table should render a <table element"
6981            );
6982            assert!(html.contains("<th"), "table should render header cells");
6983            assert!(
6984                html.contains("Name"),
6985                "table should render Name column header"
6986            );
6987            assert!(
6988                html.contains("Price"),
6989                "table should render Price column header"
6990            );
6991            assert!(html.contains("Widget"), "table should render row data");
6992        }
6993
6994        // ── Breadcrumb (Phase 107 changes separator) ──────────────────────
6995
6996        #[test]
6997        fn breadcrumb_structural_nav_and_links() {
6998            let view = JsonUiView::new().component(ComponentNode {
6999                key: "bc".to_string(),
7000                component: Component::Breadcrumb(BreadcrumbProps {
7001                    items: vec![
7002                        BreadcrumbItem {
7003                            label: "Home".to_string(),
7004                            url: Some("/".to_string()),
7005                        },
7006                        BreadcrumbItem {
7007                            label: "Products".to_string(),
7008                            url: Some("/products".to_string()),
7009                        },
7010                        BreadcrumbItem {
7011                            label: "Widget".to_string(),
7012                            url: None,
7013                        },
7014                    ],
7015                }),
7016                action: None,
7017                visibility: None,
7018            });
7019            let html = render_to_html(&view, &json!({}));
7020            assert!(
7021                html.contains("<nav"),
7022                "breadcrumb should render a <nav element"
7023            );
7024            assert!(
7025                html.contains("href=\"/\""),
7026                "breadcrumb should render Home link"
7027            );
7028            assert!(
7029                html.contains("href=\"/products\""),
7030                "breadcrumb should render Products link"
7031            );
7032            assert!(
7033                html.contains("Widget"),
7034                "breadcrumb should render last item text"
7035            );
7036        }
7037
7038        // ── Tabs (Phase 107 adds font-semibold to active tab) ─────────────
7039
7040        #[test]
7041        fn tabs_structural_buttons_and_content() {
7042            let view = JsonUiView::new().component(ComponentNode {
7043                key: "tabs".to_string(),
7044                component: Component::Tabs(TabsProps {
7045                    default_tab: "overview".to_string(),
7046                    tabs: vec![
7047                        Tab {
7048                            value: "overview".to_string(),
7049                            label: "Overview".to_string(),
7050                            children: vec![text_node("t1", "Overview content", TextElement::P)],
7051                        },
7052                        Tab {
7053                            value: "details".to_string(),
7054                            label: "Details".to_string(),
7055                            children: vec![text_node("t2", "Details content", TextElement::P)],
7056                        },
7057                    ],
7058                }),
7059                action: None,
7060                visibility: None,
7061            });
7062            let html = render_to_html(&view, &json!({}));
7063            assert!(
7064                html.contains("<button"),
7065                "tabs should render button elements"
7066            );
7067            assert!(html.contains("Overview"), "tabs should render tab labels");
7068            assert!(
7069                html.contains("Details"),
7070                "tabs should render all tab labels"
7071            );
7072            assert!(
7073                html.contains("Overview content"),
7074                "tabs should render active tab content"
7075            );
7076        }
7077
7078        // ── StatCard (Phase 103 adds bg-card) ────────────────────────────
7079
7080        #[test]
7081        fn stat_card_structural_value_and_label() {
7082            let view = JsonUiView::new().component(ComponentNode::stat_card(
7083                "sales",
7084                StatCardProps {
7085                    label: "Total Sales".to_string(),
7086                    value: "1,024".to_string(),
7087                    icon: None,
7088                    subtitle: None,
7089                    sse_target: None,
7090                },
7091            ));
7092            let html = render_to_html(&view, &json!({}));
7093            assert!(
7094                html.contains("Total Sales"),
7095                "stat card should render label"
7096            );
7097            assert!(html.contains("1,024"), "stat card should render value");
7098            assert!(
7099                has_class(&html, "rounded-lg"),
7100                "stat card should have rounded-lg class"
7101            );
7102        }
7103
7104        // ── Skeleton (Phase 107 changes animation class) ──────────────────
7105
7106        #[test]
7107        fn skeleton_structural_animate_class() {
7108            let view = JsonUiView::new().component(ComponentNode {
7109                key: "sk".to_string(),
7110                component: Component::Skeleton(SkeletonProps {
7111                    width: None,
7112                    height: None,
7113                    rounded: None,
7114                }),
7115                action: None,
7116                visibility: None,
7117            });
7118            let html = render_to_html(&view, &json!({}));
7119            assert!(html.contains("<div"), "skeleton should render a div");
7120            assert!(
7121                html.contains("ferro-shimmer"),
7122                "skeleton should have ferro-shimmer class"
7123            );
7124        }
7125
7126        // ── Collapsible (Phase 107 adds SVG chevron) ──────────────────────
7127
7128        #[test]
7129        fn collapsible_structural_details_element() {
7130            let view = JsonUiView::new().component(ComponentNode::collapsible(
7131                "col",
7132                crate::component::CollapsibleProps {
7133                    title: "Show more".into(),
7134                    expanded: false,
7135                    children: vec![text_node("t", "Collapsed content", TextElement::P)],
7136                },
7137            ));
7138            let html = render_to_html(&view, &json!({}));
7139            assert!(
7140                html.contains("<details"),
7141                "collapsible should render a <details element"
7142            );
7143            assert!(
7144                html.contains("Show more"),
7145                "collapsible should render the title"
7146            );
7147            assert!(
7148                html.contains("Collapsed content"),
7149                "collapsible should render children"
7150            );
7151        }
7152
7153        // ── CMP-01: Alert SVG icon per variant (Phase 107) ───────────────────
7154
7155        #[test]
7156        fn alert_svg_icon_per_variant() {
7157            use crate::component::AlertVariant;
7158            let variants = [
7159                AlertVariant::Info,
7160                AlertVariant::Success,
7161                AlertVariant::Warning,
7162                AlertVariant::Error,
7163            ];
7164            for variant in variants {
7165                let view = JsonUiView::new().component(ComponentNode {
7166                    key: "a".to_string(),
7167                    component: Component::Alert(AlertProps {
7168                        variant,
7169                        title: None,
7170                        message: "Test message".to_string(),
7171                    }),
7172                    action: None,
7173                    visibility: None,
7174                });
7175                let html = render_to_html(&view, &json!({}));
7176                assert!(html.contains("<svg"), "alert should contain SVG icon");
7177                assert!(
7178                    html.contains("role=\"alert\""),
7179                    "alert should preserve accessibility role"
7180                );
7181                assert!(
7182                    has_class(&html, "flex"),
7183                    "alert container should have flex class"
7184                );
7185            }
7186        }
7187
7188        // ── CMP-02: Skeleton shimmer class (Phase 107) ────────────────────
7189
7190        #[test]
7191        fn skeleton_shimmer_class() {
7192            let view = JsonUiView::new().component(ComponentNode {
7193                key: "sk".to_string(),
7194                component: Component::Skeleton(SkeletonProps {
7195                    width: None,
7196                    height: None,
7197                    rounded: None,
7198                }),
7199                action: None,
7200                visibility: None,
7201            });
7202            let html = render_to_html(&view, &json!({}));
7203            assert!(
7204                html.contains("ferro-shimmer"),
7205                "shimmer class should be present"
7206            );
7207            assert!(
7208                !html.contains("animate-pulse"),
7209                "old pulse class should be removed"
7210            );
7211            assert!(
7212                html.contains("@keyframes ferro-shimmer"),
7213                "CSS keyframe should be injected"
7214            );
7215        }
7216
7217        // ── CMP-03: Breadcrumb SVG separator (Phase 107) ─────────────────
7218
7219        #[test]
7220        fn breadcrumb_svg_separator() {
7221            let view = JsonUiView::new().component(ComponentNode {
7222                key: "bc".to_string(),
7223                component: Component::Breadcrumb(BreadcrumbProps {
7224                    items: vec![
7225                        BreadcrumbItem {
7226                            label: "Home".to_string(),
7227                            url: Some("/".to_string()),
7228                        },
7229                        BreadcrumbItem {
7230                            label: "Products".to_string(),
7231                            url: Some("/products".to_string()),
7232                        },
7233                        BreadcrumbItem {
7234                            label: "Detail".to_string(),
7235                            url: None,
7236                        },
7237                    ],
7238                }),
7239                action: None,
7240                visibility: None,
7241            });
7242            let html = render_to_html(&view, &json!({}));
7243            assert!(html.contains("<svg"), "SVG separator should be present");
7244            assert!(
7245                !html.contains("<span>/</span>"),
7246                "old text separator should be removed"
7247            );
7248            assert!(
7249                html.contains("aria-hidden"),
7250                "separator should be decorative (aria-hidden)"
7251            );
7252        }
7253
7254        // ── CMP-04: Active tab font-semibold (Phase 107) ─────────────────
7255
7256        #[test]
7257        fn tab_active_font_semibold() {
7258            let view = JsonUiView::new().component(ComponentNode {
7259                key: "tabs".to_string(),
7260                component: Component::Tabs(TabsProps {
7261                    default_tab: "tab1".to_string(),
7262                    tabs: vec![
7263                        Tab {
7264                            value: "tab1".to_string(),
7265                            label: "First Tab".to_string(),
7266                            children: vec![text_node("t1", "Content one", TextElement::P)],
7267                        },
7268                        Tab {
7269                            value: "tab2".to_string(),
7270                            label: "Second Tab".to_string(),
7271                            children: vec![text_node("t2", "Content two", TextElement::P)],
7272                        },
7273                    ],
7274                }),
7275                action: None,
7276                visibility: None,
7277            });
7278            let html = render_to_html(&view, &json!({}));
7279            assert!(
7280                has_class(&html, "font-semibold"),
7281                "active tab should have font-semibold class"
7282            );
7283            let count = html.matches("font-semibold").count();
7284            assert_eq!(count, 1, "only the active tab should have font-semibold");
7285        }
7286
7287        // ── CMP-05: Notification bell SVG (Phase 107) ─────────────────────
7288
7289        #[test]
7290        fn notification_bell_svg() {
7291            let view = JsonUiView::new().component(ComponentNode {
7292                key: "nd".to_string(),
7293                component: Component::NotificationDropdown(NotificationDropdownProps {
7294                    notifications: vec![crate::component::NotificationItem {
7295                        text: "New message".to_string(),
7296                        read: false,
7297                        icon: None,
7298                        action_url: None,
7299                        timestamp: None,
7300                    }],
7301                    empty_text: None,
7302                }),
7303                action: None,
7304                visibility: None,
7305            });
7306            let html = render_to_html(&view, &json!({}));
7307            assert!(html.contains("<svg"), "SVG bell should be present");
7308            assert!(
7309                !html.contains("&#x1F514;"),
7310                "bell emoji entity should be removed"
7311            );
7312        }
7313
7314        // ── CMP-06: Collapsible SVG chevron (Phase 107) ───────────────────
7315
7316        #[test]
7317        fn collapsible_svg_chevron() {
7318            let view = JsonUiView::new().component(ComponentNode::collapsible(
7319                "col",
7320                crate::component::CollapsibleProps {
7321                    title: "Section".into(),
7322                    expanded: false,
7323                    children: vec![text_node("t", "Body text", TextElement::P)],
7324                },
7325            ));
7326            let html = render_to_html(&view, &json!({}));
7327            assert!(html.contains("<svg"), "SVG chevron should be present");
7328            assert!(
7329                !html.contains("&#9660;"),
7330                "old down-arrow entity should be removed"
7331            );
7332            assert!(
7333                has_class(&html, "group-open:rotate-180"),
7334                "rotation class should be preserved"
7335            );
7336            assert!(
7337                has_class(&html, "transition-transform"),
7338                "transition class should be preserved"
7339            );
7340        }
7341
7342        // ── Form polish (Phase 105) ────────────────────────────────────────
7343
7344        #[test]
7345        fn select_renders_chevron_wrapper() {
7346            let view = JsonUiView::new().component(ComponentNode {
7347                key: "s".to_string(),
7348                component: Component::Select(SelectProps {
7349                    field: "role".to_string(),
7350                    label: "Role".to_string(),
7351                    options: vec![],
7352                    placeholder: None,
7353                    required: None,
7354                    disabled: None,
7355                    error: None,
7356                    description: None,
7357                    default_value: None,
7358                    data_path: None,
7359                }),
7360                action: None,
7361                visibility: None,
7362            });
7363            let html = render_to_html(&view, &json!({}));
7364            assert!(
7365                html.contains("<div class=\"relative\">"),
7366                "select should be wrapped in relative div"
7367            );
7368            assert!(
7369                html.contains("aria-hidden=\"true\""),
7370                "SVG span should have aria-hidden"
7371            );
7372            assert!(
7373                html.contains("<svg"),
7374                "inline SVG chevron should be present"
7375            );
7376            assert!(
7377                html.contains("pointer-events-none"),
7378                "SVG span should be non-interactive"
7379            );
7380            assert!(has_class(&html, "pr-10"), "select should have pr-10 class");
7381        }
7382
7383        #[test]
7384        fn input_renders_transition_classes() {
7385            let view = JsonUiView::new().component(ComponentNode {
7386                key: "i".to_string(),
7387                component: Component::Input(InputProps {
7388                    field: "name".to_string(),
7389                    label: "Name".to_string(),
7390                    input_type: InputType::Text,
7391                    placeholder: None,
7392                    required: None,
7393                    disabled: None,
7394                    error: None,
7395                    description: None,
7396                    default_value: None,
7397                    data_path: None,
7398                    step: None,
7399                    list: None,
7400                }),
7401                action: None,
7402                visibility: None,
7403            });
7404            let html = render_to_html(&view, &json!({}));
7405            assert!(
7406                has_class(&html, "transition-colors"),
7407                "input should have transition-colors"
7408            );
7409            assert!(
7410                has_class(&html, "duration-150"),
7411                "input should have duration-150"
7412            );
7413            assert!(
7414                html.contains("motion-reduce:transition-none"),
7415                "input should support reduced motion"
7416            );
7417        }
7418
7419        #[test]
7420        fn input_disabled_renders_disabled_classes() {
7421            let view = JsonUiView::new().component(ComponentNode {
7422                key: "i".to_string(),
7423                component: Component::Input(InputProps {
7424                    field: "name".to_string(),
7425                    label: "Name".to_string(),
7426                    input_type: InputType::Text,
7427                    placeholder: None,
7428                    required: None,
7429                    disabled: Some(true),
7430                    error: None,
7431                    description: None,
7432                    default_value: None,
7433                    data_path: None,
7434                    step: None,
7435                    list: None,
7436                }),
7437                action: None,
7438                visibility: None,
7439            });
7440            let html = render_to_html(&view, &json!({}));
7441            assert!(
7442                html.contains("disabled:opacity-50"),
7443                "input should have disabled:opacity-50"
7444            );
7445            assert!(
7446                html.contains("disabled:cursor-not-allowed"),
7447                "input should have disabled:cursor-not-allowed"
7448            );
7449            assert!(
7450                html.contains(" disabled"),
7451                "input should have disabled HTML attribute"
7452            );
7453        }
7454
7455        #[test]
7456        fn textarea_renders_error_focus_ring() {
7457            let view = JsonUiView::new().component(ComponentNode {
7458                key: "i".to_string(),
7459                component: Component::Input(InputProps {
7460                    field: "bio".to_string(),
7461                    label: "Bio".to_string(),
7462                    input_type: InputType::Textarea,
7463                    placeholder: None,
7464                    required: None,
7465                    disabled: None,
7466                    error: Some("Required".to_string()),
7467                    description: None,
7468                    default_value: None,
7469                    data_path: None,
7470                    step: None,
7471                    list: None,
7472                }),
7473                action: None,
7474                visibility: None,
7475            });
7476            let html = render_to_html(&view, &json!({}));
7477            assert!(
7478                html.contains("ring-destructive"),
7479                "textarea with error should have ring-destructive"
7480            );
7481        }
7482
7483        #[test]
7484        fn input_description_order() {
7485            let view = JsonUiView::new().component(ComponentNode {
7486                key: "i".to_string(),
7487                component: Component::Input(InputProps {
7488                    field: "name".to_string(),
7489                    label: "Name".to_string(),
7490                    input_type: InputType::Text,
7491                    placeholder: None,
7492                    required: None,
7493                    disabled: None,
7494                    error: None,
7495                    description: Some("Help text".to_string()),
7496                    default_value: None,
7497                    data_path: None,
7498                    step: None,
7499                    list: None,
7500                }),
7501                action: None,
7502                visibility: None,
7503            });
7504            let html = render_to_html(&view, &json!({}));
7505            let input_pos = html.find("<input").expect("input element should exist");
7506            let desc_pos = html.find("Help text").expect("description should exist");
7507            assert!(
7508                input_pos < desc_pos,
7509                "input should appear before description in DOM"
7510            );
7511        }
7512
7513        #[test]
7514        fn select_description_order() {
7515            let view = JsonUiView::new().component(ComponentNode {
7516                key: "s".to_string(),
7517                component: Component::Select(SelectProps {
7518                    field: "role".to_string(),
7519                    label: "Role".to_string(),
7520                    options: vec![],
7521                    placeholder: None,
7522                    required: None,
7523                    disabled: None,
7524                    error: None,
7525                    description: Some("Pick one".to_string()),
7526                    default_value: None,
7527                    data_path: None,
7528                }),
7529                action: None,
7530                visibility: None,
7531            });
7532            let html = render_to_html(&view, &json!({}));
7533            let select_close_pos = html.find("</select>").expect("select close should exist");
7534            let desc_pos = html.find("Pick one").expect("description should exist");
7535            assert!(
7536                select_close_pos < desc_pos,
7537                "select close should appear before description in DOM"
7538            );
7539        }
7540
7541        // ── Button (Phase 106 adds focus-visible ring) ────────────────────
7542
7543        #[test]
7544        fn button_structural_element_and_text() {
7545            let view = JsonUiView::new().component(button_node(
7546                "btn",
7547                "Submit",
7548                ButtonVariant::Default,
7549                Size::Default,
7550            ));
7551            let html = render_to_html(&view, &json!({}));
7552            assert!(
7553                html.contains("<button"),
7554                "button should render a <button element"
7555            );
7556            assert!(html.contains("Submit"), "button should render label text");
7557            assert!(
7558                has_class(&html, "bg-primary"),
7559                "default button should have bg-primary class"
7560            );
7561        }
7562
7563        // ── INT-01: Button focus ring and transition ───────────────────────
7564
7565        #[test]
7566        fn button_focus_ring() {
7567            let view = JsonUiView::new().component(button_node(
7568                "btn",
7569                "Click me",
7570                ButtonVariant::Default,
7571                Size::Default,
7572            ));
7573            let html = render_to_html(&view, &json!({}));
7574            assert!(
7575                has_class(&html, "focus-visible:ring-primary"),
7576                "button should have focus-visible:ring-primary class (INT-01)"
7577            );
7578            assert!(
7579                has_class(&html, "duration-150"),
7580                "button should have duration-150 class (INT-07)"
7581            );
7582            assert!(
7583                html.contains("motion-reduce:transition-none"),
7584                "button should have motion-reduce:transition-none (INT-07)"
7585            );
7586        }
7587
7588        // ── INT-02: Tabs focus ring and transition ─────────────────────────
7589
7590        #[test]
7591        fn tabs_focus_ring() {
7592            let view = JsonUiView::new().component(ComponentNode {
7593                key: "tabs".to_string(),
7594                component: Component::Tabs(TabsProps {
7595                    default_tab: "tab1".to_string(),
7596                    tabs: vec![
7597                        Tab {
7598                            value: "tab1".to_string(),
7599                            label: "Tab One".to_string(),
7600                            children: vec![text_node("t1", "Content one", TextElement::P)],
7601                        },
7602                        Tab {
7603                            value: "tab2".to_string(),
7604                            label: "Tab Two".to_string(),
7605                            children: vec![text_node("t2", "Content two", TextElement::P)],
7606                        },
7607                    ],
7608                }),
7609                action: None,
7610                visibility: None,
7611            });
7612            let html = render_to_html(&view, &json!({}));
7613            assert!(
7614                has_class(&html, "focus-visible:ring-primary"),
7615                "tab button/link should have focus-visible:ring-primary class (INT-02)"
7616            );
7617            assert!(
7618                has_class(&html, "duration-150"),
7619                "tab button/link should have duration-150 class (INT-07)"
7620            );
7621        }
7622
7623        // ── INT-03: Pagination focus ring and transition ───────────────────
7624
7625        #[test]
7626        fn pagination_focus_ring() {
7627            let view = JsonUiView::new().component(ComponentNode {
7628                key: "pg".to_string(),
7629                component: Component::Pagination(PaginationProps {
7630                    total: 30,
7631                    per_page: 10,
7632                    current_page: 2,
7633                    base_url: Some("?".to_string()),
7634                }),
7635                action: None,
7636                visibility: None,
7637            });
7638            let html = render_to_html(&view, &json!({}));
7639            assert!(
7640                has_class(&html, "focus-visible:ring-primary"),
7641                "pagination <a> links should have focus-visible:ring-primary class (INT-03)"
7642            );
7643            assert!(
7644                has_class(&html, "duration-150"),
7645                "pagination <a> links should have duration-150 class (INT-07)"
7646            );
7647        }
7648
7649        // ── INT-04: Breadcrumb focus ring and transition ───────────────────
7650
7651        #[test]
7652        fn breadcrumb_focus_ring() {
7653            let view = JsonUiView::new().component(ComponentNode {
7654                key: "bc".to_string(),
7655                component: Component::Breadcrumb(BreadcrumbProps {
7656                    items: vec![
7657                        BreadcrumbItem {
7658                            label: "Home".to_string(),
7659                            url: Some("/".to_string()),
7660                        },
7661                        BreadcrumbItem {
7662                            label: "Current".to_string(),
7663                            url: None,
7664                        },
7665                    ],
7666                }),
7667                action: None,
7668                visibility: None,
7669            });
7670            let html = render_to_html(&view, &json!({}));
7671            assert!(
7672                has_class(&html, "focus-visible:ring-primary"),
7673                "breadcrumb <a> link should have focus-visible:ring-primary class (INT-04)"
7674            );
7675            assert!(
7676                has_class(&html, "duration-150"),
7677                "breadcrumb <a> link should have duration-150 class (INT-07)"
7678            );
7679        }
7680
7681        // ── INT-05: Sidebar nav item focus ring and transition ─────────────
7682
7683        #[test]
7684        fn sidebar_nav_focus_ring() {
7685            let view = JsonUiView::new().component(ComponentNode::sidebar(
7686                "nav",
7687                SidebarProps {
7688                    fixed_top: vec![SidebarNavItem {
7689                        label: "Dashboard".to_string(),
7690                        href: "/dashboard".to_string(),
7691                        icon: None,
7692                        active: false,
7693                    }],
7694                    groups: vec![],
7695                    fixed_bottom: vec![],
7696                },
7697            ));
7698            let html = render_to_html(&view, &json!({}));
7699            assert!(
7700                has_class(&html, "focus-visible:ring-primary"),
7701                "sidebar nav <a> item should have focus-visible:ring-primary class (INT-05)"
7702            );
7703            assert!(
7704                has_class(&html, "duration-150"),
7705                "sidebar nav <a> item should have duration-150 class (INT-07)"
7706            );
7707        }
7708
7709        // ── INT-06: Table body row hover ───────────────────────────────────
7710
7711        #[test]
7712        fn table_row_hover() {
7713            let data = json!({"items": [{"name": "Alice"}]});
7714            let view = JsonUiView::new().component(ComponentNode {
7715                key: "t".to_string(),
7716                component: Component::Table(TableProps {
7717                    columns: vec![Column {
7718                        key: "name".to_string(),
7719                        label: "Name".to_string(),
7720                        format: None,
7721                    }],
7722                    data_path: "/items".to_string(),
7723                    row_actions: None,
7724                    empty_message: None,
7725                    sortable: None,
7726                    sort_column: None,
7727                    sort_direction: None,
7728                }),
7729                action: None,
7730                visibility: None,
7731            });
7732            let html = render_to_html(&view, &data);
7733            assert!(
7734                html.contains("<tr class=\"hover:bg-surface\">"),
7735                "table body row should have hover:bg-surface class (INT-06)"
7736            );
7737        }
7738    }
7739
7740    // ── DropdownMenu tests ───────────────────────────────────────────────
7741
7742    #[test]
7743    fn test_render_dropdown_menu() {
7744        let props = DropdownMenuProps {
7745            menu_id: "actions-1".to_string(),
7746            trigger_label: "Azioni".to_string(),
7747            items: vec![
7748                DropdownMenuAction {
7749                    label: "Modifica".to_string(),
7750                    action: Action {
7751                        handler: "items.edit".to_string(),
7752                        url: Some("/items/1/edit".to_string()),
7753                        method: HttpMethod::Get,
7754                        confirm: None,
7755                        on_success: None,
7756                        on_error: None,
7757                        target: None,
7758                    },
7759                    destructive: false,
7760                },
7761                DropdownMenuAction {
7762                    label: "Elimina".to_string(),
7763                    action: Action {
7764                        handler: "items.destroy".to_string(),
7765                        url: Some("/items/1".to_string()),
7766                        method: HttpMethod::Delete,
7767                        confirm: None,
7768                        on_success: None,
7769                        on_error: None,
7770                        target: None,
7771                    },
7772                    destructive: true,
7773                },
7774            ],
7775            trigger_variant: None,
7776        };
7777
7778        let view = JsonUiView::new().component(ComponentNode::dropdown_menu("menu", props));
7779        let html = render_to_html(&view, &json!({}));
7780
7781        assert!(
7782            html.contains("data-dropdown-toggle=\"actions-1\""),
7783            "trigger has data-dropdown-toggle"
7784        );
7785        assert!(
7786            html.contains("data-dropdown=\"actions-1\""),
7787            "panel has data-dropdown"
7788        );
7789        assert!(html.contains("hidden"), "panel starts hidden");
7790        assert!(
7791            html.contains("text-destructive"),
7792            "destructive item has text-destructive class"
7793        );
7794        assert!(html.contains("type=\"button\""), "trigger is type=button");
7795        assert!(
7796            html.contains("aria-label=\"Azioni\""),
7797            "trigger has aria-label"
7798        );
7799        assert!(html.contains("Modifica"), "normal item label present");
7800        assert!(html.contains("Elimina"), "destructive item label present");
7801        // GET action renders as <a>, DELETE renders as <form>
7802        assert!(
7803            html.contains("<a href=\"/items/1/edit\""),
7804            "GET action renders as link"
7805        );
7806        assert!(
7807            html.contains("<form action=\"/items/1\" method=\"post\">"),
7808            "DELETE action renders as form"
7809        );
7810        assert!(
7811            html.contains("name=\"_method\" value=\"DELETE\""),
7812            "DELETE method spoofing"
7813        );
7814    }
7815
7816    #[test]
7817    fn test_render_dropdown_menu_confirm() {
7818        use crate::action::{ConfirmDialog, DialogVariant};
7819
7820        let props = DropdownMenuProps {
7821            menu_id: "confirm-menu".to_string(),
7822            trigger_label: "Menu".to_string(),
7823            items: vec![DropdownMenuAction {
7824                label: "Elimina".to_string(),
7825                action: Action {
7826                    handler: "items.destroy".to_string(),
7827                    url: Some("/items/1".to_string()),
7828                    method: HttpMethod::Delete,
7829                    confirm: Some(ConfirmDialog {
7830                        title: "Conferma eliminazione".to_string(),
7831                        message: Some("Sei sicuro?".to_string()),
7832                        variant: DialogVariant::Danger,
7833                    }),
7834                    on_success: None,
7835                    on_error: None,
7836                    target: None,
7837                },
7838                destructive: true,
7839            }],
7840            trigger_variant: None,
7841        };
7842
7843        let view = JsonUiView::new().component(ComponentNode::dropdown_menu("cm", props));
7844        let html = render_to_html(&view, &json!({}));
7845
7846        assert!(
7847            html.contains("data-confirm-title=\"Conferma eliminazione\""),
7848            "confirm title attribute"
7849        );
7850        assert!(
7851            html.contains("data-confirm-message=\"Sei sicuro?\""),
7852            "confirm message attribute"
7853        );
7854        assert!(html.contains("data-confirm"), "has data-confirm attribute");
7855    }
7856
7857    // ── KanbanBoard tests ───────────────────────────────────────────────
7858
7859    fn make_kanban_props() -> KanbanBoardProps {
7860        use crate::component::{CardProps, KanbanBoardProps, KanbanColumnProps};
7861
7862        KanbanBoardProps {
7863            columns: vec![
7864                KanbanColumnProps {
7865                    id: "new".to_string(),
7866                    title: "Nuovi".to_string(),
7867                    count: 3,
7868                    children: vec![ComponentNode::card(
7869                        "card-1",
7870                        CardProps {
7871                            title: "Ordine #1".to_string(),
7872                            description: None,
7873                            children: vec![],
7874                            footer: vec![],
7875                            max_width: None,
7876                        },
7877                    )],
7878                },
7879                KanbanColumnProps {
7880                    id: "progress".to_string(),
7881                    title: "In corso".to_string(),
7882                    count: 1,
7883                    children: vec![ComponentNode::card(
7884                        "card-2",
7885                        CardProps {
7886                            title: "Ordine #2".to_string(),
7887                            description: None,
7888                            children: vec![],
7889                            footer: vec![],
7890                            max_width: None,
7891                        },
7892                    )],
7893                },
7894            ],
7895            mobile_default_column: None,
7896        }
7897    }
7898
7899    #[test]
7900    fn test_render_kanban_board_desktop() {
7901        let props = make_kanban_props();
7902        let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7903        let html = render_to_html(&view, &json!({}));
7904
7905        assert!(html.contains("hidden md:block"), "desktop wrapper present");
7906        assert!(html.contains("min-w-[260px]"), "column min width");
7907        assert!(html.contains("overflow-x-auto"), "scrollable container");
7908        assert!(html.contains("Nuovi"), "first column title");
7909        assert!(html.contains("In corso"), "second column title");
7910        assert!(
7911            html.contains("bg-primary text-primary-foreground"),
7912            "count badge styling"
7913        );
7914        assert!(html.contains(">3<"), "first column count");
7915        assert!(html.contains(">1<"), "second column count");
7916    }
7917
7918    #[test]
7919    fn test_render_kanban_board_mobile() {
7920        let props = make_kanban_props();
7921        let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7922        let html = render_to_html(&view, &json!({}));
7923
7924        assert!(html.contains("block md:hidden"), "mobile wrapper present");
7925        assert!(html.contains("data-tabs"), "tab container attribute");
7926        assert!(html.contains("data-tab=\"new\""), "first tab button");
7927        assert!(html.contains("data-tab=\"progress\""), "second tab button");
7928        assert!(html.contains("data-tab-panel=\"new\""), "first tab panel");
7929        assert!(
7930            html.contains("data-tab-panel=\"progress\""),
7931            "second tab panel"
7932        );
7933        // Default tab (first) is active
7934        assert!(
7935            html.contains("aria-selected=\"true\""),
7936            "default tab selected"
7937        );
7938        assert!(
7939            html.contains("aria-selected=\"false\""),
7940            "non-default tab not selected"
7941        );
7942    }
7943
7944    #[test]
7945    fn test_render_kanban_board_custom_default_column() {
7946        let mut props = make_kanban_props();
7947        props.mobile_default_column = Some("progress".to_string());
7948        let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7949        let html = render_to_html(&view, &json!({}));
7950
7951        // The "progress" tab should be selected, "new" should not
7952        // Check that the progress panel is NOT hidden
7953        assert!(
7954            !html.contains("data-tab-panel=\"progress\" class=\"space-y-3 hidden\""),
7955            "progress panel visible"
7956        );
7957    }
7958
7959    // ── CalendarCell tests ──────────────────────────────────────────────
7960
7961    #[test]
7962    fn test_render_calendar_cell_today() {
7963        let props = CalendarCellProps {
7964            day: 15,
7965            is_today: true,
7966            is_current_month: true,
7967            event_count: 0,
7968            dot_colors: vec![],
7969        };
7970        let html = render_calendar_cell(&props);
7971        assert!(html.contains("bg-primary"), "today has bg-primary");
7972        assert!(
7973            html.contains("text-primary-foreground"),
7974            "today has foreground color"
7975        );
7976        assert!(html.contains("font-semibold"), "today is bold");
7977        assert!(html.contains("15"), "shows day number");
7978    }
7979
7980    #[test]
7981    fn test_render_calendar_cell_out_of_month() {
7982        let props = CalendarCellProps {
7983            day: 30,
7984            is_today: false,
7985            is_current_month: false,
7986            event_count: 0,
7987            dot_colors: vec![],
7988        };
7989        let html = render_calendar_cell(&props);
7990        assert!(html.contains("opacity-40"), "out-of-month has opacity");
7991    }
7992
7993    #[test]
7994    fn test_render_calendar_cell_events() {
7995        let props = CalendarCellProps {
7996            day: 5,
7997            is_today: false,
7998            is_current_month: true,
7999            event_count: 3,
8000            dot_colors: vec![],
8001        };
8002        let html = render_calendar_cell(&props);
8003        assert!(
8004            html.contains("w-1.5 h-1.5 rounded-full bg-primary"),
8005            "shows event dots"
8006        );
8007        assert!(html.contains("flex gap-1"), "dots container present");
8008    }
8009
8010    #[test]
8011    fn test_render_calendar_cell_single_event_dot() {
8012        let props = CalendarCellProps {
8013            day: 5,
8014            is_today: false,
8015            is_current_month: true,
8016            event_count: 1,
8017            dot_colors: vec![],
8018        };
8019        let html = render_calendar_cell(&props);
8020        assert!(
8021            html.contains("w-1.5 h-1.5 rounded-full bg-primary"),
8022            "single event shows dot"
8023        );
8024    }
8025
8026    // ── ActionCard tests ────────────────────────────────────────────────
8027
8028    #[test]
8029    fn test_render_action_card_default() {
8030        let props = ActionCardProps {
8031            title: "Nuovo ordine".into(),
8032            description: "Crea un ordine".into(),
8033            icon: Some("📦".into()),
8034            variant: ActionCardVariant::Default,
8035            href: None,
8036        };
8037        let html = render_action_card(&props);
8038        assert!(
8039            html.contains("border-l-primary"),
8040            "default variant has primary border"
8041        );
8042        assert!(html.contains("Nuovo ordine"), "shows title");
8043        assert!(html.contains("Crea un ordine"), "shows description");
8044        assert!(html.contains("rsaquo"), "shows chevron");
8045    }
8046
8047    #[test]
8048    fn test_render_action_card_setup() {
8049        let props = ActionCardProps {
8050            title: "Configura".into(),
8051            description: "Completa la configurazione".into(),
8052            icon: None,
8053            variant: ActionCardVariant::Setup,
8054            href: None,
8055        };
8056        let html = render_action_card(&props);
8057        assert!(
8058            html.contains("border-l-warning"),
8059            "setup variant has warning border"
8060        );
8061    }
8062
8063    #[test]
8064    fn test_render_action_card_danger() {
8065        let props = ActionCardProps {
8066            title: "Elimina".into(),
8067            description: "Elimina questo elemento".into(),
8068            icon: None,
8069            variant: ActionCardVariant::Danger,
8070            href: None,
8071        };
8072        let html = render_action_card(&props);
8073        assert!(
8074            html.contains("border-l-destructive"),
8075            "danger variant has destructive border"
8076        );
8077    }
8078
8079    // ─── ProductTile tests ────────────────────────────────────────────
8080
8081    #[test]
8082    fn test_render_product_tile() {
8083        let props = ProductTileProps {
8084            product_id: "p1".into(),
8085            name: "Margherita".into(),
8086            price: "\u{20AC}8,50".into(),
8087            field: "qty_p1".into(),
8088            default_quantity: None,
8089        };
8090        let html = render_product_tile(&props);
8091        assert!(html.contains("Margherita"), "shows product name");
8092        assert!(html.contains("\u{20AC}8,50"), "shows price");
8093        assert!(
8094            html.contains("data-qty-inc=\"qty_p1\""),
8095            "inc button has data attr"
8096        );
8097        assert!(
8098            html.contains("data-qty-dec=\"qty_p1\""),
8099            "dec button has data attr"
8100        );
8101        assert!(
8102            html.contains("data-qty-display=\"qty_p1\""),
8103            "display span has data attr"
8104        );
8105        assert!(
8106            html.contains("data-qty-input=\"qty_p1\""),
8107            "hidden input has data attr"
8108        );
8109        assert!(html.contains("type=\"button\""), "buttons use type=button");
8110        assert!(html.contains("type=\"hidden\""), "hidden input present");
8111        assert!(html.contains("min-h-[44px]"), "44px touch target height");
8112        assert!(html.contains("min-w-[44px]"), "44px touch target width");
8113        assert!(
8114            html.contains("touch-manipulation"),
8115            "touch-manipulation on container"
8116        );
8117        assert!(html.contains("value=\"0\""), "default quantity is 0");
8118    }
8119
8120    #[test]
8121    fn test_render_product_tile_default_qty() {
8122        let props = ProductTileProps {
8123            product_id: "p2".into(),
8124            name: "Diavola".into(),
8125            price: "\u{20AC}10,00".into(),
8126            field: "qty_p2".into(),
8127            default_quantity: Some(2),
8128        };
8129        let html = render_product_tile(&props);
8130        assert!(html.contains("value=\"2\""), "default quantity is 2");
8131        assert!(html.contains(">2<"), "display shows 2");
8132    }
8133
8134    #[test]
8135    fn test_render_data_table_rows() {
8136        let props = DataTableProps {
8137            columns: vec![
8138                Column {
8139                    key: "name".into(),
8140                    label: "Nome".into(),
8141                    format: None,
8142                },
8143                Column {
8144                    key: "price".into(),
8145                    label: "Prezzo".into(),
8146                    format: None,
8147                },
8148            ],
8149            data_path: "items".into(),
8150            row_actions: None,
8151            empty_message: None,
8152            row_key: None,
8153            row_href: None,
8154        };
8155        let data = json!({
8156            "items": [
8157                {"name": "Margherita", "price": "8.50"},
8158                {"name": "Diavola", "price": "10.00"}
8159            ]
8160        });
8161        let html = render_data_table(&props, &data);
8162        assert!(html.contains("hidden md:block"), "desktop wrapper");
8163        assert!(html.contains("even:bg-surface"), "alternating rows");
8164        assert!(html.contains("block md:hidden"), "mobile wrapper");
8165        assert!(html.contains("uppercase"), "column header style");
8166        assert!(html.contains("Margherita"), "first row value");
8167        assert!(html.contains("Diavola"), "second row value");
8168    }
8169
8170    #[test]
8171    fn test_render_data_table_with_actions() {
8172        let props = DataTableProps {
8173            columns: vec![Column {
8174                key: "name".into(),
8175                label: "Nome".into(),
8176                format: None,
8177            }],
8178            data_path: "items".into(),
8179            row_actions: Some(vec![
8180                DropdownMenuAction {
8181                    label: "Modifica".into(),
8182                    action: Action {
8183                        handler: "edit".into(),
8184                        method: HttpMethod::Get,
8185                        url: Some("/edit".into()),
8186                        confirm: None,
8187                        on_success: None,
8188                        on_error: None,
8189                        target: None,
8190                    },
8191                    destructive: false,
8192                },
8193                DropdownMenuAction {
8194                    label: "Elimina".into(),
8195                    action: Action {
8196                        handler: "delete".into(),
8197                        method: HttpMethod::Delete,
8198                        url: Some("/delete".into()),
8199                        confirm: None,
8200                        on_success: None,
8201                        on_error: None,
8202                        target: None,
8203                    },
8204                    destructive: true,
8205                },
8206            ]),
8207            empty_message: None,
8208            row_key: Some("id".into()),
8209            row_href: None,
8210        };
8211        let data = json!({
8212            "items": [{"id": "p1", "name": "Margherita"}]
8213        });
8214        let html = render_data_table(&props, &data);
8215        assert!(
8216            html.contains("data-dropdown-toggle"),
8217            "DropdownMenu trigger present"
8218        );
8219        assert!(
8220            html.contains("text-destructive"),
8221            "destructive action in menu"
8222        );
8223    }
8224
8225    #[test]
8226    fn test_render_data_table_empty() {
8227        let props = DataTableProps {
8228            columns: vec![Column {
8229                key: "name".into(),
8230                label: "Nome".into(),
8231                format: None,
8232            }],
8233            data_path: "items".into(),
8234            row_actions: None,
8235            empty_message: None,
8236            row_key: None,
8237            row_href: None,
8238        };
8239        let data = json!({"items": []});
8240        let html = render_data_table(&props, &data);
8241        assert!(
8242            html.contains("Nessun elemento trovato"),
8243            "default empty message"
8244        );
8245    }
8246
8247    #[test]
8248    fn test_render_data_table_mobile_cards() {
8249        let props = DataTableProps {
8250            columns: vec![
8251                Column {
8252                    key: "name".into(),
8253                    label: "Nome".into(),
8254                    format: None,
8255                },
8256                Column {
8257                    key: "price".into(),
8258                    label: "Prezzo".into(),
8259                    format: None,
8260                },
8261            ],
8262            data_path: "items".into(),
8263            row_actions: None,
8264            empty_message: None,
8265            row_key: None,
8266            row_href: None,
8267        };
8268        let data = json!({
8269            "items": [
8270                {"name": "Margherita", "price": "8.50"},
8271                {"name": "Diavola", "price": "10.00"}
8272            ]
8273        });
8274        let html = render_data_table(&props, &data);
8275        assert!(html.contains("block md:hidden"), "mobile cards shown");
8276        assert!(
8277            html.contains("text-xs font-semibold text-text-muted uppercase"),
8278            "label styling"
8279        );
8280    }
8281
8282    // ── Modal dialog tests ─────────────────────────────────────────────────
8283
8284    #[test]
8285    fn test_render_modal_dialog() {
8286        let props = ModalProps {
8287            id: "modal-test".into(),
8288            title: "Test Title".into(),
8289            description: None,
8290            children: vec![],
8291            footer: vec![],
8292            trigger_label: Some("Open".into()),
8293        };
8294        let html = render_modal(&props, &serde_json::Value::Null);
8295        assert!(html.contains("<dialog"), "uses dialog element");
8296        assert!(html.contains("aria-modal=\"true\""), "has aria-modal");
8297        assert!(
8298            html.contains("aria-labelledby=\"modal-test-title\""),
8299            "has aria-labelledby"
8300        );
8301        assert!(
8302            html.contains("data-modal-open=\"modal-test\""),
8303            "trigger has data-modal-open"
8304        );
8305        assert!(html.contains("data-modal-close"), "has close button");
8306        assert!(
8307            html.contains("Chiudi"),
8308            "close button has Italian aria-label"
8309        );
8310        assert!(!html.contains("<details"), "no details element");
8311        assert!(!html.contains("<summary"), "no summary element");
8312    }
8313
8314    #[test]
8315    fn test_render_modal_with_description() {
8316        let props = ModalProps {
8317            id: "modal-desc".into(),
8318            title: "Title".into(),
8319            description: Some("A description".into()),
8320            children: vec![],
8321            footer: vec![],
8322            trigger_label: None,
8323        };
8324        let html = render_modal(&props, &serde_json::Value::Null);
8325        assert!(html.contains("A description"), "shows description");
8326    }
8327
8328    // ── Form layout tests ──────────────────────────────────────────────────
8329
8330    #[test]
8331    fn test_render_form_max_width_narrow() {
8332        let props = FormProps {
8333            action: Action::new("save"),
8334            fields: vec![],
8335            method: None,
8336            guard: None,
8337            max_width: Some(FormMaxWidth::Narrow),
8338        };
8339        let html = render_form(&props, &serde_json::Value::Null);
8340        assert!(html.contains("max-w-2xl"), "narrow form has max-w-2xl");
8341        assert!(html.contains("mx-auto"), "narrow form is centered");
8342    }
8343
8344    #[test]
8345    fn test_render_form_max_width_default() {
8346        let props = FormProps {
8347            action: Action::new("save"),
8348            fields: vec![],
8349            method: None,
8350            guard: None,
8351            max_width: None,
8352        };
8353        let html = render_form(&props, &serde_json::Value::Null);
8354        assert!(
8355            !html.contains("max-w-2xl"),
8356            "default form has no max-width wrapper"
8357        );
8358    }
8359
8360    #[test]
8361    fn test_render_form_narrow_wraps_in_w_full_flex_slot() {
8362        // Regression: two narrow forms/cards in a flex-wrap parent must stack
8363        // vertically. The outer wrapper must be `w-full` so the flex slot
8364        // spans the row; only the inner wrapper carries `max-w-2xl`.
8365        let props = FormProps {
8366            action: Action::new("save"),
8367            fields: vec![],
8368            method: None,
8369            guard: None,
8370            max_width: Some(FormMaxWidth::Narrow),
8371        };
8372        let html = render_form(&props, &serde_json::Value::Null);
8373        assert!(
8374            html.starts_with("<div class=\"w-full\">"),
8375            "narrow form outer wrapper must be w-full so flex-wrap stacks siblings: {html}"
8376        );
8377        assert!(
8378            html.contains("<div class=\"max-w-2xl mx-auto\">"),
8379            "narrow form inner wrapper must carry max-w-2xl mx-auto"
8380        );
8381    }
8382
8383    #[test]
8384    fn test_render_form_section_two_column() {
8385        let props = FormSectionProps {
8386            title: "Section".into(),
8387            description: Some("Desc".into()),
8388            children: vec![],
8389            layout: Some(FormSectionLayout::TwoColumn),
8390        };
8391        let html = render_form_section(&props, &serde_json::Value::Null);
8392        assert!(html.contains("md:grid"), "two-column uses grid");
8393        assert!(
8394            html.contains("md:grid-cols-5"),
8395            "two-column uses 5-col grid"
8396        );
8397        assert!(html.contains("md:col-span-2"), "description takes 2 cols");
8398        assert!(html.contains("md:col-span-3"), "controls take 3 cols");
8399    }
8400
8401    // ── Input ARIA tests ───────────────────────────────────────────────────
8402
8403    #[test]
8404    fn test_render_input_with_error() {
8405        let props = InputProps {
8406            field: "email".into(),
8407            label: "Email".into(),
8408            input_type: InputType::Email,
8409            error: Some("Campo obbligatorio".into()),
8410            placeholder: None,
8411            default_value: None,
8412            data_path: None,
8413            required: None,
8414            disabled: None,
8415            step: None,
8416            description: None,
8417            list: None,
8418        };
8419        let html = render_input(&props, &serde_json::Value::Null);
8420        assert!(
8421            html.contains("aria-invalid=\"true\""),
8422            "input has aria-invalid"
8423        );
8424        assert!(
8425            html.contains("aria-describedby=\"err-email\""),
8426            "input has aria-describedby"
8427        );
8428        assert!(
8429            html.contains("id=\"err-email\""),
8430            "error paragraph has matching id"
8431        );
8432        assert!(
8433            html.contains("Campo obbligatorio"),
8434            "error message rendered"
8435        );
8436    }
8437
8438    #[test]
8439    fn test_render_input_hidden_no_label() {
8440        let props = InputProps {
8441            field: "csrf".into(),
8442            label: "".into(),
8443            input_type: InputType::Hidden,
8444            default_value: Some("token123".into()),
8445            error: None,
8446            placeholder: None,
8447            data_path: None,
8448            required: None,
8449            disabled: None,
8450            step: None,
8451            description: None,
8452            list: None,
8453        };
8454        let html = render_input(&props, &serde_json::Value::Null);
8455        assert!(!html.contains("<label"), "hidden input has no label");
8456        assert!(
8457            !html.contains("space-y-1"),
8458            "hidden input has no wrapper div"
8459        );
8460        assert!(html.contains("type=\"hidden\""), "hidden input present");
8461    }
8462
8463    // ── Switch ARIA tests ──────────────────────────────────────────────────
8464
8465    #[test]
8466    fn test_render_switch_role_switch() {
8467        let props = SwitchProps {
8468            field: "active".into(),
8469            label: "Attivo".into(),
8470            description: None,
8471            checked: Some(true),
8472            data_path: None,
8473            required: None,
8474            disabled: None,
8475            error: None,
8476            action: None,
8477            compact: false,
8478        };
8479        let html = render_switch(&props, &serde_json::Value::Null);
8480        assert!(html.contains("role=\"switch\""), "switch has role=switch");
8481        assert!(
8482            html.contains("aria-checked=\"true\""),
8483            "checked switch has aria-checked=true"
8484        );
8485    }
8486
8487    // ── Tabs ARIA tests ────────────────────────────────────────────────────
8488
8489    #[test]
8490    fn test_render_tabs_aria_attributes() {
8491        let props = TabsProps {
8492            default_tab: "general".into(),
8493            tabs: vec![
8494                Tab {
8495                    value: "general".into(),
8496                    label: "Generale".into(),
8497                    children: vec![],
8498                },
8499                Tab {
8500                    value: "advanced".into(),
8501                    label: "Avanzate".into(),
8502                    children: vec![],
8503                },
8504            ],
8505        };
8506        let html = render_tabs(&props, &serde_json::Value::Null);
8507        assert!(html.contains("id=\"tab-btn-general\""), "tab button has id");
8508        assert!(
8509            html.contains("aria-controls=\"tab-panel-general\""),
8510            "tab button has aria-controls"
8511        );
8512        assert!(
8513            html.contains("id=\"tab-panel-general\""),
8514            "tab panel has id"
8515        );
8516        assert!(
8517            html.contains("aria-labelledby=\"tab-btn-general\""),
8518            "tab panel has aria-labelledby"
8519        );
8520    }
8521
8522    // ── Collapsible ARIA tests ─────────────────────────────────────────────
8523
8524    #[test]
8525    fn test_render_collapsible_aria_expanded() {
8526        let props = CollapsibleProps {
8527            title: "Details".into(),
8528            expanded: false,
8529            children: vec![],
8530        };
8531        let html = render_collapsible(&props, &serde_json::Value::Null);
8532        assert!(
8533            html.contains("aria-expanded=\"false\""),
8534            "closed collapsible has aria-expanded=false"
8535        );
8536    }
8537
8538    // ── ActionCard href tests ──────────────────────────────────────────────
8539
8540    #[test]
8541    fn test_render_action_card_with_href() {
8542        let props = ActionCardProps {
8543            title: "Nuovo ordine".into(),
8544            description: "Crea un ordine".into(),
8545            icon: None,
8546            variant: ActionCardVariant::Default,
8547            href: Some("/ordini/nuovo".into()),
8548        };
8549        let html = render_action_card(&props);
8550        assert!(
8551            html.contains("<a href=\"/ordini/nuovo\""),
8552            "card wraps in <a> with href"
8553        );
8554        assert!(
8555            html.contains("aria-label=\"Nuovo ordine\""),
8556            "card link has aria-label"
8557        );
8558        assert!(
8559            !html.contains("<div class=\"rounded"),
8560            "no div wrapper when href present"
8561        );
8562    }
8563
8564    #[test]
8565    fn test_render_action_card_without_href() {
8566        let props = ActionCardProps {
8567            title: "Test".into(),
8568            description: "Desc".into(),
8569            icon: None,
8570            variant: ActionCardVariant::Default,
8571            href: None,
8572        };
8573        let html = render_action_card(&props);
8574        assert!(
8575            html.contains("<div class=\"rounded"),
8576            "uses div when no href"
8577        );
8578        assert!(!html.contains("<a "), "no anchor when no href");
8579    }
8580
8581    #[test]
8582    fn test_render_button_type_button_default() {
8583        let props = ButtonProps {
8584            label: "Click".into(),
8585            variant: ButtonVariant::Default,
8586            size: Size::Default,
8587            disabled: None,
8588            icon: None,
8589            icon_position: None,
8590            button_type: None,
8591        };
8592        let html = render_button(&props);
8593        assert!(
8594            !html.contains("type=\""),
8595            "default omits type attribute so browser applies HTML default (submit in forms)"
8596        );
8597    }
8598
8599    #[test]
8600    fn test_render_button_type_button_explicit() {
8601        let props = ButtonProps {
8602            label: "Click".into(),
8603            variant: ButtonVariant::Default,
8604            size: Size::Default,
8605            disabled: None,
8606            icon: None,
8607            icon_position: None,
8608            button_type: Some(ButtonType::Button),
8609        };
8610        let html = render_button(&props);
8611        assert!(html.contains("type=\"button\""));
8612    }
8613
8614    #[test]
8615    fn test_render_button_type_submit() {
8616        let props = ButtonProps {
8617            label: "Salva".into(),
8618            variant: ButtonVariant::Default,
8619            size: Size::Default,
8620            disabled: None,
8621            icon: None,
8622            icon_position: None,
8623            button_type: Some(ButtonType::Submit),
8624        };
8625        let html = render_button(&props);
8626        assert!(
8627            html.contains("type=\"submit\""),
8628            "submit button type is submit"
8629        );
8630    }
8631
8632    #[test]
8633    fn data_table_row_actions_url_templating() {
8634        use crate::action::*;
8635        use crate::component::*;
8636        let props = DataTableProps {
8637            columns: vec![Column {
8638                key: "name".into(),
8639                label: "Name".into(),
8640                format: None,
8641            }],
8642            data_path: "items".into(),
8643            row_actions: Some(vec![DropdownMenuAction {
8644                label: "Delete".into(),
8645                action: {
8646                    let mut a = Action::new("items.delete");
8647                    a.url = Some("/items/{row_key}/delete".into());
8648                    a.method = HttpMethod::Delete;
8649                    a
8650                },
8651                destructive: true,
8652            }]),
8653            empty_message: None,
8654            row_key: Some("id".into()),
8655            row_href: None,
8656        };
8657        let data = serde_json::json!({ "items": [{"id": "42", "name": "Widget"}] });
8658        let html = render_data_table(&props, &data);
8659        assert!(
8660            html.contains("/items/42/delete"),
8661            "URL must have {{row_key}} replaced with actual row key value '42'"
8662        );
8663        assert!(
8664            !html.contains("{row_key}"),
8665            "No unreplaced {{row_key}} placeholders should remain"
8666        );
8667    }
8668
8669    // ── KeyValueEditor render tests (Phase 146, Wave 1 RED) ──────────────
8670
8671    fn kv_props_minimal(field: &str) -> KeyValueEditorProps {
8672        KeyValueEditorProps {
8673            field: field.to_string(),
8674            label: None,
8675            suggested_keys: Vec::new(),
8676            allow_custom_keys: true,
8677            data_path: None,
8678            error: None,
8679        }
8680    }
8681
8682    #[test]
8683    fn render_key_value_editor_empty_state() {
8684        let props = kv_props_minimal("meta");
8685        let html = render_key_value_editor(&props, &serde_json::Value::Null);
8686        assert!(
8687            html.contains("data-kv-editor"),
8688            "missing data-kv-editor: {html}"
8689        );
8690        assert!(
8691            html.contains(r#"data-kv-field="meta""#),
8692            "missing data-kv-field attr"
8693        );
8694        assert!(html.contains(r#"name="meta""#), "missing hidden field name");
8695        assert!(
8696            html.contains(r#"value="{}""#),
8697            "hidden field should default to {{}}"
8698        );
8699        assert!(html.contains("data-kv-add"), "missing add-row button");
8700        assert!(
8701            html.contains("data-kv-row-template"),
8702            "missing row template"
8703        );
8704        // Only the template row should be present (no pre-filled rows).
8705        let row_occurrences = html.matches("data-kv-row").count();
8706        assert!(
8707            row_occurrences >= 1,
8708            "expected at least the template row, got {row_occurrences}"
8709        );
8710    }
8711
8712    #[test]
8713    fn render_key_value_editor_prefilled_rows() {
8714        let mut props = kv_props_minimal("meta");
8715        props.data_path = Some("/meta".to_string());
8716        let data = json!({"meta": {"alpha": "one", "beta": "two"}});
8717        let html = render_key_value_editor(&props, &data);
8718        assert!(
8719            html.contains(r#"value="alpha""#),
8720            "missing prefilled key 'alpha'"
8721        );
8722        assert!(
8723            html.contains(r#"value="one""#),
8724            "missing prefilled value 'one'"
8725        );
8726        assert!(
8727            html.contains(r#"value="beta""#),
8728            "missing prefilled key 'beta'"
8729        );
8730        assert!(
8731            html.contains(r#"value="two""#),
8732            "missing prefilled value 'two'"
8733        );
8734        // Hidden field should contain both entries serialized.
8735        assert!(
8736            html.contains(r#""alpha":"one""#)
8737                || html.contains(r#"&quot;alpha&quot;:&quot;one&quot;"#),
8738            "hidden field missing alpha entry: {html}"
8739        );
8740    }
8741
8742    #[test]
8743    fn render_key_value_editor_error_state() {
8744        let mut props = kv_props_minimal("meta");
8745        props.error = Some("required".to_string());
8746        // Use a prefilled row so there is a live row to carry aria attributes.
8747        props.data_path = Some("/meta".to_string());
8748        let data = json!({"meta": {"key1": "val1"}});
8749        let html = render_key_value_editor(&props, &data);
8750        assert!(
8751            html.contains("border-destructive"),
8752            "missing error border class"
8753        );
8754        assert!(
8755            html.contains("focus-visible:ring-destructive"),
8756            "missing error focus ring class"
8757        );
8758        // aria-invalid must appear on the live row.
8759        assert!(
8760            html.contains(r#"aria-invalid="true""#),
8761            "missing aria-invalid on live row"
8762        );
8763        assert!(
8764            html.contains(r#"aria-describedby="err-meta""#),
8765            "missing aria-describedby on live row"
8766        );
8767        // Template row must NOT carry aria-invalid (it has no validated value yet).
8768        let template_start = html
8769            .find("data-kv-row-template")
8770            .expect("missing template marker");
8771        let template_fragment = &html[template_start..];
8772        assert!(
8773            !template_fragment.contains(r#"aria-invalid="true""#),
8774            "template row must not carry aria-invalid"
8775        );
8776        assert!(
8777            html.contains(r#"id="err-meta""#),
8778            "missing error paragraph id"
8779        );
8780        assert!(html.contains("required"), "missing error text");
8781    }
8782
8783    #[test]
8784    fn render_key_value_editor_select_variant() {
8785        let mut props = kv_props_minimal("meta");
8786        props.allow_custom_keys = false;
8787        props.suggested_keys = vec!["env".to_string(), "region".to_string()];
8788        let html = render_key_value_editor(&props, &serde_json::Value::Null);
8789        assert!(
8790            html.contains("<select"),
8791            "missing <select> in select variant"
8792        );
8793        assert!(
8794            html.contains("data-kv-key"),
8795            "missing data-kv-key on select"
8796        );
8797        assert!(
8798            html.contains(r#"<option value="env">env</option>"#),
8799            "missing env option"
8800        );
8801        assert!(
8802            html.contains(r#"<option value="region">region</option>"#),
8803            "missing region option"
8804        );
8805        assert!(
8806            !html.contains(r#"list="meta-suggestions""#),
8807            "select variant must not use datalist"
8808        );
8809        assert!(
8810            !html.contains("<datalist"),
8811            "select variant must not render a <datalist>"
8812        );
8813    }
8814
8815    #[test]
8816    fn render_key_value_editor_datalist_present() {
8817        let mut props = kv_props_minimal("meta");
8818        props.suggested_keys = vec!["env".to_string(), "region".to_string()];
8819        let html = render_key_value_editor(&props, &serde_json::Value::Null);
8820        assert!(
8821            html.contains(r#"<datalist id="meta-suggestions">"#),
8822            "missing datalist"
8823        );
8824        assert!(
8825            html.contains(r#"<option value="env">"#),
8826            "missing datalist option env"
8827        );
8828        assert!(
8829            html.contains(r#"<option value="region">"#),
8830            "missing datalist option region"
8831        );
8832        assert!(
8833            html.contains(r#"list="meta-suggestions""#),
8834            "key input missing list attr"
8835        );
8836    }
8837
8838    #[test]
8839    fn render_key_value_editor_hidden_field_empty_object() {
8840        let props = kv_props_minimal("meta");
8841        let html = render_key_value_editor(&props, &serde_json::Value::Null);
8842        assert!(html.contains(r#"type="hidden""#), "missing hidden input");
8843        assert!(
8844            html.contains(r#"value="{}""#),
8845            "hidden input should default to {{}}"
8846        );
8847    }
8848
8849    #[test]
8850    fn render_key_value_editor_html_escape_in_prefill() {
8851        let mut props = kv_props_minimal("meta");
8852        props.data_path = Some("/m".to_string());
8853        let data = json!({"m": {"<k>": "\"v\""}});
8854        let html = render_key_value_editor(&props, &data);
8855        // Key with angle brackets must be escaped before reaching the attribute.
8856        assert!(
8857            !html.contains(r#"value="<k>""#),
8858            "unescaped < in key attribute: {html}"
8859        );
8860        assert!(
8861            html.contains("&lt;k&gt;"),
8862            "expected &lt;k&gt; in escaped output"
8863        );
8864        // Value with double quotes must be escaped.
8865        assert!(
8866            html.contains("&quot;v&quot;"),
8867            "expected &quot;v&quot; in escaped output"
8868        );
8869    }
8870
8871    // ── 25. DetailForm (Phase 147, Wave 0 RED) ────────────────────────────
8872
8873    fn df_props_minimal(mode: EditMode) -> DetailFormProps {
8874        DetailFormProps {
8875            mode,
8876            action: Action {
8877                handler: "users.update".to_string(),
8878                url: Some("/users/1".to_string()),
8879                method: HttpMethod::Post,
8880                confirm: None,
8881                on_success: None,
8882                on_error: None,
8883                target: None,
8884            },
8885            fields: vec![DetailField {
8886                label: "Name".to_string(),
8887                value: "Ada".to_string(),
8888                input: ComponentNode::input(
8889                    "name",
8890                    InputProps {
8891                        field: "name".to_string(),
8892                        label: "".to_string(),
8893                        input_type: InputType::Text,
8894                        placeholder: None,
8895                        required: None,
8896                        disabled: None,
8897                        error: None,
8898                        description: None,
8899                        default_value: None,
8900                        data_path: None,
8901                        step: None,
8902                        list: None,
8903                    },
8904                ),
8905            }],
8906            edit_url: "/users/1?mode=edit".to_string(),
8907            cancel_url: "/users/1".to_string(),
8908            edit_label: None,
8909            save_label: None,
8910            cancel_label: None,
8911            method: None,
8912        }
8913    }
8914
8915    fn render_df(props: DetailFormProps) -> String {
8916        let view = JsonUiView::new().component(ComponentNode::detail_form("df", props));
8917        render_to_html(&view, &serde_json::Value::Null)
8918    }
8919
8920    #[test]
8921    fn render_detail_form_view_mode() {
8922        let html = render_df(df_props_minimal(EditMode::View));
8923        assert!(
8924            html.contains("<dl class=\"grid grid-cols-1 gap-4\">"),
8925            "missing <dl> scaffold: {html}"
8926        );
8927        assert!(
8928            html.contains("<dt class=\"text-sm font-medium text-text-muted\">Name</dt>"),
8929            "missing <dt> label"
8930        );
8931        assert!(
8932            html.contains("<dd class=\"mt-1 text-sm text-text\">Ada</dd>"),
8933            "missing plain <dd> value in View mode: {html}"
8934        );
8935        assert!(
8936            !html.contains("<form"),
8937            "View mode must NOT wrap in <form>: {html}"
8938        );
8939        assert!(
8940            !html.contains("name=\"_method\""),
8941            "View mode must NOT emit method spoofing"
8942        );
8943    }
8944
8945    #[test]
8946    fn render_detail_form_edit_mode() {
8947        let html = render_df(df_props_minimal(EditMode::Edit));
8948        assert!(
8949            html.contains("<form"),
8950            "Edit mode must wrap in <form>: {html}"
8951        );
8952        assert!(html.contains("action=\"/users/1\""), "missing action URL");
8953        assert!(html.contains("method=\"post\""), "missing method=post");
8954        assert!(
8955            html.contains("<dl class=\"grid grid-cols-1 gap-4\">"),
8956            "missing <dl> scaffold"
8957        );
8958        // <dd> in Edit mode contains the rendered input (via render_node).
8959        assert!(
8960            html.contains("<input"),
8961            "Edit mode <dd> must contain rendered <input>: {html}"
8962        );
8963    }
8964
8965    #[test]
8966    fn render_detail_form_scaffold_invariance() {
8967        let view_html = render_df(df_props_minimal(EditMode::View));
8968        let edit_html = render_df(df_props_minimal(EditMode::Edit));
8969        let extract_dl = |h: &str| -> String {
8970            let start = h.find("<dl").expect("no <dl in html");
8971            let end = h.find("</dl>").expect("no </dl> in html") + "</dl>".len();
8972            h[start..end].to_string()
8973        };
8974        let view_dl = extract_dl(&view_html);
8975        let edit_dl = extract_dl(&edit_html);
8976        // Only the <dd> contents should differ between View and Edit; the <dl>, <dt>,
8977        // and row wrappers must be identical. Per §5 of 147-UI-SPEC the OUTER <dl>
8978        // opening tag + classes must match byte-for-byte.
8979        let view_open = &view_dl[..view_dl.find('>').expect("dl open") + 1];
8980        let edit_open = &edit_dl[..edit_dl.find('>').expect("dl open") + 1];
8981        assert_eq!(
8982            view_open, edit_open,
8983            "<dl> opening tag must be byte-identical"
8984        );
8985        // <dt> blocks must match as well.
8986        for (v_dt, e_dt) in view_dl
8987            .match_indices("<dt")
8988            .zip(edit_dl.match_indices("<dt"))
8989        {
8990            let v_end =
8991                view_dl[v_dt.0..].find("</dt>").expect("close dt view") + v_dt.0 + "</dt>".len();
8992            let e_end =
8993                edit_dl[e_dt.0..].find("</dt>").expect("close dt edit") + e_dt.0 + "</dt>".len();
8994            assert_eq!(
8995                &view_dl[v_dt.0..v_end],
8996                &edit_dl[e_dt.0..e_end],
8997                "<dt> content must be byte-identical"
8998            );
8999        }
9000    }
9001
9002    #[test]
9003    fn render_detail_form_edit_method_spoofing_put() {
9004        let mut props = df_props_minimal(EditMode::Edit);
9005        props.method = Some(HttpMethod::Put);
9006        let html = render_df(props);
9007        assert!(
9008            html.contains("method=\"post\""),
9009            "spoofed form must still use method=post"
9010        );
9011        assert!(
9012            html.contains("<input type=\"hidden\" name=\"_method\" value=\"PUT\">"),
9013            "missing _method=PUT hidden input: {html}"
9014        );
9015    }
9016
9017    #[test]
9018    fn render_detail_form_edit_method_spoofing_patch() {
9019        let mut props = df_props_minimal(EditMode::Edit);
9020        props.method = Some(HttpMethod::Patch);
9021        let html = render_df(props);
9022        assert!(html.contains("method=\"post\""));
9023        assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"PATCH\">"));
9024    }
9025
9026    #[test]
9027    fn render_detail_form_edit_method_spoofing_delete() {
9028        let mut props = df_props_minimal(EditMode::Edit);
9029        props.method = Some(HttpMethod::Delete);
9030        let html = render_df(props);
9031        assert!(html.contains("method=\"post\""));
9032        assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"DELETE\">"));
9033    }
9034
9035    #[test]
9036    fn render_detail_form_edit_get_no_spoofing() {
9037        let mut props = df_props_minimal(EditMode::Edit);
9038        props.action.method = HttpMethod::Get;
9039        props.method = None;
9040        let html = render_df(props);
9041        assert!(html.contains("method=\"get\""));
9042        assert!(
9043            !html.contains("name=\"_method\""),
9044            "GET must not emit method spoofing"
9045        );
9046    }
9047
9048    #[test]
9049    fn render_detail_form_view_shows_modifica_link() {
9050        let html = render_df(df_props_minimal(EditMode::View));
9051        assert!(
9052            html.contains("<a href=\"/users/1?mode=edit\""),
9053            "missing Modifica anchor"
9054        );
9055        assert!(
9056            html.contains(">Modifica</a>"),
9057            "missing default Modifica label"
9058        );
9059    }
9060
9061    #[test]
9062    fn render_detail_form_edit_shows_salva_and_annulla() {
9063        let html = render_df(df_props_minimal(EditMode::Edit));
9064        assert!(
9065            html.contains("<button type=\"submit\""),
9066            "missing submit button"
9067        );
9068        assert!(
9069            html.contains(">Salva</button>"),
9070            "missing default Salva label"
9071        );
9072        assert!(
9073            html.contains("<a href=\"/users/1\""),
9074            "missing Annulla anchor targeting cancel_url"
9075        );
9076        assert!(
9077            html.contains(">Annulla</a>"),
9078            "missing default Annulla label"
9079        );
9080    }
9081
9082    #[test]
9083    fn render_detail_form_view_xss_escapes_strings() {
9084        let mut props = df_props_minimal(EditMode::View);
9085        props.fields[0].label = "<script>alert(1)</script>".to_string();
9086        props.fields[0].value = "\"><svg/onload=alert(2)>".to_string();
9087        props.edit_url = "/foo\"/>x".to_string();
9088        let html = render_df(props);
9089        assert!(
9090            !html.contains("<script>alert(1)</script>"),
9091            "raw <script> must be escaped: {html}"
9092        );
9093        assert!(
9094            !html.contains("\"><svg/onload"),
9095            "raw attribute-break must be escaped"
9096        );
9097        assert!(
9098            html.contains("&lt;script&gt;"),
9099            "expected &lt;script&gt; in escaped output"
9100        );
9101        assert!(html.contains("&quot;"), "expected &quot; in escaped output");
9102    }
9103
9104    #[test]
9105    fn render_detail_form_edit_xss_escapes_cancel_url() {
9106        let mut props = df_props_minimal(EditMode::Edit);
9107        props.cancel_url = "/x\"/>y".to_string();
9108        let html = render_df(props);
9109        assert!(
9110            !html.contains("\"/>y"),
9111            "cancel_url must be escaped inside href: {html}"
9112        );
9113        assert!(
9114            html.contains("&quot;"),
9115            "expected &quot; in escaped cancel_url"
9116        );
9117    }
9118
9119    #[test]
9120    fn render_detail_form_custom_labels() {
9121        let mut props = df_props_minimal(EditMode::Edit);
9122        props.save_label = Some("Save".to_string());
9123        props.cancel_label = Some("Cancel".to_string());
9124        let html = render_df(props);
9125        assert!(html.contains(">Save</button>"), "missing custom Save label");
9126        assert!(html.contains(">Cancel</a>"), "missing custom Cancel label");
9127    }
9128
9129    #[test]
9130    fn render_detail_form_view_action_bar_below_dl() {
9131        let html = render_df(df_props_minimal(EditMode::View));
9132        let dl_close = html.find("</dl>").expect("no </dl>");
9133        let modifica = html.find(">Modifica</a>").expect("no Modifica anchor");
9134        assert!(
9135            dl_close < modifica,
9136            "action bar must render below <dl>; got dl_close={dl_close}, modifica={modifica}"
9137        );
9138    }
9139}