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