Skip to main content

ferro_json_ui/runtime/
mod.rs

1//! Built-in JavaScript runtime for ferro-json-ui (split per concern).
2//!
3//! Each submodule contributes one `setup*` function. The assembled bundle
4//! wraps them in a single IIFE with a `ferroRuntime()` dispatcher invoked
5//! on DOMContentLoaded. The emitted output is a single string — no extra
6//! HTTP requests.
7
8mod dismissibles;
9mod dropdowns;
10mod form_guards;
11mod hero_lazy;
12mod kanban;
13mod modals;
14mod notifications;
15mod product_tiles;
16mod scroll_preserve;
17mod sidebar;
18mod sse;
19mod tabs;
20mod toasts;
21
22use std::sync::LazyLock;
23
24/// Assembled JS runtime bundle. Lazily concatenated from per-concern
25/// submodules on first access; the resulting string is stable for the
26/// process lifetime.
27pub static FERRO_RUNTIME_JS: LazyLock<String> = LazyLock::new(|| {
28    let mut s = String::with_capacity(8 * 1024);
29    s.push_str("(function() {\n    'use strict';\n");
30    s.push_str(sse::SOURCE);
31    s.push_str(tabs::SOURCE);
32    s.push_str(toasts::SOURCE);
33    s.push_str(dismissibles::SOURCE);
34    s.push_str(notifications::SOURCE);
35    s.push_str(dropdowns::SOURCE);
36    s.push_str(modals::SOURCE);
37    s.push_str(sidebar::SOURCE);
38    s.push_str(form_guards::SOURCE);
39    s.push_str(product_tiles::SOURCE);
40    s.push_str(kanban::SOURCE);
41    s.push_str(scroll_preserve::SOURCE);
42    s.push_str(hero_lazy::SOURCE);
43    s.push_str(
44        "\n    function ferroRuntime() {\n\
45         \x20       setupScrollPreserve();\n\
46         \x20       setupSSE();\n\
47         \x20       setupTabs();\n\
48         \x20       setupDismissibles();\n\
49         \x20       setupNotifications();\n\
50         \x20       setupDropdowns();\n\
51         \x20       setupKanban();\n\
52         \x20       setupSidebar();\n\
53         \x20       setupFormGuards();\n\
54         \x20       setupProductTiles();\n\
55         \x20       setupModals();\n\
56         \x20       setupToasts();\n\
57         \x20       setupLazyHeroes();\n\
58         \x20   }\n\
59         \x20   document.addEventListener('DOMContentLoaded', ferroRuntime);\n\
60         })();\n",
61    );
62    s
63});
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn variant_classes_use_semantic_tokens() {
71        assert!(FERRO_RUNTIME_JS.contains("bg-primary"));
72        assert!(FERRO_RUNTIME_JS.contains("bg-success"));
73        assert!(FERRO_RUNTIME_JS.contains("bg-warning"));
74        assert!(FERRO_RUNTIME_JS.contains("bg-destructive"));
75        assert!(!FERRO_RUNTIME_JS.contains("bg-blue-500"));
76        assert!(!FERRO_RUNTIME_JS.contains("bg-green-500"));
77        assert!(!FERRO_RUNTIME_JS.contains("bg-yellow-500"));
78        assert!(!FERRO_RUNTIME_JS.contains("bg-red-500"));
79        // Retired toast vocabulary must not resurface: the SSR emitter and
80        // this runtime were renamed to `data-toast-tone` in lockstep.
81        assert!(!FERRO_RUNTIME_JS.contains("data-toast-variant"));
82        // Retired motion vocabulary: the toast fade uses the duration-base
83        // token and dismissal is transitionend-driven (500ms fallback bound).
84        assert!(!FERRO_RUNTIME_JS.contains("duration-300"));
85        assert!(!FERRO_RUNTIME_JS.contains("duration-150"));
86        assert!(FERRO_RUNTIME_JS.contains("duration-base"));
87        assert!(FERRO_RUNTIME_JS.contains("transitionend"));
88    }
89
90    #[test]
91    fn tab_switcher_uses_semantic_tokens() {
92        assert!(FERRO_RUNTIME_JS.contains("border-primary"));
93        assert!(FERRO_RUNTIME_JS.contains("text-primary"));
94        assert!(FERRO_RUNTIME_JS.contains("text-text-muted"));
95        assert!(!FERRO_RUNTIME_JS.contains("border-blue-600"));
96        assert!(!FERRO_RUNTIME_JS.contains("text-blue-600"));
97        assert!(!FERRO_RUNTIME_JS.contains("text-gray-500"));
98    }
99
100    #[test]
101    fn toast_uses_semantic_text_color() {
102        assert!(FERRO_RUNTIME_JS.contains("text-primary-foreground"));
103        assert!(!FERRO_RUNTIME_JS.contains("text-white"));
104    }
105
106    /// JS↔SSR lockstep: the runtime's VARIANT_CLASSES must carry the exact
107    /// tone-class strings the SSR `render_toast` composes from
108    /// `render::classes::TOAST_TONE_*`, plus the shared backdrop blur, so
109    /// server-rendered and JS-created toasts look identical.
110    #[test]
111    fn toast_tone_classes_match_ssr() {
112        use crate::render::classes::{
113            TOAST_TONE_DESTRUCTIVE, TOAST_TONE_NEUTRAL, TOAST_TONE_SUCCESS, TOAST_TONE_WARNING,
114        };
115        for tone_classes in [
116            TOAST_TONE_NEUTRAL,
117            TOAST_TONE_SUCCESS,
118            TOAST_TONE_WARNING,
119            TOAST_TONE_DESTRUCTIVE,
120        ] {
121            assert!(
122                FERRO_RUNTIME_JS.contains(tone_classes),
123                "runtime VARIANT_CLASSES drifted from SSR toast tone classes: missing `{tone_classes}`"
124            );
125        }
126        assert!(
127            FERRO_RUNTIME_JS.contains("backdrop-blur-md"),
128            "runtime toast shell drifted from SSR: missing `backdrop-blur-md`"
129        );
130    }
131
132    /// Popover-based dropdown wiring: the panel uses the HTML `popover`
133    /// attribute so the browser lifts it into the top layer (escaping any
134    /// `overflow:hidden` ancestor and the surrounding z-index stack). We
135    /// supply only the anchor positioning and the close-on-scroll behavior.
136    #[test]
137    fn test_runtime_contains_popover_dropdown_wiring() {
138        assert!(FERRO_RUNTIME_JS.contains("data-popover-menu"));
139        assert!(FERRO_RUNTIME_JS.contains(":popover-open"));
140        assert!(FERRO_RUNTIME_JS.contains("hidePopover"));
141        assert!(FERRO_RUNTIME_JS.contains("positionUnderTrigger"));
142        assert!(FERRO_RUNTIME_JS.contains("getBoundingClientRect"));
143    }
144
145    #[test]
146    fn test_runtime_contains_modal_wiring() {
147        assert!(FERRO_RUNTIME_JS.contains("setupModals"));
148        assert!(FERRO_RUNTIME_JS.contains("data-modal-open"));
149        assert!(FERRO_RUNTIME_JS.contains("showModal"));
150        assert!(FERRO_RUNTIME_JS.contains("data-modal-close"));
151    }
152
153    #[test]
154    fn test_runtime_contains_toast_from_url() {
155        assert!(FERRO_RUNTIME_JS.contains("initToastFromUrl"));
156        assert!(FERRO_RUNTIME_JS.contains("URLSearchParams"));
157        assert!(FERRO_RUNTIME_JS.contains("history.replaceState"));
158    }
159
160    #[test]
161    fn runtime_contains_init_tab_from_url() {
162        assert!(
163            FERRO_RUNTIME_JS.contains("initTabFromUrl"),
164            "FERRO_RUNTIME_JS must include initTabFromUrl for F3 — URL-driven tab init"
165        );
166        assert!(
167            FERRO_RUNTIME_JS.contains("URLSearchParams"),
168            "FERRO_RUNTIME_JS must use URLSearchParams to parse ?tab= for initTabFromUrl"
169        );
170    }
171
172    #[test]
173    fn bundle_contains_dispatcher() {
174        assert!(FERRO_RUNTIME_JS.contains("function ferroRuntime()"));
175        assert!(FERRO_RUNTIME_JS.contains("DOMContentLoaded"));
176        assert!(FERRO_RUNTIME_JS.contains("ferroRuntime"));
177    }
178
179    #[test]
180    fn bundle_contains_all_setup_functions() {
181        for fn_name in [
182            "setupSSE",
183            "setupTabs",
184            "setupToasts",
185            "setupSidebar",
186            "setupDropdowns",
187            "setupModals",
188            "setupDismissibles",
189            "setupNotifications",
190            "setupFormGuards",
191            "setupProductTiles",
192            "setupKanban",
193            "setupScrollPreserve",
194            "setupLazyHeroes",
195        ] {
196            assert!(
197                FERRO_RUNTIME_JS.contains(fn_name),
198                "bundle missing {fn_name}"
199            );
200        }
201    }
202
203    #[test]
204    fn bundle_is_single_iife() {
205        assert!(FERRO_RUNTIME_JS.starts_with("(function() {"));
206        assert!(FERRO_RUNTIME_JS.trim_end().ends_with("})();"));
207    }
208
209    #[test]
210    fn dispatcher_invokes_every_setup() {
211        let js: &str = FERRO_RUNTIME_JS.as_str();
212        let dispatcher_start = js.find("function ferroRuntime()").unwrap();
213        let dispatcher = &js[dispatcher_start..];
214        for call in [
215            "setupSSE();",
216            "setupTabs();",
217            "setupToasts();",
218            "setupSidebar();",
219            "setupDropdowns();",
220            "setupModals();",
221            "setupDismissibles();",
222            "setupNotifications();",
223            "setupFormGuards();",
224            "setupProductTiles();",
225            "setupKanban();",
226            "setupScrollPreserve();",
227            "setupLazyHeroes();",
228        ] {
229            assert!(dispatcher.contains(call), "dispatcher missing {call}");
230        }
231    }
232
233    #[test]
234    fn runtime_contains_lazy_hero_setup() {
235        assert!(FERRO_RUNTIME_JS.contains("setupLazyHeroes"));
236        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero"));
237        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero-margin"));
238        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero-promoted"));
239        assert!(FERRO_RUNTIME_JS.contains("IntersectionObserver"));
240        assert!(FERRO_RUNTIME_JS.contains("preload"));
241        // JS source uses single quotes (`setAttribute('preload', 'auto')`),
242        // matching sibling-runtime convention; assert the single-quoted literal.
243        assert!(FERRO_RUNTIME_JS.contains("'auto'"));
244        assert!(FERRO_RUNTIME_JS.contains("unobserve"));
245    }
246}