1use std::collections::HashMap;
14use std::sync::{OnceLock, RwLock};
15
16use crate::component::{HeaderProps, SidebarGroup, SidebarNavItem, SidebarProps};
17use crate::render::html_escape;
18
19pub struct LayoutContext<'a> {
27 pub title: &'a str,
29 pub content: &'a str,
31 pub head: &'a str,
33 pub body_class: &'a str,
35 pub view_json: &'a str,
37 pub data_json: &'a str,
39 pub scripts: &'a str,
41}
42
43pub trait Layout: Send + Sync {
51 fn render(&self, ctx: &LayoutContext) -> String;
53}
54
55fn base_document(
63 title: &str,
64 head: &str,
65 body_class: &str,
66 body_content: &str,
67 scripts: &str,
68) -> String {
69 format!(
70 r#"<!DOCTYPE html>
71<html lang="en">
72<head>
73 <meta charset="UTF-8">
74 <meta name="viewport" content="width=device-width, initial-scale=1.0">
75 <title>{title}</title>
76 {head}
77</head>
78<body class="{body_class}">
79 {body_content}
80 {scripts}
81</body>
82</html>"#,
83 title = html_escape(title),
84 head = head,
85 body_class = html_escape(body_class),
86 body_content = body_content,
87 scripts = scripts,
88 )
89}
90
91fn ferro_wrapper(ctx: &LayoutContext) -> String {
93 format!(
94 r#"<div id="ferro-json-ui" data-view="{view}" data-props="{props}">{content}</div>"#,
95 view = html_escape(ctx.view_json),
96 props = html_escape(ctx.data_json),
97 content = ctx.content,
98 )
99}
100
101fn base_document_ext(
106 title: &str,
107 head: &str,
108 body_class: &str,
109 body_data: &str,
110 body_content: &str,
111 scripts: &str,
112) -> String {
113 let body_data_attr = if body_data.is_empty() {
114 String::new()
115 } else {
116 format!(" {body_data}")
117 };
118 format!(
119 r#"<!DOCTYPE html>
120<html lang="en">
121<head>
122 <meta charset="UTF-8">
123 <meta name="viewport" content="width=device-width, initial-scale=1.0">
124 <title>{title}</title>
125 {head}
126</head>
127<body class="{body_class}"{body_data_attr}>
128 {body_content}
129 {scripts}
130</body>
131</html>"#,
132 title = html_escape(title),
133 head = head,
134 body_class = html_escape(body_class),
135 body_data_attr = body_data_attr,
136 body_content = body_content,
137 scripts = scripts,
138 )
139}
140
141fn layout_sidebar_nav_item(item: &SidebarNavItem) -> String {
145 let disabled = item.disabled.unwrap_or(false);
146 let (tag, classes) = if disabled {
147 (
148 "span",
149 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted opacity-50 cursor-not-allowed select-none",
150 )
151 } else if item.active {
152 (
153 "a",
154 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
155 )
156 } else {
157 (
158 "a",
159 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
160 )
161 };
162 let mut html = if disabled {
163 format!("<{tag} aria-disabled=\"true\" class=\"{classes}\">")
164 } else {
165 format!(
166 "<{tag} href=\"{}\" class=\"{classes}\">",
167 html_escape(&item.href),
168 )
169 };
170 if let Some(ref icon) = item.icon {
171 html.push_str(&format!(
172 "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" ));
174 }
175 html.push_str(&format!("{}</{tag}>", html_escape(&item.label)));
176 html
177}
178
179fn layout_sidebar_group(group: &SidebarGroup) -> String {
181 let mut html = String::from("<div data-sidebar-group");
182 if group.collapsed {
183 html.push_str(" data-collapsed");
184 }
185 html.push('>');
186 html.push_str(&format!(
187 "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted\">{}</p>",
188 html_escape(&group.label)
189 ));
190 html.push_str("<nav class=\"space-y-1\">");
191 for item in &group.items {
192 html.push_str(&layout_sidebar_nav_item(item));
193 }
194 html.push_str("</nav></div>");
195 html
196}
197
198fn layout_sidebar_html(props: &SidebarProps) -> String {
200 let mut html = String::from(
201 "<aside data-sidebar class=\"fixed inset-y-0 left-0 z-40 w-64 flex flex-col \
202 bg-background border-r border-border hidden md:flex\">",
203 );
204 if !props.fixed_top.is_empty() {
205 html.push_str("<nav class=\"p-4 space-y-1\">");
206 for item in &props.fixed_top {
207 html.push_str(&layout_sidebar_nav_item(item));
208 }
209 html.push_str("</nav>");
210 }
211 if !props.groups.is_empty() {
212 html.push_str("<div class=\"flex-1 overflow-y-auto p-4 space-y-4\">");
213 for group in &props.groups {
214 html.push_str(&layout_sidebar_group(group));
215 }
216 html.push_str("</div>");
217 }
218 if !props.fixed_bottom.is_empty() {
219 html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
220 for item in &props.fixed_bottom {
221 html.push_str(&layout_sidebar_nav_item(item));
222 }
223 html.push_str("</nav>");
224 }
225 html.push_str("</aside>");
226 html.push_str(
228 "<div data-sidebar-backdrop class=\"fixed inset-0 z-30 bg-black/50 hidden md:hidden\"></div>",
229 );
230 html
231}
232
233fn layout_header_html(props: &HeaderProps) -> String {
235 let mut html = String::from(
236 "<header class=\"sticky top-0 z-30 relative flex items-center \
237 px-4 py-3 bg-background border-b border-border md:pl-72\">",
238 );
239 html.push_str(
241 "<button data-sidebar-toggle class=\"md:hidden p-2 rounded-md text-text-muted \
242 hover:text-text hover:bg-surface\" aria-label=\"Toggle sidebar\">\
243 <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
244 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
245 d=\"M4 6h16M4 12h16M4 18h16\"/></svg></button>",
246 );
247 html.push_str(&format!(
250 "<span class=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
251 html_escape(&props.business_name)
252 ));
253 html.push_str("<div class=\"ml-auto flex items-center gap-4\">");
254 html.push_str("<div class=\"relative\">");
256 if let Some(count) = props.notification_count {
257 if count > 0 {
258 html.push_str(&format!(
259 "<button data-notification-toggle class=\"relative p-2 text-text-muted hover:text-text\">\
260 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
261 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
262 d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/></svg>\
263 <span class=\"absolute top-1 right-1 inline-flex items-center justify-center h-4 w-4 \
264 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{count}</span></button>",
265 ));
266 } else {
267 html.push_str(
268 "<button data-notification-toggle class=\"p-2 text-text-muted hover:text-text\">\
269 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
270 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
271 d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/></svg></button>",
272 );
273 }
274 }
275 html.push_str(
276 "<div data-notification-dropdown class=\"hidden absolute right-0 top-full mt-1 w-80 \
277 bg-card rounded-lg shadow-lg border border-border z-50\"></div></div>",
278 );
279 html.push_str("<div class=\"flex items-center gap-2\">");
281 if let Some(ref avatar) = props.user_avatar {
282 html.push_str(&format!(
283 "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
284 html_escape(avatar)
285 ));
286 } else if let Some(ref name) = props.user_name {
287 let initials: String = name
288 .split_whitespace()
289 .filter_map(|w| w.chars().next())
290 .take(2)
291 .collect();
292 html.push_str(&format!(
293 "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full \
294 bg-card text-text-muted text-sm font-medium\">{}</span>",
295 html_escape(&initials)
296 ));
297 html.push_str(&format!(
298 "<span class=\"text-sm text-text\">{}</span>",
299 html_escape(name)
300 ));
301 }
302 if let Some(ref logout) = props.logout_url {
303 html.push_str(&format!(
304 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text\">Logout</a>",
305 html_escape(logout)
306 ));
307 }
308 html.push_str("</div></div></header>");
309 html
310}
311
312fn with_runtime(ctx_scripts: &str) -> String {
314 let runtime = format!(
315 "<script>\n{}\n</script>",
316 crate::runtime::FERRO_RUNTIME_JS.as_str()
317 );
318 if ctx_scripts.is_empty() {
319 runtime
320 } else {
321 format!("{ctx_scripts}\n{runtime}")
322 }
323}
324
325pub struct DefaultLayout;
333
334impl Layout for DefaultLayout {
335 fn render(&self, ctx: &LayoutContext) -> String {
336 let wrapper = ferro_wrapper(ctx);
337 let scripts = with_runtime(ctx.scripts);
338 base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, &scripts)
339 }
340}
341
342pub struct AppLayout;
352
353impl Layout for AppLayout {
354 fn render(&self, ctx: &LayoutContext) -> String {
355 let nav = navigation(&[]);
356 let side = sidebar(&[]);
357 let wrapper = ferro_wrapper(ctx);
358
359 let body = format!(
360 r#"{nav}
361 <div class="flex">
362 {side}
363 <main class="flex-1 px-3 py-4 md:p-6">
364 {wrapper}
365 </main>
366 </div>"#,
367 );
368
369 let scripts = with_runtime(ctx.scripts);
370 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
371 }
372}
373
374pub struct AuthLayout;
382
383impl Layout for AuthLayout {
384 fn render(&self, ctx: &LayoutContext) -> String {
385 let wrapper = ferro_wrapper(ctx);
386
387 let body = format!(
388 r#"<div class="min-h-screen flex items-center justify-center">
389 <div class="w-full max-w-md">
390 {wrapper}
391 </div>
392 </div>"#,
393 );
394
395 let scripts = with_runtime(ctx.scripts);
396 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
397 }
398}
399
400pub struct NavItem {
404 pub label: String,
406 pub url: String,
408 pub active: bool,
410}
411
412impl NavItem {
413 pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
415 Self {
416 label: label.into(),
417 url: url.into(),
418 active: false,
419 }
420 }
421
422 pub fn active(mut self) -> Self {
424 self.active = true;
425 self
426 }
427}
428
429pub struct SidebarSection {
431 pub title: String,
433 pub items: Vec<NavItem>,
435}
436
437impl SidebarSection {
438 pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
440 Self {
441 title: title.into(),
442 items,
443 }
444 }
445}
446
447pub fn navigation(items: &[NavItem]) -> String {
452 let mut html =
453 String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
454
455 for item in items {
456 let class = if item.active {
457 "text-primary font-medium"
458 } else {
459 "text-text-muted hover:text-text"
460 };
461 html.push_str(&format!(
462 "<a href=\"{}\" class=\"{}\">{}</a>",
463 html_escape(&item.url),
464 class,
465 html_escape(&item.label),
466 ));
467 }
468
469 html.push_str("</div></nav>");
470 html
471}
472
473pub fn sidebar(sections: &[SidebarSection]) -> String {
478 let mut html =
479 String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
480
481 for section in sections {
482 html.push_str("<div class=\"mb-6\">");
483 html.push_str(&format!(
484 "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
485 html_escape(§ion.title),
486 ));
487 html.push_str("<ul class=\"space-y-1\">");
488 for item in §ion.items {
489 let class = if item.active {
490 "text-primary font-medium"
491 } else {
492 "text-text-muted hover:text-text"
493 };
494 html.push_str(&format!(
495 "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm {}\">{}</a></li>",
496 html_escape(&item.url),
497 class,
498 html_escape(&item.label),
499 ));
500 }
501 html.push_str("</ul></div>");
502 }
503
504 html.push_str("</aside>");
505 html
506}
507
508pub fn footer(text: &str) -> String {
512 format!(
513 "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
514 html_escape(text),
515 )
516}
517
518pub struct DashboardLayoutConfig {
544 pub sidebar: SidebarProps,
546 pub header: HeaderProps,
548 pub sse_url: Option<String>,
552}
553
554pub struct DashboardLayout {
583 pub config: DashboardLayoutConfig,
585}
586
587impl DashboardLayout {
588 pub fn new(config: DashboardLayoutConfig) -> Self {
590 Self { config }
591 }
592}
593
594impl Layout for DashboardLayout {
595 fn render(&self, ctx: &LayoutContext) -> String {
596 let sidebar_html = layout_sidebar_html(&self.config.sidebar);
597 let header_html = layout_header_html(&self.config.header);
598 let wrapper = ferro_wrapper(ctx);
599
600 let body_data = if let Some(ref url) = self.config.sse_url {
601 format!("data-sse-url=\"{}\"", html_escape(url))
602 } else {
603 String::new()
604 };
605
606 let runtime_script = format!(
607 "<script>\n{}\n</script>",
608 crate::runtime::FERRO_RUNTIME_JS.as_str()
609 );
610 let scripts = if ctx.scripts.is_empty() {
611 runtime_script
612 } else {
613 format!("{}\n{}", ctx.scripts, runtime_script)
614 };
615
616 let body_content = format!(
617 r#"{sidebar_html}
618 <div class="flex flex-col md:pl-64">
619 {header_html}
620 <main class="flex-1 px-3 py-4 md:p-6">
621 {wrapper}
622 </main>
623 <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
624 </div>"#,
625 );
626
627 let body_class = if ctx.body_class.is_empty() {
628 "bg-surface"
629 } else {
630 ctx.body_class
631 };
632
633 base_document_ext(
634 ctx.title,
635 ctx.head,
636 body_class,
637 &body_data,
638 &body_content,
639 &scripts,
640 )
641 }
642}
643
644pub struct LayoutRegistry {
652 layouts: HashMap<String, Box<dyn Layout>>,
653 default: String,
654}
655
656impl LayoutRegistry {
657 pub fn new() -> Self {
659 let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
660 layouts.insert("default".to_string(), Box::new(DefaultLayout));
661 layouts.insert("app".to_string(), Box::new(AppLayout));
662 layouts.insert("auth".to_string(), Box::new(AuthLayout));
663
664 Self {
665 layouts,
666 default: "default".to_string(),
667 }
668 }
669
670 pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
672 self.layouts.insert(name.into(), Box::new(layout));
673 }
674
675 pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
678 let layout_name = name.unwrap_or(&self.default);
679 let layout = self
680 .layouts
681 .get(layout_name)
682 .or_else(|| self.layouts.get(&self.default))
683 .expect("default layout must exist in registry");
684 layout.render(ctx)
685 }
686
687 pub fn has(&self, name: &str) -> bool {
689 self.layouts.contains_key(name)
690 }
691}
692
693impl Default for LayoutRegistry {
694 fn default() -> Self {
695 Self::new()
696 }
697}
698
699static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
702
703pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
707 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
708}
709
710pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
714 global_registry()
715 .write()
716 .expect("layout registry poisoned")
717 .register(name, layout);
718}
719
720pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
724 global_registry()
725 .read()
726 .expect("layout registry poisoned")
727 .render(name, ctx)
728}
729
730#[cfg(test)]
733mod tests {
734 use super::*;
735
736 fn test_ctx() -> LayoutContext<'static> {
737 LayoutContext {
738 title: "Test Page",
739 content: "<p>Hello</p>",
740 head: "<link rel=\"stylesheet\" href=\"/style.css\">",
741 body_class: "bg-background",
742 view_json: "{\"schema\":\"ferro-json-ui/v2\"}",
743 data_json: "{\"key\":\"value\"}",
744 scripts: "",
745 }
746 }
747
748 #[test]
751 fn base_document_produces_valid_html_structure() {
752 let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
753 assert!(html.starts_with("<!DOCTYPE html>"));
754 assert!(html.contains("<html lang=\"en\">"));
755 assert!(html.contains("<meta charset=\"UTF-8\">"));
756 assert!(html.contains("<meta name=\"viewport\""));
757 assert!(html.contains("<title>Title</title>"));
758 assert!(html.contains("<style></style>"));
759 assert!(html.contains("<body class=\"my-class\">"));
760 assert!(html.contains("<p>body</p>"));
761 assert!(html.contains("</html>"));
762 }
763
764 #[test]
765 fn base_document_escapes_title() {
766 let html = base_document("Tom & Jerry <script>", "", "", "", "");
767 assert!(html.contains("<title>Tom & Jerry <script></title>"));
768 }
769
770 #[test]
771 fn base_document_escapes_body_class() {
772 let html = base_document("T", "", "a\"b", "", "");
773 assert!(html.contains("class=\"a"b\""));
774 }
775
776 #[test]
779 fn default_layout_renders_all_context_fields() {
780 let ctx = test_ctx();
781 let html = DefaultLayout.render(&ctx);
782
783 assert!(html.contains("<!DOCTYPE html>"));
784 assert!(html.contains("<title>Test Page</title>"));
785 assert!(html.contains("href=\"/style.css\""));
786 assert!(html.contains("class=\"bg-background\""));
787 assert!(html.contains("id=\"ferro-json-ui\""));
788 assert!(html.contains("data-view=\""));
789 assert!(html.contains("data-props=\""));
790 assert!(html.contains("<p>Hello</p>"));
791 }
792
793 #[test]
794 fn default_layout_contains_ferro_wrapper() {
795 let ctx = test_ctx();
796 let html = DefaultLayout.render(&ctx);
797 assert!(html.contains("<div id=\"ferro-json-ui\""));
798 }
799
800 #[test]
803 fn app_layout_includes_nav_and_sidebar() {
804 let ctx = test_ctx();
805 let html = AppLayout.render(&ctx);
806
807 assert!(html.contains("<nav"));
808 assert!(html.contains("<aside"));
809 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
810 assert!(html.contains("<div id=\"ferro-json-ui\""));
811 assert!(html.contains("<p>Hello</p>"));
812 }
813
814 #[test]
815 fn app_layout_has_flex_structure() {
816 let ctx = test_ctx();
817 let html = AppLayout.render(&ctx);
818 assert!(html.contains("class=\"flex\""));
819 }
820
821 #[test]
824 fn auth_layout_centers_content() {
825 let ctx = test_ctx();
826 let html = AuthLayout.render(&ctx);
827
828 assert!(
830 html.contains("min-h-screen flex items-center justify-center"),
831 "centering wrapper must remain"
832 );
833 assert!(
834 html.contains("w-full max-w-md"),
835 "max-width wrapper must remain"
836 );
837 assert!(html.contains("<div id=\"ferro-json-ui\""));
838 assert!(
840 !html.contains("bg-card rounded-lg shadow-md p-8"),
841 "card chrome must be removed from AuthLayout; spec root must declare its own Card"
842 );
843 }
844
845 #[test]
846 fn auth_layout_has_no_nav_or_sidebar() {
847 let ctx = test_ctx();
848 let html = AuthLayout.render(&ctx);
849 assert!(!html.contains("<nav"));
850 assert!(!html.contains("<aside"));
851 }
852
853 #[test]
856 fn registry_returns_default_for_none_name() {
857 let registry = LayoutRegistry::new();
858 let ctx = test_ctx();
859 let html = registry.render(None, &ctx);
860 assert!(html.contains("<div id=\"ferro-json-ui\""));
862 assert!(!html.contains("<nav"));
863 }
864
865 #[test]
866 fn registry_returns_default_for_unknown_name() {
867 let registry = LayoutRegistry::new();
868 let ctx = test_ctx();
869 let html = registry.render(Some("nonexistent"), &ctx);
870 assert!(html.contains("<div id=\"ferro-json-ui\""));
872 assert!(!html.contains("<nav"));
873 }
874
875 #[test]
876 fn registry_renders_named_layout() {
877 let registry = LayoutRegistry::new();
878 let ctx = test_ctx();
879 let html = registry.render(Some("app"), &ctx);
880 assert!(html.contains("<nav"));
881 assert!(html.contains("<aside"));
882 }
883
884 #[test]
885 fn registry_renders_auth_layout() {
886 let registry = LayoutRegistry::new();
887 let ctx = test_ctx();
888 let html = registry.render(Some("auth"), &ctx);
889 assert!(html.contains("flex items-center justify-center"));
890 }
891
892 #[test]
893 fn registry_has_returns_true_for_registered() {
894 let registry = LayoutRegistry::new();
895 assert!(registry.has("default"));
896 assert!(registry.has("app"));
897 assert!(registry.has("auth"));
898 }
899
900 #[test]
901 fn registry_has_returns_false_for_unknown() {
902 let registry = LayoutRegistry::new();
903 assert!(!registry.has("nonexistent"));
904 }
905
906 #[test]
907 fn registry_register_adds_custom_layout() {
908 let mut registry = LayoutRegistry::new();
909 struct Custom;
910 impl Layout for Custom {
911 fn render(&self, _ctx: &LayoutContext) -> String {
912 "CUSTOM".to_string()
913 }
914 }
915 registry.register("custom", Custom);
916 assert!(registry.has("custom"));
917
918 let ctx = test_ctx();
919 let html = registry.render(Some("custom"), &ctx);
920 assert_eq!(html, "CUSTOM");
921 }
922
923 #[test]
924 fn registry_register_replaces_existing() {
925 let mut registry = LayoutRegistry::new();
926 struct Replacement;
927 impl Layout for Replacement {
928 fn render(&self, _ctx: &LayoutContext) -> String {
929 "REPLACED".to_string()
930 }
931 }
932 registry.register("default", Replacement);
933 let ctx = test_ctx();
934 let html = registry.render(None, &ctx);
935 assert_eq!(html, "REPLACED");
936 }
937
938 #[test]
941 fn global_registry_returns_valid_registry() {
942 let reg = global_registry();
943 let guard = reg.read().unwrap();
944 assert!(guard.has("default"));
945 assert!(guard.has("app"));
946 assert!(guard.has("auth"));
947 }
948
949 #[test]
950 fn render_layout_global_function_works() {
951 let ctx = test_ctx();
952 let html = render_layout(None, &ctx);
953 assert!(html.contains("<!DOCTYPE html>"));
954 assert!(html.contains("<div id=\"ferro-json-ui\""));
955 }
956
957 #[test]
960 fn navigation_renders_empty_gracefully() {
961 let html = navigation(&[]);
962 assert!(html.contains("<nav"));
963 assert!(html.contains("</nav>"));
964 }
965
966 #[test]
967 fn navigation_renders_items_with_correct_classes() {
968 let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
969 let html = navigation(&items);
970 assert!(html.contains("href=\"/\""));
971 assert!(html.contains(">Home</a>"));
972 assert!(html.contains("href=\"/users\""));
973 assert!(html.contains(">Users</a>"));
974 assert!(html.contains("text-text-muted hover:text-text"));
976 }
977
978 #[test]
979 fn navigation_marks_active_item() {
980 let items = vec![
981 NavItem::new("Home", "/").active(),
982 NavItem::new("Users", "/users"),
983 ];
984 let html = navigation(&items);
985 assert!(html.contains("text-primary font-medium"));
986 }
987
988 #[test]
989 fn sidebar_renders_sections_with_headers() {
990 let sections = vec![SidebarSection::new(
991 "Main Menu",
992 vec![
993 NavItem::new("Dashboard", "/"),
994 NavItem::new("Settings", "/settings"),
995 ],
996 )];
997 let html = sidebar(§ions);
998 assert!(html.contains("<aside"));
999 assert!(html.contains("Main Menu"));
1000 assert!(html.contains("Dashboard"));
1001 assert!(html.contains("Settings"));
1002 assert!(html.contains("</aside>"));
1003 }
1004
1005 #[test]
1006 fn sidebar_renders_empty_gracefully() {
1007 let html = sidebar(&[]);
1008 assert!(html.contains("<aside"));
1009 assert!(html.contains("</aside>"));
1010 }
1011
1012 #[test]
1013 fn footer_renders_text() {
1014 let html = footer("Copyright 2026");
1015 assert!(html.contains("<footer"));
1016 assert!(html.contains("Copyright 2026"));
1017 assert!(html.contains("</footer>"));
1018 }
1019
1020 #[test]
1021 fn partials_escape_user_strings() {
1022 let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
1023 let html = navigation(&items);
1024 assert!(html.contains("Tom & Jerry"));
1025 assert!(html.contains("href=\"/a&b\""));
1026
1027 let sections = vec![SidebarSection::new(
1028 "A<B",
1029 vec![NavItem::new("<script>", "/x\"y")],
1030 )];
1031 let html = sidebar(§ions);
1032 assert!(html.contains("A<B"));
1033 assert!(html.contains("<script>"));
1034
1035 let html = footer("<script>alert('xss')</script>");
1036 assert!(html.contains("<script>"));
1037 }
1038
1039 #[test]
1042 fn ferro_wrapper_includes_data_attributes() {
1043 let ctx = test_ctx();
1044 let html = ferro_wrapper(&ctx);
1045 assert!(html.contains("id=\"ferro-json-ui\""));
1046 assert!(html.contains("data-view=\""));
1047 assert!(html.contains("data-props=\""));
1048 assert!(html.contains("<p>Hello</p>"));
1049 }
1050
1051 fn dashboard_layout() -> DashboardLayout {
1054 use crate::component::{HeaderProps, SidebarProps};
1055 DashboardLayout::new(DashboardLayoutConfig {
1056 sidebar: SidebarProps {
1057 fixed_top: vec![],
1058 groups: vec![],
1059 fixed_bottom: vec![],
1060 },
1061 header: HeaderProps {
1062 business_name: "Acme".to_string(),
1063 notification_count: None,
1064 user_name: Some("Alice".to_string()),
1065 user_avatar: None,
1066 logout_url: Some("/logout".to_string()),
1067 },
1068 sse_url: None,
1069 })
1070 }
1071
1072 #[test]
1073 fn dashboard_layout_renders_full_html_structure() {
1074 let ctx = test_ctx();
1075 let html = dashboard_layout().render(&ctx);
1076
1077 assert!(html.starts_with("<!DOCTYPE html>"));
1078 assert!(html.contains("<title>Test Page</title>"));
1079 assert!(html.contains("<div id=\"ferro-json-ui\""));
1080 assert!(html.contains("<p>Hello</p>"));
1081 }
1082
1083 #[test]
1084 fn dashboard_layout_has_persistent_sidebar() {
1085 let ctx = test_ctx();
1086 let html = dashboard_layout().render(&ctx);
1087 assert!(html.contains("<aside data-sidebar"));
1088 }
1089
1090 #[test]
1091 fn dashboard_layout_has_persistent_header() {
1092 let ctx = test_ctx();
1093 let html = dashboard_layout().render(&ctx);
1094 assert!(html.contains("<header"));
1095 assert!(html.contains("Acme"));
1096 }
1097
1098 #[test]
1099 fn dashboard_layout_has_main_content_area() {
1100 let ctx = test_ctx();
1101 let html = dashboard_layout().render(&ctx);
1102 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
1103 }
1104
1105 #[test]
1106 fn dashboard_layout_has_toast_container() {
1107 let ctx = test_ctx();
1108 let html = dashboard_layout().render(&ctx);
1109 assert!(html.contains("data-toast-container"));
1110 }
1111
1112 #[test]
1113 fn dashboard_layout_injects_runtime_js() {
1114 let ctx = test_ctx();
1115 let html = dashboard_layout().render(&ctx);
1116 assert!(html.contains("<script>"));
1118 assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1119 }
1120
1121 #[test]
1122 fn dashboard_layout_has_mobile_hamburger_toggle() {
1123 let ctx = test_ctx();
1124 let html = dashboard_layout().render(&ctx);
1125 assert!(html.contains("data-sidebar-toggle"));
1126 }
1127
1128 #[test]
1129 fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1130 let ctx = test_ctx();
1131 let html = dashboard_layout().render(&ctx);
1132 let body_start = html.find("<body").unwrap_or(0);
1136 let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1137 let body_tag = &html[body_start..=body_tag_end];
1138 assert!(!body_tag.contains("data-sse-url="));
1139 }
1140
1141 #[test]
1142 fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1143 use crate::component::{HeaderProps, SidebarProps};
1144 let layout = DashboardLayout::new(DashboardLayoutConfig {
1145 sidebar: SidebarProps {
1146 fixed_top: vec![],
1147 groups: vec![],
1148 fixed_bottom: vec![],
1149 },
1150 header: HeaderProps {
1151 business_name: "App".to_string(),
1152 notification_count: None,
1153 user_name: None,
1154 user_avatar: None,
1155 logout_url: None,
1156 },
1157 sse_url: Some("/events".to_string()),
1158 });
1159 let ctx = test_ctx();
1160 let html = layout.render(&ctx);
1161 assert!(html.contains("data-sse-url=\"/events\""));
1162 }
1163
1164 #[test]
1165 fn dashboard_layout_escapes_sse_url_xss() {
1166 use crate::component::{HeaderProps, SidebarProps};
1167 let layout = DashboardLayout::new(DashboardLayoutConfig {
1168 sidebar: SidebarProps {
1169 fixed_top: vec![],
1170 groups: vec![],
1171 fixed_bottom: vec![],
1172 },
1173 header: HeaderProps {
1174 business_name: "App".to_string(),
1175 notification_count: None,
1176 user_name: None,
1177 user_avatar: None,
1178 logout_url: None,
1179 },
1180 sse_url: Some("/events?a=1&b=2".to_string()),
1181 });
1182 let ctx = test_ctx();
1183 let html = layout.render(&ctx);
1184 assert!(html.contains("data-sse-url=\"/events?a=1&b=2\""));
1185 }
1186
1187 #[test]
1188 fn dashboard_layout_notification_toggle_present_with_count() {
1189 use crate::component::{HeaderProps, SidebarProps};
1190 let layout = DashboardLayout::new(DashboardLayoutConfig {
1191 sidebar: SidebarProps {
1192 fixed_top: vec![],
1193 groups: vec![],
1194 fixed_bottom: vec![],
1195 },
1196 header: HeaderProps {
1197 business_name: "App".to_string(),
1198 notification_count: Some(5),
1199 user_name: None,
1200 user_avatar: None,
1201 logout_url: None,
1202 },
1203 sse_url: None,
1204 });
1205 let ctx = test_ctx();
1206 let html = layout.render(&ctx);
1207 assert!(html.contains("data-notification-toggle"));
1208 }
1209
1210 #[test]
1211 fn dashboard_layout_has_sidebar_backdrop() {
1212 let ctx = test_ctx();
1213 let html = dashboard_layout().render(&ctx);
1214 assert!(html.contains("data-sidebar-backdrop"));
1215 assert!(html.contains("bg-black/50"));
1216 assert!(html.contains("md:hidden"));
1217 }
1218
1219 #[test]
1220 fn dashboard_layout_sidebar_mobile_classes() {
1221 let ctx = test_ctx();
1222 let html = dashboard_layout().render(&ctx);
1223 assert!(html.contains("hidden md:flex"));
1225 }
1226
1227 #[test]
1228 fn dashboard_layout_uses_default_body_class() {
1229 let ctx = test_ctx();
1230 let html = dashboard_layout().render(&ctx);
1231 assert!(html.contains("class=\"bg-background\""));
1233 }
1234
1235 #[test]
1236 fn sidebar_nav_item_renders_icon_as_raw_svg() {
1237 let item = SidebarNavItem {
1238 label: "Dashboard".to_string(),
1239 href: "/dashboard".to_string(),
1240 icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1241 active: false,
1242 disabled: None,
1243 };
1244 let html = layout_sidebar_nav_item(&item);
1245 assert!(
1246 html.contains("<svg"),
1247 "icon SVG should be rendered raw, not escaped"
1248 );
1249 assert!(
1250 !html.contains("<svg"),
1251 "icon SVG should NOT be html-escaped"
1252 );
1253 assert!(html.contains("Dashboard"), "label should still appear");
1254 }
1255
1256 #[test]
1257 fn sidebar_group_label_uses_normal_casing() {
1258 let group = SidebarGroup {
1259 label: "Cassa".to_string(),
1260 collapsed: false,
1261 items: vec![],
1262 };
1263 let html = layout_sidebar_group(&group);
1264 assert!(html.contains("Cassa"));
1265 assert!(html.contains("font-semibold"));
1266 assert!(html.contains("text-text"));
1267 assert!(
1268 !html.contains("uppercase"),
1269 "sidebar group label should not use uppercase"
1270 );
1271 assert!(
1272 !html.contains("tracking-wider"),
1273 "sidebar group label should not use letter-spacing"
1274 );
1275 }
1276
1277 #[test]
1280 fn layout_sidebar_nav_focus_ring() {
1281 let item = SidebarNavItem {
1282 label: "Dashboard".to_string(),
1283 href: "/dashboard".to_string(),
1284 icon: None,
1285 active: false,
1286 disabled: None,
1287 };
1288 let html = layout_sidebar_nav_item(&item);
1289 assert!(
1290 html.contains("focus-visible:ring-primary"),
1291 "layout sidebar nav <a> item should have focus-visible:ring-primary (INT-07)"
1292 );
1293 assert!(
1294 html.contains("duration-150"),
1295 "layout sidebar nav <a> item should have duration-150 (INT-07)"
1296 );
1297 }
1298}