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/// Combine plugin scripts with the built-in JS runtime.
298fn with_runtime(ctx_scripts: &str) -> String {
299    let runtime = format!(
300        "<script>\n{}\n</script>",
301        crate::runtime::FERRO_RUNTIME_JS.as_str()
302    );
303    if ctx_scripts.is_empty() {
304        runtime
305    } else {
306        format!("{ctx_scripts}\n{runtime}")
307    }
308}
309
310// ── DefaultLayout ───────────────────────────────────────────────────────
311
312/// Minimal layout wrapping content in a valid HTML page.
313///
314/// Produces the same structure as the existing framework HTML shell:
315/// doctype, meta tags, title, head content, body with the ferro-json-ui
316/// wrapper div containing the rendered components.
317pub struct DefaultLayout;
318
319impl Layout for DefaultLayout {
320    fn render(&self, ctx: &LayoutContext) -> String {
321        let wrapper = ferro_wrapper(ctx);
322        let scripts = with_runtime(ctx.scripts);
323        base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, &scripts)
324    }
325}
326
327// ── AppLayout ───────────────────────────────────────────────────────────
328
329/// Dashboard-style layout with navigation bar, sidebar, and main content area.
330///
331/// Uses a flex layout with the sidebar on the left and main content on the
332/// right. The ferro-json-ui wrapper div is placed inside the `<main>` element.
333///
334/// By default, renders empty navigation and sidebar placeholders. Users create
335/// custom Layout implementations that call the partial functions with real data.
336pub struct AppLayout;
337
338impl Layout for AppLayout {
339    fn render(&self, ctx: &LayoutContext) -> String {
340        let nav = navigation(&[]);
341        let side = sidebar(&[]);
342        let wrapper = ferro_wrapper(ctx);
343
344        let body = format!(
345            r#"{nav}
346    <div class="flex">
347        {side}
348        <main class="flex-1 px-3 py-4 md:p-6">
349            {wrapper}
350        </main>
351    </div>"#,
352        );
353
354        let scripts = with_runtime(ctx.scripts);
355        base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
356    }
357}
358
359// ── AuthLayout ──────────────────────────────────────────────────────────
360
361/// Centered card layout for authentication pages (login, register).
362///
363/// Centers the content vertically and horizontally within a max-width
364/// container. No navigation or sidebar.
365pub struct AuthLayout;
366
367impl Layout for AuthLayout {
368    fn render(&self, ctx: &LayoutContext) -> String {
369        let wrapper = ferro_wrapper(ctx);
370
371        let body = format!(
372            r#"<div class="min-h-screen flex items-center justify-center">
373        <div class="w-full max-w-md">
374            <div class="bg-card rounded-lg shadow-md p-8">
375                {wrapper}
376            </div>
377        </div>
378    </div>"#,
379        );
380
381        let scripts = with_runtime(ctx.scripts);
382        base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
383    }
384}
385
386// ── Partial types and functions ─────────────────────────────────────────
387
388/// A navigation link item.
389pub struct NavItem {
390    /// Display label for the link.
391    pub label: String,
392    /// URL the link points to.
393    pub url: String,
394    /// Whether this item represents the current page.
395    pub active: bool,
396}
397
398impl NavItem {
399    /// Create a new navigation item (inactive by default).
400    pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
401        Self {
402            label: label.into(),
403            url: url.into(),
404            active: false,
405        }
406    }
407
408    /// Mark this navigation item as active (builder pattern).
409    pub fn active(mut self) -> Self {
410        self.active = true;
411        self
412    }
413}
414
415/// A sidebar section containing a title and a list of navigation items.
416pub struct SidebarSection {
417    /// Section heading.
418    pub title: String,
419    /// Navigation items in this section.
420    pub items: Vec<NavItem>,
421}
422
423impl SidebarSection {
424    /// Create a new sidebar section.
425    pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
426        Self {
427            title: title.into(),
428            items,
429        }
430    }
431}
432
433/// Render a horizontal navigation bar.
434///
435/// Produces a `<nav>` element with Tailwind CSS classes. Active items
436/// are highlighted with blue text and medium font weight.
437pub fn navigation(items: &[NavItem]) -> String {
438    let mut html =
439        String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
440
441    for item in items {
442        let class = if item.active {
443            "text-primary font-medium"
444        } else {
445            "text-text-muted hover:text-text"
446        };
447        html.push_str(&format!(
448            "<a href=\"{}\" class=\"{}\">{}</a>",
449            html_escape(&item.url),
450            class,
451            html_escape(&item.label),
452        ));
453    }
454
455    html.push_str("</div></nav>");
456    html
457}
458
459/// Render a vertical sidebar with sections.
460///
461/// Produces an `<aside>` element with sections, each containing a heading
462/// and a list of navigation links.
463pub fn sidebar(sections: &[SidebarSection]) -> String {
464    let mut html =
465        String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
466
467    for section in sections {
468        html.push_str("<div class=\"mb-6\">");
469        html.push_str(&format!(
470            "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
471            html_escape(&section.title),
472        ));
473        html.push_str("<ul class=\"space-y-1\">");
474        for item in &section.items {
475            let class = if item.active {
476                "text-primary font-medium"
477            } else {
478                "text-text-muted hover:text-text"
479            };
480            html.push_str(&format!(
481                "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm {}\">{}</a></li>",
482                html_escape(&item.url),
483                class,
484                html_escape(&item.label),
485            ));
486        }
487        html.push_str("</ul></div>");
488    }
489
490    html.push_str("</aside>");
491    html
492}
493
494/// Render a simple footer.
495///
496/// Produces a `<footer>` element with centered text.
497pub fn footer(text: &str) -> String {
498    format!(
499        "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
500        html_escape(text),
501    )
502}
503
504// ── DashboardLayout ─────────────────────────────────────────────────────
505
506/// Configuration for `DashboardLayout`.
507///
508/// Provides the per-application sidebar navigation and header data needed
509/// to render the persistent dashboard shell. Users construct this at app
510/// startup and register it with the layout registry.
511///
512/// # Example
513///
514/// ```rust
515/// use ferro_json_ui::{DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, register_layout};
516///
517/// register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig {
518///     sidebar: SidebarProps { fixed_top: vec![], groups: vec![], fixed_bottom: vec![] },
519///     header: HeaderProps {
520///         business_name: "My App".to_string(),
521///         notification_count: None,
522///         user_name: Some("Alice".to_string()),
523///         user_avatar: None,
524///         logout_url: Some("/logout".to_string()),
525///     },
526///     sse_url: None,
527/// }));
528/// ```
529pub struct DashboardLayoutConfig {
530    /// Sidebar navigation data for the persistent sidebar shell.
531    pub sidebar: SidebarProps,
532    /// Header data for the persistent header shell.
533    pub header: HeaderProps,
534    /// Optional SSE endpoint URL. When set, the JS runtime opens an
535    /// `EventSource` connection to this URL and dispatches live-value
536    /// and toast updates from incoming messages.
537    pub sse_url: Option<String>,
538}
539
540/// Dashboard layout with persistent sidebar, header, and main content area.
541///
542/// Renders a full-page shell with a fixed sidebar on the left (desktop)
543/// and a sticky header at the top. The rendered view content appears in
544/// the `<main>` area. The built-in JS runtime (`FERRO_RUNTIME_JS`) is
545/// injected once as a `<script>` tag, enabling SSE, live-value updates,
546/// and toast notifications.
547///
548/// Mobile: sidebar is hidden by default and toggled via the hamburger button
549/// in the header (using responsive Tailwind classes).
550///
551/// This layout is NOT auto-registered. Users must register it at startup:
552///
553/// ```rust
554/// use ferro_json_ui::{DashboardLayout, DashboardLayoutConfig, HeaderProps, SidebarProps, register_layout};
555///
556/// register_layout("dashboard", DashboardLayout::new(DashboardLayoutConfig {
557///     sidebar: SidebarProps { fixed_top: vec![], groups: vec![], fixed_bottom: vec![] },
558///     header: HeaderProps {
559///         business_name: "My App".to_string(),
560///         notification_count: None,
561///         user_name: None,
562///         user_avatar: None,
563///         logout_url: None,
564///     },
565///     sse_url: None,
566/// }));
567/// ```
568pub struct DashboardLayout {
569    /// Layout configuration (sidebar, header, SSE URL).
570    pub config: DashboardLayoutConfig,
571}
572
573impl DashboardLayout {
574    /// Create a new `DashboardLayout` from a `DashboardLayoutConfig`.
575    pub fn new(config: DashboardLayoutConfig) -> Self {
576        Self { config }
577    }
578}
579
580impl Layout for DashboardLayout {
581    fn render(&self, ctx: &LayoutContext) -> String {
582        let sidebar_html = layout_sidebar_html(&self.config.sidebar);
583        let header_html = layout_header_html(&self.config.header);
584        let wrapper = ferro_wrapper(ctx);
585
586        let body_data = if let Some(ref url) = self.config.sse_url {
587            format!("data-sse-url=\"{}\"", html_escape(url))
588        } else {
589            String::new()
590        };
591
592        let runtime_script = format!(
593            "<script>\n{}\n</script>",
594            crate::runtime::FERRO_RUNTIME_JS.as_str()
595        );
596        let scripts = if ctx.scripts.is_empty() {
597            runtime_script
598        } else {
599            format!("{}\n{}", ctx.scripts, runtime_script)
600        };
601
602        let body_content = format!(
603            r#"{sidebar_html}
604    <div class="flex flex-col md:pl-64">
605        {header_html}
606        <main class="flex-1 px-3 py-4 md:p-6">
607            {wrapper}
608        </main>
609        <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
610    </div>"#,
611        );
612
613        let body_class = if ctx.body_class.is_empty() {
614            "bg-surface"
615        } else {
616            ctx.body_class
617        };
618
619        base_document_ext(
620            ctx.title,
621            ctx.head,
622            body_class,
623            &body_data,
624            &body_content,
625            &scripts,
626        )
627    }
628}
629
630// ── Layout registry ─────────────────────────────────────────────────────
631
632/// Registry mapping layout names to implementations.
633///
634/// Created with three built-in layouts: "default" (`DefaultLayout`),
635/// "app" (`AppLayout`), and "auth" (`AuthLayout`). Additional layouts
636/// can be registered at application startup.
637pub struct LayoutRegistry {
638    layouts: HashMap<String, Box<dyn Layout>>,
639    default: String,
640}
641
642impl LayoutRegistry {
643    /// Create a new registry with the three built-in layouts.
644    pub fn new() -> Self {
645        let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
646        layouts.insert("default".to_string(), Box::new(DefaultLayout));
647        layouts.insert("app".to_string(), Box::new(AppLayout));
648        layouts.insert("auth".to_string(), Box::new(AuthLayout));
649
650        Self {
651            layouts,
652            default: "default".to_string(),
653        }
654    }
655
656    /// Register a layout by name. Replaces any existing layout with the same name.
657    pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
658        self.layouts.insert(name.into(), Box::new(layout));
659    }
660
661    /// Render using the named layout. Falls back to default if name is None
662    /// or the name is not found in the registry.
663    pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
664        let layout_name = name.unwrap_or(&self.default);
665        let layout = self
666            .layouts
667            .get(layout_name)
668            .or_else(|| self.layouts.get(&self.default))
669            .expect("default layout must exist in registry");
670        layout.render(ctx)
671    }
672
673    /// Check whether a layout with the given name is registered.
674    pub fn has(&self, name: &str) -> bool {
675        self.layouts.contains_key(name)
676    }
677}
678
679impl Default for LayoutRegistry {
680    fn default() -> Self {
681        Self::new()
682    }
683}
684
685// ── Global registry ─────────────────────────────────────────────────────
686
687static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
688
689/// Access the global layout registry.
690///
691/// Lazily initialized on first call with the three built-in layouts.
692pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
693    GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
694}
695
696/// Register a layout in the global registry.
697///
698/// Convenience wrapper around `global_registry().write()`.
699pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
700    global_registry()
701        .write()
702        .expect("layout registry poisoned")
703        .register(name, layout);
704}
705
706/// Render using the global registry.
707///
708/// Convenience wrapper around `global_registry().read()`.
709pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
710    global_registry()
711        .read()
712        .expect("layout registry poisoned")
713        .render(name, ctx)
714}
715
716// ── Tests ───────────────────────────────────────────────────────────────
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    fn test_ctx() -> LayoutContext<'static> {
723        LayoutContext {
724            title: "Test Page",
725            content: "<p>Hello</p>",
726            head: "<link rel=\"stylesheet\" href=\"/style.css\">",
727            body_class: "bg-background",
728            view_json: "{\"schema\":\"v1\"}",
729            data_json: "{\"key\":\"value\"}",
730            scripts: "",
731        }
732    }
733
734    // ── base_document tests ─────────────────────────────────────────
735
736    #[test]
737    fn base_document_produces_valid_html_structure() {
738        let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
739        assert!(html.starts_with("<!DOCTYPE html>"));
740        assert!(html.contains("<html lang=\"en\">"));
741        assert!(html.contains("<meta charset=\"UTF-8\">"));
742        assert!(html.contains("<meta name=\"viewport\""));
743        assert!(html.contains("<title>Title</title>"));
744        assert!(html.contains("<style></style>"));
745        assert!(html.contains("<body class=\"my-class\">"));
746        assert!(html.contains("<p>body</p>"));
747        assert!(html.contains("</html>"));
748    }
749
750    #[test]
751    fn base_document_escapes_title() {
752        let html = base_document("Tom & Jerry <script>", "", "", "", "");
753        assert!(html.contains("<title>Tom &amp; Jerry &lt;script&gt;</title>"));
754    }
755
756    #[test]
757    fn base_document_escapes_body_class() {
758        let html = base_document("T", "", "a\"b", "", "");
759        assert!(html.contains("class=\"a&quot;b\""));
760    }
761
762    // ── DefaultLayout tests ─────────────────────────────────────────
763
764    #[test]
765    fn default_layout_renders_all_context_fields() {
766        let ctx = test_ctx();
767        let html = DefaultLayout.render(&ctx);
768
769        assert!(html.contains("<!DOCTYPE html>"));
770        assert!(html.contains("<title>Test Page</title>"));
771        assert!(html.contains("href=\"/style.css\""));
772        assert!(html.contains("class=\"bg-background\""));
773        assert!(html.contains("id=\"ferro-json-ui\""));
774        assert!(html.contains("data-view=\""));
775        assert!(html.contains("data-props=\""));
776        assert!(html.contains("<p>Hello</p>"));
777    }
778
779    #[test]
780    fn default_layout_contains_ferro_wrapper() {
781        let ctx = test_ctx();
782        let html = DefaultLayout.render(&ctx);
783        assert!(html.contains("<div id=\"ferro-json-ui\""));
784    }
785
786    // ── AppLayout tests ─────────────────────────────────────────────
787
788    #[test]
789    fn app_layout_includes_nav_and_sidebar() {
790        let ctx = test_ctx();
791        let html = AppLayout.render(&ctx);
792
793        assert!(html.contains("<nav"));
794        assert!(html.contains("<aside"));
795        assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
796        assert!(html.contains("<div id=\"ferro-json-ui\""));
797        assert!(html.contains("<p>Hello</p>"));
798    }
799
800    #[test]
801    fn app_layout_has_flex_structure() {
802        let ctx = test_ctx();
803        let html = AppLayout.render(&ctx);
804        assert!(html.contains("class=\"flex\""));
805    }
806
807    // ── AuthLayout tests ────────────────────────────────────────────
808
809    #[test]
810    fn auth_layout_centers_content() {
811        let ctx = test_ctx();
812        let html = AuthLayout.render(&ctx);
813
814        assert!(html.contains("flex items-center justify-center"));
815        assert!(html.contains("max-w-md"));
816        assert!(html.contains("rounded-lg shadow-md"));
817        assert!(html.contains("<div id=\"ferro-json-ui\""));
818    }
819
820    #[test]
821    fn auth_layout_has_no_nav_or_sidebar() {
822        let ctx = test_ctx();
823        let html = AuthLayout.render(&ctx);
824        assert!(!html.contains("<nav"));
825        assert!(!html.contains("<aside"));
826    }
827
828    // ── LayoutRegistry tests ────────────────────────────────────────
829
830    #[test]
831    fn registry_returns_default_for_none_name() {
832        let registry = LayoutRegistry::new();
833        let ctx = test_ctx();
834        let html = registry.render(None, &ctx);
835        // DefaultLayout produces the simple wrapper (no nav/sidebar)
836        assert!(html.contains("<div id=\"ferro-json-ui\""));
837        assert!(!html.contains("<nav"));
838    }
839
840    #[test]
841    fn registry_returns_default_for_unknown_name() {
842        let registry = LayoutRegistry::new();
843        let ctx = test_ctx();
844        let html = registry.render(Some("nonexistent"), &ctx);
845        // Falls back to default
846        assert!(html.contains("<div id=\"ferro-json-ui\""));
847        assert!(!html.contains("<nav"));
848    }
849
850    #[test]
851    fn registry_renders_named_layout() {
852        let registry = LayoutRegistry::new();
853        let ctx = test_ctx();
854        let html = registry.render(Some("app"), &ctx);
855        assert!(html.contains("<nav"));
856        assert!(html.contains("<aside"));
857    }
858
859    #[test]
860    fn registry_renders_auth_layout() {
861        let registry = LayoutRegistry::new();
862        let ctx = test_ctx();
863        let html = registry.render(Some("auth"), &ctx);
864        assert!(html.contains("flex items-center justify-center"));
865    }
866
867    #[test]
868    fn registry_has_returns_true_for_registered() {
869        let registry = LayoutRegistry::new();
870        assert!(registry.has("default"));
871        assert!(registry.has("app"));
872        assert!(registry.has("auth"));
873    }
874
875    #[test]
876    fn registry_has_returns_false_for_unknown() {
877        let registry = LayoutRegistry::new();
878        assert!(!registry.has("nonexistent"));
879    }
880
881    #[test]
882    fn registry_register_adds_custom_layout() {
883        let mut registry = LayoutRegistry::new();
884        struct Custom;
885        impl Layout for Custom {
886            fn render(&self, _ctx: &LayoutContext) -> String {
887                "CUSTOM".to_string()
888            }
889        }
890        registry.register("custom", Custom);
891        assert!(registry.has("custom"));
892
893        let ctx = test_ctx();
894        let html = registry.render(Some("custom"), &ctx);
895        assert_eq!(html, "CUSTOM");
896    }
897
898    #[test]
899    fn registry_register_replaces_existing() {
900        let mut registry = LayoutRegistry::new();
901        struct Replacement;
902        impl Layout for Replacement {
903            fn render(&self, _ctx: &LayoutContext) -> String {
904                "REPLACED".to_string()
905            }
906        }
907        registry.register("default", Replacement);
908        let ctx = test_ctx();
909        let html = registry.render(None, &ctx);
910        assert_eq!(html, "REPLACED");
911    }
912
913    // ── Global registry tests ───────────────────────────────────────
914
915    #[test]
916    fn global_registry_returns_valid_registry() {
917        let reg = global_registry();
918        let guard = reg.read().unwrap();
919        assert!(guard.has("default"));
920        assert!(guard.has("app"));
921        assert!(guard.has("auth"));
922    }
923
924    #[test]
925    fn render_layout_global_function_works() {
926        let ctx = test_ctx();
927        let html = render_layout(None, &ctx);
928        assert!(html.contains("<!DOCTYPE html>"));
929        assert!(html.contains("<div id=\"ferro-json-ui\""));
930    }
931
932    // ── Partial tests ───────────────────────────────────────────────
933
934    #[test]
935    fn navigation_renders_empty_gracefully() {
936        let html = navigation(&[]);
937        assert!(html.contains("<nav"));
938        assert!(html.contains("</nav>"));
939    }
940
941    #[test]
942    fn navigation_renders_items_with_correct_classes() {
943        let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
944        let html = navigation(&items);
945        assert!(html.contains("href=\"/\""));
946        assert!(html.contains(">Home</a>"));
947        assert!(html.contains("href=\"/users\""));
948        assert!(html.contains(">Users</a>"));
949        // Both should be inactive
950        assert!(html.contains("text-text-muted hover:text-text"));
951    }
952
953    #[test]
954    fn navigation_marks_active_item() {
955        let items = vec![
956            NavItem::new("Home", "/").active(),
957            NavItem::new("Users", "/users"),
958        ];
959        let html = navigation(&items);
960        assert!(html.contains("text-primary font-medium"));
961    }
962
963    #[test]
964    fn sidebar_renders_sections_with_headers() {
965        let sections = vec![SidebarSection::new(
966            "Main Menu",
967            vec![
968                NavItem::new("Dashboard", "/"),
969                NavItem::new("Settings", "/settings"),
970            ],
971        )];
972        let html = sidebar(&sections);
973        assert!(html.contains("<aside"));
974        assert!(html.contains("Main Menu"));
975        assert!(html.contains("Dashboard"));
976        assert!(html.contains("Settings"));
977        assert!(html.contains("</aside>"));
978    }
979
980    #[test]
981    fn sidebar_renders_empty_gracefully() {
982        let html = sidebar(&[]);
983        assert!(html.contains("<aside"));
984        assert!(html.contains("</aside>"));
985    }
986
987    #[test]
988    fn footer_renders_text() {
989        let html = footer("Copyright 2026");
990        assert!(html.contains("<footer"));
991        assert!(html.contains("Copyright 2026"));
992        assert!(html.contains("</footer>"));
993    }
994
995    #[test]
996    fn partials_escape_user_strings() {
997        let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
998        let html = navigation(&items);
999        assert!(html.contains("Tom &amp; Jerry"));
1000        assert!(html.contains("href=\"/a&amp;b\""));
1001
1002        let sections = vec![SidebarSection::new(
1003            "A<B",
1004            vec![NavItem::new("<script>", "/x\"y")],
1005        )];
1006        let html = sidebar(&sections);
1007        assert!(html.contains("A&lt;B"));
1008        assert!(html.contains("&lt;script&gt;"));
1009
1010        let html = footer("<script>alert('xss')</script>");
1011        assert!(html.contains("&lt;script&gt;"));
1012    }
1013
1014    // ── ferro_wrapper tests ─────────────────────────────────────────
1015
1016    #[test]
1017    fn ferro_wrapper_includes_data_attributes() {
1018        let ctx = test_ctx();
1019        let html = ferro_wrapper(&ctx);
1020        assert!(html.contains("id=\"ferro-json-ui\""));
1021        assert!(html.contains("data-view=\""));
1022        assert!(html.contains("data-props=\""));
1023        assert!(html.contains("<p>Hello</p>"));
1024    }
1025
1026    // ── DashboardLayout tests ───────────────────────────────────────
1027
1028    fn dashboard_layout() -> DashboardLayout {
1029        use crate::component::{HeaderProps, SidebarProps};
1030        DashboardLayout::new(DashboardLayoutConfig {
1031            sidebar: SidebarProps {
1032                fixed_top: vec![],
1033                groups: vec![],
1034                fixed_bottom: vec![],
1035            },
1036            header: HeaderProps {
1037                business_name: "Acme".to_string(),
1038                notification_count: None,
1039                user_name: Some("Alice".to_string()),
1040                user_avatar: None,
1041                logout_url: Some("/logout".to_string()),
1042            },
1043            sse_url: None,
1044        })
1045    }
1046
1047    #[test]
1048    fn dashboard_layout_renders_full_html_structure() {
1049        let ctx = test_ctx();
1050        let html = dashboard_layout().render(&ctx);
1051
1052        assert!(html.starts_with("<!DOCTYPE html>"));
1053        assert!(html.contains("<title>Test Page</title>"));
1054        assert!(html.contains("<div id=\"ferro-json-ui\""));
1055        assert!(html.contains("<p>Hello</p>"));
1056    }
1057
1058    #[test]
1059    fn dashboard_layout_has_persistent_sidebar() {
1060        let ctx = test_ctx();
1061        let html = dashboard_layout().render(&ctx);
1062        assert!(html.contains("<aside data-sidebar"));
1063    }
1064
1065    #[test]
1066    fn dashboard_layout_has_persistent_header() {
1067        let ctx = test_ctx();
1068        let html = dashboard_layout().render(&ctx);
1069        assert!(html.contains("<header"));
1070        assert!(html.contains("Acme"));
1071    }
1072
1073    #[test]
1074    fn dashboard_layout_has_main_content_area() {
1075        let ctx = test_ctx();
1076        let html = dashboard_layout().render(&ctx);
1077        assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
1078    }
1079
1080    #[test]
1081    fn dashboard_layout_has_toast_container() {
1082        let ctx = test_ctx();
1083        let html = dashboard_layout().render(&ctx);
1084        assert!(html.contains("data-toast-container"));
1085    }
1086
1087    #[test]
1088    fn dashboard_layout_injects_runtime_js() {
1089        let ctx = test_ctx();
1090        let html = dashboard_layout().render(&ctx);
1091        // JS runtime is injected as a <script> tag containing the IIFE
1092        assert!(html.contains("<script>"));
1093        assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1094    }
1095
1096    #[test]
1097    fn dashboard_layout_has_mobile_hamburger_toggle() {
1098        let ctx = test_ctx();
1099        let html = dashboard_layout().render(&ctx);
1100        assert!(html.contains("data-sidebar-toggle"));
1101    }
1102
1103    #[test]
1104    fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1105        let ctx = test_ctx();
1106        let html = dashboard_layout().render(&ctx);
1107        // data-sse-url appears in the JS runtime source as a string literal,
1108        // but should NOT appear as a body element attribute when sse_url is None.
1109        // Check that the body tag does not contain the attribute.
1110        let body_start = html.find("<body").unwrap_or(0);
1111        let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1112        let body_tag = &html[body_start..=body_tag_end];
1113        assert!(!body_tag.contains("data-sse-url="));
1114    }
1115
1116    #[test]
1117    fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1118        use crate::component::{HeaderProps, SidebarProps};
1119        let layout = DashboardLayout::new(DashboardLayoutConfig {
1120            sidebar: SidebarProps {
1121                fixed_top: vec![],
1122                groups: vec![],
1123                fixed_bottom: vec![],
1124            },
1125            header: HeaderProps {
1126                business_name: "App".to_string(),
1127                notification_count: None,
1128                user_name: None,
1129                user_avatar: None,
1130                logout_url: None,
1131            },
1132            sse_url: Some("/events".to_string()),
1133        });
1134        let ctx = test_ctx();
1135        let html = layout.render(&ctx);
1136        assert!(html.contains("data-sse-url=\"/events\""));
1137    }
1138
1139    #[test]
1140    fn dashboard_layout_escapes_sse_url_xss() {
1141        use crate::component::{HeaderProps, SidebarProps};
1142        let layout = DashboardLayout::new(DashboardLayoutConfig {
1143            sidebar: SidebarProps {
1144                fixed_top: vec![],
1145                groups: vec![],
1146                fixed_bottom: vec![],
1147            },
1148            header: HeaderProps {
1149                business_name: "App".to_string(),
1150                notification_count: None,
1151                user_name: None,
1152                user_avatar: None,
1153                logout_url: None,
1154            },
1155            sse_url: Some("/events?a=1&b=2".to_string()),
1156        });
1157        let ctx = test_ctx();
1158        let html = layout.render(&ctx);
1159        assert!(html.contains("data-sse-url=\"/events?a=1&amp;b=2\""));
1160    }
1161
1162    #[test]
1163    fn dashboard_layout_notification_toggle_present_with_count() {
1164        use crate::component::{HeaderProps, SidebarProps};
1165        let layout = DashboardLayout::new(DashboardLayoutConfig {
1166            sidebar: SidebarProps {
1167                fixed_top: vec![],
1168                groups: vec![],
1169                fixed_bottom: vec![],
1170            },
1171            header: HeaderProps {
1172                business_name: "App".to_string(),
1173                notification_count: Some(5),
1174                user_name: None,
1175                user_avatar: None,
1176                logout_url: None,
1177            },
1178            sse_url: None,
1179        });
1180        let ctx = test_ctx();
1181        let html = layout.render(&ctx);
1182        assert!(html.contains("data-notification-toggle"));
1183    }
1184
1185    #[test]
1186    fn dashboard_layout_has_sidebar_backdrop() {
1187        let ctx = test_ctx();
1188        let html = dashboard_layout().render(&ctx);
1189        assert!(html.contains("data-sidebar-backdrop"));
1190        assert!(html.contains("bg-black/50"));
1191        assert!(html.contains("md:hidden"));
1192    }
1193
1194    #[test]
1195    fn dashboard_layout_sidebar_mobile_classes() {
1196        let ctx = test_ctx();
1197        let html = dashboard_layout().render(&ctx);
1198        // Sidebar uses responsive classes: hidden on mobile, flex on md+
1199        assert!(html.contains("hidden md:flex"));
1200    }
1201
1202    #[test]
1203    fn dashboard_layout_uses_default_body_class() {
1204        let ctx = test_ctx();
1205        let html = dashboard_layout().render(&ctx);
1206        // body_class from test_ctx is "bg-background" — should be preserved
1207        assert!(html.contains("class=\"bg-background\""));
1208    }
1209
1210    #[test]
1211    fn sidebar_nav_item_renders_icon_as_raw_svg() {
1212        let item = SidebarNavItem {
1213            label: "Dashboard".to_string(),
1214            href: "/dashboard".to_string(),
1215            icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1216            active: false,
1217        };
1218        let html = layout_sidebar_nav_item(&item);
1219        assert!(
1220            html.contains("<svg"),
1221            "icon SVG should be rendered raw, not escaped"
1222        );
1223        assert!(
1224            !html.contains("&lt;svg"),
1225            "icon SVG should NOT be html-escaped"
1226        );
1227        assert!(html.contains("Dashboard"), "label should still appear");
1228    }
1229
1230    #[test]
1231    fn sidebar_group_label_uses_normal_casing() {
1232        let group = SidebarGroup {
1233            label: "Cassa".to_string(),
1234            collapsed: false,
1235            items: vec![],
1236        };
1237        let html = layout_sidebar_group(&group);
1238        assert!(html.contains("Cassa"));
1239        assert!(html.contains("font-semibold"));
1240        assert!(html.contains("text-text"));
1241        assert!(
1242            !html.contains("uppercase"),
1243            "sidebar group label should not use uppercase"
1244        );
1245        assert!(
1246            !html.contains("tracking-wider"),
1247            "sidebar group label should not use letter-spacing"
1248        );
1249    }
1250
1251    // ── INT-07 (layout): DashboardLayout sidebar nav item focus ring ──────
1252
1253    #[test]
1254    fn layout_sidebar_nav_focus_ring() {
1255        let item = SidebarNavItem {
1256            label: "Dashboard".to_string(),
1257            href: "/dashboard".to_string(),
1258            icon: None,
1259            active: false,
1260        };
1261        let html = layout_sidebar_nav_item(&item);
1262        assert!(
1263            html.contains("focus-visible:ring-primary"),
1264            "layout sidebar nav <a> item should have focus-visible:ring-primary (INT-07)"
1265        );
1266        assert!(
1267            html.contains("duration-150"),
1268            "layout sidebar nav <a> item should have duration-150 (INT-07)"
1269        );
1270    }
1271}