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