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