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