Skip to main content

ferro_json_ui/
layout.rs

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