Skip to main content

ferro_json_ui/
render.rs

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