Skip to main content

ferro_json_ui/
layout.rs

1//! Layout system for JSON-UI page rendering.
2//!
3//! Provides a trait-based layout system where named layouts wrap rendered
4//! component HTML in full page shells. Three built-in layouts are provided:
5//! `DefaultLayout` (minimal), `AppLayout` (dashboard with nav + sidebar),
6//! and `AuthLayout` (centered, no card chrome). `DashboardLayout` is an optional
7//! layout that users register themselves with per-app config.
8//!
9//! A global `LayoutRegistry` maps layout names to implementations. Specs
10//! specify a layout via `Spec.layout`, and the render pipeline looks it up
11//! in the registry.
12
13use std::collections::HashMap;
14use std::sync::{OnceLock, RwLock};
15
16use crate::component::{HeaderProps, SidebarGroup, SidebarNavItem, SidebarProps};
17use crate::render::classes::INTERACTIVE_BASE;
18use crate::render::html_escape;
19
20// ── Layout context ──────────────────────────────────────────────────────
21
22/// Context passed to layout render functions.
23///
24/// Contains all data a layout needs to produce a complete HTML page:
25/// the rendered component HTML, page metadata, and serialized view/data
26/// for potential frontend hydration.
27pub struct LayoutContext<'a> {
28    /// Page title for the `<title>` element.
29    pub title: &'a str,
30    /// Rendered component HTML fragment (output of `render_spec_to_html`).
31    pub content: &'a str,
32    /// Additional `<head>` content (Tailwind CDN link, custom styles).
33    pub head: &'a str,
34    /// CSS classes for the `<body>` element.
35    pub body_class: &'a str,
36    /// Serialized view JSON for the `data-view` attribute.
37    pub view_json: &'a str,
38    /// Serialized data JSON for the `data-props` attribute.
39    pub data_json: &'a str,
40    /// JS assets and init scripts for plugins, injected before closing body tag.
41    pub scripts: &'a str,
42}
43
44// ── Layout trait ────────────────────────────────────────────────────────
45
46/// Trait for layout implementations.
47///
48/// Layouts produce a complete HTML page string wrapping the rendered
49/// component content. They must be `Send + Sync` for use in the global
50/// registry across threads.
51pub trait Layout: Send + Sync {
52    /// Render a complete HTML page using the provided context.
53    fn render(&self, ctx: &LayoutContext) -> String;
54}
55
56// ── Base document helper ────────────────────────────────────────────────
57
58/// Produce the common `<!DOCTYPE html>` shell shared by all built-in layouts.
59///
60/// All three built-in layouts delegate to this function to avoid duplicating
61/// the HTML/head/body boilerplate. The `body_content` parameter receives the
62/// inner body HTML which varies per layout.
63fn base_document(
64    title: &str,
65    head: &str,
66    body_class: &str,
67    body_content: &str,
68    scripts: &str,
69) -> String {
70    format!(
71        r#"<!DOCTYPE html>
72<html lang="en">
73<head>
74    <meta charset="UTF-8">
75    <meta name="viewport" content="width=device-width, initial-scale=1.0">
76    <title>{title}</title>
77    {head}
78</head>
79<body class="{body_class}">
80    {body_content}
81    {scripts}
82</body>
83</html>"#,
84        title = html_escape(title),
85        head = head,
86        body_class = html_escape(body_class),
87        body_content = body_content,
88        scripts = scripts,
89    )
90}
91
92/// Produce the ferro-json-ui wrapper div with data attributes.
93fn ferro_wrapper(ctx: &LayoutContext) -> String {
94    format!(
95        r#"<div id="ferro-json-ui" data-view="{view}" data-props="{props}">{content}</div>"#,
96        view = html_escape(ctx.view_json),
97        props = html_escape(ctx.data_json),
98        content = ctx.content,
99    )
100}
101
102/// Produce the common `<!DOCTYPE html>` shell with optional extra body attributes.
103///
104/// Extends `base_document` with a `body_data` parameter for additional
105/// `data-*` attributes on the `<body>` element (e.g., `data-sse-url`).
106fn base_document_ext(
107    title: &str,
108    head: &str,
109    body_class: &str,
110    body_data: &str,
111    body_content: &str,
112    scripts: &str,
113) -> String {
114    let body_data_attr = if body_data.is_empty() {
115        String::new()
116    } else {
117        format!(" {body_data}")
118    };
119    format!(
120        r#"<!DOCTYPE html>
121<html lang="en">
122<head>
123    <meta charset="UTF-8">
124    <meta name="viewport" content="width=device-width, initial-scale=1.0">
125    <title>{title}</title>
126    {head}
127</head>
128<body class="{body_class}"{body_data_attr}>
129    {body_content}
130    {scripts}
131</body>
132</html>"#,
133        title = html_escape(title),
134        head = head,
135        body_class = html_escape(body_class),
136        body_data_attr = body_data_attr,
137        body_content = body_content,
138        scripts = scripts,
139    )
140}
141
142// ── DashboardLayout helpers ─────────────────────────────────────────────
143
144/// Render a sidebar nav item for the layout shell.
145fn layout_sidebar_nav_item(item: &SidebarNavItem) -> String {
146    let disabled = item.disabled.unwrap_or(false);
147    // Appearance is handled by the fjui-sidebar__nav-item skin rule (SKIN-01).
148    // Layout utilities (flex items-center gap-*) stay inline (D-02).
149    let (tag, classes) = if disabled {
150        (
151            "span",
152            "fjui-sidebar__nav-item flex items-center gap-2 opacity-50 pointer-events-none select-none".to_string(),
153        )
154    } else if item.active {
155        (
156            "a",
157            "fjui-sidebar__nav-item fjui-sidebar__nav-item--active flex items-center gap-2"
158                .to_string(),
159        )
160    } else {
161        (
162            "a",
163            "fjui-sidebar__nav-item flex items-center gap-2".to_string(),
164        )
165    };
166    let mut html = if disabled {
167        format!("<{tag} aria-disabled=\"true\" class=\"{classes}\">")
168    } else {
169        format!(
170            "<{tag} href=\"{}\" class=\"{classes}\">",
171            html_escape(&item.href),
172        )
173    };
174    if let Some(ref icon) = item.icon {
175        html.push_str(&format!(
176            "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" // raw SVG
177        ));
178    }
179    html.push_str(&format!("{}</{tag}>", html_escape(&item.label)));
180    html
181}
182
183/// Render a sidebar group for the layout shell.
184fn layout_sidebar_group(group: &SidebarGroup) -> String {
185    let mut html = String::from("<div data-sidebar-group");
186    if group.collapsed {
187        html.push_str(" data-collapsed");
188    }
189    html.push('>');
190    // Appearance (font-size, color, text-transform) handled by fjui-sidebar__group-label skin rule.
191    html.push_str(&format!(
192        "<p class=\"fjui-sidebar__group-label\">{}</p>",
193        html_escape(&group.label)
194    ));
195    html.push_str("<nav class=\"space-y-1\">");
196    for item in &group.items {
197        html.push_str(&layout_sidebar_nav_item(item));
198    }
199    html.push_str("</nav></div>");
200    html
201}
202
203/// Render the sidebar shell from SidebarProps for DashboardLayout.
204fn layout_sidebar_html(props: &SidebarProps) -> String {
205    // fjui-sidebar: appearance (bg, border-right, width, position:fixed) handled by skin rule (SKIN-01).
206    // Layout utilities (inset-y-0 left-0 z-40 flex flex-col hidden md:flex) stay inline (D-02).
207    let mut html = String::from(
208        "<aside data-sidebar class=\"fjui-sidebar inset-y-0 left-0 z-40 flex flex-col hidden md:flex\">",
209    );
210    if !props.fixed_top.is_empty() {
211        html.push_str("<nav class=\"px-4 pt-4 pb-1 space-y-1\">");
212        for item in &props.fixed_top {
213            html.push_str(&layout_sidebar_nav_item(item));
214        }
215        html.push_str("</nav>");
216    }
217    if !props.groups.is_empty() {
218        html.push_str("<div class=\"flex-1 overflow-y-auto px-4 pb-4 pt-0 space-y-2\">");
219        for group in &props.groups {
220            html.push_str(&layout_sidebar_group(group));
221        }
222        html.push_str("</div>");
223    }
224    if !props.fixed_bottom.is_empty() {
225        // pb-safe: env(safe-area-inset-bottom) prevents bottom items from being clipped
226        // by Chrome mobile's dynamic URL bar when the sidebar is open full-height (dvh fix).
227        html.push_str("<nav class=\"fjui-sidebar__bottom p-4 space-y-1 border-t border-border\">");
228        for item in &props.fixed_bottom {
229            html.push_str(&layout_sidebar_nav_item(item));
230        }
231        html.push_str("</nav>");
232    }
233    html.push_str("</aside>");
234    // Backdrop for mobile sidebar overlay — sibling of aside so it covers the viewport behind it.
235    html.push_str(
236        "<div data-sidebar-backdrop class=\"fixed inset-0 z-30 bg-black/50 hidden md:hidden\"></div>",
237    );
238    html
239}
240
241/// Render the header shell from HeaderProps for DashboardLayout.
242fn layout_header_html(props: &HeaderProps) -> String {
243    // fjui-header: appearance (bg, border-bottom, height, sticky positioning, padding) handled by skin rule (SKIN-01).
244    // Layout utilities (z-30 flex items-center) stay inline (D-02). `relative` is intentionally
245    // absent: the skin sets `position: sticky` on .fjui-header, and `relative` (a Tailwind
246    // utility outside @layer components) would override it, causing the header to scroll away
247    // (Finding A — sticky header fix). Sticky positioning creates its own stacking context,
248    // so z-30 remains effective without an explicit `relative`. The header sits inside the
249    // sidebar-offset content column, so it must NOT re-pad for the sidebar itself
250    // (a legacy md:pl-72 here doubled the offset and pushed the workspace label off-left).
251    // md:pl-64 offsets the header *content* to clear the sidebar, while the
252    // border-bottom on the fjui-header element spans the full viewport width
253    // (Finding 3 — full-width separator). The header element itself is
254    // full-width; only its inner padding mirrors the content column offset.
255    let mut html = String::from("<header class=\"fjui-header z-30 flex items-center md:pl-64\">");
256    // Mobile hamburger button — visible only on small screens. -ml-2 cancels the
257    // button's own padding so the icon glyph (not the touch target) aligns with
258    // the 12px content gutter below.
259    html.push_str(&format!(
260        "<button data-sidebar-toggle class=\"md:hidden p-2 -ml-2 rounded-md text-text-muted \
261         hover:text-text hover:bg-surface {INTERACTIVE_BASE}\" aria-label=\"Toggle sidebar\">\
262         <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
263         <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
264         d=\"M4 6h16M4 12h16M4 18h16\"/></svg></button>"
265    ));
266    // Business name — left-aligned workspace label (CHR-01).
267    html.push_str(&format!(
268        "<span class=\"fjui-header__workspace\">{}</span>",
269        html_escape(&props.business_name)
270    ));
271    html.push_str("<div class=\"ml-auto flex items-center gap-4\">");
272    // Notification bell with dropdown toggle.
273    html.push_str("<div class=\"relative\">");
274    if let Some(count) = props.notification_count {
275        if count > 0 {
276            html.push_str(&format!(
277                "<button data-notification-toggle class=\"relative p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\">\
278                 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
279                 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
280                 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\"/></svg>\
281                 <span class=\"absolute top-1 right-1 inline-flex items-center justify-center h-4 w-4 \
282                 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{count}</span></button>",
283            ));
284        } else {
285            html.push_str(&format!(
286                "<button data-notification-toggle class=\"p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\">\
287                 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
288                 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
289                 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\"/></svg></button>"
290            ));
291        }
292    }
293    html.push_str(
294        "<div data-notification-dropdown class=\"hidden absolute right-0 top-full mt-1 w-80 \
295         bg-card rounded-lg shadow-lg border border-border z-50\"></div></div>",
296    );
297    // Search affordance button (CHR-01 / UX-02): magnifier + ⌘K kbd chip + tooltip.
298    // Dispatches fjui:open-command-palette — handler wired in Phase 249 (D-06).
299    html.push_str(&format!(
300        "<button type=\"button\" class=\"fjui-header__search-btn inline-flex items-center gap-2 {INTERACTIVE_BASE}\" \
301         data-tooltip=\"Cerca\" aria-label=\"Cerca (⌘K)\" \
302         onclick=\"document.dispatchEvent(new CustomEvent('fjui:open-command-palette'))\">\
303         <svg class=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" stroke-width=\"2\" \
304           stroke-linecap=\"round\" stroke-linejoin=\"round\">\
305           <circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.35-4.35\"/>\
306         </svg>\
307         <kbd class=\"fjui-kbd hidden md:inline\">\u{2318}K</kbd></button>"
308    ));
309    // Avatar initials button opens the fjui-avatar-menu popover (CHR-01).
310    // Falls back to business_name initials when user_name is absent.
311    let name_source = props
312        .user_name
313        .as_deref()
314        .filter(|s| !s.trim().is_empty())
315        .unwrap_or(&props.business_name);
316    let initials: String = name_source
317        .split_whitespace()
318        .filter_map(|w| w.chars().next())
319        .take(2)
320        .collect::<String>()
321        .to_uppercase();
322    html.push_str(&format!(
323        "<button class=\"fjui-avatar fjui-avatar--md inline-flex items-center justify-center \
324         cursor-pointer {INTERACTIVE_BASE}\" \
325         popovertarget=\"fjui-avatar-menu\" aria-label=\"Menu utente\" aria-haspopup=\"true\">{}</button>",
326        html_escape(&initials)
327    ));
328    // Avatar menu popover panel: Profilo / Tema toggle (only when `theme_url`
329    // is configured) / separator / Esci POST form.
330    // Esci is a plain <form method="post"> with no CSRF token — matching how
331    // consumer apps handle their other POST forms. The operative cross-site
332    // mitigation is the app's SameSite=Lax session cookie (cross-site POSTs
333    // arrive unauthenticated). Note ferro's CsrfMiddleware validates only the
334    // X-CSRF-TOKEN/X-XSRF-TOKEN headers, so it would reject token-less form
335    // posts; apps enabling it must exempt or tokenize this form themselves.
336    // Tema onclick POSTs to `theme_url`; the dark class toggles only on a 2xx
337    // response so visual state stays in sync with the persisted preference.
338    let logout_action = props.logout_url.as_deref().unwrap_or("/logout");
339    let theme_item = match props.theme_url.as_deref() {
340        Some(url) => {
341            // JS-string-escape then HTML-escape: the URL sits inside a
342            // single-quoted JS literal within a double-quoted onclick attribute
343            // (entities decode before JS parses, so `'` alone would break out).
344            let js_url = url.replace('\\', "\\\\").replace('\'', "\\'");
345            format!(
346                "<button type=\"button\" class=\"fjui-avatar-menu__item\" \
347                 onclick=\"fetch('{}',{{method:'POST',body:'theme='+(document.documentElement.classList.contains('dark')?'light':'dark'),headers:{{'Content-Type':'application/x-www-form-urlencoded'}}}}).then(function(r){{if(r.ok)document.documentElement.classList.toggle('dark')}})\">Tema</button>",
348                html_escape(&js_url)
349            )
350        }
351        None => String::new(),
352    };
353    let profile_item = match props.profile_url.as_deref() {
354        Some(url) => format!(
355            "<a href=\"{}\" class=\"fjui-avatar-menu__item\">Profilo</a>",
356            html_escape(url)
357        ),
358        None => String::new(),
359    };
360    html.push_str(&format!(
361        "<div popover id=\"fjui-avatar-menu\" data-popover-menu class=\"fjui-avatar-menu\">\
362           {profile_item}\
363           {theme_item}\
364           <div class=\"fjui-avatar-menu__separator\"></div>\
365           <form method=\"post\" action=\"{}\">\
366             <button type=\"submit\" class=\"fjui-avatar-menu__item fjui-avatar-menu__item--destructive\">Esci</button>\
367           </form>\
368         </div>",
369        html_escape(logout_action)
370    ));
371    html.push_str("</div></header>");
372    html
373}
374
375/// Combine plugin scripts with the built-in JS runtime.
376fn with_runtime(ctx_scripts: &str) -> String {
377    let runtime = format!(
378        "<script>\n{}\n</script>",
379        crate::runtime::FERRO_RUNTIME_JS.as_str()
380    );
381    if ctx_scripts.is_empty() {
382        runtime
383    } else {
384        format!("{ctx_scripts}\n{runtime}")
385    }
386}
387
388// ── DefaultLayout ───────────────────────────────────────────────────────
389
390/// Minimal layout wrapping content in a valid HTML page.
391///
392/// Produces the same structure as the existing framework HTML shell:
393/// doctype, meta tags, title, head content, body with the ferro-json-ui
394/// wrapper div containing the rendered components.
395pub struct DefaultLayout;
396
397impl Layout for DefaultLayout {
398    fn render(&self, ctx: &LayoutContext) -> String {
399        let wrapper = ferro_wrapper(ctx);
400        let scripts = with_runtime(ctx.scripts);
401        base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, &scripts)
402    }
403}
404
405// ── AppLayout ───────────────────────────────────────────────────────────
406
407/// Dashboard-style layout with navigation bar, sidebar, and main content area.
408///
409/// Uses a flex layout with the sidebar on the left and main content on the
410/// right. The ferro-json-ui wrapper div is placed inside the `<main>` element.
411///
412/// By default, renders empty navigation and sidebar placeholders. Users create
413/// custom Layout implementations that call the partial functions with real data.
414pub struct AppLayout;
415
416impl Layout for AppLayout {
417    fn render(&self, ctx: &LayoutContext) -> String {
418        let nav = navigation(&[]);
419        let side = sidebar(&[]);
420        let wrapper = ferro_wrapper(ctx);
421
422        let body = format!(
423            r#"{nav}
424    <div class="flex">
425        {side}
426        <main class="flex-1 px-3 py-4 md:p-6">
427            <div class="mx-auto w-full max-w-7xl">
428                {wrapper}
429            </div>
430        </main>
431    </div>"#,
432        );
433
434        let scripts = with_runtime(ctx.scripts);
435        base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
436    }
437}
438
439// ── AuthLayout ──────────────────────────────────────────────────────────
440
441/// Centered layout for authentication pages (login, register).
442///
443/// Centers the content vertically and horizontally within a max-width
444/// container. No navigation or sidebar. No card chrome — the spec's
445/// root component is responsible for its own card styling (D-05).
446pub struct AuthLayout;
447
448impl Layout for AuthLayout {
449    fn render(&self, ctx: &LayoutContext) -> String {
450        let wrapper = ferro_wrapper(ctx);
451
452        let body = format!(
453            r#"<div class="min-h-screen flex items-center justify-center">
454        <div class="w-full max-w-md">
455            {wrapper}
456        </div>
457    </div>"#,
458        );
459
460        let scripts = with_runtime(ctx.scripts);
461        base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
462    }
463}
464
465// ── Partial types and functions ─────────────────────────────────────────
466
467/// A navigation link item.
468pub struct NavItem {
469    /// Display label for the link.
470    pub label: String,
471    /// URL the link points to.
472    pub url: String,
473    /// Whether this item represents the current page.
474    pub active: bool,
475}
476
477impl NavItem {
478    /// Create a new navigation item (inactive by default).
479    pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
480        Self {
481            label: label.into(),
482            url: url.into(),
483            active: false,
484        }
485    }
486
487    /// Mark this navigation item as active (builder pattern).
488    pub fn active(mut self) -> Self {
489        self.active = true;
490        self
491    }
492}
493
494/// A sidebar section containing a title and a list of navigation items.
495pub struct SidebarSection {
496    /// Section heading.
497    pub title: String,
498    /// Navigation items in this section.
499    pub items: Vec<NavItem>,
500}
501
502impl SidebarSection {
503    /// Create a new sidebar section.
504    pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
505        Self {
506            title: title.into(),
507            items,
508        }
509    }
510}
511
512/// Render a horizontal navigation bar.
513///
514/// Produces a `<nav>` element with Tailwind CSS classes. Active items
515/// are highlighted with blue text and medium font weight.
516pub fn navigation(items: &[NavItem]) -> String {
517    let mut html =
518        String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
519
520    for item in items {
521        let class = if item.active {
522            "text-primary font-medium"
523        } else {
524            "text-text-muted hover:text-text"
525        };
526        html.push_str(&format!(
527            "<a href=\"{}\" class=\"{} {INTERACTIVE_BASE}\">{}</a>",
528            html_escape(&item.url),
529            class,
530            html_escape(&item.label),
531        ));
532    }
533
534    html.push_str("</div></nav>");
535    html
536}
537
538/// Render a vertical sidebar with sections.
539///
540/// Produces an `<aside>` element with sections, each containing a heading
541/// and a list of navigation links.
542pub fn sidebar(sections: &[SidebarSection]) -> String {
543    let mut html =
544        String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
545
546    for section in sections {
547        html.push_str("<div class=\"mb-6\">");
548        html.push_str(&format!(
549            "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
550            html_escape(&section.title),
551        ));
552        html.push_str("<ul class=\"space-y-1\">");
553        for item in &section.items {
554            let class = if item.active {
555                "text-primary font-medium"
556            } else {
557                "text-text-muted hover:text-text"
558            };
559            html.push_str(&format!(
560                "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm rounded-md {} {INTERACTIVE_BASE}\">{}</a></li>",
561                html_escape(&item.url),
562                class,
563                html_escape(&item.label),
564            ));
565        }
566        html.push_str("</ul></div>");
567    }
568
569    html.push_str("</aside>");
570    html
571}
572
573/// Render a simple footer.
574///
575/// Produces a `<footer>` element with centered text.
576pub fn footer(text: &str) -> String {
577    format!(
578        "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
579        html_escape(text),
580    )
581}
582
583// ── DashboardLayout ─────────────────────────────────────────────────────
584
585/// Configuration for `DashboardLayout`.
586///
587/// Provides the per-application sidebar navigation and header data needed
588/// to render the persistent dashboard shell. Users construct this at app
589/// startup and register it with the layout registry.
590///
591/// # Example
592///
593/// ```rust
594/// use ferro_json_ui::{DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, register_layout};
595///
596/// register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig {
597///     sidebar: SidebarProps { fixed_top: vec![], groups: vec![], fixed_bottom: vec![] },
598///     header: HeaderProps {
599///         business_name: "My App".to_string(),
600///         notification_count: None,
601///         user_name: Some("Alice".to_string()),
602///         user_avatar: None,
603///         logout_url: Some("/logout".to_string()),
604///         theme_url: Some("/theme".to_string()),
605///         profile_url: Some("/settings".to_string()),
606///     },
607///     sse_url: None,
608/// }));
609/// ```
610pub struct DashboardLayoutConfig {
611    /// Sidebar navigation data for the persistent sidebar shell.
612    pub sidebar: SidebarProps,
613    /// Header data for the persistent header shell.
614    pub header: HeaderProps,
615    /// Optional SSE endpoint URL. When set, the JS runtime opens an
616    /// `EventSource` connection to this URL and dispatches live-value
617    /// and toast updates from incoming messages.
618    pub sse_url: Option<String>,
619}
620
621/// Dashboard layout with persistent sidebar, header, and main content area.
622///
623/// Renders a full-page shell with a fixed sidebar on the left (desktop)
624/// and a sticky header at the top. The rendered view content appears in
625/// the `<main>` area. The built-in JS runtime (`FERRO_RUNTIME_JS`) is
626/// injected once as a `<script>` tag, enabling SSE, live-value updates,
627/// and toast notifications.
628///
629/// Mobile: sidebar is hidden by default and toggled via the hamburger button
630/// in the header (using responsive Tailwind classes).
631///
632/// This layout is NOT auto-registered. Users must register it at startup:
633///
634/// ```rust
635/// use ferro_json_ui::{DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, register_layout};
636///
637/// register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig {
638///     sidebar: SidebarProps { fixed_top: vec![], groups: vec![], fixed_bottom: vec![] },
639///     header: HeaderProps {
640///         business_name: "My App".to_string(),
641///         notification_count: None,
642///         user_name: None,
643///         user_avatar: None,
644///         logout_url: None,
645///         theme_url: None,
646///         profile_url: None,
647///     },
648///     sse_url: None,
649/// }));
650/// ```
651pub struct DashboardLayout {
652    /// Layout configuration (sidebar, header, SSE URL).
653    pub config: DashboardLayoutConfig,
654}
655
656impl DashboardLayout {
657    /// Create a new `DashboardLayout` from a `DashboardLayoutConfig`.
658    pub fn new(config: DashboardLayoutConfig) -> Self {
659        Self { config }
660    }
661}
662
663impl Layout for DashboardLayout {
664    fn render(&self, ctx: &LayoutContext) -> String {
665        let sidebar_html = layout_sidebar_html(&self.config.sidebar);
666        let header_html = layout_header_html(&self.config.header);
667        let wrapper = ferro_wrapper(ctx);
668
669        let body_data = if let Some(ref url) = self.config.sse_url {
670            format!("data-sse-url=\"{}\"", html_escape(url))
671        } else {
672            String::new()
673        };
674
675        let runtime_script = format!(
676            "<script>\n{}\n</script>",
677            crate::runtime::FERRO_RUNTIME_JS.as_str()
678        );
679        let scripts = if ctx.scripts.is_empty() {
680            runtime_script
681        } else {
682            format!("{}\n{}", ctx.scripts, runtime_script)
683        };
684
685        // Header is a sibling of the content column (not inside it) so its
686        // border-bottom spans the full viewport width edge-to-edge (Finding 3).
687        // The header's own md:pl-64 class mirrors the sidebar offset for content
688        // alignment. Main keeps md:pl-64 so its content also clears the sidebar.
689        let body_content = format!(
690            r#"{sidebar_html}
691    <div class="flex flex-col">
692        {header_html}
693        <main class="flex-1 px-3 py-4 md:p-6 md:pl-64">
694            <div class="mx-auto w-full max-w-7xl">
695                {wrapper}
696            </div>
697        </main>
698        <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
699    </div>"#,
700        );
701
702        let body_class = if ctx.body_class.is_empty() {
703            "bg-surface"
704        } else {
705            ctx.body_class
706        };
707
708        base_document_ext(
709            ctx.title,
710            ctx.head,
711            body_class,
712            &body_data,
713            &body_content,
714            &scripts,
715        )
716    }
717}
718
719// ── Layout registry ─────────────────────────────────────────────────────
720
721/// Registry mapping layout names to implementations.
722///
723/// Created with three built-in layouts: "default" (`DefaultLayout`),
724/// "app" (`AppLayout`), and "auth" (`AuthLayout`). Additional layouts
725/// can be registered at application startup.
726pub struct LayoutRegistry {
727    layouts: HashMap<String, Box<dyn Layout>>,
728    default: String,
729}
730
731impl LayoutRegistry {
732    /// Create a new registry with the three built-in layouts.
733    pub fn new() -> Self {
734        let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
735        layouts.insert("default".to_string(), Box::new(DefaultLayout));
736        layouts.insert("app".to_string(), Box::new(AppLayout));
737        layouts.insert("auth".to_string(), Box::new(AuthLayout));
738
739        Self {
740            layouts,
741            default: "default".to_string(),
742        }
743    }
744
745    /// Register a layout by name. Replaces any existing layout with the same name.
746    pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
747        self.layouts.insert(name.into(), Box::new(layout));
748    }
749
750    /// Render using the named layout. Falls back to default if name is None
751    /// or the name is not found in the registry.
752    pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
753        let layout_name = name.unwrap_or(&self.default);
754        let layout = self
755            .layouts
756            .get(layout_name)
757            .or_else(|| self.layouts.get(&self.default))
758            .expect("default layout must exist in registry");
759        layout.render(ctx)
760    }
761
762    /// Check whether a layout with the given name is registered.
763    pub fn has(&self, name: &str) -> bool {
764        self.layouts.contains_key(name)
765    }
766}
767
768impl Default for LayoutRegistry {
769    fn default() -> Self {
770        Self::new()
771    }
772}
773
774// ── Global registry ─────────────────────────────────────────────────────
775
776static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
777
778/// Access the global layout registry.
779///
780/// Lazily initialized on first call with the three built-in layouts.
781pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
782    GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
783}
784
785/// Register a layout in the global registry.
786///
787/// Convenience wrapper around `global_registry().write()`.
788pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
789    global_registry()
790        .write()
791        .expect("layout registry poisoned")
792        .register(name, layout);
793}
794
795/// Render using the global registry.
796///
797/// Convenience wrapper around `global_registry().read()`.
798pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
799    global_registry()
800        .read()
801        .expect("layout registry poisoned")
802        .render(name, ctx)
803}
804
805// ── Tests ───────────────────────────────────────────────────────────────
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    fn test_ctx() -> LayoutContext<'static> {
812        LayoutContext {
813            title: "Test Page",
814            content: "<p>Hello</p>",
815            head: "<link rel=\"stylesheet\" href=\"/style.css\">",
816            body_class: "bg-background",
817            view_json: "{\"schema\":\"ferro-json-ui/v2\"}",
818            data_json: "{\"key\":\"value\"}",
819            scripts: "",
820        }
821    }
822
823    // ── base_document tests ─────────────────────────────────────────
824
825    #[test]
826    fn base_document_produces_valid_html_structure() {
827        let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
828        assert!(html.starts_with("<!DOCTYPE html>"));
829        assert!(html.contains("<html lang=\"en\">"));
830        assert!(html.contains("<meta charset=\"UTF-8\">"));
831        assert!(html.contains("<meta name=\"viewport\""));
832        assert!(html.contains("<title>Title</title>"));
833        assert!(html.contains("<style></style>"));
834        assert!(html.contains("<body class=\"my-class\">"));
835        assert!(html.contains("<p>body</p>"));
836        assert!(html.contains("</html>"));
837    }
838
839    #[test]
840    fn base_document_escapes_title() {
841        let html = base_document("Tom & Jerry <script>", "", "", "", "");
842        assert!(html.contains("<title>Tom &amp; Jerry &lt;script&gt;</title>"));
843    }
844
845    #[test]
846    fn base_document_escapes_body_class() {
847        let html = base_document("T", "", "a\"b", "", "");
848        assert!(html.contains("class=\"a&quot;b\""));
849    }
850
851    // ── DefaultLayout tests ─────────────────────────────────────────
852
853    #[test]
854    fn default_layout_renders_all_context_fields() {
855        let ctx = test_ctx();
856        let html = DefaultLayout.render(&ctx);
857
858        assert!(html.contains("<!DOCTYPE html>"));
859        assert!(html.contains("<title>Test Page</title>"));
860        assert!(html.contains("href=\"/style.css\""));
861        assert!(html.contains("class=\"bg-background\""));
862        assert!(html.contains("id=\"ferro-json-ui\""));
863        assert!(html.contains("data-view=\""));
864        assert!(html.contains("data-props=\""));
865        assert!(html.contains("<p>Hello</p>"));
866    }
867
868    #[test]
869    fn default_layout_contains_ferro_wrapper() {
870        let ctx = test_ctx();
871        let html = DefaultLayout.render(&ctx);
872        assert!(html.contains("<div id=\"ferro-json-ui\""));
873    }
874
875    // ── AppLayout tests ─────────────────────────────────────────────
876
877    #[test]
878    fn app_layout_includes_nav_and_sidebar() {
879        let ctx = test_ctx();
880        let html = AppLayout.render(&ctx);
881
882        assert!(html.contains("<nav"));
883        assert!(html.contains("<aside"));
884        assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
885        assert!(html.contains("<div id=\"ferro-json-ui\""));
886        assert!(html.contains("<p>Hello</p>"));
887    }
888
889    #[test]
890    fn app_layout_has_flex_structure() {
891        let ctx = test_ctx();
892        let html = AppLayout.render(&ctx);
893        assert!(html.contains("class=\"flex\""));
894    }
895
896    // ── AuthLayout tests ────────────────────────────────────────────
897
898    #[test]
899    fn auth_layout_centers_content() {
900        let ctx = test_ctx();
901        let html = AuthLayout.render(&ctx);
902
903        // Structural centering and max-width are preserved.
904        assert!(
905            html.contains("min-h-screen flex items-center justify-center"),
906            "centering wrapper must remain"
907        );
908        assert!(
909            html.contains("w-full max-w-md"),
910            "max-width wrapper must remain"
911        );
912        assert!(html.contains("<div id=\"ferro-json-ui\""));
913        // D-05: layout no longer applies card chrome; the spec's root declares its own Card.
914        assert!(
915            !html.contains("bg-card rounded-lg shadow-md p-8"),
916            "card chrome must be removed from AuthLayout; spec root must declare its own Card"
917        );
918    }
919
920    #[test]
921    fn auth_layout_has_no_nav_or_sidebar() {
922        let ctx = test_ctx();
923        let html = AuthLayout.render(&ctx);
924        assert!(!html.contains("<nav"));
925        assert!(!html.contains("<aside"));
926    }
927
928    // ── LayoutRegistry tests ────────────────────────────────────────
929
930    #[test]
931    fn registry_returns_default_for_none_name() {
932        let registry = LayoutRegistry::new();
933        let ctx = test_ctx();
934        let html = registry.render(None, &ctx);
935        // DefaultLayout produces the simple wrapper (no nav/sidebar)
936        assert!(html.contains("<div id=\"ferro-json-ui\""));
937        assert!(!html.contains("<nav"));
938    }
939
940    #[test]
941    fn registry_returns_default_for_unknown_name() {
942        let registry = LayoutRegistry::new();
943        let ctx = test_ctx();
944        let html = registry.render(Some("nonexistent"), &ctx);
945        // Falls back to default
946        assert!(html.contains("<div id=\"ferro-json-ui\""));
947        assert!(!html.contains("<nav"));
948    }
949
950    #[test]
951    fn registry_renders_named_layout() {
952        let registry = LayoutRegistry::new();
953        let ctx = test_ctx();
954        let html = registry.render(Some("app"), &ctx);
955        assert!(html.contains("<nav"));
956        assert!(html.contains("<aside"));
957    }
958
959    #[test]
960    fn registry_renders_auth_layout() {
961        let registry = LayoutRegistry::new();
962        let ctx = test_ctx();
963        let html = registry.render(Some("auth"), &ctx);
964        assert!(html.contains("flex items-center justify-center"));
965    }
966
967    #[test]
968    fn registry_has_returns_true_for_registered() {
969        let registry = LayoutRegistry::new();
970        assert!(registry.has("default"));
971        assert!(registry.has("app"));
972        assert!(registry.has("auth"));
973    }
974
975    #[test]
976    fn registry_has_returns_false_for_unknown() {
977        let registry = LayoutRegistry::new();
978        assert!(!registry.has("nonexistent"));
979    }
980
981    #[test]
982    fn registry_register_adds_custom_layout() {
983        let mut registry = LayoutRegistry::new();
984        struct Custom;
985        impl Layout for Custom {
986            fn render(&self, _ctx: &LayoutContext) -> String {
987                "CUSTOM".to_string()
988            }
989        }
990        registry.register("custom", Custom);
991        assert!(registry.has("custom"));
992
993        let ctx = test_ctx();
994        let html = registry.render(Some("custom"), &ctx);
995        assert_eq!(html, "CUSTOM");
996    }
997
998    #[test]
999    fn registry_register_replaces_existing() {
1000        let mut registry = LayoutRegistry::new();
1001        struct Replacement;
1002        impl Layout for Replacement {
1003            fn render(&self, _ctx: &LayoutContext) -> String {
1004                "REPLACED".to_string()
1005            }
1006        }
1007        registry.register("default", Replacement);
1008        let ctx = test_ctx();
1009        let html = registry.render(None, &ctx);
1010        assert_eq!(html, "REPLACED");
1011    }
1012
1013    // ── Global registry tests ───────────────────────────────────────
1014
1015    #[test]
1016    fn global_registry_returns_valid_registry() {
1017        let reg = global_registry();
1018        let guard = reg.read().unwrap();
1019        assert!(guard.has("default"));
1020        assert!(guard.has("app"));
1021        assert!(guard.has("auth"));
1022    }
1023
1024    #[test]
1025    fn render_layout_global_function_works() {
1026        let ctx = test_ctx();
1027        let html = render_layout(None, &ctx);
1028        assert!(html.contains("<!DOCTYPE html>"));
1029        assert!(html.contains("<div id=\"ferro-json-ui\""));
1030    }
1031
1032    // ── Partial tests ───────────────────────────────────────────────
1033
1034    #[test]
1035    fn navigation_renders_empty_gracefully() {
1036        let html = navigation(&[]);
1037        assert!(html.contains("<nav"));
1038        assert!(html.contains("</nav>"));
1039    }
1040
1041    #[test]
1042    fn navigation_renders_items_with_correct_classes() {
1043        let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
1044        let html = navigation(&items);
1045        assert!(html.contains("href=\"/\""));
1046        assert!(html.contains(">Home</a>"));
1047        assert!(html.contains("href=\"/users\""));
1048        assert!(html.contains(">Users</a>"));
1049        // Both should be inactive
1050        assert!(html.contains("text-text-muted hover:text-text"));
1051    }
1052
1053    #[test]
1054    fn navigation_marks_active_item() {
1055        let items = vec![
1056            NavItem::new("Home", "/").active(),
1057            NavItem::new("Users", "/users"),
1058        ];
1059        let html = navigation(&items);
1060        assert!(html.contains("text-primary font-medium"));
1061    }
1062
1063    #[test]
1064    fn sidebar_renders_sections_with_headers() {
1065        let sections = vec![SidebarSection::new(
1066            "Main Menu",
1067            vec![
1068                NavItem::new("Dashboard", "/"),
1069                NavItem::new("Settings", "/settings"),
1070            ],
1071        )];
1072        let html = sidebar(&sections);
1073        assert!(html.contains("<aside"));
1074        assert!(html.contains("Main Menu"));
1075        assert!(html.contains("Dashboard"));
1076        assert!(html.contains("Settings"));
1077        assert!(html.contains("</aside>"));
1078    }
1079
1080    #[test]
1081    fn sidebar_renders_empty_gracefully() {
1082        let html = sidebar(&[]);
1083        assert!(html.contains("<aside"));
1084        assert!(html.contains("</aside>"));
1085    }
1086
1087    #[test]
1088    fn footer_renders_text() {
1089        let html = footer("Copyright 2026");
1090        assert!(html.contains("<footer"));
1091        assert!(html.contains("Copyright 2026"));
1092        assert!(html.contains("</footer>"));
1093    }
1094
1095    #[test]
1096    fn partials_escape_user_strings() {
1097        let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
1098        let html = navigation(&items);
1099        assert!(html.contains("Tom &amp; Jerry"));
1100        assert!(html.contains("href=\"/a&amp;b\""));
1101
1102        let sections = vec![SidebarSection::new(
1103            "A<B",
1104            vec![NavItem::new("<script>", "/x\"y")],
1105        )];
1106        let html = sidebar(&sections);
1107        assert!(html.contains("A&lt;B"));
1108        assert!(html.contains("&lt;script&gt;"));
1109
1110        let html = footer("<script>alert('xss')</script>");
1111        assert!(html.contains("&lt;script&gt;"));
1112    }
1113
1114    // ── ferro_wrapper tests ─────────────────────────────────────────
1115
1116    #[test]
1117    fn ferro_wrapper_includes_data_attributes() {
1118        let ctx = test_ctx();
1119        let html = ferro_wrapper(&ctx);
1120        assert!(html.contains("id=\"ferro-json-ui\""));
1121        assert!(html.contains("data-view=\""));
1122        assert!(html.contains("data-props=\""));
1123        assert!(html.contains("<p>Hello</p>"));
1124    }
1125
1126    // ── DashboardLayout tests ───────────────────────────────────────
1127
1128    fn dashboard_layout() -> DashboardLayout {
1129        use crate::component::{HeaderProps, SidebarProps};
1130        DashboardLayout::new(DashboardLayoutConfig {
1131            sidebar: SidebarProps {
1132                fixed_top: vec![],
1133                groups: vec![],
1134                fixed_bottom: vec![],
1135            },
1136            header: HeaderProps {
1137                business_name: "Acme".to_string(),
1138                notification_count: None,
1139                user_name: Some("Alice".to_string()),
1140                user_avatar: None,
1141                logout_url: Some("/logout".to_string()),
1142                theme_url: None,
1143                profile_url: None,
1144            },
1145            sse_url: None,
1146        })
1147    }
1148
1149    #[test]
1150    fn dashboard_layout_renders_full_html_structure() {
1151        let ctx = test_ctx();
1152        let html = dashboard_layout().render(&ctx);
1153
1154        assert!(html.starts_with("<!DOCTYPE html>"));
1155        assert!(html.contains("<title>Test Page</title>"));
1156        assert!(html.contains("<div id=\"ferro-json-ui\""));
1157        assert!(html.contains("<p>Hello</p>"));
1158    }
1159
1160    #[test]
1161    fn dashboard_layout_has_persistent_sidebar() {
1162        let ctx = test_ctx();
1163        let html = dashboard_layout().render(&ctx);
1164        assert!(html.contains("<aside data-sidebar"));
1165    }
1166
1167    #[test]
1168    fn dashboard_layout_has_persistent_header() {
1169        let ctx = test_ctx();
1170        let html = dashboard_layout().render(&ctx);
1171        assert!(html.contains("<header"));
1172        assert!(html.contains("Acme"));
1173    }
1174
1175    #[test]
1176    fn dashboard_layout_has_main_content_area() {
1177        let ctx = test_ctx();
1178        let html = dashboard_layout().render(&ctx);
1179        // md:pl-64 clears the fixed sidebar; added alongside md:p-6 (Finding 3).
1180        assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6 md:pl-64\">"));
1181    }
1182
1183    #[test]
1184    fn dashboard_layout_has_toast_container() {
1185        let ctx = test_ctx();
1186        let html = dashboard_layout().render(&ctx);
1187        assert!(html.contains("data-toast-container"));
1188    }
1189
1190    #[test]
1191    fn dashboard_layout_injects_runtime_js() {
1192        let ctx = test_ctx();
1193        let html = dashboard_layout().render(&ctx);
1194        // JS runtime is injected as a <script> tag containing the IIFE
1195        assert!(html.contains("<script>"));
1196        assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1197    }
1198
1199    #[test]
1200    fn dashboard_layout_has_mobile_hamburger_toggle() {
1201        let ctx = test_ctx();
1202        let html = dashboard_layout().render(&ctx);
1203        assert!(html.contains("data-sidebar-toggle"));
1204    }
1205
1206    #[test]
1207    fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1208        let ctx = test_ctx();
1209        let html = dashboard_layout().render(&ctx);
1210        // data-sse-url appears in the JS runtime source as a string literal,
1211        // but should NOT appear as a body element attribute when sse_url is None.
1212        // Check that the body tag does not contain the attribute.
1213        let body_start = html.find("<body").unwrap_or(0);
1214        let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1215        let body_tag = &html[body_start..=body_tag_end];
1216        assert!(!body_tag.contains("data-sse-url="));
1217    }
1218
1219    #[test]
1220    fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1221        use crate::component::{HeaderProps, SidebarProps};
1222        let layout = DashboardLayout::new(DashboardLayoutConfig {
1223            sidebar: SidebarProps {
1224                fixed_top: vec![],
1225                groups: vec![],
1226                fixed_bottom: vec![],
1227            },
1228            header: HeaderProps {
1229                business_name: "App".to_string(),
1230                notification_count: None,
1231                user_name: None,
1232                user_avatar: None,
1233                logout_url: None,
1234                theme_url: None,
1235                profile_url: None,
1236            },
1237            sse_url: Some("/events".to_string()),
1238        });
1239        let ctx = test_ctx();
1240        let html = layout.render(&ctx);
1241        assert!(html.contains("data-sse-url=\"/events\""));
1242    }
1243
1244    #[test]
1245    fn dashboard_layout_escapes_sse_url_xss() {
1246        use crate::component::{HeaderProps, SidebarProps};
1247        let layout = DashboardLayout::new(DashboardLayoutConfig {
1248            sidebar: SidebarProps {
1249                fixed_top: vec![],
1250                groups: vec![],
1251                fixed_bottom: vec![],
1252            },
1253            header: HeaderProps {
1254                business_name: "App".to_string(),
1255                notification_count: None,
1256                user_name: None,
1257                user_avatar: None,
1258                logout_url: None,
1259                theme_url: None,
1260                profile_url: None,
1261            },
1262            sse_url: Some("/events?a=1&b=2".to_string()),
1263        });
1264        let ctx = test_ctx();
1265        let html = layout.render(&ctx);
1266        assert!(html.contains("data-sse-url=\"/events?a=1&amp;b=2\""));
1267    }
1268
1269    #[test]
1270    fn dashboard_layout_notification_toggle_present_with_count() {
1271        use crate::component::{HeaderProps, SidebarProps};
1272        let layout = DashboardLayout::new(DashboardLayoutConfig {
1273            sidebar: SidebarProps {
1274                fixed_top: vec![],
1275                groups: vec![],
1276                fixed_bottom: vec![],
1277            },
1278            header: HeaderProps {
1279                business_name: "App".to_string(),
1280                notification_count: Some(5),
1281                user_name: None,
1282                user_avatar: None,
1283                logout_url: None,
1284                theme_url: None,
1285                profile_url: None,
1286            },
1287            sse_url: None,
1288        });
1289        let ctx = test_ctx();
1290        let html = layout.render(&ctx);
1291        assert!(html.contains("data-notification-toggle"));
1292    }
1293
1294    #[test]
1295    fn dashboard_layout_has_sidebar_backdrop() {
1296        let ctx = test_ctx();
1297        let html = dashboard_layout().render(&ctx);
1298        assert!(html.contains("data-sidebar-backdrop"));
1299        assert!(html.contains("bg-black/50"));
1300        assert!(html.contains("md:hidden"));
1301    }
1302
1303    #[test]
1304    fn dashboard_layout_sidebar_mobile_classes() {
1305        let ctx = test_ctx();
1306        let html = dashboard_layout().render(&ctx);
1307        // Sidebar uses responsive classes: hidden on mobile, flex on md+
1308        assert!(html.contains("hidden md:flex"));
1309    }
1310
1311    #[test]
1312    fn dashboard_layout_uses_default_body_class() {
1313        let ctx = test_ctx();
1314        let html = dashboard_layout().render(&ctx);
1315        // body_class from test_ctx is "bg-background" — should be preserved
1316        assert!(html.contains("class=\"bg-background\""));
1317    }
1318
1319    #[test]
1320    fn sidebar_nav_item_renders_icon_as_raw_svg() {
1321        let item = SidebarNavItem {
1322            label: "Dashboard".to_string(),
1323            href: "/dashboard".to_string(),
1324            icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1325            active: false,
1326            disabled: None,
1327        };
1328        let html = layout_sidebar_nav_item(&item);
1329        assert!(
1330            html.contains("<svg"),
1331            "icon SVG should be rendered raw, not escaped"
1332        );
1333        assert!(
1334            !html.contains("&lt;svg"),
1335            "icon SVG should NOT be html-escaped"
1336        );
1337        assert!(html.contains("Dashboard"), "label should still appear");
1338    }
1339
1340    #[test]
1341    fn sidebar_group_label_uses_fjui_class() {
1342        // After Plan 09 migration: appearance (11px/500/uppercase) is in the
1343        // fjui-sidebar__group-label skin rule; Rust only emits the semantic class.
1344        let group = SidebarGroup {
1345            label: "Cassa".to_string(),
1346            collapsed: false,
1347            items: vec![],
1348        };
1349        let html = layout_sidebar_group(&group);
1350        assert!(html.contains("Cassa"));
1351        assert!(
1352            html.contains("fjui-sidebar__group-label"),
1353            "sidebar group label must emit fjui-sidebar__group-label"
1354        );
1355        // No appearance utilities in Rust output (SKIN-01).
1356        assert!(
1357            !html.contains("font-semibold"),
1358            "font-semibold must be removed from Rust emission; handled by skin rule"
1359        );
1360    }
1361
1362    // ── INT-07 (layout): DashboardLayout sidebar nav item focus ring ──────
1363    // After Plan 09 migration: focus ring and transitions are in the
1364    // fjui-sidebar__nav-item skin rule (:focus-visible, transition-property).
1365    // The Rust emission carries only the semantic class — no appearance utilities.
1366
1367    #[test]
1368    fn layout_sidebar_nav_focus_ring() {
1369        let item = SidebarNavItem {
1370            label: "Dashboard".to_string(),
1371            href: "/dashboard".to_string(),
1372            icon: None,
1373            active: false,
1374            disabled: None,
1375        };
1376        let html = layout_sidebar_nav_item(&item);
1377        assert!(
1378            html.contains("fjui-sidebar__nav-item"),
1379            "layout sidebar nav <a> item must emit fjui-sidebar__nav-item (INT-07 — focus ring in skin rule)"
1380        );
1381        // Focus ring and motion are handled by the fjui-sidebar__nav-item skin rule (SKIN-01).
1382        // They must NOT be inlined as Tailwind utilities in the Rust output.
1383        assert!(
1384            !html.contains("focus-visible:ring-ring"),
1385            "focus-visible:ring-ring must be removed from Rust emission; handled by skin rule"
1386        );
1387        assert!(
1388            !html.contains("duration-fast"),
1389            "duration-fast must be removed from Rust emission; handled by skin rule"
1390        );
1391    }
1392
1393    // ── Plan 09: fjui-* chrome class migration tests ────────────────────────
1394
1395    /// Sidebar shell emits fjui-sidebar (T-246-19: swap-target preserved).
1396    #[test]
1397    fn layout_sidebar_html_emits_fjui_sidebar_class() {
1398        use crate::component::SidebarProps;
1399        let props = SidebarProps {
1400            fixed_top: vec![],
1401            groups: vec![],
1402            fixed_bottom: vec![],
1403        };
1404        let html = layout_sidebar_html(&props);
1405        assert!(
1406            html.contains("fjui-sidebar"),
1407            "layout_sidebar_html must emit fjui-sidebar class; found: {html}"
1408        );
1409        // Structural attributes must be preserved (T-246-19).
1410        assert!(
1411            html.contains("data-sidebar"),
1412            "data-sidebar attribute must be preserved"
1413        );
1414        assert!(
1415            html.contains("data-sidebar-backdrop"),
1416            "data-sidebar-backdrop must be preserved"
1417        );
1418    }
1419
1420    /// Header shell emits fjui-header.
1421    #[test]
1422    fn layout_header_html_emits_fjui_header_class() {
1423        use crate::component::HeaderProps;
1424        let props = HeaderProps {
1425            business_name: "Test".to_string(),
1426            notification_count: None,
1427            user_name: None,
1428            user_avatar: None,
1429            logout_url: None,
1430            theme_url: None,
1431            profile_url: None,
1432        };
1433        let html = layout_header_html(&props);
1434        assert!(
1435            html.contains("fjui-header"),
1436            "layout_header_html must emit fjui-header class; found: {html}"
1437        );
1438    }
1439
1440    /// Sidebar nav item emits fjui-sidebar__nav-item (not old appearance utilities).
1441    #[test]
1442    fn layout_sidebar_nav_item_emits_fjui_class() {
1443        let item = SidebarNavItem {
1444            label: "Dashboard".to_string(),
1445            href: "/dashboard".to_string(),
1446            icon: None,
1447            active: false,
1448            disabled: None,
1449        };
1450        let html = layout_sidebar_nav_item(&item);
1451        assert!(
1452            html.contains("fjui-sidebar__nav-item"),
1453            "inactive nav item must emit fjui-sidebar__nav-item; found: {html}"
1454        );
1455    }
1456
1457    /// Active sidebar nav item emits fjui-sidebar__nav-item--active modifier.
1458    #[test]
1459    fn layout_sidebar_nav_item_active_emits_fjui_active_modifier() {
1460        let item = SidebarNavItem {
1461            label: "Dashboard".to_string(),
1462            href: "/dashboard".to_string(),
1463            icon: None,
1464            active: true,
1465            disabled: None,
1466        };
1467        let html = layout_sidebar_nav_item(&item);
1468        assert!(
1469            html.contains("fjui-sidebar__nav-item--active"),
1470            "active nav item must emit fjui-sidebar__nav-item--active; found: {html}"
1471        );
1472    }
1473
1474    /// Sidebar group label emits fjui-sidebar__group-label.
1475    #[test]
1476    fn layout_sidebar_group_label_emits_fjui_class() {
1477        let group = SidebarGroup {
1478            label: "Cassa".to_string(),
1479            collapsed: false,
1480            items: vec![],
1481        };
1482        let html = layout_sidebar_group(&group);
1483        assert!(
1484            html.contains("fjui-sidebar__group-label"),
1485            "sidebar group label must emit fjui-sidebar__group-label; found: {html}"
1486        );
1487    }
1488
1489    /// DashboardLayout full render emits fjui-sidebar and fjui-header (structural id/attrs preserved).
1490    #[test]
1491    fn dashboard_layout_shell_emits_fjui_chrome_classes() {
1492        let ctx = test_ctx();
1493        let html = dashboard_layout().render(&ctx);
1494        assert!(
1495            html.contains("fjui-sidebar"),
1496            "DashboardLayout must emit fjui-sidebar"
1497        );
1498        assert!(
1499            html.contains("fjui-header"),
1500            "DashboardLayout must emit fjui-header"
1501        );
1502        // Structural swap-target preserved (T-246-19).
1503        assert!(
1504            html.contains("id=\"ferro-json-ui\""),
1505            "ferro-json-ui swap-target must be preserved"
1506        );
1507        assert!(
1508            html.contains("data-sidebar"),
1509            "data-sidebar must be preserved"
1510        );
1511        assert!(
1512            html.contains("data-toast-container"),
1513            "data-toast-container must be preserved"
1514        );
1515        // Grid layout utilities preserved (D-02).
1516        assert!(
1517            html.contains("md:pl-64"),
1518            "DashboardLayout grid utility md:pl-64 must be preserved"
1519        );
1520        assert!(
1521            html.contains("max-w-7xl"),
1522            "DashboardLayout grid utility max-w-7xl must be preserved"
1523        );
1524    }
1525
1526    // ── Plan 03 (247): Avatar menu + search affordance tests ────────────────
1527
1528    #[test]
1529    fn header_emits_avatar_initials() {
1530        use crate::component::HeaderProps;
1531        let props = HeaderProps {
1532            business_name: "Acme".to_string(),
1533            notification_count: None,
1534            user_name: Some("Alice Rossi".into()),
1535            user_avatar: None,
1536            logout_url: Some("/logout".into()),
1537            theme_url: None,
1538            profile_url: None,
1539        };
1540        let html = layout_header_html(&props);
1541        assert!(
1542            html.contains("fjui-avatar"),
1543            "avatar button must be present; got: {html}"
1544        );
1545        assert!(
1546            html.contains("popovertarget=\"fjui-avatar-menu\""),
1547            "avatar button must wire to fjui-avatar-menu popover; got: {html}"
1548        );
1549        assert!(
1550            html.contains("AR"),
1551            "initials AR must be present from 'Alice Rossi'; got: {html}"
1552        );
1553    }
1554
1555    #[test]
1556    fn header_no_bare_logout_link() {
1557        use crate::component::HeaderProps;
1558        let props = HeaderProps {
1559            business_name: "Acme".to_string(),
1560            notification_count: None,
1561            user_name: None,
1562            user_avatar: None,
1563            logout_url: Some("/logout".into()),
1564            theme_url: None,
1565            profile_url: None,
1566        };
1567        let html = layout_header_html(&props);
1568        assert!(
1569            !html.contains(">Logout</a>"),
1570            "bare Logout link must be gone; got: {html}"
1571        );
1572        assert!(
1573            html.contains("action=\"/logout\""),
1574            "logout must be moved into Esci POST form; got: {html}"
1575        );
1576    }
1577
1578    #[test]
1579    fn header_search_button_present() {
1580        use crate::component::HeaderProps;
1581        let props = HeaderProps {
1582            business_name: "Acme".to_string(),
1583            notification_count: None,
1584            user_name: None,
1585            user_avatar: None,
1586            logout_url: None,
1587            theme_url: None,
1588            profile_url: None,
1589        };
1590        let html = layout_header_html(&props);
1591        assert!(
1592            html.contains("fjui-header__search-btn"),
1593            "search button must be present; got: {html}"
1594        );
1595        assert!(
1596            html.contains("data-tooltip=\"Cerca\""),
1597            "search button must carry data-tooltip Cerca (UX-02); got: {html}"
1598        );
1599        assert!(
1600            html.contains("fjui:open-command-palette"),
1601            "search button must dispatch fjui:open-command-palette (D-06); got: {html}"
1602        );
1603        assert!(
1604            html.contains("fjui-kbd"),
1605            "search button must include fjui-kbd chip; got: {html}"
1606        );
1607    }
1608
1609    /// Tema item renders only when theme_url is configured, POSTs to that URL,
1610    /// and toggles the dark class only on a 2xx response (WR-04).
1611    #[test]
1612    fn header_theme_toggle_only_when_theme_url_set() {
1613        use crate::component::HeaderProps;
1614        let mut props = HeaderProps {
1615            business_name: "Acme".to_string(),
1616            notification_count: None,
1617            user_name: None,
1618            user_avatar: None,
1619            logout_url: None,
1620            theme_url: None,
1621            profile_url: None,
1622        };
1623        let html = layout_header_html(&props);
1624        assert!(
1625            !html.contains(">Tema</button>"),
1626            "Tema item must be omitted when theme_url is None; got: {html}"
1627        );
1628
1629        props.theme_url = Some("/dashboard/theme".to_string());
1630        let html = layout_header_html(&props);
1631        assert!(
1632            html.contains("fetch('/dashboard/theme'"),
1633            "Tema must POST to the configured theme_url; got: {html}"
1634        );
1635        assert!(
1636            html.contains("if(r.ok)document.documentElement.classList.toggle('dark')"),
1637            "dark-class toggle must be gated on response.ok; got: {html}"
1638        );
1639    }
1640}