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