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