Skip to main content

ferro_json_ui/
layout.rs

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