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