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