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 filters;
11mod form_guards;
12mod hero_lazy;
13mod kanban;
14mod live_fragment;
15mod modals;
16mod notifications;
17mod numpad;
18mod scroll_preserve;
19mod selection;
20mod sidebar;
21mod sse;
22mod tabs;
23mod tiles;
24mod toasts;
25
26use std::sync::LazyLock;
27
28/// Assembled JS runtime bundle. Lazily concatenated from per-concern
29/// submodules on first access; the resulting string is stable for the
30/// process lifetime.
31pub static FERRO_RUNTIME_JS: LazyLock<String> = LazyLock::new(|| {
32    let mut s = String::with_capacity(8 * 1024);
33    s.push_str("(function() {\n    'use strict';\n");
34    s.push_str(sse::SOURCE);
35    s.push_str(tabs::SOURCE);
36    s.push_str(toasts::SOURCE);
37    s.push_str(dismissibles::SOURCE);
38    s.push_str(notifications::SOURCE);
39    s.push_str(dropdowns::SOURCE);
40    s.push_str(modals::SOURCE);
41    s.push_str(sidebar::SOURCE);
42    s.push_str(form_guards::SOURCE);
43    s.push_str(tiles::SOURCE);
44    s.push_str(numpad::SOURCE);
45    s.push_str(filters::SOURCE);
46    s.push_str(selection::SOURCE);
47    s.push_str(kanban::SOURCE);
48    s.push_str(scroll_preserve::SOURCE);
49    s.push_str(hero_lazy::SOURCE);
50    s.push_str(live_fragment::SOURCE);
51    s.push_str(
52        "\n    function ferroRuntime() {\n\
53         \x20       // Run each setup in isolation: a throw in one concern (e.g. an\n\
54         \x20       // invalid selector built from a malformed attribute) must not\n\
55         \x20       // abort the remaining setups.\n\
56         \x20       var setups = [\n\
57         \x20           setupScrollPreserve,\n\
58         \x20           setupSSE,\n\
59         \x20           setupTabs,\n\
60         \x20           setupDismissibles,\n\
61         \x20           setupNotifications,\n\
62         \x20           setupDropdowns,\n\
63         \x20           setupKanban,\n\
64         \x20           setupSidebar,\n\
65         \x20           setupFormGuards,\n\
66         \x20           setupTiles,\n\
67         \x20           setupNumpad,\n\
68         \x20           setupFilters,\n\
69         \x20           setupSelection,\n\
70         \x20           setupModals,\n\
71         \x20           setupToasts,\n\
72         \x20           setupLazyHeroes,\n\
73         \x20           setupLiveFragments\n\
74         \x20       ];\n\
75         \x20       for (var i = 0; i < setups.length; i++) {\n\
76         \x20           try { setups[i](); } catch (err) { /* isolated per concern */ }\n\
77         \x20       }\n\
78         \x20   }\n\
79         \x20   document.addEventListener('DOMContentLoaded', ferroRuntime);\n\
80         })();\n",
81    );
82    s
83});
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn variant_classes_use_semantic_tokens() {
91        assert!(FERRO_RUNTIME_JS.contains("bg-primary"));
92        assert!(FERRO_RUNTIME_JS.contains("bg-success"));
93        assert!(FERRO_RUNTIME_JS.contains("bg-warning"));
94        assert!(FERRO_RUNTIME_JS.contains("bg-destructive"));
95        assert!(!FERRO_RUNTIME_JS.contains("bg-blue-500"));
96        assert!(!FERRO_RUNTIME_JS.contains("bg-green-500"));
97        assert!(!FERRO_RUNTIME_JS.contains("bg-yellow-500"));
98        assert!(!FERRO_RUNTIME_JS.contains("bg-red-500"));
99        // Retired toast vocabulary must not resurface: the SSR emitter and
100        // this runtime were renamed to `data-toast-tone` in lockstep.
101        assert!(!FERRO_RUNTIME_JS.contains("data-toast-variant"));
102        // Retired motion vocabulary: the toast fade uses the duration-base
103        // token and dismissal is transitionend-driven (500ms fallback bound).
104        assert!(!FERRO_RUNTIME_JS.contains("duration-300"));
105        assert!(!FERRO_RUNTIME_JS.contains("duration-150"));
106        assert!(FERRO_RUNTIME_JS.contains("duration-base"));
107        assert!(FERRO_RUNTIME_JS.contains("transitionend"));
108    }
109
110    #[test]
111    fn tab_switcher_uses_semantic_tokens() {
112        assert!(FERRO_RUNTIME_JS.contains("border-primary"));
113        assert!(FERRO_RUNTIME_JS.contains("text-primary"));
114        assert!(FERRO_RUNTIME_JS.contains("text-text-muted"));
115        assert!(!FERRO_RUNTIME_JS.contains("border-blue-600"));
116        assert!(!FERRO_RUNTIME_JS.contains("text-blue-600"));
117        assert!(!FERRO_RUNTIME_JS.contains("text-gray-500"));
118    }
119
120    #[test]
121    fn toast_uses_semantic_text_color() {
122        assert!(FERRO_RUNTIME_JS.contains("text-primary-foreground"));
123        assert!(!FERRO_RUNTIME_JS.contains("text-white"));
124    }
125
126    /// JS↔SSR lockstep: the runtime's VARIANT_CLASSES must carry the exact
127    /// tone-class strings the SSR `render_toast` composes from
128    /// `render::classes::TOAST_TONE_*`, plus the shared backdrop blur, so
129    /// server-rendered and JS-created toasts look identical.
130    #[test]
131    fn toast_tone_classes_match_ssr() {
132        use crate::render::classes::{
133            TOAST_TONE_DESTRUCTIVE, TOAST_TONE_NEUTRAL, TOAST_TONE_SUCCESS, TOAST_TONE_WARNING,
134        };
135        for tone_classes in [
136            TOAST_TONE_NEUTRAL,
137            TOAST_TONE_SUCCESS,
138            TOAST_TONE_WARNING,
139            TOAST_TONE_DESTRUCTIVE,
140        ] {
141            assert!(
142                FERRO_RUNTIME_JS.contains(tone_classes),
143                "runtime VARIANT_CLASSES drifted from SSR toast tone classes: missing `{tone_classes}`"
144            );
145        }
146        assert!(
147            FERRO_RUNTIME_JS.contains("backdrop-blur-md"),
148            "runtime toast shell drifted from SSR: missing `backdrop-blur-md`"
149        );
150    }
151
152    /// Popover-based dropdown wiring: the panel uses the HTML `popover`
153    /// attribute so the browser lifts it into the top layer (escaping any
154    /// `overflow:hidden` ancestor and the surrounding z-index stack). We
155    /// supply only the anchor positioning and the close-on-scroll behavior.
156    #[test]
157    fn test_runtime_contains_popover_dropdown_wiring() {
158        assert!(FERRO_RUNTIME_JS.contains("data-popover-menu"));
159        assert!(FERRO_RUNTIME_JS.contains(":popover-open"));
160        assert!(FERRO_RUNTIME_JS.contains("hidePopover"));
161        assert!(FERRO_RUNTIME_JS.contains("positionUnderTrigger"));
162        assert!(FERRO_RUNTIME_JS.contains("getBoundingClientRect"));
163    }
164
165    #[test]
166    fn test_runtime_contains_modal_wiring() {
167        assert!(FERRO_RUNTIME_JS.contains("setupModals"));
168        assert!(FERRO_RUNTIME_JS.contains("data-modal-open"));
169        assert!(FERRO_RUNTIME_JS.contains("showModal"));
170        assert!(FERRO_RUNTIME_JS.contains("data-modal-close"));
171    }
172
173    #[test]
174    fn test_runtime_contains_toast_from_url() {
175        assert!(FERRO_RUNTIME_JS.contains("initToastFromUrl"));
176        assert!(FERRO_RUNTIME_JS.contains("URLSearchParams"));
177        assert!(FERRO_RUNTIME_JS.contains("history.replaceState"));
178    }
179
180    #[test]
181    fn runtime_contains_init_tab_from_url() {
182        assert!(
183            FERRO_RUNTIME_JS.contains("initTabFromUrl"),
184            "FERRO_RUNTIME_JS must include initTabFromUrl for F3 — URL-driven tab init"
185        );
186        assert!(
187            FERRO_RUNTIME_JS.contains("URLSearchParams"),
188            "FERRO_RUNTIME_JS must use URLSearchParams to parse ?tab= for initTabFromUrl"
189        );
190    }
191
192    #[test]
193    fn bundle_contains_dispatcher() {
194        assert!(FERRO_RUNTIME_JS.contains("function ferroRuntime()"));
195        assert!(FERRO_RUNTIME_JS.contains("DOMContentLoaded"));
196        assert!(FERRO_RUNTIME_JS.contains("ferroRuntime"));
197    }
198
199    #[test]
200    fn bundle_contains_all_setup_functions() {
201        for fn_name in [
202            "setupSSE",
203            "setupTabs",
204            "setupToasts",
205            "setupSidebar",
206            "setupDropdowns",
207            "setupModals",
208            "setupDismissibles",
209            "setupNotifications",
210            "setupFormGuards",
211            "setupTiles",
212            "setupNumpad",
213            "setupFilters",
214            "setupSelection",
215            "setupKanban",
216            "setupScrollPreserve",
217            "setupLazyHeroes",
218            "setupLiveFragments",
219        ] {
220            assert!(
221                FERRO_RUNTIME_JS.contains(fn_name),
222                "bundle missing {fn_name}"
223            );
224        }
225    }
226
227    #[test]
228    fn bundle_is_single_iife() {
229        assert!(FERRO_RUNTIME_JS.starts_with("(function() {"));
230        assert!(FERRO_RUNTIME_JS.trim_end().ends_with("})();"));
231    }
232
233    /// Every setup must be registered in the dispatcher's `setups` array and
234    /// invoked through the per-concern try/catch loop so one throwing setup
235    /// cannot abort the rest.
236    #[test]
237    fn dispatcher_invokes_every_setup() {
238        let js: &str = FERRO_RUNTIME_JS.as_str();
239        let dispatcher_start = js.find("function ferroRuntime()").unwrap();
240        let dispatcher = &js[dispatcher_start..];
241        for name in [
242            "setupSSE",
243            "setupTabs",
244            "setupToasts",
245            "setupSidebar",
246            "setupDropdowns",
247            "setupModals",
248            "setupDismissibles",
249            "setupNotifications",
250            "setupFormGuards",
251            "setupTiles",
252            "setupNumpad",
253            "setupFilters",
254            "setupSelection",
255            "setupKanban",
256            "setupScrollPreserve",
257            "setupLazyHeroes",
258            "setupLiveFragments",
259        ] {
260            assert!(
261                dispatcher.contains(name),
262                "dispatcher setups array missing {name}"
263            );
264        }
265        assert!(
266            dispatcher.contains("try { setups[i](); } catch"),
267            "dispatcher must invoke setups through the per-concern try/catch"
268        );
269    }
270
271    #[test]
272    fn runtime_contains_lazy_hero_setup() {
273        assert!(FERRO_RUNTIME_JS.contains("setupLazyHeroes"));
274        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero"));
275        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero-margin"));
276        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero-promoted"));
277        assert!(FERRO_RUNTIME_JS.contains("IntersectionObserver"));
278        assert!(FERRO_RUNTIME_JS.contains("preload"));
279        // JS source uses single quotes (`setAttribute('preload', 'auto')`),
280        // matching sibling-runtime convention; assert the single-quoted literal.
281        assert!(FERRO_RUNTIME_JS.contains("'auto'"));
282        assert!(FERRO_RUNTIME_JS.contains("unobserve"));
283    }
284
285    /// SC-4: inline-source assertion confirming the double-submit guard contract
286    /// is present in the assembled bundle (setupFormGuards extension, D-13/D-14/D-15).
287    #[test]
288    fn runtime_wires_disable_on_submit() {
289        assert!(
290            FERRO_RUNTIME_JS.contains("data-disable-on-submit"),
291            "bundle must contain data-disable-on-submit for the double-submit guard (SC-4)"
292        );
293    }
294
295    /// SC-3: inline-source assertions confirming the numpad and filter runtime
296    /// contracts are present in the assembled bundle.
297    #[test]
298    fn runtime_exposes_numpad_and_filter_contract() {
299        // Numpad attribute contract
300        assert!(
301            FERRO_RUNTIME_JS.contains("data-numpad-key"),
302            "bundle must contain data-numpad-key attribute selector"
303        );
304        assert!(
305            FERRO_RUNTIME_JS.contains("data-numpad-target"),
306            "bundle must contain data-numpad-target attribute"
307        );
308        assert!(
309            FERRO_RUNTIME_JS.contains("data-numpad-input"),
310            "bundle must contain data-numpad-input attribute"
311        );
312        assert!(
313            FERRO_RUNTIME_JS.contains("data-numpad-display"),
314            "bundle must contain data-numpad-display attribute"
315        );
316        // D-04: every key tap dispatches a bubbling input event
317        assert!(
318            FERRO_RUNTIME_JS.contains("bubbles: true"),
319            "bundle must dispatch bubbling input event on numpad key tap (D-04)"
320        );
321        // Filter attribute contract
322        assert!(
323            FERRO_RUNTIME_JS.contains("data-filter-tokens"),
324            "bundle must contain data-filter-tokens for token matching"
325        );
326        assert!(
327            FERRO_RUNTIME_JS.contains("data-filter-search"),
328            "bundle must contain data-filter-search for text filtering"
329        );
330        assert!(
331            FERRO_RUNTIME_JS.contains("data-filter-text"),
332            "bundle must contain data-filter-text as the universal tile marker"
333        );
334    }
335}