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 {
148 (
149 "span",
150 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted opacity-50 pointer-events-none select-none".to_string(),
151 )
152 } else if item.active {
153 (
154 "a",
155 format!(
156 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary {INTERACTIVE_BASE}"
157 ),
158 )
159 } else {
160 (
161 "a",
162 format!(
163 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface {INTERACTIVE_BASE}"
164 ),
165 )
166 };
167 let mut html = if disabled {
168 format!("<{tag} aria-disabled=\"true\" class=\"{classes}\">")
169 } else {
170 format!(
171 "<{tag} href=\"{}\" class=\"{classes}\">",
172 html_escape(&item.href),
173 )
174 };
175 if let Some(ref icon) = item.icon {
176 html.push_str(&format!(
177 "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" ));
179 }
180 html.push_str(&format!("{}</{tag}>", html_escape(&item.label)));
181 html
182}
183
184fn layout_sidebar_group(group: &SidebarGroup) -> String {
186 let mut html = String::from("<div data-sidebar-group");
187 if group.collapsed {
188 html.push_str(" data-collapsed");
189 }
190 html.push('>');
191 html.push_str(&format!(
192 "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted\">{}</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(
206 "<aside data-sidebar class=\"fixed inset-y-0 left-0 z-40 w-64 flex flex-col \
207 bg-background border-r border-border hidden md:flex\">",
208 );
209 if !props.fixed_top.is_empty() {
210 html.push_str("<nav class=\"p-4 space-y-1\">");
211 for item in &props.fixed_top {
212 html.push_str(&layout_sidebar_nav_item(item));
213 }
214 html.push_str("</nav>");
215 }
216 if !props.groups.is_empty() {
217 html.push_str("<div class=\"flex-1 overflow-y-auto p-4 space-y-4\">");
218 for group in &props.groups {
219 html.push_str(&layout_sidebar_group(group));
220 }
221 html.push_str("</div>");
222 }
223 if !props.fixed_bottom.is_empty() {
224 html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
225 for item in &props.fixed_bottom {
226 html.push_str(&layout_sidebar_nav_item(item));
227 }
228 html.push_str("</nav>");
229 }
230 html.push_str("</aside>");
231 html.push_str(
233 "<div data-sidebar-backdrop class=\"fixed inset-0 z-30 bg-black/50 hidden md:hidden\"></div>",
234 );
235 html
236}
237
238fn layout_header_html(props: &HeaderProps) -> String {
240 let mut html = String::from(
241 "<header class=\"sticky top-0 z-30 relative flex items-center \
242 px-4 py-3 bg-background border-b border-border md:pl-72\">",
243 );
244 html.push_str(&format!(
246 "<button data-sidebar-toggle class=\"md:hidden p-2 rounded-md text-text-muted \
247 hover:text-text hover:bg-surface {INTERACTIVE_BASE}\" aria-label=\"Toggle sidebar\">\
248 <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
249 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
250 d=\"M4 6h16M4 12h16M4 18h16\"/></svg></button>"
251 ));
252 html.push_str(&format!(
255 "<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>",
256 html_escape(&props.business_name)
257 ));
258 html.push_str("<div class=\"ml-auto flex items-center gap-4\">");
259 html.push_str("<div class=\"relative\">");
261 if let Some(count) = props.notification_count {
262 if count > 0 {
263 html.push_str(&format!(
264 "<button data-notification-toggle class=\"relative p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\">\
265 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
266 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
267 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>\
268 <span class=\"absolute top-1 right-1 inline-flex items-center justify-center h-4 w-4 \
269 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{count}</span></button>",
270 ));
271 } else {
272 html.push_str(&format!(
273 "<button data-notification-toggle class=\"p-2 rounded-md text-text-muted hover:text-text {INTERACTIVE_BASE}\">\
274 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
275 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
276 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>"
277 ));
278 }
279 }
280 html.push_str(
281 "<div data-notification-dropdown class=\"hidden absolute right-0 top-full mt-1 w-80 \
282 bg-card rounded-lg shadow-lg border border-border z-50\"></div></div>",
283 );
284 html.push_str("<div class=\"flex items-center gap-2\">");
286 if let Some(ref avatar) = props.user_avatar {
287 html.push_str(&format!(
288 "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
289 html_escape(avatar)
290 ));
291 } else if let Some(ref name) = props.user_name {
292 let initials: String = name
293 .split_whitespace()
294 .filter_map(|w| w.chars().next())
295 .take(2)
296 .collect();
297 html.push_str(&format!(
298 "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full \
299 bg-card text-text-muted text-sm font-medium\">{}</span>",
300 html_escape(&initials)
301 ));
302 html.push_str(&format!(
303 "<span class=\"text-sm text-text\">{}</span>",
304 html_escape(name)
305 ));
306 }
307 if let Some(ref logout) = props.logout_url {
308 html.push_str(&format!(
309 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text {INTERACTIVE_BASE}\">Logout</a>",
310 html_escape(logout)
311 ));
312 }
313 html.push_str("</div></div></header>");
314 html
315}
316
317fn with_runtime(ctx_scripts: &str) -> String {
319 let runtime = format!(
320 "<script>\n{}\n</script>",
321 crate::runtime::FERRO_RUNTIME_JS.as_str()
322 );
323 if ctx_scripts.is_empty() {
324 runtime
325 } else {
326 format!("{ctx_scripts}\n{runtime}")
327 }
328}
329
330pub struct DefaultLayout;
338
339impl Layout for DefaultLayout {
340 fn render(&self, ctx: &LayoutContext) -> String {
341 let wrapper = ferro_wrapper(ctx);
342 let scripts = with_runtime(ctx.scripts);
343 base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, &scripts)
344 }
345}
346
347pub struct AppLayout;
357
358impl Layout for AppLayout {
359 fn render(&self, ctx: &LayoutContext) -> String {
360 let nav = navigation(&[]);
361 let side = sidebar(&[]);
362 let wrapper = ferro_wrapper(ctx);
363
364 let body = format!(
365 r#"{nav}
366 <div class="flex">
367 {side}
368 <main class="flex-1 px-3 py-4 md:p-6">
369 <div class="mx-auto w-full max-w-7xl">
370 {wrapper}
371 </div>
372 </main>
373 </div>"#,
374 );
375
376 let scripts = with_runtime(ctx.scripts);
377 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
378 }
379}
380
381pub struct AuthLayout;
389
390impl Layout for AuthLayout {
391 fn render(&self, ctx: &LayoutContext) -> String {
392 let wrapper = ferro_wrapper(ctx);
393
394 let body = format!(
395 r#"<div class="min-h-screen flex items-center justify-center">
396 <div class="w-full max-w-md">
397 {wrapper}
398 </div>
399 </div>"#,
400 );
401
402 let scripts = with_runtime(ctx.scripts);
403 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
404 }
405}
406
407pub struct NavItem {
411 pub label: String,
413 pub url: String,
415 pub active: bool,
417}
418
419impl NavItem {
420 pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
422 Self {
423 label: label.into(),
424 url: url.into(),
425 active: false,
426 }
427 }
428
429 pub fn active(mut self) -> Self {
431 self.active = true;
432 self
433 }
434}
435
436pub struct SidebarSection {
438 pub title: String,
440 pub items: Vec<NavItem>,
442}
443
444impl SidebarSection {
445 pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
447 Self {
448 title: title.into(),
449 items,
450 }
451 }
452}
453
454pub fn navigation(items: &[NavItem]) -> String {
459 let mut html =
460 String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
461
462 for item in items {
463 let class = if item.active {
464 "text-primary font-medium"
465 } else {
466 "text-text-muted hover:text-text"
467 };
468 html.push_str(&format!(
469 "<a href=\"{}\" class=\"{} {INTERACTIVE_BASE}\">{}</a>",
470 html_escape(&item.url),
471 class,
472 html_escape(&item.label),
473 ));
474 }
475
476 html.push_str("</div></nav>");
477 html
478}
479
480pub fn sidebar(sections: &[SidebarSection]) -> String {
485 let mut html =
486 String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
487
488 for section in sections {
489 html.push_str("<div class=\"mb-6\">");
490 html.push_str(&format!(
491 "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
492 html_escape(§ion.title),
493 ));
494 html.push_str("<ul class=\"space-y-1\">");
495 for item in §ion.items {
496 let class = if item.active {
497 "text-primary font-medium"
498 } else {
499 "text-text-muted hover:text-text"
500 };
501 html.push_str(&format!(
502 "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm rounded-md {} {INTERACTIVE_BASE}\">{}</a></li>",
503 html_escape(&item.url),
504 class,
505 html_escape(&item.label),
506 ));
507 }
508 html.push_str("</ul></div>");
509 }
510
511 html.push_str("</aside>");
512 html
513}
514
515pub fn footer(text: &str) -> String {
519 format!(
520 "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
521 html_escape(text),
522 )
523}
524
525pub struct DashboardLayoutConfig {
551 pub sidebar: SidebarProps,
553 pub header: HeaderProps,
555 pub sse_url: Option<String>,
559}
560
561pub struct DashboardLayout {
590 pub config: DashboardLayoutConfig,
592}
593
594impl DashboardLayout {
595 pub fn new(config: DashboardLayoutConfig) -> Self {
597 Self { config }
598 }
599}
600
601impl Layout for DashboardLayout {
602 fn render(&self, ctx: &LayoutContext) -> String {
603 let sidebar_html = layout_sidebar_html(&self.config.sidebar);
604 let header_html = layout_header_html(&self.config.header);
605 let wrapper = ferro_wrapper(ctx);
606
607 let body_data = if let Some(ref url) = self.config.sse_url {
608 format!("data-sse-url=\"{}\"", html_escape(url))
609 } else {
610 String::new()
611 };
612
613 let runtime_script = format!(
614 "<script>\n{}\n</script>",
615 crate::runtime::FERRO_RUNTIME_JS.as_str()
616 );
617 let scripts = if ctx.scripts.is_empty() {
618 runtime_script
619 } else {
620 format!("{}\n{}", ctx.scripts, runtime_script)
621 };
622
623 let body_content = format!(
624 r#"{sidebar_html}
625 <div class="flex flex-col md:pl-64">
626 {header_html}
627 <main class="flex-1 px-3 py-4 md:p-6">
628 <div class="mx-auto w-full max-w-7xl">
629 {wrapper}
630 </div>
631 </main>
632 <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
633 </div>"#,
634 );
635
636 let body_class = if ctx.body_class.is_empty() {
637 "bg-surface"
638 } else {
639 ctx.body_class
640 };
641
642 base_document_ext(
643 ctx.title,
644 ctx.head,
645 body_class,
646 &body_data,
647 &body_content,
648 &scripts,
649 )
650 }
651}
652
653pub struct LayoutRegistry {
661 layouts: HashMap<String, Box<dyn Layout>>,
662 default: String,
663}
664
665impl LayoutRegistry {
666 pub fn new() -> Self {
668 let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
669 layouts.insert("default".to_string(), Box::new(DefaultLayout));
670 layouts.insert("app".to_string(), Box::new(AppLayout));
671 layouts.insert("auth".to_string(), Box::new(AuthLayout));
672
673 Self {
674 layouts,
675 default: "default".to_string(),
676 }
677 }
678
679 pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
681 self.layouts.insert(name.into(), Box::new(layout));
682 }
683
684 pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
687 let layout_name = name.unwrap_or(&self.default);
688 let layout = self
689 .layouts
690 .get(layout_name)
691 .or_else(|| self.layouts.get(&self.default))
692 .expect("default layout must exist in registry");
693 layout.render(ctx)
694 }
695
696 pub fn has(&self, name: &str) -> bool {
698 self.layouts.contains_key(name)
699 }
700}
701
702impl Default for LayoutRegistry {
703 fn default() -> Self {
704 Self::new()
705 }
706}
707
708static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
711
712pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
716 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
717}
718
719pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
723 global_registry()
724 .write()
725 .expect("layout registry poisoned")
726 .register(name, layout);
727}
728
729pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
733 global_registry()
734 .read()
735 .expect("layout registry poisoned")
736 .render(name, ctx)
737}
738
739#[cfg(test)]
742mod tests {
743 use super::*;
744
745 fn test_ctx() -> LayoutContext<'static> {
746 LayoutContext {
747 title: "Test Page",
748 content: "<p>Hello</p>",
749 head: "<link rel=\"stylesheet\" href=\"/style.css\">",
750 body_class: "bg-background",
751 view_json: "{\"schema\":\"ferro-json-ui/v2\"}",
752 data_json: "{\"key\":\"value\"}",
753 scripts: "",
754 }
755 }
756
757 #[test]
760 fn base_document_produces_valid_html_structure() {
761 let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
762 assert!(html.starts_with("<!DOCTYPE html>"));
763 assert!(html.contains("<html lang=\"en\">"));
764 assert!(html.contains("<meta charset=\"UTF-8\">"));
765 assert!(html.contains("<meta name=\"viewport\""));
766 assert!(html.contains("<title>Title</title>"));
767 assert!(html.contains("<style></style>"));
768 assert!(html.contains("<body class=\"my-class\">"));
769 assert!(html.contains("<p>body</p>"));
770 assert!(html.contains("</html>"));
771 }
772
773 #[test]
774 fn base_document_escapes_title() {
775 let html = base_document("Tom & Jerry <script>", "", "", "", "");
776 assert!(html.contains("<title>Tom & Jerry <script></title>"));
777 }
778
779 #[test]
780 fn base_document_escapes_body_class() {
781 let html = base_document("T", "", "a\"b", "", "");
782 assert!(html.contains("class=\"a"b\""));
783 }
784
785 #[test]
788 fn default_layout_renders_all_context_fields() {
789 let ctx = test_ctx();
790 let html = DefaultLayout.render(&ctx);
791
792 assert!(html.contains("<!DOCTYPE html>"));
793 assert!(html.contains("<title>Test Page</title>"));
794 assert!(html.contains("href=\"/style.css\""));
795 assert!(html.contains("class=\"bg-background\""));
796 assert!(html.contains("id=\"ferro-json-ui\""));
797 assert!(html.contains("data-view=\""));
798 assert!(html.contains("data-props=\""));
799 assert!(html.contains("<p>Hello</p>"));
800 }
801
802 #[test]
803 fn default_layout_contains_ferro_wrapper() {
804 let ctx = test_ctx();
805 let html = DefaultLayout.render(&ctx);
806 assert!(html.contains("<div id=\"ferro-json-ui\""));
807 }
808
809 #[test]
812 fn app_layout_includes_nav_and_sidebar() {
813 let ctx = test_ctx();
814 let html = AppLayout.render(&ctx);
815
816 assert!(html.contains("<nav"));
817 assert!(html.contains("<aside"));
818 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
819 assert!(html.contains("<div id=\"ferro-json-ui\""));
820 assert!(html.contains("<p>Hello</p>"));
821 }
822
823 #[test]
824 fn app_layout_has_flex_structure() {
825 let ctx = test_ctx();
826 let html = AppLayout.render(&ctx);
827 assert!(html.contains("class=\"flex\""));
828 }
829
830 #[test]
833 fn auth_layout_centers_content() {
834 let ctx = test_ctx();
835 let html = AuthLayout.render(&ctx);
836
837 assert!(
839 html.contains("min-h-screen flex items-center justify-center"),
840 "centering wrapper must remain"
841 );
842 assert!(
843 html.contains("w-full max-w-md"),
844 "max-width wrapper must remain"
845 );
846 assert!(html.contains("<div id=\"ferro-json-ui\""));
847 assert!(
849 !html.contains("bg-card rounded-lg shadow-md p-8"),
850 "card chrome must be removed from AuthLayout; spec root must declare its own Card"
851 );
852 }
853
854 #[test]
855 fn auth_layout_has_no_nav_or_sidebar() {
856 let ctx = test_ctx();
857 let html = AuthLayout.render(&ctx);
858 assert!(!html.contains("<nav"));
859 assert!(!html.contains("<aside"));
860 }
861
862 #[test]
865 fn registry_returns_default_for_none_name() {
866 let registry = LayoutRegistry::new();
867 let ctx = test_ctx();
868 let html = registry.render(None, &ctx);
869 assert!(html.contains("<div id=\"ferro-json-ui\""));
871 assert!(!html.contains("<nav"));
872 }
873
874 #[test]
875 fn registry_returns_default_for_unknown_name() {
876 let registry = LayoutRegistry::new();
877 let ctx = test_ctx();
878 let html = registry.render(Some("nonexistent"), &ctx);
879 assert!(html.contains("<div id=\"ferro-json-ui\""));
881 assert!(!html.contains("<nav"));
882 }
883
884 #[test]
885 fn registry_renders_named_layout() {
886 let registry = LayoutRegistry::new();
887 let ctx = test_ctx();
888 let html = registry.render(Some("app"), &ctx);
889 assert!(html.contains("<nav"));
890 assert!(html.contains("<aside"));
891 }
892
893 #[test]
894 fn registry_renders_auth_layout() {
895 let registry = LayoutRegistry::new();
896 let ctx = test_ctx();
897 let html = registry.render(Some("auth"), &ctx);
898 assert!(html.contains("flex items-center justify-center"));
899 }
900
901 #[test]
902 fn registry_has_returns_true_for_registered() {
903 let registry = LayoutRegistry::new();
904 assert!(registry.has("default"));
905 assert!(registry.has("app"));
906 assert!(registry.has("auth"));
907 }
908
909 #[test]
910 fn registry_has_returns_false_for_unknown() {
911 let registry = LayoutRegistry::new();
912 assert!(!registry.has("nonexistent"));
913 }
914
915 #[test]
916 fn registry_register_adds_custom_layout() {
917 let mut registry = LayoutRegistry::new();
918 struct Custom;
919 impl Layout for Custom {
920 fn render(&self, _ctx: &LayoutContext) -> String {
921 "CUSTOM".to_string()
922 }
923 }
924 registry.register("custom", Custom);
925 assert!(registry.has("custom"));
926
927 let ctx = test_ctx();
928 let html = registry.render(Some("custom"), &ctx);
929 assert_eq!(html, "CUSTOM");
930 }
931
932 #[test]
933 fn registry_register_replaces_existing() {
934 let mut registry = LayoutRegistry::new();
935 struct Replacement;
936 impl Layout for Replacement {
937 fn render(&self, _ctx: &LayoutContext) -> String {
938 "REPLACED".to_string()
939 }
940 }
941 registry.register("default", Replacement);
942 let ctx = test_ctx();
943 let html = registry.render(None, &ctx);
944 assert_eq!(html, "REPLACED");
945 }
946
947 #[test]
950 fn global_registry_returns_valid_registry() {
951 let reg = global_registry();
952 let guard = reg.read().unwrap();
953 assert!(guard.has("default"));
954 assert!(guard.has("app"));
955 assert!(guard.has("auth"));
956 }
957
958 #[test]
959 fn render_layout_global_function_works() {
960 let ctx = test_ctx();
961 let html = render_layout(None, &ctx);
962 assert!(html.contains("<!DOCTYPE html>"));
963 assert!(html.contains("<div id=\"ferro-json-ui\""));
964 }
965
966 #[test]
969 fn navigation_renders_empty_gracefully() {
970 let html = navigation(&[]);
971 assert!(html.contains("<nav"));
972 assert!(html.contains("</nav>"));
973 }
974
975 #[test]
976 fn navigation_renders_items_with_correct_classes() {
977 let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
978 let html = navigation(&items);
979 assert!(html.contains("href=\"/\""));
980 assert!(html.contains(">Home</a>"));
981 assert!(html.contains("href=\"/users\""));
982 assert!(html.contains(">Users</a>"));
983 assert!(html.contains("text-text-muted hover:text-text"));
985 }
986
987 #[test]
988 fn navigation_marks_active_item() {
989 let items = vec![
990 NavItem::new("Home", "/").active(),
991 NavItem::new("Users", "/users"),
992 ];
993 let html = navigation(&items);
994 assert!(html.contains("text-primary font-medium"));
995 }
996
997 #[test]
998 fn sidebar_renders_sections_with_headers() {
999 let sections = vec![SidebarSection::new(
1000 "Main Menu",
1001 vec![
1002 NavItem::new("Dashboard", "/"),
1003 NavItem::new("Settings", "/settings"),
1004 ],
1005 )];
1006 let html = sidebar(§ions);
1007 assert!(html.contains("<aside"));
1008 assert!(html.contains("Main Menu"));
1009 assert!(html.contains("Dashboard"));
1010 assert!(html.contains("Settings"));
1011 assert!(html.contains("</aside>"));
1012 }
1013
1014 #[test]
1015 fn sidebar_renders_empty_gracefully() {
1016 let html = sidebar(&[]);
1017 assert!(html.contains("<aside"));
1018 assert!(html.contains("</aside>"));
1019 }
1020
1021 #[test]
1022 fn footer_renders_text() {
1023 let html = footer("Copyright 2026");
1024 assert!(html.contains("<footer"));
1025 assert!(html.contains("Copyright 2026"));
1026 assert!(html.contains("</footer>"));
1027 }
1028
1029 #[test]
1030 fn partials_escape_user_strings() {
1031 let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
1032 let html = navigation(&items);
1033 assert!(html.contains("Tom & Jerry"));
1034 assert!(html.contains("href=\"/a&b\""));
1035
1036 let sections = vec![SidebarSection::new(
1037 "A<B",
1038 vec![NavItem::new("<script>", "/x\"y")],
1039 )];
1040 let html = sidebar(§ions);
1041 assert!(html.contains("A<B"));
1042 assert!(html.contains("<script>"));
1043
1044 let html = footer("<script>alert('xss')</script>");
1045 assert!(html.contains("<script>"));
1046 }
1047
1048 #[test]
1051 fn ferro_wrapper_includes_data_attributes() {
1052 let ctx = test_ctx();
1053 let html = ferro_wrapper(&ctx);
1054 assert!(html.contains("id=\"ferro-json-ui\""));
1055 assert!(html.contains("data-view=\""));
1056 assert!(html.contains("data-props=\""));
1057 assert!(html.contains("<p>Hello</p>"));
1058 }
1059
1060 fn dashboard_layout() -> DashboardLayout {
1063 use crate::component::{HeaderProps, SidebarProps};
1064 DashboardLayout::new(DashboardLayoutConfig {
1065 sidebar: SidebarProps {
1066 fixed_top: vec![],
1067 groups: vec![],
1068 fixed_bottom: vec![],
1069 },
1070 header: HeaderProps {
1071 business_name: "Acme".to_string(),
1072 notification_count: None,
1073 user_name: Some("Alice".to_string()),
1074 user_avatar: None,
1075 logout_url: Some("/logout".to_string()),
1076 },
1077 sse_url: None,
1078 })
1079 }
1080
1081 #[test]
1082 fn dashboard_layout_renders_full_html_structure() {
1083 let ctx = test_ctx();
1084 let html = dashboard_layout().render(&ctx);
1085
1086 assert!(html.starts_with("<!DOCTYPE html>"));
1087 assert!(html.contains("<title>Test Page</title>"));
1088 assert!(html.contains("<div id=\"ferro-json-ui\""));
1089 assert!(html.contains("<p>Hello</p>"));
1090 }
1091
1092 #[test]
1093 fn dashboard_layout_has_persistent_sidebar() {
1094 let ctx = test_ctx();
1095 let html = dashboard_layout().render(&ctx);
1096 assert!(html.contains("<aside data-sidebar"));
1097 }
1098
1099 #[test]
1100 fn dashboard_layout_has_persistent_header() {
1101 let ctx = test_ctx();
1102 let html = dashboard_layout().render(&ctx);
1103 assert!(html.contains("<header"));
1104 assert!(html.contains("Acme"));
1105 }
1106
1107 #[test]
1108 fn dashboard_layout_has_main_content_area() {
1109 let ctx = test_ctx();
1110 let html = dashboard_layout().render(&ctx);
1111 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
1112 }
1113
1114 #[test]
1115 fn dashboard_layout_has_toast_container() {
1116 let ctx = test_ctx();
1117 let html = dashboard_layout().render(&ctx);
1118 assert!(html.contains("data-toast-container"));
1119 }
1120
1121 #[test]
1122 fn dashboard_layout_injects_runtime_js() {
1123 let ctx = test_ctx();
1124 let html = dashboard_layout().render(&ctx);
1125 assert!(html.contains("<script>"));
1127 assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1128 }
1129
1130 #[test]
1131 fn dashboard_layout_has_mobile_hamburger_toggle() {
1132 let ctx = test_ctx();
1133 let html = dashboard_layout().render(&ctx);
1134 assert!(html.contains("data-sidebar-toggle"));
1135 }
1136
1137 #[test]
1138 fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1139 let ctx = test_ctx();
1140 let html = dashboard_layout().render(&ctx);
1141 let body_start = html.find("<body").unwrap_or(0);
1145 let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1146 let body_tag = &html[body_start..=body_tag_end];
1147 assert!(!body_tag.contains("data-sse-url="));
1148 }
1149
1150 #[test]
1151 fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1152 use crate::component::{HeaderProps, SidebarProps};
1153 let layout = DashboardLayout::new(DashboardLayoutConfig {
1154 sidebar: SidebarProps {
1155 fixed_top: vec![],
1156 groups: vec![],
1157 fixed_bottom: vec![],
1158 },
1159 header: HeaderProps {
1160 business_name: "App".to_string(),
1161 notification_count: None,
1162 user_name: None,
1163 user_avatar: None,
1164 logout_url: None,
1165 },
1166 sse_url: Some("/events".to_string()),
1167 });
1168 let ctx = test_ctx();
1169 let html = layout.render(&ctx);
1170 assert!(html.contains("data-sse-url=\"/events\""));
1171 }
1172
1173 #[test]
1174 fn dashboard_layout_escapes_sse_url_xss() {
1175 use crate::component::{HeaderProps, SidebarProps};
1176 let layout = DashboardLayout::new(DashboardLayoutConfig {
1177 sidebar: SidebarProps {
1178 fixed_top: vec![],
1179 groups: vec![],
1180 fixed_bottom: vec![],
1181 },
1182 header: HeaderProps {
1183 business_name: "App".to_string(),
1184 notification_count: None,
1185 user_name: None,
1186 user_avatar: None,
1187 logout_url: None,
1188 },
1189 sse_url: Some("/events?a=1&b=2".to_string()),
1190 });
1191 let ctx = test_ctx();
1192 let html = layout.render(&ctx);
1193 assert!(html.contains("data-sse-url=\"/events?a=1&b=2\""));
1194 }
1195
1196 #[test]
1197 fn dashboard_layout_notification_toggle_present_with_count() {
1198 use crate::component::{HeaderProps, SidebarProps};
1199 let layout = DashboardLayout::new(DashboardLayoutConfig {
1200 sidebar: SidebarProps {
1201 fixed_top: vec![],
1202 groups: vec![],
1203 fixed_bottom: vec![],
1204 },
1205 header: HeaderProps {
1206 business_name: "App".to_string(),
1207 notification_count: Some(5),
1208 user_name: None,
1209 user_avatar: None,
1210 logout_url: None,
1211 },
1212 sse_url: None,
1213 });
1214 let ctx = test_ctx();
1215 let html = layout.render(&ctx);
1216 assert!(html.contains("data-notification-toggle"));
1217 }
1218
1219 #[test]
1220 fn dashboard_layout_has_sidebar_backdrop() {
1221 let ctx = test_ctx();
1222 let html = dashboard_layout().render(&ctx);
1223 assert!(html.contains("data-sidebar-backdrop"));
1224 assert!(html.contains("bg-black/50"));
1225 assert!(html.contains("md:hidden"));
1226 }
1227
1228 #[test]
1229 fn dashboard_layout_sidebar_mobile_classes() {
1230 let ctx = test_ctx();
1231 let html = dashboard_layout().render(&ctx);
1232 assert!(html.contains("hidden md:flex"));
1234 }
1235
1236 #[test]
1237 fn dashboard_layout_uses_default_body_class() {
1238 let ctx = test_ctx();
1239 let html = dashboard_layout().render(&ctx);
1240 assert!(html.contains("class=\"bg-background\""));
1242 }
1243
1244 #[test]
1245 fn sidebar_nav_item_renders_icon_as_raw_svg() {
1246 let item = SidebarNavItem {
1247 label: "Dashboard".to_string(),
1248 href: "/dashboard".to_string(),
1249 icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1250 active: false,
1251 disabled: None,
1252 };
1253 let html = layout_sidebar_nav_item(&item);
1254 assert!(
1255 html.contains("<svg"),
1256 "icon SVG should be rendered raw, not escaped"
1257 );
1258 assert!(
1259 !html.contains("<svg"),
1260 "icon SVG should NOT be html-escaped"
1261 );
1262 assert!(html.contains("Dashboard"), "label should still appear");
1263 }
1264
1265 #[test]
1266 fn sidebar_group_label_uses_normal_casing() {
1267 let group = SidebarGroup {
1268 label: "Cassa".to_string(),
1269 collapsed: false,
1270 items: vec![],
1271 };
1272 let html = layout_sidebar_group(&group);
1273 assert!(html.contains("Cassa"));
1274 assert!(html.contains("font-semibold"));
1275 assert!(html.contains("text-text"));
1276 assert!(
1277 !html.contains("uppercase"),
1278 "sidebar group label should not use uppercase"
1279 );
1280 assert!(
1281 !html.contains("tracking-wider"),
1282 "sidebar group label should not use letter-spacing"
1283 );
1284 }
1285
1286 #[test]
1289 fn layout_sidebar_nav_focus_ring() {
1290 let item = SidebarNavItem {
1291 label: "Dashboard".to_string(),
1292 href: "/dashboard".to_string(),
1293 icon: None,
1294 active: false,
1295 disabled: None,
1296 };
1297 let html = layout_sidebar_nav_item(&item);
1298 assert!(
1299 html.contains("focus-visible:ring-ring"),
1300 "layout sidebar nav <a> item should have focus-visible:ring-ring (INT-07)"
1301 );
1302 assert!(
1303 html.contains("duration-fast"),
1304 "layout sidebar nav <a> item should have duration-fast (INT-07)"
1305 );
1306 }
1307}