1use 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
20pub struct LayoutContext<'a> {
28 pub title: &'a str,
30 pub content: &'a str,
32 pub head: &'a str,
34 pub body_class: &'a str,
36 pub view_json: &'a str,
38 pub data_json: &'a str,
40 pub scripts: &'a str,
42}
43
44pub trait Layout: Send + Sync {
52 fn render(&self, ctx: &LayoutContext) -> String;
54}
55
56fn 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
92fn 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
102fn 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
142fn layout_sidebar_nav_item(item: &SidebarNavItem) -> String {
146 let disabled = item.disabled.unwrap_or(false);
147 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>" ));
178 }
179 html.push_str(&format!("{}</{tag}>", html_escape(&item.label)));
180 html
181}
182
183fn 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 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
203fn layout_sidebar_html(props: &SidebarProps) -> String {
205 let mut html = String::from(
208 "<aside data-sidebar class=\"fjui-sidebar inset-y-0 left-0 z-40 flex flex-col hidden md:flex\">",
209 );
210 if !props.fixed_top.is_empty() {
211 html.push_str("<nav class=\"px-4 pt-4 pb-1 space-y-1\">");
212 for item in &props.fixed_top {
213 html.push_str(&layout_sidebar_nav_item(item));
214 }
215 html.push_str("</nav>");
216 }
217 if !props.groups.is_empty() {
218 html.push_str("<div class=\"flex-1 overflow-y-auto px-4 pb-4 pt-0 space-y-2\">");
219 for group in &props.groups {
220 html.push_str(&layout_sidebar_group(group));
221 }
222 html.push_str("</div>");
223 }
224 if !props.fixed_bottom.is_empty() {
225 html.push_str("<nav class=\"fjui-sidebar__bottom p-4 space-y-1 border-t border-border\">");
228 for item in &props.fixed_bottom {
229 html.push_str(&layout_sidebar_nav_item(item));
230 }
231 html.push_str("</nav>");
232 }
233 html.push_str("</aside>");
234 html.push_str(
236 "<div data-sidebar-backdrop class=\"fixed inset-0 z-30 bg-black/50 hidden md:hidden\"></div>",
237 );
238 html
239}
240
241fn layout_header_html(props: &HeaderProps) -> String {
243 let mut html = String::from("<header class=\"fjui-header z-30 flex items-center md:pl-64\">");
256 html.push_str(&format!(
260 "<button data-sidebar-toggle class=\"md:hidden p-2 -ml-2 rounded-md text-text-muted \
261 hover:text-text hover:bg-surface {INTERACTIVE_BASE}\" aria-label=\"Toggle sidebar\">\
262 <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
263 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
264 d=\"M4 6h16M4 12h16M4 18h16\"/></svg></button>"
265 ));
266 html.push_str(&format!(
268 "<span class=\"fjui-header__workspace\">{}</span>",
269 html_escape(&props.business_name)
270 ));
271 html.push_str("<div class=\"ml-auto flex items-center gap-4\">");
272 html.push_str("<div class=\"relative\">");
274 if let Some(count) = props.notification_count {
275 if count > 0 {
276 html.push_str(&format!(
277 "<button data-notification-toggle class=\"relative p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\">\
278 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
279 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
280 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>\
281 <span class=\"absolute top-1 right-1 inline-flex items-center justify-center h-4 w-4 \
282 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{count}</span></button>",
283 ));
284 } else {
285 html.push_str(&format!(
286 "<button data-notification-toggle class=\"p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\">\
287 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
288 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
289 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>"
290 ));
291 }
292 }
293 html.push_str(
294 "<div data-notification-dropdown class=\"hidden absolute right-0 top-full mt-1 w-80 \
295 bg-card rounded-lg shadow-lg border border-border z-50\"></div></div>",
296 );
297 html.push_str(&format!(
300 "<button type=\"button\" class=\"fjui-header__search-btn inline-flex items-center gap-2 {INTERACTIVE_BASE}\" \
301 data-tooltip=\"Cerca\" aria-label=\"Cerca (⌘K)\" \
302 onclick=\"document.dispatchEvent(new CustomEvent('fjui:open-command-palette'))\">\
303 <svg class=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" stroke-width=\"2\" \
304 stroke-linecap=\"round\" stroke-linejoin=\"round\">\
305 <circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.35-4.35\"/>\
306 </svg>\
307 <kbd class=\"fjui-kbd hidden md:inline\">\u{2318}K</kbd></button>"
308 ));
309 let name_source = props
312 .user_name
313 .as_deref()
314 .filter(|s| !s.trim().is_empty())
315 .unwrap_or(&props.business_name);
316 let initials: String = name_source
317 .split_whitespace()
318 .filter_map(|w| w.chars().next())
319 .take(2)
320 .collect::<String>()
321 .to_uppercase();
322 html.push_str(&format!(
323 "<button class=\"fjui-avatar fjui-avatar--md inline-flex items-center justify-center \
324 cursor-pointer {INTERACTIVE_BASE}\" \
325 popovertarget=\"fjui-avatar-menu\" aria-label=\"Menu utente\" aria-haspopup=\"true\">{}</button>",
326 html_escape(&initials)
327 ));
328 let logout_action = props.logout_url.as_deref().unwrap_or("/logout");
339 let theme_item = match props.theme_url.as_deref() {
340 Some(url) => {
341 let js_url = url.replace('\\', "\\\\").replace('\'', "\\'");
345 format!(
346 "<button type=\"button\" class=\"fjui-avatar-menu__item\" \
347 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>",
348 html_escape(&js_url)
349 )
350 }
351 None => String::new(),
352 };
353 let profile_item = match props.profile_url.as_deref() {
354 Some(url) => format!(
355 "<a href=\"{}\" class=\"fjui-avatar-menu__item\">Profilo</a>",
356 html_escape(url)
357 ),
358 None => String::new(),
359 };
360 html.push_str(&format!(
361 "<div popover id=\"fjui-avatar-menu\" data-popover-menu class=\"fjui-avatar-menu\">\
362 {profile_item}\
363 {theme_item}\
364 <div class=\"fjui-avatar-menu__separator\"></div>\
365 <form method=\"post\" action=\"{}\">\
366 <button type=\"submit\" class=\"fjui-avatar-menu__item fjui-avatar-menu__item--destructive\">Esci</button>\
367 </form>\
368 </div>",
369 html_escape(logout_action)
370 ));
371 html.push_str("</div></header>");
372 html
373}
374
375fn with_runtime(ctx_scripts: &str) -> String {
377 let runtime = format!(
378 "<script>\n{}\n</script>",
379 crate::runtime::FERRO_RUNTIME_JS.as_str()
380 );
381 if ctx_scripts.is_empty() {
382 runtime
383 } else {
384 format!("{ctx_scripts}\n{runtime}")
385 }
386}
387
388pub struct DefaultLayout;
396
397impl Layout for DefaultLayout {
398 fn render(&self, ctx: &LayoutContext) -> String {
399 let wrapper = ferro_wrapper(ctx);
400 let scripts = with_runtime(ctx.scripts);
401 base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, &scripts)
402 }
403}
404
405pub struct AppLayout;
415
416impl Layout for AppLayout {
417 fn render(&self, ctx: &LayoutContext) -> String {
418 let nav = navigation(&[]);
419 let side = sidebar(&[]);
420 let wrapper = ferro_wrapper(ctx);
421
422 let body = format!(
423 r#"{nav}
424 <div class="flex">
425 {side}
426 <main class="flex-1 px-3 py-4 md:p-6">
427 <div class="mx-auto w-full max-w-7xl">
428 {wrapper}
429 </div>
430 </main>
431 </div>"#,
432 );
433
434 let scripts = with_runtime(ctx.scripts);
435 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
436 }
437}
438
439pub struct AuthLayout;
447
448impl Layout for AuthLayout {
449 fn render(&self, ctx: &LayoutContext) -> String {
450 let wrapper = ferro_wrapper(ctx);
451
452 let body = format!(
453 r#"<div class="min-h-screen flex items-center justify-center">
454 <div class="w-full max-w-md">
455 {wrapper}
456 </div>
457 </div>"#,
458 );
459
460 let scripts = with_runtime(ctx.scripts);
461 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
462 }
463}
464
465pub struct NavItem {
469 pub label: String,
471 pub url: String,
473 pub active: bool,
475}
476
477impl NavItem {
478 pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
480 Self {
481 label: label.into(),
482 url: url.into(),
483 active: false,
484 }
485 }
486
487 pub fn active(mut self) -> Self {
489 self.active = true;
490 self
491 }
492}
493
494pub struct SidebarSection {
496 pub title: String,
498 pub items: Vec<NavItem>,
500}
501
502impl SidebarSection {
503 pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
505 Self {
506 title: title.into(),
507 items,
508 }
509 }
510}
511
512pub fn navigation(items: &[NavItem]) -> String {
517 let mut html =
518 String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
519
520 for item in items {
521 let class = if item.active {
522 "text-primary font-medium"
523 } else {
524 "text-text-muted hover:text-text"
525 };
526 html.push_str(&format!(
527 "<a href=\"{}\" class=\"{} {INTERACTIVE_BASE}\">{}</a>",
528 html_escape(&item.url),
529 class,
530 html_escape(&item.label),
531 ));
532 }
533
534 html.push_str("</div></nav>");
535 html
536}
537
538pub fn sidebar(sections: &[SidebarSection]) -> String {
543 let mut html =
544 String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
545
546 for section in sections {
547 html.push_str("<div class=\"mb-6\">");
548 html.push_str(&format!(
549 "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
550 html_escape(§ion.title),
551 ));
552 html.push_str("<ul class=\"space-y-1\">");
553 for item in §ion.items {
554 let class = if item.active {
555 "text-primary font-medium"
556 } else {
557 "text-text-muted hover:text-text"
558 };
559 html.push_str(&format!(
560 "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm rounded-md {} {INTERACTIVE_BASE}\">{}</a></li>",
561 html_escape(&item.url),
562 class,
563 html_escape(&item.label),
564 ));
565 }
566 html.push_str("</ul></div>");
567 }
568
569 html.push_str("</aside>");
570 html
571}
572
573pub fn footer(text: &str) -> String {
577 format!(
578 "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
579 html_escape(text),
580 )
581}
582
583pub struct DashboardLayoutConfig {
611 pub sidebar: SidebarProps,
613 pub header: HeaderProps,
615 pub sse_url: Option<String>,
619}
620
621pub struct DashboardLayout {
652 pub config: DashboardLayoutConfig,
654}
655
656impl DashboardLayout {
657 pub fn new(config: DashboardLayoutConfig) -> Self {
659 Self { config }
660 }
661}
662
663impl Layout for DashboardLayout {
664 fn render(&self, ctx: &LayoutContext) -> String {
665 let sidebar_html = layout_sidebar_html(&self.config.sidebar);
666 let header_html = layout_header_html(&self.config.header);
667 let wrapper = ferro_wrapper(ctx);
668
669 let body_data = if let Some(ref url) = self.config.sse_url {
670 format!("data-sse-url=\"{}\"", html_escape(url))
671 } else {
672 String::new()
673 };
674
675 let runtime_script = format!(
676 "<script>\n{}\n</script>",
677 crate::runtime::FERRO_RUNTIME_JS.as_str()
678 );
679 let scripts = if ctx.scripts.is_empty() {
680 runtime_script
681 } else {
682 format!("{}\n{}", ctx.scripts, runtime_script)
683 };
684
685 let body_content = format!(
690 r#"{sidebar_html}
691 <div class="flex flex-col">
692 {header_html}
693 <main class="flex-1 px-3 py-4 md:p-6 md:pl-64">
694 <div class="mx-auto w-full max-w-7xl">
695 {wrapper}
696 </div>
697 </main>
698 <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
699 </div>"#,
700 );
701
702 let body_class = if ctx.body_class.is_empty() {
703 "bg-surface"
704 } else {
705 ctx.body_class
706 };
707
708 base_document_ext(
709 ctx.title,
710 ctx.head,
711 body_class,
712 &body_data,
713 &body_content,
714 &scripts,
715 )
716 }
717}
718
719pub struct LayoutRegistry {
727 layouts: HashMap<String, Box<dyn Layout>>,
728 default: String,
729}
730
731impl LayoutRegistry {
732 pub fn new() -> Self {
734 let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
735 layouts.insert("default".to_string(), Box::new(DefaultLayout));
736 layouts.insert("app".to_string(), Box::new(AppLayout));
737 layouts.insert("auth".to_string(), Box::new(AuthLayout));
738
739 Self {
740 layouts,
741 default: "default".to_string(),
742 }
743 }
744
745 pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
747 self.layouts.insert(name.into(), Box::new(layout));
748 }
749
750 pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
753 let layout_name = name.unwrap_or(&self.default);
754 let layout = self
755 .layouts
756 .get(layout_name)
757 .or_else(|| self.layouts.get(&self.default))
758 .expect("default layout must exist in registry");
759 layout.render(ctx)
760 }
761
762 pub fn has(&self, name: &str) -> bool {
764 self.layouts.contains_key(name)
765 }
766}
767
768impl Default for LayoutRegistry {
769 fn default() -> Self {
770 Self::new()
771 }
772}
773
774static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
777
778pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
782 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
783}
784
785pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
789 global_registry()
790 .write()
791 .expect("layout registry poisoned")
792 .register(name, layout);
793}
794
795pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
799 global_registry()
800 .read()
801 .expect("layout registry poisoned")
802 .render(name, ctx)
803}
804
805#[cfg(test)]
808mod tests {
809 use super::*;
810
811 fn test_ctx() -> LayoutContext<'static> {
812 LayoutContext {
813 title: "Test Page",
814 content: "<p>Hello</p>",
815 head: "<link rel=\"stylesheet\" href=\"/style.css\">",
816 body_class: "bg-background",
817 view_json: "{\"schema\":\"ferro-json-ui/v2\"}",
818 data_json: "{\"key\":\"value\"}",
819 scripts: "",
820 }
821 }
822
823 #[test]
826 fn base_document_produces_valid_html_structure() {
827 let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
828 assert!(html.starts_with("<!DOCTYPE html>"));
829 assert!(html.contains("<html lang=\"en\">"));
830 assert!(html.contains("<meta charset=\"UTF-8\">"));
831 assert!(html.contains("<meta name=\"viewport\""));
832 assert!(html.contains("<title>Title</title>"));
833 assert!(html.contains("<style></style>"));
834 assert!(html.contains("<body class=\"my-class\">"));
835 assert!(html.contains("<p>body</p>"));
836 assert!(html.contains("</html>"));
837 }
838
839 #[test]
840 fn base_document_escapes_title() {
841 let html = base_document("Tom & Jerry <script>", "", "", "", "");
842 assert!(html.contains("<title>Tom & Jerry <script></title>"));
843 }
844
845 #[test]
846 fn base_document_escapes_body_class() {
847 let html = base_document("T", "", "a\"b", "", "");
848 assert!(html.contains("class=\"a"b\""));
849 }
850
851 #[test]
854 fn default_layout_renders_all_context_fields() {
855 let ctx = test_ctx();
856 let html = DefaultLayout.render(&ctx);
857
858 assert!(html.contains("<!DOCTYPE html>"));
859 assert!(html.contains("<title>Test Page</title>"));
860 assert!(html.contains("href=\"/style.css\""));
861 assert!(html.contains("class=\"bg-background\""));
862 assert!(html.contains("id=\"ferro-json-ui\""));
863 assert!(html.contains("data-view=\""));
864 assert!(html.contains("data-props=\""));
865 assert!(html.contains("<p>Hello</p>"));
866 }
867
868 #[test]
869 fn default_layout_contains_ferro_wrapper() {
870 let ctx = test_ctx();
871 let html = DefaultLayout.render(&ctx);
872 assert!(html.contains("<div id=\"ferro-json-ui\""));
873 }
874
875 #[test]
878 fn app_layout_includes_nav_and_sidebar() {
879 let ctx = test_ctx();
880 let html = AppLayout.render(&ctx);
881
882 assert!(html.contains("<nav"));
883 assert!(html.contains("<aside"));
884 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
885 assert!(html.contains("<div id=\"ferro-json-ui\""));
886 assert!(html.contains("<p>Hello</p>"));
887 }
888
889 #[test]
890 fn app_layout_has_flex_structure() {
891 let ctx = test_ctx();
892 let html = AppLayout.render(&ctx);
893 assert!(html.contains("class=\"flex\""));
894 }
895
896 #[test]
899 fn auth_layout_centers_content() {
900 let ctx = test_ctx();
901 let html = AuthLayout.render(&ctx);
902
903 assert!(
905 html.contains("min-h-screen flex items-center justify-center"),
906 "centering wrapper must remain"
907 );
908 assert!(
909 html.contains("w-full max-w-md"),
910 "max-width wrapper must remain"
911 );
912 assert!(html.contains("<div id=\"ferro-json-ui\""));
913 assert!(
915 !html.contains("bg-card rounded-lg shadow-md p-8"),
916 "card chrome must be removed from AuthLayout; spec root must declare its own Card"
917 );
918 }
919
920 #[test]
921 fn auth_layout_has_no_nav_or_sidebar() {
922 let ctx = test_ctx();
923 let html = AuthLayout.render(&ctx);
924 assert!(!html.contains("<nav"));
925 assert!(!html.contains("<aside"));
926 }
927
928 #[test]
931 fn registry_returns_default_for_none_name() {
932 let registry = LayoutRegistry::new();
933 let ctx = test_ctx();
934 let html = registry.render(None, &ctx);
935 assert!(html.contains("<div id=\"ferro-json-ui\""));
937 assert!(!html.contains("<nav"));
938 }
939
940 #[test]
941 fn registry_returns_default_for_unknown_name() {
942 let registry = LayoutRegistry::new();
943 let ctx = test_ctx();
944 let html = registry.render(Some("nonexistent"), &ctx);
945 assert!(html.contains("<div id=\"ferro-json-ui\""));
947 assert!(!html.contains("<nav"));
948 }
949
950 #[test]
951 fn registry_renders_named_layout() {
952 let registry = LayoutRegistry::new();
953 let ctx = test_ctx();
954 let html = registry.render(Some("app"), &ctx);
955 assert!(html.contains("<nav"));
956 assert!(html.contains("<aside"));
957 }
958
959 #[test]
960 fn registry_renders_auth_layout() {
961 let registry = LayoutRegistry::new();
962 let ctx = test_ctx();
963 let html = registry.render(Some("auth"), &ctx);
964 assert!(html.contains("flex items-center justify-center"));
965 }
966
967 #[test]
968 fn registry_has_returns_true_for_registered() {
969 let registry = LayoutRegistry::new();
970 assert!(registry.has("default"));
971 assert!(registry.has("app"));
972 assert!(registry.has("auth"));
973 }
974
975 #[test]
976 fn registry_has_returns_false_for_unknown() {
977 let registry = LayoutRegistry::new();
978 assert!(!registry.has("nonexistent"));
979 }
980
981 #[test]
982 fn registry_register_adds_custom_layout() {
983 let mut registry = LayoutRegistry::new();
984 struct Custom;
985 impl Layout for Custom {
986 fn render(&self, _ctx: &LayoutContext) -> String {
987 "CUSTOM".to_string()
988 }
989 }
990 registry.register("custom", Custom);
991 assert!(registry.has("custom"));
992
993 let ctx = test_ctx();
994 let html = registry.render(Some("custom"), &ctx);
995 assert_eq!(html, "CUSTOM");
996 }
997
998 #[test]
999 fn registry_register_replaces_existing() {
1000 let mut registry = LayoutRegistry::new();
1001 struct Replacement;
1002 impl Layout for Replacement {
1003 fn render(&self, _ctx: &LayoutContext) -> String {
1004 "REPLACED".to_string()
1005 }
1006 }
1007 registry.register("default", Replacement);
1008 let ctx = test_ctx();
1009 let html = registry.render(None, &ctx);
1010 assert_eq!(html, "REPLACED");
1011 }
1012
1013 #[test]
1016 fn global_registry_returns_valid_registry() {
1017 let reg = global_registry();
1018 let guard = reg.read().unwrap();
1019 assert!(guard.has("default"));
1020 assert!(guard.has("app"));
1021 assert!(guard.has("auth"));
1022 }
1023
1024 #[test]
1025 fn render_layout_global_function_works() {
1026 let ctx = test_ctx();
1027 let html = render_layout(None, &ctx);
1028 assert!(html.contains("<!DOCTYPE html>"));
1029 assert!(html.contains("<div id=\"ferro-json-ui\""));
1030 }
1031
1032 #[test]
1035 fn navigation_renders_empty_gracefully() {
1036 let html = navigation(&[]);
1037 assert!(html.contains("<nav"));
1038 assert!(html.contains("</nav>"));
1039 }
1040
1041 #[test]
1042 fn navigation_renders_items_with_correct_classes() {
1043 let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
1044 let html = navigation(&items);
1045 assert!(html.contains("href=\"/\""));
1046 assert!(html.contains(">Home</a>"));
1047 assert!(html.contains("href=\"/users\""));
1048 assert!(html.contains(">Users</a>"));
1049 assert!(html.contains("text-text-muted hover:text-text"));
1051 }
1052
1053 #[test]
1054 fn navigation_marks_active_item() {
1055 let items = vec![
1056 NavItem::new("Home", "/").active(),
1057 NavItem::new("Users", "/users"),
1058 ];
1059 let html = navigation(&items);
1060 assert!(html.contains("text-primary font-medium"));
1061 }
1062
1063 #[test]
1064 fn sidebar_renders_sections_with_headers() {
1065 let sections = vec![SidebarSection::new(
1066 "Main Menu",
1067 vec![
1068 NavItem::new("Dashboard", "/"),
1069 NavItem::new("Settings", "/settings"),
1070 ],
1071 )];
1072 let html = sidebar(§ions);
1073 assert!(html.contains("<aside"));
1074 assert!(html.contains("Main Menu"));
1075 assert!(html.contains("Dashboard"));
1076 assert!(html.contains("Settings"));
1077 assert!(html.contains("</aside>"));
1078 }
1079
1080 #[test]
1081 fn sidebar_renders_empty_gracefully() {
1082 let html = sidebar(&[]);
1083 assert!(html.contains("<aside"));
1084 assert!(html.contains("</aside>"));
1085 }
1086
1087 #[test]
1088 fn footer_renders_text() {
1089 let html = footer("Copyright 2026");
1090 assert!(html.contains("<footer"));
1091 assert!(html.contains("Copyright 2026"));
1092 assert!(html.contains("</footer>"));
1093 }
1094
1095 #[test]
1096 fn partials_escape_user_strings() {
1097 let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
1098 let html = navigation(&items);
1099 assert!(html.contains("Tom & Jerry"));
1100 assert!(html.contains("href=\"/a&b\""));
1101
1102 let sections = vec![SidebarSection::new(
1103 "A<B",
1104 vec![NavItem::new("<script>", "/x\"y")],
1105 )];
1106 let html = sidebar(§ions);
1107 assert!(html.contains("A<B"));
1108 assert!(html.contains("<script>"));
1109
1110 let html = footer("<script>alert('xss')</script>");
1111 assert!(html.contains("<script>"));
1112 }
1113
1114 #[test]
1117 fn ferro_wrapper_includes_data_attributes() {
1118 let ctx = test_ctx();
1119 let html = ferro_wrapper(&ctx);
1120 assert!(html.contains("id=\"ferro-json-ui\""));
1121 assert!(html.contains("data-view=\""));
1122 assert!(html.contains("data-props=\""));
1123 assert!(html.contains("<p>Hello</p>"));
1124 }
1125
1126 fn dashboard_layout() -> DashboardLayout {
1129 use crate::component::{HeaderProps, SidebarProps};
1130 DashboardLayout::new(DashboardLayoutConfig {
1131 sidebar: SidebarProps {
1132 fixed_top: vec![],
1133 groups: vec![],
1134 fixed_bottom: vec![],
1135 },
1136 header: HeaderProps {
1137 business_name: "Acme".to_string(),
1138 notification_count: None,
1139 user_name: Some("Alice".to_string()),
1140 user_avatar: None,
1141 logout_url: Some("/logout".to_string()),
1142 theme_url: None,
1143 profile_url: None,
1144 },
1145 sse_url: None,
1146 })
1147 }
1148
1149 #[test]
1150 fn dashboard_layout_renders_full_html_structure() {
1151 let ctx = test_ctx();
1152 let html = dashboard_layout().render(&ctx);
1153
1154 assert!(html.starts_with("<!DOCTYPE html>"));
1155 assert!(html.contains("<title>Test Page</title>"));
1156 assert!(html.contains("<div id=\"ferro-json-ui\""));
1157 assert!(html.contains("<p>Hello</p>"));
1158 }
1159
1160 #[test]
1161 fn dashboard_layout_has_persistent_sidebar() {
1162 let ctx = test_ctx();
1163 let html = dashboard_layout().render(&ctx);
1164 assert!(html.contains("<aside data-sidebar"));
1165 }
1166
1167 #[test]
1168 fn dashboard_layout_has_persistent_header() {
1169 let ctx = test_ctx();
1170 let html = dashboard_layout().render(&ctx);
1171 assert!(html.contains("<header"));
1172 assert!(html.contains("Acme"));
1173 }
1174
1175 #[test]
1176 fn dashboard_layout_has_main_content_area() {
1177 let ctx = test_ctx();
1178 let html = dashboard_layout().render(&ctx);
1179 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6 md:pl-64\">"));
1181 }
1182
1183 #[test]
1184 fn dashboard_layout_has_toast_container() {
1185 let ctx = test_ctx();
1186 let html = dashboard_layout().render(&ctx);
1187 assert!(html.contains("data-toast-container"));
1188 }
1189
1190 #[test]
1191 fn dashboard_layout_injects_runtime_js() {
1192 let ctx = test_ctx();
1193 let html = dashboard_layout().render(&ctx);
1194 assert!(html.contains("<script>"));
1196 assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1197 }
1198
1199 #[test]
1200 fn dashboard_layout_has_mobile_hamburger_toggle() {
1201 let ctx = test_ctx();
1202 let html = dashboard_layout().render(&ctx);
1203 assert!(html.contains("data-sidebar-toggle"));
1204 }
1205
1206 #[test]
1207 fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1208 let ctx = test_ctx();
1209 let html = dashboard_layout().render(&ctx);
1210 let body_start = html.find("<body").unwrap_or(0);
1214 let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1215 let body_tag = &html[body_start..=body_tag_end];
1216 assert!(!body_tag.contains("data-sse-url="));
1217 }
1218
1219 #[test]
1220 fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1221 use crate::component::{HeaderProps, SidebarProps};
1222 let layout = DashboardLayout::new(DashboardLayoutConfig {
1223 sidebar: SidebarProps {
1224 fixed_top: vec![],
1225 groups: vec![],
1226 fixed_bottom: vec![],
1227 },
1228 header: HeaderProps {
1229 business_name: "App".to_string(),
1230 notification_count: None,
1231 user_name: None,
1232 user_avatar: None,
1233 logout_url: None,
1234 theme_url: None,
1235 profile_url: None,
1236 },
1237 sse_url: Some("/events".to_string()),
1238 });
1239 let ctx = test_ctx();
1240 let html = layout.render(&ctx);
1241 assert!(html.contains("data-sse-url=\"/events\""));
1242 }
1243
1244 #[test]
1245 fn dashboard_layout_escapes_sse_url_xss() {
1246 use crate::component::{HeaderProps, SidebarProps};
1247 let layout = DashboardLayout::new(DashboardLayoutConfig {
1248 sidebar: SidebarProps {
1249 fixed_top: vec![],
1250 groups: vec![],
1251 fixed_bottom: vec![],
1252 },
1253 header: HeaderProps {
1254 business_name: "App".to_string(),
1255 notification_count: None,
1256 user_name: None,
1257 user_avatar: None,
1258 logout_url: None,
1259 theme_url: None,
1260 profile_url: None,
1261 },
1262 sse_url: Some("/events?a=1&b=2".to_string()),
1263 });
1264 let ctx = test_ctx();
1265 let html = layout.render(&ctx);
1266 assert!(html.contains("data-sse-url=\"/events?a=1&b=2\""));
1267 }
1268
1269 #[test]
1270 fn dashboard_layout_notification_toggle_present_with_count() {
1271 use crate::component::{HeaderProps, SidebarProps};
1272 let layout = DashboardLayout::new(DashboardLayoutConfig {
1273 sidebar: SidebarProps {
1274 fixed_top: vec![],
1275 groups: vec![],
1276 fixed_bottom: vec![],
1277 },
1278 header: HeaderProps {
1279 business_name: "App".to_string(),
1280 notification_count: Some(5),
1281 user_name: None,
1282 user_avatar: None,
1283 logout_url: None,
1284 theme_url: None,
1285 profile_url: None,
1286 },
1287 sse_url: None,
1288 });
1289 let ctx = test_ctx();
1290 let html = layout.render(&ctx);
1291 assert!(html.contains("data-notification-toggle"));
1292 }
1293
1294 #[test]
1295 fn dashboard_layout_has_sidebar_backdrop() {
1296 let ctx = test_ctx();
1297 let html = dashboard_layout().render(&ctx);
1298 assert!(html.contains("data-sidebar-backdrop"));
1299 assert!(html.contains("bg-black/50"));
1300 assert!(html.contains("md:hidden"));
1301 }
1302
1303 #[test]
1304 fn dashboard_layout_sidebar_mobile_classes() {
1305 let ctx = test_ctx();
1306 let html = dashboard_layout().render(&ctx);
1307 assert!(html.contains("hidden md:flex"));
1309 }
1310
1311 #[test]
1312 fn dashboard_layout_uses_default_body_class() {
1313 let ctx = test_ctx();
1314 let html = dashboard_layout().render(&ctx);
1315 assert!(html.contains("class=\"bg-background\""));
1317 }
1318
1319 #[test]
1320 fn sidebar_nav_item_renders_icon_as_raw_svg() {
1321 let item = SidebarNavItem {
1322 label: "Dashboard".to_string(),
1323 href: "/dashboard".to_string(),
1324 icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1325 active: false,
1326 disabled: None,
1327 };
1328 let html = layout_sidebar_nav_item(&item);
1329 assert!(
1330 html.contains("<svg"),
1331 "icon SVG should be rendered raw, not escaped"
1332 );
1333 assert!(
1334 !html.contains("<svg"),
1335 "icon SVG should NOT be html-escaped"
1336 );
1337 assert!(html.contains("Dashboard"), "label should still appear");
1338 }
1339
1340 #[test]
1341 fn sidebar_group_label_uses_fjui_class() {
1342 let group = SidebarGroup {
1345 label: "Cassa".to_string(),
1346 collapsed: false,
1347 items: vec![],
1348 };
1349 let html = layout_sidebar_group(&group);
1350 assert!(html.contains("Cassa"));
1351 assert!(
1352 html.contains("fjui-sidebar__group-label"),
1353 "sidebar group label must emit fjui-sidebar__group-label"
1354 );
1355 assert!(
1357 !html.contains("font-semibold"),
1358 "font-semibold must be removed from Rust emission; handled by skin rule"
1359 );
1360 }
1361
1362 #[test]
1368 fn layout_sidebar_nav_focus_ring() {
1369 let item = SidebarNavItem {
1370 label: "Dashboard".to_string(),
1371 href: "/dashboard".to_string(),
1372 icon: None,
1373 active: false,
1374 disabled: None,
1375 };
1376 let html = layout_sidebar_nav_item(&item);
1377 assert!(
1378 html.contains("fjui-sidebar__nav-item"),
1379 "layout sidebar nav <a> item must emit fjui-sidebar__nav-item (INT-07 — focus ring in skin rule)"
1380 );
1381 assert!(
1384 !html.contains("focus-visible:ring-ring"),
1385 "focus-visible:ring-ring must be removed from Rust emission; handled by skin rule"
1386 );
1387 assert!(
1388 !html.contains("duration-fast"),
1389 "duration-fast must be removed from Rust emission; handled by skin rule"
1390 );
1391 }
1392
1393 #[test]
1397 fn layout_sidebar_html_emits_fjui_sidebar_class() {
1398 use crate::component::SidebarProps;
1399 let props = SidebarProps {
1400 fixed_top: vec![],
1401 groups: vec![],
1402 fixed_bottom: vec![],
1403 };
1404 let html = layout_sidebar_html(&props);
1405 assert!(
1406 html.contains("fjui-sidebar"),
1407 "layout_sidebar_html must emit fjui-sidebar class; found: {html}"
1408 );
1409 assert!(
1411 html.contains("data-sidebar"),
1412 "data-sidebar attribute must be preserved"
1413 );
1414 assert!(
1415 html.contains("data-sidebar-backdrop"),
1416 "data-sidebar-backdrop must be preserved"
1417 );
1418 }
1419
1420 #[test]
1422 fn layout_header_html_emits_fjui_header_class() {
1423 use crate::component::HeaderProps;
1424 let props = HeaderProps {
1425 business_name: "Test".to_string(),
1426 notification_count: None,
1427 user_name: None,
1428 user_avatar: None,
1429 logout_url: None,
1430 theme_url: None,
1431 profile_url: None,
1432 };
1433 let html = layout_header_html(&props);
1434 assert!(
1435 html.contains("fjui-header"),
1436 "layout_header_html must emit fjui-header class; found: {html}"
1437 );
1438 }
1439
1440 #[test]
1442 fn layout_sidebar_nav_item_emits_fjui_class() {
1443 let item = SidebarNavItem {
1444 label: "Dashboard".to_string(),
1445 href: "/dashboard".to_string(),
1446 icon: None,
1447 active: false,
1448 disabled: None,
1449 };
1450 let html = layout_sidebar_nav_item(&item);
1451 assert!(
1452 html.contains("fjui-sidebar__nav-item"),
1453 "inactive nav item must emit fjui-sidebar__nav-item; found: {html}"
1454 );
1455 }
1456
1457 #[test]
1459 fn layout_sidebar_nav_item_active_emits_fjui_active_modifier() {
1460 let item = SidebarNavItem {
1461 label: "Dashboard".to_string(),
1462 href: "/dashboard".to_string(),
1463 icon: None,
1464 active: true,
1465 disabled: None,
1466 };
1467 let html = layout_sidebar_nav_item(&item);
1468 assert!(
1469 html.contains("fjui-sidebar__nav-item--active"),
1470 "active nav item must emit fjui-sidebar__nav-item--active; found: {html}"
1471 );
1472 }
1473
1474 #[test]
1476 fn layout_sidebar_group_label_emits_fjui_class() {
1477 let group = SidebarGroup {
1478 label: "Cassa".to_string(),
1479 collapsed: false,
1480 items: vec![],
1481 };
1482 let html = layout_sidebar_group(&group);
1483 assert!(
1484 html.contains("fjui-sidebar__group-label"),
1485 "sidebar group label must emit fjui-sidebar__group-label; found: {html}"
1486 );
1487 }
1488
1489 #[test]
1491 fn dashboard_layout_shell_emits_fjui_chrome_classes() {
1492 let ctx = test_ctx();
1493 let html = dashboard_layout().render(&ctx);
1494 assert!(
1495 html.contains("fjui-sidebar"),
1496 "DashboardLayout must emit fjui-sidebar"
1497 );
1498 assert!(
1499 html.contains("fjui-header"),
1500 "DashboardLayout must emit fjui-header"
1501 );
1502 assert!(
1504 html.contains("id=\"ferro-json-ui\""),
1505 "ferro-json-ui swap-target must be preserved"
1506 );
1507 assert!(
1508 html.contains("data-sidebar"),
1509 "data-sidebar must be preserved"
1510 );
1511 assert!(
1512 html.contains("data-toast-container"),
1513 "data-toast-container must be preserved"
1514 );
1515 assert!(
1517 html.contains("md:pl-64"),
1518 "DashboardLayout grid utility md:pl-64 must be preserved"
1519 );
1520 assert!(
1521 html.contains("max-w-7xl"),
1522 "DashboardLayout grid utility max-w-7xl must be preserved"
1523 );
1524 }
1525
1526 #[test]
1529 fn header_emits_avatar_initials() {
1530 use crate::component::HeaderProps;
1531 let props = HeaderProps {
1532 business_name: "Acme".to_string(),
1533 notification_count: None,
1534 user_name: Some("Alice Rossi".into()),
1535 user_avatar: None,
1536 logout_url: Some("/logout".into()),
1537 theme_url: None,
1538 profile_url: None,
1539 };
1540 let html = layout_header_html(&props);
1541 assert!(
1542 html.contains("fjui-avatar"),
1543 "avatar button must be present; got: {html}"
1544 );
1545 assert!(
1546 html.contains("popovertarget=\"fjui-avatar-menu\""),
1547 "avatar button must wire to fjui-avatar-menu popover; got: {html}"
1548 );
1549 assert!(
1550 html.contains("AR"),
1551 "initials AR must be present from 'Alice Rossi'; got: {html}"
1552 );
1553 }
1554
1555 #[test]
1556 fn header_no_bare_logout_link() {
1557 use crate::component::HeaderProps;
1558 let props = HeaderProps {
1559 business_name: "Acme".to_string(),
1560 notification_count: None,
1561 user_name: None,
1562 user_avatar: None,
1563 logout_url: Some("/logout".into()),
1564 theme_url: None,
1565 profile_url: None,
1566 };
1567 let html = layout_header_html(&props);
1568 assert!(
1569 !html.contains(">Logout</a>"),
1570 "bare Logout link must be gone; got: {html}"
1571 );
1572 assert!(
1573 html.contains("action=\"/logout\""),
1574 "logout must be moved into Esci POST form; got: {html}"
1575 );
1576 }
1577
1578 #[test]
1579 fn header_search_button_present() {
1580 use crate::component::HeaderProps;
1581 let props = HeaderProps {
1582 business_name: "Acme".to_string(),
1583 notification_count: None,
1584 user_name: None,
1585 user_avatar: None,
1586 logout_url: None,
1587 theme_url: None,
1588 profile_url: None,
1589 };
1590 let html = layout_header_html(&props);
1591 assert!(
1592 html.contains("fjui-header__search-btn"),
1593 "search button must be present; got: {html}"
1594 );
1595 assert!(
1596 html.contains("data-tooltip=\"Cerca\""),
1597 "search button must carry data-tooltip Cerca (UX-02); got: {html}"
1598 );
1599 assert!(
1600 html.contains("fjui:open-command-palette"),
1601 "search button must dispatch fjui:open-command-palette (D-06); got: {html}"
1602 );
1603 assert!(
1604 html.contains("fjui-kbd"),
1605 "search button must include fjui-kbd chip; got: {html}"
1606 );
1607 }
1608
1609 #[test]
1612 fn header_theme_toggle_only_when_theme_url_set() {
1613 use crate::component::HeaderProps;
1614 let mut props = HeaderProps {
1615 business_name: "Acme".to_string(),
1616 notification_count: None,
1617 user_name: None,
1618 user_avatar: None,
1619 logout_url: None,
1620 theme_url: None,
1621 profile_url: None,
1622 };
1623 let html = layout_header_html(&props);
1624 assert!(
1625 !html.contains(">Tema</button>"),
1626 "Tema item must be omitted when theme_url is None; got: {html}"
1627 );
1628
1629 props.theme_url = Some("/dashboard/theme".to_string());
1630 let html = layout_header_html(&props);
1631 assert!(
1632 html.contains("fetch('/dashboard/theme'"),
1633 "Tema must POST to the configured theme_url; got: {html}"
1634 );
1635 assert!(
1636 html.contains("if(r.ok)document.documentElement.classList.toggle('dark')"),
1637 "dark-class toggle must be gated on response.ok; got: {html}"
1638 );
1639 }
1640}