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(
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 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 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
245fn layout_header_html(props: &HeaderProps) -> String {
247 let mut html = String::from("<header class=\"fjui-header z-30 flex items-center md:pl-64\">");
260 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 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 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 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 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 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 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
382fn 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
395pub 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
412pub 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
446pub 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
472pub struct NavItem {
476 pub label: String,
478 pub url: String,
480 pub active: bool,
482}
483
484impl NavItem {
485 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 pub fn active(mut self) -> Self {
496 self.active = true;
497 self
498 }
499}
500
501pub struct SidebarSection {
503 pub title: String,
505 pub items: Vec<NavItem>,
507}
508
509impl SidebarSection {
510 pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
512 Self {
513 title: title.into(),
514 items,
515 }
516 }
517}
518
519pub 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
545pub 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(§ion.title),
558 ));
559 html.push_str("<ul class=\"space-y-1\">");
560 for item in §ion.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
580pub 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
590pub struct DashboardLayoutConfig {
618 pub sidebar: SidebarProps,
620 pub header: HeaderProps,
622 pub sse_url: Option<String>,
626}
627
628pub struct DashboardLayout {
659 pub config: DashboardLayoutConfig,
661}
662
663impl DashboardLayout {
664 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 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
726pub struct LayoutRegistry {
734 layouts: HashMap<String, Box<dyn Layout>>,
735 default: String,
736}
737
738impl LayoutRegistry {
739 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 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 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 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
781static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
784
785pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
789 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
790}
791
792pub 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
802pub 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#[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 #[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 & Jerry <script></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"b\""));
856 }
857
858 #[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 #[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 #[test]
906 fn auth_layout_centers_content() {
907 let ctx = test_ctx();
908 let html = AuthLayout.render(&ctx);
909
910 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 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 #[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 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 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 #[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 #[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 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(§ions);
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 & Jerry"));
1107 assert!(html.contains("href=\"/a&b\""));
1108
1109 let sections = vec![SidebarSection::new(
1110 "A<B",
1111 vec![NavItem::new("<script>", "/x\"y")],
1112 )];
1113 let html = sidebar(§ions);
1114 assert!(html.contains("A<B"));
1115 assert!(html.contains("<script>"));
1116
1117 let html = footer("<script>alert('xss')</script>");
1118 assert!(html.contains("<script>"));
1119 }
1120
1121 #[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 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 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 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 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&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 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 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("<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 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 assert!(
1364 !html.contains("font-semibold"),
1365 "font-semibold must be removed from Rust emission; handled by skin rule"
1366 );
1367 }
1368
1369 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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}