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