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 card). `DashboardLayout` is an optional layout
7//! that users register themselves with per-app config.
8//!
9//! A global `LayoutRegistry` maps layout names to implementations. Views
10//! specify a layout via `JsonUiView.layout`, and the render pipeline looks
11//! it up in the registry.
12
13use std::collections::HashMap;
14use std::sync::{OnceLock, RwLock};
15
16use crate::component::{HeaderProps, SidebarGroup, SidebarNavItem, SidebarProps};
17use crate::render::html_escape;
18
19// ── Layout context ──────────────────────────────────────────────────────
20
21/// Context passed to layout render functions.
22///
23/// Contains all data a layout needs to produce a complete HTML page:
24/// the rendered component HTML, page metadata, and serialized view/data
25/// for potential frontend hydration.
26pub struct LayoutContext<'a> {
27    /// Page title for the `<title>` element.
28    pub title: &'a str,
29    /// Rendered component HTML fragment (output of `render_to_html`).
30    pub content: &'a str,
31    /// Additional `<head>` content (Tailwind CDN link, custom styles).
32    pub head: &'a str,
33    /// CSS classes for the `<body>` element.
34    pub body_class: &'a str,
35    /// Serialized view JSON for the `data-view` attribute.
36    pub view_json: &'a str,
37    /// Serialized data JSON for the `data-props` attribute.
38    pub data_json: &'a str,
39    /// JS assets and init scripts for plugins, injected before closing body tag.
40    pub scripts: &'a str,
41}
42
43// ── Layout trait ────────────────────────────────────────────────────────
44
45/// Trait for layout implementations.
46///
47/// Layouts produce a complete HTML page string wrapping the rendered
48/// component content. They must be `Send + Sync` for use in the global
49/// registry across threads.
50pub trait Layout: Send + Sync {
51    /// Render a complete HTML page using the provided context.
52    fn render(&self, ctx: &LayoutContext) -> String;
53}
54
55// ── Base document helper ────────────────────────────────────────────────
56
57/// Produce the common `<!DOCTYPE html>` shell shared by all built-in layouts.
58///
59/// All three built-in layouts delegate to this function to avoid duplicating
60/// the HTML/head/body boilerplate. The `body_content` parameter receives the
61/// inner body HTML which varies per layout.
62fn base_document(
63    title: &str,
64    head: &str,
65    body_class: &str,
66    body_content: &str,
67    scripts: &str,
68) -> String {
69    format!(
70        r#"<!DOCTYPE html>
71<html lang="en">
72<head>
73    <meta charset="UTF-8">
74    <meta name="viewport" content="width=device-width, initial-scale=1.0">
75    <title>{title}</title>
76    {head}
77</head>
78<body class="{body_class}">
79    {body_content}
80    {scripts}
81</body>
82</html>"#,
83        title = html_escape(title),
84        head = head,
85        body_class = html_escape(body_class),
86        body_content = body_content,
87        scripts = scripts,
88    )
89}
90
91/// Produce the ferro-json-ui wrapper div with data attributes.
92fn ferro_wrapper(ctx: &LayoutContext) -> String {
93    format!(
94        r#"<div id="ferro-json-ui" data-view="{view}" data-props="{props}">{content}</div>"#,
95        view = html_escape(ctx.view_json),
96        props = html_escape(ctx.data_json),
97        content = ctx.content,
98    )
99}
100
101/// Produce the common `<!DOCTYPE html>` shell with optional extra body attributes.
102///
103/// Extends `base_document` with a `body_data` parameter for additional
104/// `data-*` attributes on the `<body>` element (e.g., `data-sse-url`).
105fn base_document_ext(
106    title: &str,
107    head: &str,
108    body_class: &str,
109    body_data: &str,
110    body_content: &str,
111    scripts: &str,
112) -> String {
113    let body_data_attr = if body_data.is_empty() {
114        String::new()
115    } else {
116        format!(" {body_data}")
117    };
118    format!(
119        r#"<!DOCTYPE html>
120<html lang="en">
121<head>
122    <meta charset="UTF-8">
123    <meta name="viewport" content="width=device-width, initial-scale=1.0">
124    <title>{title}</title>
125    {head}
126</head>
127<body class="{body_class}"{body_data_attr}>
128    {body_content}
129    {scripts}
130</body>
131</html>"#,
132        title = html_escape(title),
133        head = head,
134        body_class = html_escape(body_class),
135        body_data_attr = body_data_attr,
136        body_content = body_content,
137        scripts = scripts,
138    )
139}
140
141// ── DashboardLayout helpers ─────────────────────────────────────────────
142
143/// Render a sidebar nav item for the layout shell.
144fn layout_sidebar_nav_item(item: &SidebarNavItem) -> String {
145    let classes = if item.active {
146        "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"
147    } else {
148        "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"
149    };
150    let mut html = format!(
151        "<a href=\"{}\" class=\"{}\">",
152        html_escape(&item.href),
153        classes
154    );
155    if let Some(ref icon) = item.icon {
156        html.push_str(&format!(
157            "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" // raw SVG
158        ));
159    }
160    html.push_str(&format!("{}</a>", html_escape(&item.label)));
161    html
162}
163
164/// Render a sidebar group for the layout shell.
165fn layout_sidebar_group(group: &SidebarGroup) -> String {
166    let mut html = String::from("<div data-sidebar-group");
167    if group.collapsed {
168        html.push_str(" data-collapsed");
169    }
170    html.push('>');
171    html.push_str(&format!(
172        "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted\">{}</p>",
173        html_escape(&group.label)
174    ));
175    html.push_str("<nav class=\"space-y-1\">");
176    for item in &group.items {
177        html.push_str(&layout_sidebar_nav_item(item));
178    }
179    html.push_str("</nav></div>");
180    html
181}
182
183/// Render the sidebar shell from SidebarProps for DashboardLayout.
184fn layout_sidebar_html(props: &SidebarProps) -> String {
185    let mut html = String::from(
186        "<aside data-sidebar class=\"fixed inset-y-0 left-0 z-40 w-64 flex flex-col \
187         bg-background border-r border-border hidden md:flex\">",
188    );
189    if !props.fixed_top.is_empty() {
190        html.push_str("<nav class=\"p-4 space-y-1\">");
191        for item in &props.fixed_top {
192            html.push_str(&layout_sidebar_nav_item(item));
193        }
194        html.push_str("</nav>");
195    }
196    if !props.groups.is_empty() {
197        html.push_str("<div class=\"flex-1 overflow-y-auto p-4 space-y-4\">");
198        for group in &props.groups {
199            html.push_str(&layout_sidebar_group(group));
200        }
201        html.push_str("</div>");
202    }
203    if !props.fixed_bottom.is_empty() {
204        html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
205        for item in &props.fixed_bottom {
206            html.push_str(&layout_sidebar_nav_item(item));
207        }
208        html.push_str("</nav>");
209    }
210    html.push_str("</aside>");
211    // Backdrop for mobile sidebar overlay — sibling of aside so it covers the viewport behind it.
212    html.push_str(
213        "<div data-sidebar-backdrop class=\"fixed inset-0 z-30 bg-black/50 hidden md:hidden\"></div>",
214    );
215    html
216}
217
218/// Render the header shell from HeaderProps for DashboardLayout.
219fn layout_header_html(props: &HeaderProps) -> String {
220    let mut html = String::from(
221        "<header class=\"sticky top-0 z-30 relative flex items-center \
222         px-4 py-3 bg-background border-b border-border md:pl-72\">",
223    );
224    // Mobile hamburger button — visible only on small screens.
225    html.push_str(
226        "<button data-sidebar-toggle class=\"md:hidden p-2 rounded-md text-text-muted \
227         hover:text-text hover:bg-surface\" aria-label=\"Toggle sidebar\">\
228         <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
229         <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
230         d=\"M4 6h16M4 12h16M4 18h16\"/></svg></button>",
231    );
232    // Business name — absolutely centered relative to the header box,
233    // independent of hamburger/notification/user elements.
234    html.push_str(&format!(
235        "<span class=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
236        html_escape(&props.business_name)
237    ));
238    html.push_str("<div class=\"ml-auto flex items-center gap-4\">");
239    // Notification bell with dropdown toggle.
240    html.push_str("<div class=\"relative\">");
241    if let Some(count) = props.notification_count {
242        if count > 0 {
243            html.push_str(&format!(
244                "<button data-notification-toggle class=\"relative p-2 text-text-muted hover:text-text\">\
245                 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
246                 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
247                 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>\
248                 <span class=\"absolute top-1 right-1 inline-flex items-center justify-center h-4 w-4 \
249                 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{count}</span></button>",
250            ));
251        } else {
252            html.push_str(
253                "<button data-notification-toggle class=\"p-2 text-text-muted hover:text-text\">\
254                 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
255                 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
256                 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>",
257            );
258        }
259    }
260    html.push_str(
261        "<div data-notification-dropdown class=\"hidden absolute right-0 top-full mt-1 w-80 \
262         bg-card rounded-lg shadow-lg border border-border z-50\"></div></div>",
263    );
264    // User section.
265    html.push_str("<div class=\"flex items-center gap-2\">");
266    if let Some(ref avatar) = props.user_avatar {
267        html.push_str(&format!(
268            "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
269            html_escape(avatar)
270        ));
271    } else if let Some(ref name) = props.user_name {
272        let initials: String = name
273            .split_whitespace()
274            .filter_map(|w| w.chars().next())
275            .take(2)
276            .collect();
277        html.push_str(&format!(
278            "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full \
279             bg-card text-text-muted text-sm font-medium\">{}</span>",
280            html_escape(&initials)
281        ));
282        html.push_str(&format!(
283            "<span class=\"text-sm text-text\">{}</span>",
284            html_escape(name)
285        ));
286    }
287    if let Some(ref logout) = props.logout_url {
288        html.push_str(&format!(
289            "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text\">Logout</a>",
290            html_escape(logout)
291        ));
292    }
293    html.push_str("</div></div></header>");
294    html
295}
296
297// ── DefaultLayout ───────────────────────────────────────────────────────
298
299/// Minimal layout wrapping content in a valid HTML page.
300///
301/// Produces the same structure as the existing framework HTML shell:
302/// doctype, meta tags, title, head content, body with the ferro-json-ui
303/// wrapper div containing the rendered components.
304pub struct DefaultLayout;
305
306impl Layout for DefaultLayout {
307    fn render(&self, ctx: &LayoutContext) -> String {
308        let wrapper = ferro_wrapper(ctx);
309        base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, ctx.scripts)
310    }
311}
312
313// ── AppLayout ───────────────────────────────────────────────────────────
314
315/// Dashboard-style layout with navigation bar, sidebar, and main content area.
316///
317/// Uses a flex layout with the sidebar on the left and main content on the
318/// right. The ferro-json-ui wrapper div is placed inside the `<main>` element.
319///
320/// By default, renders empty navigation and sidebar placeholders. Users create
321/// custom Layout implementations that call the partial functions with real data.
322pub struct AppLayout;
323
324impl Layout for AppLayout {
325    fn render(&self, ctx: &LayoutContext) -> String {
326        let nav = navigation(&[]);
327        let side = sidebar(&[]);
328        let wrapper = ferro_wrapper(ctx);
329
330        let body = format!(
331            r#"{nav}
332    <div class="flex">
333        {side}
334        <main class="flex-1 px-3 py-4 md:p-6">
335            {wrapper}
336        </main>
337    </div>"#,
338        );
339
340        base_document(ctx.title, ctx.head, ctx.body_class, &body, ctx.scripts)
341    }
342}
343
344// ── AuthLayout ──────────────────────────────────────────────────────────
345
346/// Centered card layout for authentication pages (login, register).
347///
348/// Centers the content vertically and horizontally within a max-width
349/// container. No navigation or sidebar.
350pub struct AuthLayout;
351
352impl Layout for AuthLayout {
353    fn render(&self, ctx: &LayoutContext) -> String {
354        let wrapper = ferro_wrapper(ctx);
355
356        let body = format!(
357            r#"<div class="min-h-screen flex items-center justify-center">
358        <div class="w-full max-w-md">
359            <div class="bg-card rounded-lg shadow-md p-8">
360                {wrapper}
361            </div>
362        </div>
363    </div>"#,
364        );
365
366        base_document(ctx.title, ctx.head, ctx.body_class, &body, ctx.scripts)
367    }
368}
369
370// ── Partial types and functions ─────────────────────────────────────────
371
372/// A navigation link item.
373pub struct NavItem {
374    /// Display label for the link.
375    pub label: String,
376    /// URL the link points to.
377    pub url: String,
378    /// Whether this item represents the current page.
379    pub active: bool,
380}
381
382impl NavItem {
383    /// Create a new navigation item (inactive by default).
384    pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
385        Self {
386            label: label.into(),
387            url: url.into(),
388            active: false,
389        }
390    }
391
392    /// Mark this navigation item as active (builder pattern).
393    pub fn active(mut self) -> Self {
394        self.active = true;
395        self
396    }
397}
398
399/// A sidebar section containing a title and a list of navigation items.
400pub struct SidebarSection {
401    /// Section heading.
402    pub title: String,
403    /// Navigation items in this section.
404    pub items: Vec<NavItem>,
405}
406
407impl SidebarSection {
408    /// Create a new sidebar section.
409    pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
410        Self {
411            title: title.into(),
412            items,
413        }
414    }
415}
416
417/// Render a horizontal navigation bar.
418///
419/// Produces a `<nav>` element with Tailwind CSS classes. Active items
420/// are highlighted with blue text and medium font weight.
421pub fn navigation(items: &[NavItem]) -> String {
422    let mut html =
423        String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
424
425    for item in items {
426        let class = if item.active {
427            "text-primary font-medium"
428        } else {
429            "text-text-muted hover:text-text"
430        };
431        html.push_str(&format!(
432            "<a href=\"{}\" class=\"{}\">{}</a>",
433            html_escape(&item.url),
434            class,
435            html_escape(&item.label),
436        ));
437    }
438
439    html.push_str("</div></nav>");
440    html
441}
442
443/// Render a vertical sidebar with sections.
444///
445/// Produces an `<aside>` element with sections, each containing a heading
446/// and a list of navigation links.
447pub fn sidebar(sections: &[SidebarSection]) -> String {
448    let mut html =
449        String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
450
451    for section in sections {
452        html.push_str("<div class=\"mb-6\">");
453        html.push_str(&format!(
454            "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
455            html_escape(&section.title),
456        ));
457        html.push_str("<ul class=\"space-y-1\">");
458        for item in &section.items {
459            let class = if item.active {
460                "text-primary font-medium"
461            } else {
462                "text-text-muted hover:text-text"
463            };
464            html.push_str(&format!(
465                "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm {}\">{}</a></li>",
466                html_escape(&item.url),
467                class,
468                html_escape(&item.label),
469            ));
470        }
471        html.push_str("</ul></div>");
472    }
473
474    html.push_str("</aside>");
475    html
476}
477
478/// Render a simple footer.
479///
480/// Produces a `<footer>` element with centered text.
481pub fn footer(text: &str) -> String {
482    format!(
483        "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
484        html_escape(text),
485    )
486}
487
488// ── DashboardLayout ─────────────────────────────────────────────────────
489
490/// Configuration for `DashboardLayout`.
491///
492/// Provides the per-application sidebar navigation and header data needed
493/// to render the persistent dashboard shell. Users construct this at app
494/// startup and register it with the layout registry.
495///
496/// # Example
497///
498/// ```rust
499/// use ferro_json_ui::{DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, register_layout};
500///
501/// register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig {
502///     sidebar: SidebarProps { fixed_top: vec![], groups: vec![], fixed_bottom: vec![] },
503///     header: HeaderProps {
504///         business_name: "My App".to_string(),
505///         notification_count: None,
506///         user_name: Some("Alice".to_string()),
507///         user_avatar: None,
508///         logout_url: Some("/logout".to_string()),
509///     },
510///     sse_url: None,
511/// }));
512/// ```
513pub struct DashboardLayoutConfig {
514    /// Sidebar navigation data for the persistent sidebar shell.
515    pub sidebar: SidebarProps,
516    /// Header data for the persistent header shell.
517    pub header: HeaderProps,
518    /// Optional SSE endpoint URL. When set, the JS runtime opens an
519    /// `EventSource` connection to this URL and dispatches live-value
520    /// and toast updates from incoming messages.
521    pub sse_url: Option<String>,
522}
523
524/// Dashboard layout with persistent sidebar, header, and main content area.
525///
526/// Renders a full-page shell with a fixed sidebar on the left (desktop)
527/// and a sticky header at the top. The rendered view content appears in
528/// the `<main>` area. The built-in JS runtime (`FERRO_RUNTIME_JS`) is
529/// injected once as a `<script>` tag, enabling SSE, live-value updates,
530/// and toast notifications.
531///
532/// Mobile: sidebar is hidden by default and toggled via the hamburger button
533/// in the header (using responsive Tailwind classes).
534///
535/// This layout is NOT auto-registered. Users must register it at startup:
536///
537/// ```rust
538/// use ferro_json_ui::{DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, register_layout};
539///
540/// register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig {
541///     sidebar: SidebarProps { fixed_top: vec![], groups: vec![], fixed_bottom: vec![] },
542///     header: HeaderProps {
543///         business_name: "My App".to_string(),
544///         notification_count: None,
545///         user_name: None,
546///         user_avatar: None,
547///         logout_url: None,
548///     },
549///     sse_url: None,
550/// }));
551/// ```
552pub struct DashboardLayout {
553    /// Layout configuration (sidebar, header, SSE URL).
554    pub config: DashboardLayoutConfig,
555}
556
557impl DashboardLayout {
558    /// Create a new `DashboardLayout` from a `DashboardLayoutConfig`.
559    pub fn new(config: DashboardLayoutConfig) -> Self {
560        Self { config }
561    }
562}
563
564impl Layout for DashboardLayout {
565    fn render(&self, ctx: &LayoutContext) -> String {
566        let sidebar_html = layout_sidebar_html(&self.config.sidebar);
567        let header_html = layout_header_html(&self.config.header);
568        let wrapper = ferro_wrapper(ctx);
569
570        let body_data = if let Some(ref url) = self.config.sse_url {
571            format!("data-sse-url=\"{}\"", html_escape(url))
572        } else {
573            String::new()
574        };
575
576        let runtime_script = format!(
577            "<script>\n{}\n</script>",
578            crate::runtime::FERRO_RUNTIME_JS.as_str()
579        );
580        let scripts = if ctx.scripts.is_empty() {
581            runtime_script
582        } else {
583            format!("{}\n{}", ctx.scripts, runtime_script)
584        };
585
586        let body_content = format!(
587            r#"{sidebar_html}
588    <div class="flex flex-col md:pl-64">
589        {header_html}
590        <main class="flex-1 px-3 py-4 md:p-6">
591            {wrapper}
592        </main>
593        <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
594    </div>"#,
595        );
596
597        let body_class = if ctx.body_class.is_empty() {
598            "bg-surface"
599        } else {
600            ctx.body_class
601        };
602
603        base_document_ext(
604            ctx.title,
605            ctx.head,
606            body_class,
607            &body_data,
608            &body_content,
609            &scripts,
610        )
611    }
612}
613
614// ── Layout registry ─────────────────────────────────────────────────────
615
616/// Registry mapping layout names to implementations.
617///
618/// Created with three built-in layouts: "default" (`DefaultLayout`),
619/// "app" (`AppLayout`), and "auth" (`AuthLayout`). Additional layouts
620/// can be registered at application startup.
621pub struct LayoutRegistry {
622    layouts: HashMap<String, Box<dyn Layout>>,
623    default: String,
624}
625
626impl LayoutRegistry {
627    /// Create a new registry with the three built-in layouts.
628    pub fn new() -> Self {
629        let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
630        layouts.insert("default".to_string(), Box::new(DefaultLayout));
631        layouts.insert("app".to_string(), Box::new(AppLayout));
632        layouts.insert("auth".to_string(), Box::new(AuthLayout));
633
634        Self {
635            layouts,
636            default: "default".to_string(),
637        }
638    }
639
640    /// Register a layout by name. Replaces any existing layout with the same name.
641    pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
642        self.layouts.insert(name.into(), Box::new(layout));
643    }
644
645    /// Render using the named layout. Falls back to default if name is None
646    /// or the name is not found in the registry.
647    pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
648        let layout_name = name.unwrap_or(&self.default);
649        let layout = self
650            .layouts
651            .get(layout_name)
652            .or_else(|| self.layouts.get(&self.default))
653            .expect("default layout must exist in registry");
654        layout.render(ctx)
655    }
656
657    /// Check whether a layout with the given name is registered.
658    pub fn has(&self, name: &str) -> bool {
659        self.layouts.contains_key(name)
660    }
661}
662
663impl Default for LayoutRegistry {
664    fn default() -> Self {
665        Self::new()
666    }
667}
668
669// ── Global registry ─────────────────────────────────────────────────────
670
671static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
672
673/// Access the global layout registry.
674///
675/// Lazily initialized on first call with the three built-in layouts.
676pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
677    GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
678}
679
680/// Register a layout in the global registry.
681///
682/// Convenience wrapper around `global_registry().write()`.
683pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
684    global_registry()
685        .write()
686        .expect("layout registry poisoned")
687        .register(name, layout);
688}
689
690/// Render using the global registry.
691///
692/// Convenience wrapper around `global_registry().read()`.
693pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
694    global_registry()
695        .read()
696        .expect("layout registry poisoned")
697        .render(name, ctx)
698}
699
700// ── Tests ───────────────────────────────────────────────────────────────
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    fn test_ctx() -> LayoutContext<'static> {
707        LayoutContext {
708            title: "Test Page",
709            content: "<p>Hello</p>",
710            head: "<link rel=\"stylesheet\" href=\"/style.css\">",
711            body_class: "bg-background",
712            view_json: "{\"schema\":\"v1\"}",
713            data_json: "{\"key\":\"value\"}",
714            scripts: "",
715        }
716    }
717
718    // ── base_document tests ─────────────────────────────────────────
719
720    #[test]
721    fn base_document_produces_valid_html_structure() {
722        let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
723        assert!(html.starts_with("<!DOCTYPE html>"));
724        assert!(html.contains("<html lang=\"en\">"));
725        assert!(html.contains("<meta charset=\"UTF-8\">"));
726        assert!(html.contains("<meta name=\"viewport\""));
727        assert!(html.contains("<title>Title</title>"));
728        assert!(html.contains("<style></style>"));
729        assert!(html.contains("<body class=\"my-class\">"));
730        assert!(html.contains("<p>body</p>"));
731        assert!(html.contains("</html>"));
732    }
733
734    #[test]
735    fn base_document_escapes_title() {
736        let html = base_document("Tom & Jerry <script>", "", "", "", "");
737        assert!(html.contains("<title>Tom &amp; Jerry &lt;script&gt;</title>"));
738    }
739
740    #[test]
741    fn base_document_escapes_body_class() {
742        let html = base_document("T", "", "a\"b", "", "");
743        assert!(html.contains("class=\"a&quot;b\""));
744    }
745
746    // ── DefaultLayout tests ─────────────────────────────────────────
747
748    #[test]
749    fn default_layout_renders_all_context_fields() {
750        let ctx = test_ctx();
751        let html = DefaultLayout.render(&ctx);
752
753        assert!(html.contains("<!DOCTYPE html>"));
754        assert!(html.contains("<title>Test Page</title>"));
755        assert!(html.contains("href=\"/style.css\""));
756        assert!(html.contains("class=\"bg-background\""));
757        assert!(html.contains("id=\"ferro-json-ui\""));
758        assert!(html.contains("data-view=\""));
759        assert!(html.contains("data-props=\""));
760        assert!(html.contains("<p>Hello</p>"));
761    }
762
763    #[test]
764    fn default_layout_contains_ferro_wrapper() {
765        let ctx = test_ctx();
766        let html = DefaultLayout.render(&ctx);
767        assert!(html.contains("<div id=\"ferro-json-ui\""));
768    }
769
770    // ── AppLayout tests ─────────────────────────────────────────────
771
772    #[test]
773    fn app_layout_includes_nav_and_sidebar() {
774        let ctx = test_ctx();
775        let html = AppLayout.render(&ctx);
776
777        assert!(html.contains("<nav"));
778        assert!(html.contains("<aside"));
779        assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
780        assert!(html.contains("<div id=\"ferro-json-ui\""));
781        assert!(html.contains("<p>Hello</p>"));
782    }
783
784    #[test]
785    fn app_layout_has_flex_structure() {
786        let ctx = test_ctx();
787        let html = AppLayout.render(&ctx);
788        assert!(html.contains("class=\"flex\""));
789    }
790
791    // ── AuthLayout tests ────────────────────────────────────────────
792
793    #[test]
794    fn auth_layout_centers_content() {
795        let ctx = test_ctx();
796        let html = AuthLayout.render(&ctx);
797
798        assert!(html.contains("flex items-center justify-center"));
799        assert!(html.contains("max-w-md"));
800        assert!(html.contains("rounded-lg shadow-md"));
801        assert!(html.contains("<div id=\"ferro-json-ui\""));
802    }
803
804    #[test]
805    fn auth_layout_has_no_nav_or_sidebar() {
806        let ctx = test_ctx();
807        let html = AuthLayout.render(&ctx);
808        assert!(!html.contains("<nav"));
809        assert!(!html.contains("<aside"));
810    }
811
812    // ── LayoutRegistry tests ────────────────────────────────────────
813
814    #[test]
815    fn registry_returns_default_for_none_name() {
816        let registry = LayoutRegistry::new();
817        let ctx = test_ctx();
818        let html = registry.render(None, &ctx);
819        // DefaultLayout produces the simple wrapper (no nav/sidebar)
820        assert!(html.contains("<div id=\"ferro-json-ui\""));
821        assert!(!html.contains("<nav"));
822    }
823
824    #[test]
825    fn registry_returns_default_for_unknown_name() {
826        let registry = LayoutRegistry::new();
827        let ctx = test_ctx();
828        let html = registry.render(Some("nonexistent"), &ctx);
829        // Falls back to default
830        assert!(html.contains("<div id=\"ferro-json-ui\""));
831        assert!(!html.contains("<nav"));
832    }
833
834    #[test]
835    fn registry_renders_named_layout() {
836        let registry = LayoutRegistry::new();
837        let ctx = test_ctx();
838        let html = registry.render(Some("app"), &ctx);
839        assert!(html.contains("<nav"));
840        assert!(html.contains("<aside"));
841    }
842
843    #[test]
844    fn registry_renders_auth_layout() {
845        let registry = LayoutRegistry::new();
846        let ctx = test_ctx();
847        let html = registry.render(Some("auth"), &ctx);
848        assert!(html.contains("flex items-center justify-center"));
849    }
850
851    #[test]
852    fn registry_has_returns_true_for_registered() {
853        let registry = LayoutRegistry::new();
854        assert!(registry.has("default"));
855        assert!(registry.has("app"));
856        assert!(registry.has("auth"));
857    }
858
859    #[test]
860    fn registry_has_returns_false_for_unknown() {
861        let registry = LayoutRegistry::new();
862        assert!(!registry.has("nonexistent"));
863    }
864
865    #[test]
866    fn registry_register_adds_custom_layout() {
867        let mut registry = LayoutRegistry::new();
868        struct Custom;
869        impl Layout for Custom {
870            fn render(&self, _ctx: &LayoutContext) -> String {
871                "CUSTOM".to_string()
872            }
873        }
874        registry.register("custom", Custom);
875        assert!(registry.has("custom"));
876
877        let ctx = test_ctx();
878        let html = registry.render(Some("custom"), &ctx);
879        assert_eq!(html, "CUSTOM");
880    }
881
882    #[test]
883    fn registry_register_replaces_existing() {
884        let mut registry = LayoutRegistry::new();
885        struct Replacement;
886        impl Layout for Replacement {
887            fn render(&self, _ctx: &LayoutContext) -> String {
888                "REPLACED".to_string()
889            }
890        }
891        registry.register("default", Replacement);
892        let ctx = test_ctx();
893        let html = registry.render(None, &ctx);
894        assert_eq!(html, "REPLACED");
895    }
896
897    // ── Global registry tests ───────────────────────────────────────
898
899    #[test]
900    fn global_registry_returns_valid_registry() {
901        let reg = global_registry();
902        let guard = reg.read().unwrap();
903        assert!(guard.has("default"));
904        assert!(guard.has("app"));
905        assert!(guard.has("auth"));
906    }
907
908    #[test]
909    fn render_layout_global_function_works() {
910        let ctx = test_ctx();
911        let html = render_layout(None, &ctx);
912        assert!(html.contains("<!DOCTYPE html>"));
913        assert!(html.contains("<div id=\"ferro-json-ui\""));
914    }
915
916    // ── Partial tests ───────────────────────────────────────────────
917
918    #[test]
919    fn navigation_renders_empty_gracefully() {
920        let html = navigation(&[]);
921        assert!(html.contains("<nav"));
922        assert!(html.contains("</nav>"));
923    }
924
925    #[test]
926    fn navigation_renders_items_with_correct_classes() {
927        let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
928        let html = navigation(&items);
929        assert!(html.contains("href=\"/\""));
930        assert!(html.contains(">Home</a>"));
931        assert!(html.contains("href=\"/users\""));
932        assert!(html.contains(">Users</a>"));
933        // Both should be inactive
934        assert!(html.contains("text-text-muted hover:text-text"));
935    }
936
937    #[test]
938    fn navigation_marks_active_item() {
939        let items = vec![
940            NavItem::new("Home", "/").active(),
941            NavItem::new("Users", "/users"),
942        ];
943        let html = navigation(&items);
944        assert!(html.contains("text-primary font-medium"));
945    }
946
947    #[test]
948    fn sidebar_renders_sections_with_headers() {
949        let sections = vec![SidebarSection::new(
950            "Main Menu",
951            vec![
952                NavItem::new("Dashboard", "/"),
953                NavItem::new("Settings", "/settings"),
954            ],
955        )];
956        let html = sidebar(&sections);
957        assert!(html.contains("<aside"));
958        assert!(html.contains("Main Menu"));
959        assert!(html.contains("Dashboard"));
960        assert!(html.contains("Settings"));
961        assert!(html.contains("</aside>"));
962    }
963
964    #[test]
965    fn sidebar_renders_empty_gracefully() {
966        let html = sidebar(&[]);
967        assert!(html.contains("<aside"));
968        assert!(html.contains("</aside>"));
969    }
970
971    #[test]
972    fn footer_renders_text() {
973        let html = footer("Copyright 2026");
974        assert!(html.contains("<footer"));
975        assert!(html.contains("Copyright 2026"));
976        assert!(html.contains("</footer>"));
977    }
978
979    #[test]
980    fn partials_escape_user_strings() {
981        let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
982        let html = navigation(&items);
983        assert!(html.contains("Tom &amp; Jerry"));
984        assert!(html.contains("href=\"/a&amp;b\""));
985
986        let sections = vec![SidebarSection::new(
987            "A<B",
988            vec![NavItem::new("<script>", "/x\"y")],
989        )];
990        let html = sidebar(&sections);
991        assert!(html.contains("A&lt;B"));
992        assert!(html.contains("&lt;script&gt;"));
993
994        let html = footer("<script>alert('xss')</script>");
995        assert!(html.contains("&lt;script&gt;"));
996    }
997
998    // ── ferro_wrapper tests ─────────────────────────────────────────
999
1000    #[test]
1001    fn ferro_wrapper_includes_data_attributes() {
1002        let ctx = test_ctx();
1003        let html = ferro_wrapper(&ctx);
1004        assert!(html.contains("id=\"ferro-json-ui\""));
1005        assert!(html.contains("data-view=\""));
1006        assert!(html.contains("data-props=\""));
1007        assert!(html.contains("<p>Hello</p>"));
1008    }
1009
1010    // ── DashboardLayout tests ───────────────────────────────────────
1011
1012    fn dashboard_layout() -> DashboardLayout {
1013        use crate::component::{HeaderProps, SidebarProps};
1014        DashboardLayout::new(DashboardLayoutConfig {
1015            sidebar: SidebarProps {
1016                fixed_top: vec![],
1017                groups: vec![],
1018                fixed_bottom: vec![],
1019            },
1020            header: HeaderProps {
1021                business_name: "Acme".to_string(),
1022                notification_count: None,
1023                user_name: Some("Alice".to_string()),
1024                user_avatar: None,
1025                logout_url: Some("/logout".to_string()),
1026            },
1027            sse_url: None,
1028        })
1029    }
1030
1031    #[test]
1032    fn dashboard_layout_renders_full_html_structure() {
1033        let ctx = test_ctx();
1034        let html = dashboard_layout().render(&ctx);
1035
1036        assert!(html.starts_with("<!DOCTYPE html>"));
1037        assert!(html.contains("<title>Test Page</title>"));
1038        assert!(html.contains("<div id=\"ferro-json-ui\""));
1039        assert!(html.contains("<p>Hello</p>"));
1040    }
1041
1042    #[test]
1043    fn dashboard_layout_has_persistent_sidebar() {
1044        let ctx = test_ctx();
1045        let html = dashboard_layout().render(&ctx);
1046        assert!(html.contains("<aside data-sidebar"));
1047    }
1048
1049    #[test]
1050    fn dashboard_layout_has_persistent_header() {
1051        let ctx = test_ctx();
1052        let html = dashboard_layout().render(&ctx);
1053        assert!(html.contains("<header"));
1054        assert!(html.contains("Acme"));
1055    }
1056
1057    #[test]
1058    fn dashboard_layout_has_main_content_area() {
1059        let ctx = test_ctx();
1060        let html = dashboard_layout().render(&ctx);
1061        assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
1062    }
1063
1064    #[test]
1065    fn dashboard_layout_has_toast_container() {
1066        let ctx = test_ctx();
1067        let html = dashboard_layout().render(&ctx);
1068        assert!(html.contains("data-toast-container"));
1069    }
1070
1071    #[test]
1072    fn dashboard_layout_injects_runtime_js() {
1073        let ctx = test_ctx();
1074        let html = dashboard_layout().render(&ctx);
1075        // JS runtime is injected as a <script> tag containing the IIFE
1076        assert!(html.contains("<script>"));
1077        assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1078    }
1079
1080    #[test]
1081    fn dashboard_layout_has_mobile_hamburger_toggle() {
1082        let ctx = test_ctx();
1083        let html = dashboard_layout().render(&ctx);
1084        assert!(html.contains("data-sidebar-toggle"));
1085    }
1086
1087    #[test]
1088    fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1089        let ctx = test_ctx();
1090        let html = dashboard_layout().render(&ctx);
1091        // data-sse-url appears in the JS runtime source as a string literal,
1092        // but should NOT appear as a body element attribute when sse_url is None.
1093        // Check that the body tag does not contain the attribute.
1094        let body_start = html.find("<body").unwrap_or(0);
1095        let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1096        let body_tag = &html[body_start..=body_tag_end];
1097        assert!(!body_tag.contains("data-sse-url="));
1098    }
1099
1100    #[test]
1101    fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1102        use crate::component::{HeaderProps, SidebarProps};
1103        let layout = DashboardLayout::new(DashboardLayoutConfig {
1104            sidebar: SidebarProps {
1105                fixed_top: vec![],
1106                groups: vec![],
1107                fixed_bottom: vec![],
1108            },
1109            header: HeaderProps {
1110                business_name: "App".to_string(),
1111                notification_count: None,
1112                user_name: None,
1113                user_avatar: None,
1114                logout_url: None,
1115            },
1116            sse_url: Some("/events".to_string()),
1117        });
1118        let ctx = test_ctx();
1119        let html = layout.render(&ctx);
1120        assert!(html.contains("data-sse-url=\"/events\""));
1121    }
1122
1123    #[test]
1124    fn dashboard_layout_escapes_sse_url_xss() {
1125        use crate::component::{HeaderProps, SidebarProps};
1126        let layout = DashboardLayout::new(DashboardLayoutConfig {
1127            sidebar: SidebarProps {
1128                fixed_top: vec![],
1129                groups: vec![],
1130                fixed_bottom: vec![],
1131            },
1132            header: HeaderProps {
1133                business_name: "App".to_string(),
1134                notification_count: None,
1135                user_name: None,
1136                user_avatar: None,
1137                logout_url: None,
1138            },
1139            sse_url: Some("/events?a=1&b=2".to_string()),
1140        });
1141        let ctx = test_ctx();
1142        let html = layout.render(&ctx);
1143        assert!(html.contains("data-sse-url=\"/events?a=1&amp;b=2\""));
1144    }
1145
1146    #[test]
1147    fn dashboard_layout_notification_toggle_present_with_count() {
1148        use crate::component::{HeaderProps, SidebarProps};
1149        let layout = DashboardLayout::new(DashboardLayoutConfig {
1150            sidebar: SidebarProps {
1151                fixed_top: vec![],
1152                groups: vec![],
1153                fixed_bottom: vec![],
1154            },
1155            header: HeaderProps {
1156                business_name: "App".to_string(),
1157                notification_count: Some(5),
1158                user_name: None,
1159                user_avatar: None,
1160                logout_url: None,
1161            },
1162            sse_url: None,
1163        });
1164        let ctx = test_ctx();
1165        let html = layout.render(&ctx);
1166        assert!(html.contains("data-notification-toggle"));
1167    }
1168
1169    #[test]
1170    fn dashboard_layout_has_sidebar_backdrop() {
1171        let ctx = test_ctx();
1172        let html = dashboard_layout().render(&ctx);
1173        assert!(html.contains("data-sidebar-backdrop"));
1174        assert!(html.contains("bg-black/50"));
1175        assert!(html.contains("md:hidden"));
1176    }
1177
1178    #[test]
1179    fn dashboard_layout_sidebar_mobile_classes() {
1180        let ctx = test_ctx();
1181        let html = dashboard_layout().render(&ctx);
1182        // Sidebar uses responsive classes: hidden on mobile, flex on md+
1183        assert!(html.contains("hidden md:flex"));
1184    }
1185
1186    #[test]
1187    fn dashboard_layout_uses_default_body_class() {
1188        let ctx = test_ctx();
1189        let html = dashboard_layout().render(&ctx);
1190        // body_class from test_ctx is "bg-background" — should be preserved
1191        assert!(html.contains("class=\"bg-background\""));
1192    }
1193
1194    #[test]
1195    fn sidebar_nav_item_renders_icon_as_raw_svg() {
1196        let item = SidebarNavItem {
1197            label: "Dashboard".to_string(),
1198            href: "/dashboard".to_string(),
1199            icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1200            active: false,
1201        };
1202        let html = layout_sidebar_nav_item(&item);
1203        assert!(
1204            html.contains("<svg"),
1205            "icon SVG should be rendered raw, not escaped"
1206        );
1207        assert!(
1208            !html.contains("&lt;svg"),
1209            "icon SVG should NOT be html-escaped"
1210        );
1211        assert!(html.contains("Dashboard"), "label should still appear");
1212    }
1213
1214    #[test]
1215    fn sidebar_group_label_uses_normal_casing() {
1216        let group = SidebarGroup {
1217            label: "Cassa".to_string(),
1218            collapsed: false,
1219            items: vec![],
1220        };
1221        let html = layout_sidebar_group(&group);
1222        assert!(html.contains("Cassa"));
1223        assert!(html.contains("font-semibold"));
1224        assert!(html.contains("text-text"));
1225        assert!(
1226            !html.contains("uppercase"),
1227            "sidebar group label should not use uppercase"
1228        );
1229        assert!(
1230            !html.contains("tracking-wider"),
1231            "sidebar group label should not use letter-spacing"
1232        );
1233    }
1234
1235    // ── INT-07 (layout): DashboardLayout sidebar nav item focus ring ──────
1236
1237    #[test]
1238    fn layout_sidebar_nav_focus_ring() {
1239        let item = SidebarNavItem {
1240            label: "Dashboard".to_string(),
1241            href: "/dashboard".to_string(),
1242            icon: None,
1243            active: false,
1244        };
1245        let html = layout_sidebar_nav_item(&item);
1246        assert!(
1247            html.contains("focus-visible:ring-primary"),
1248            "layout sidebar nav <a> item should have focus-visible:ring-primary (INT-07)"
1249        );
1250        assert!(
1251            html.contains("duration-150"),
1252            "layout sidebar nav <a> item should have duration-150 (INT-07)"
1253        );
1254    }
1255}