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 bulk_bar;
9mod combobox;
10mod command_palette;
11mod date_picker;
12mod dismissibles;
13mod dropdowns;
14mod filter_bar;
15mod filters;
16mod form_guards;
17mod hero_lazy;
18mod inline_edit;
19mod kanban;
20mod listbox_engine;
21mod live_fragment;
22mod modals;
23mod nav;
24mod notifications;
25mod numpad;
26mod peek;
27mod scroll_preserve;
28mod selection;
29mod sidebar;
30mod sse;
31mod tabs;
32mod tiles;
33mod toast_undo;
34mod toasts;
35
36use std::sync::LazyLock;
37
38/// Assembled JS runtime bundle. Lazily concatenated from per-concern
39/// submodules on first access; the resulting string is stable for the
40/// process lifetime.
41pub static FERRO_RUNTIME_JS: LazyLock<String> = LazyLock::new(|| {
42    let mut s = String::with_capacity(8 * 1024);
43    s.push_str("(function() {\n    'use strict';\n");
44    s.push_str(sse::SOURCE);
45    s.push_str(tabs::SOURCE);
46    s.push_str(toasts::SOURCE);
47    s.push_str(dismissibles::SOURCE);
48    s.push_str(notifications::SOURCE);
49    s.push_str(dropdowns::SOURCE);
50    s.push_str(modals::SOURCE);
51    s.push_str(sidebar::SOURCE);
52    s.push_str(form_guards::SOURCE);
53    s.push_str(tiles::SOURCE);
54    s.push_str(numpad::SOURCE);
55    s.push_str(filters::SOURCE);
56    s.push_str(selection::SOURCE);
57    s.push_str(kanban::SOURCE);
58    s.push_str(scroll_preserve::SOURCE);
59    s.push_str(hero_lazy::SOURCE);
60    s.push_str(live_fragment::SOURCE);
61    s.push_str(nav::SOURCE);
62    s.push_str(listbox_engine::SOURCE);
63    s.push_str(command_palette::SOURCE);
64    s.push_str(combobox::SOURCE);
65    s.push_str(date_picker::SOURCE);
66    s.push_str(inline_edit::SOURCE);
67    s.push_str(filter_bar::SOURCE);
68    s.push_str(bulk_bar::SOURCE);
69    s.push_str(peek::SOURCE);
70    s.push_str(toast_undo::SOURCE);
71    s.push_str(
72        "\n    function ferroRuntime() {\n\
73         \x20       // Run each setup in isolation: a throw in one concern (e.g. an\n\
74         \x20       // invalid selector built from a malformed attribute) must not\n\
75         \x20       // abort the remaining setups.\n\
76         \x20       var setups = [\n\
77         \x20           setupScrollPreserve,\n\
78         \x20           setupSSE,\n\
79         \x20           setupTabs,\n\
80         \x20           setupDismissibles,\n\
81         \x20           setupNotifications,\n\
82         \x20           setupDropdowns,\n\
83         \x20           setupKanban,\n\
84         \x20           setupSidebar,\n\
85         \x20           setupFormGuards,\n\
86         \x20           setupTiles,\n\
87         \x20           setupNumpad,\n\
88         \x20           setupFilters,\n\
89         \x20           setupSelection,\n\
90         \x20           setupModals,\n\
91         \x20           setupToasts,\n\
92         \x20           setupLazyHeroes,\n\
93         \x20           setupLiveFragments,\n\
94         \x20           setupNav,\n\
95         \x20           setupProgressHairline,\n\
96         \x20           setupCommandPalette,\n\
97         \x20           setupCombobox,\n\
98         \x20           setupDatePicker,\n\
99         \x20           setupInlineEdit,\n\
100         \x20           setupFilterBar,\n\
101         \x20           setupBulkBar,\n\
102         \x20           setupPeek,\n\
103         \x20           setupUndo\n\
104         \x20       ];\n\
105         \x20       for (var i = 0; i < setups.length; i++) {\n\
106         \x20           try { setups[i](); } catch (err) { /* isolated per concern */ }\n\
107         \x20       }\n\
108         \x20   }\n\
109         \x20   document.addEventListener('DOMContentLoaded', ferroRuntime);\n\
110         })();\n",
111    );
112    s
113});
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn variant_classes_use_semantic_tokens() {
121        // Plan 06: VARIANT_CLASSES now emit fjui-toast--{tone} skin modifiers instead
122        // of raw bg-* utilities. Appearance is owned by ferro-skin.css.
123        assert!(FERRO_RUNTIME_JS.contains("fjui-toast--neutral"));
124        assert!(FERRO_RUNTIME_JS.contains("fjui-toast--success"));
125        assert!(FERRO_RUNTIME_JS.contains("fjui-toast--warning"));
126        assert!(FERRO_RUNTIME_JS.contains("fjui-toast--destructive"));
127        assert!(!FERRO_RUNTIME_JS.contains("bg-blue-500"));
128        assert!(!FERRO_RUNTIME_JS.contains("bg-green-500"));
129        assert!(!FERRO_RUNTIME_JS.contains("bg-yellow-500"));
130        assert!(!FERRO_RUNTIME_JS.contains("bg-red-500"));
131        // Retired toast vocabulary must not resurface: the SSR emitter and
132        // this runtime were renamed to `data-toast-tone` in lockstep.
133        assert!(!FERRO_RUNTIME_JS.contains("data-toast-variant"));
134        // Retired motion vocabulary: the toast fade uses the duration-base
135        // token and dismissal is transitionend-driven (500ms fallback bound).
136        assert!(!FERRO_RUNTIME_JS.contains("duration-300"));
137        assert!(!FERRO_RUNTIME_JS.contains("duration-150"));
138        assert!(FERRO_RUNTIME_JS.contains("duration-base"));
139        assert!(FERRO_RUNTIME_JS.contains("transitionend"));
140    }
141
142    #[test]
143    fn tab_switcher_uses_semantic_tokens() {
144        assert!(FERRO_RUNTIME_JS.contains("border-primary"));
145        assert!(FERRO_RUNTIME_JS.contains("text-primary"));
146        assert!(FERRO_RUNTIME_JS.contains("text-text-muted"));
147        assert!(!FERRO_RUNTIME_JS.contains("border-blue-600"));
148        assert!(!FERRO_RUNTIME_JS.contains("text-blue-600"));
149        assert!(!FERRO_RUNTIME_JS.contains("text-gray-500"));
150    }
151
152    #[test]
153    fn toast_uses_semantic_text_color() {
154        // After Plan 06 migration, toast appearance is owned by fjui-toast skin rules.
155        // The runtime VARIANT_CLASSES now emit fjui-toast--{tone} (no raw utility strings).
156        assert!(!FERRO_RUNTIME_JS.contains("text-white"));
157        // Ensure the fjui-toast base class is present in the JS showToast output.
158        assert!(FERRO_RUNTIME_JS.contains("fjui-toast"));
159    }
160
161    /// JS↔SSR lockstep: the runtime's VARIANT_CLASSES must carry the exact
162    /// fjui-toast--{tone} modifier classes that SSR render_toast emits (Plan 06),
163    /// so server-rendered and JS-created toasts share the same skin rules.
164    #[test]
165    fn toast_tone_classes_match_ssr() {
166        // Plan 06: TOAST_TONE_* constants in classes.rs are now JS-runtime-only;
167        // SSR render_toast emits fjui-toast--{tone} full literals instead.
168        // The lockstep check now verifies the fjui- modifier strings appear in the
169        // runtime bundle (VARIANT_CLASSES entries).
170        for tone_class in [
171            "fjui-toast--neutral",
172            "fjui-toast--success",
173            "fjui-toast--warning",
174            "fjui-toast--destructive",
175        ] {
176            assert!(
177                FERRO_RUNTIME_JS.contains(tone_class),
178                "runtime VARIANT_CLASSES drifted from SSR toast tone classes: missing `{tone_class}`"
179            );
180        }
181        // The base fjui-toast class must also appear in showToast's className assembly.
182        assert!(
183            FERRO_RUNTIME_JS.contains("fjui-toast "),
184            "runtime showToast must emit fjui-toast base class"
185        );
186    }
187
188    /// Popover-based dropdown wiring: the panel uses the HTML `popover`
189    /// attribute so the browser lifts it into the top layer (escaping any
190    /// `overflow:hidden` ancestor and the surrounding z-index stack). We
191    /// supply only the anchor positioning and the close-on-scroll behavior.
192    #[test]
193    fn test_runtime_contains_popover_dropdown_wiring() {
194        assert!(FERRO_RUNTIME_JS.contains("data-popover-menu"));
195        assert!(FERRO_RUNTIME_JS.contains(":popover-open"));
196        assert!(FERRO_RUNTIME_JS.contains("hidePopover"));
197        assert!(FERRO_RUNTIME_JS.contains("positionUnderTrigger"));
198        assert!(FERRO_RUNTIME_JS.contains("getBoundingClientRect"));
199    }
200
201    #[test]
202    fn test_runtime_contains_modal_wiring() {
203        assert!(FERRO_RUNTIME_JS.contains("setupModals"));
204        assert!(FERRO_RUNTIME_JS.contains("data-modal-open"));
205        assert!(FERRO_RUNTIME_JS.contains("showModal"));
206        assert!(FERRO_RUNTIME_JS.contains("data-modal-close"));
207    }
208
209    #[test]
210    fn test_runtime_contains_toast_from_url() {
211        assert!(FERRO_RUNTIME_JS.contains("initToastFromUrl"));
212        assert!(FERRO_RUNTIME_JS.contains("URLSearchParams"));
213        assert!(FERRO_RUNTIME_JS.contains("history.replaceState"));
214    }
215
216    #[test]
217    fn runtime_contains_init_tab_from_url() {
218        assert!(
219            FERRO_RUNTIME_JS.contains("initTabFromUrl"),
220            "FERRO_RUNTIME_JS must include initTabFromUrl for F3 — URL-driven tab init"
221        );
222        assert!(
223            FERRO_RUNTIME_JS.contains("URLSearchParams"),
224            "FERRO_RUNTIME_JS must use URLSearchParams to parse ?tab= for initTabFromUrl"
225        );
226    }
227
228    #[test]
229    fn bundle_contains_dispatcher() {
230        assert!(FERRO_RUNTIME_JS.contains("function ferroRuntime()"));
231        assert!(FERRO_RUNTIME_JS.contains("DOMContentLoaded"));
232        assert!(FERRO_RUNTIME_JS.contains("ferroRuntime"));
233    }
234
235    #[test]
236    fn bundle_contains_all_setup_functions() {
237        for fn_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            "setupLiveFragments",
255            "setupNav",
256            "setupProgressHairline",
257            "setupCommandPalette",
258            "setupCombobox",
259            "setupDatePicker",
260            "setupInlineEdit",
261            "setupFilterBar",
262            "setupBulkBar",
263            "setupPeek",
264            "setupUndo",
265        ] {
266            assert!(
267                FERRO_RUNTIME_JS.contains(fn_name),
268                "bundle missing {fn_name}"
269            );
270        }
271        assert!(
272            FERRO_RUNTIME_JS.contains("createListboxEngine"),
273            "bundle missing createListboxEngine shared keyboard engine"
274        );
275    }
276
277    /// Nav runtime contract: asserts all key tokens are present in the bundle.
278    /// This test must FAIL before nav.rs is created (RED state) and PASS after (GREEN).
279    #[test]
280    fn runtime_contains_nav_setup() {
281        // Explicit function-name checks (also covered by bundle_contains_all_setup_functions,
282        // but duplicated here so this test is self-contained and readable as a spec).
283        assert!(
284            FERRO_RUNTIME_JS.contains("setupNav"),
285            "bundle missing setupNav"
286        );
287        assert!(
288            FERRO_RUNTIME_JS.contains("setupProgressHairline"),
289            "bundle missing setupProgressHairline"
290        );
291        // Nav runtime behavioral tokens:
292        for token in [
293            "fjui:navigated",
294            "fjui:before-navigate",
295            "scrollRestoration",
296            "pointerdown",
297            "AbortController",
298            "ferro-json-ui",
299            "fjui-nav-progress",
300            "fjui-sidebar__nav-item--active",
301        ] {
302            assert!(
303                FERRO_RUNTIME_JS.contains(token),
304                "nav runtime bundle missing token: {token}"
305            );
306        }
307    }
308
309    #[test]
310    fn bundle_is_single_iife() {
311        assert!(FERRO_RUNTIME_JS.starts_with("(function() {"));
312        assert!(FERRO_RUNTIME_JS.trim_end().ends_with("})();"));
313    }
314
315    /// Every setup must be registered in the dispatcher's `setups` array and
316    /// invoked through the per-concern try/catch loop so one throwing setup
317    /// cannot abort the rest.
318    #[test]
319    fn dispatcher_invokes_every_setup() {
320        let js: &str = FERRO_RUNTIME_JS.as_str();
321        let dispatcher_start = js.find("function ferroRuntime()").unwrap();
322        let dispatcher = &js[dispatcher_start..];
323        for name in [
324            "setupSSE",
325            "setupTabs",
326            "setupToasts",
327            "setupSidebar",
328            "setupDropdowns",
329            "setupModals",
330            "setupDismissibles",
331            "setupNotifications",
332            "setupFormGuards",
333            "setupTiles",
334            "setupNumpad",
335            "setupFilters",
336            "setupSelection",
337            "setupKanban",
338            "setupScrollPreserve",
339            "setupLazyHeroes",
340            "setupLiveFragments",
341            "setupNav",
342            "setupProgressHairline",
343            "setupCommandPalette",
344            "setupCombobox",
345            "setupDatePicker",
346            "setupInlineEdit",
347            "setupFilterBar",
348            "setupBulkBar",
349            "setupPeek",
350            "setupUndo",
351        ] {
352            assert!(
353                dispatcher.contains(name),
354                "dispatcher setups array missing {name}"
355            );
356        }
357        assert!(
358            dispatcher.contains("try { setups[i](); } catch"),
359            "dispatcher must invoke setups through the per-concern try/catch"
360        );
361    }
362
363    #[test]
364    fn runtime_contains_lazy_hero_setup() {
365        assert!(FERRO_RUNTIME_JS.contains("setupLazyHeroes"));
366        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero"));
367        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero-margin"));
368        assert!(FERRO_RUNTIME_JS.contains("data-lazy-hero-promoted"));
369        assert!(FERRO_RUNTIME_JS.contains("IntersectionObserver"));
370        assert!(FERRO_RUNTIME_JS.contains("preload"));
371        // JS source uses single quotes (`setAttribute('preload', 'auto')`),
372        // matching sibling-runtime convention; assert the single-quoted literal.
373        assert!(FERRO_RUNTIME_JS.contains("'auto'"));
374        assert!(FERRO_RUNTIME_JS.contains("unobserve"));
375    }
376
377    /// SC-4: inline-source assertion confirming the double-submit guard contract
378    /// is present in the assembled bundle (setupFormGuards extension, D-13/D-14/D-15).
379    #[test]
380    fn runtime_wires_disable_on_submit() {
381        assert!(
382            FERRO_RUNTIME_JS.contains("data-disable-on-submit"),
383            "bundle must contain data-disable-on-submit for the double-submit guard (SC-4)"
384        );
385    }
386
387    /// SC-3: inline-source assertions confirming the numpad and filter runtime
388    /// contracts are present in the assembled bundle.
389    #[test]
390    fn runtime_exposes_numpad_and_filter_contract() {
391        // Numpad attribute contract
392        assert!(
393            FERRO_RUNTIME_JS.contains("data-numpad-key"),
394            "bundle must contain data-numpad-key attribute selector"
395        );
396        assert!(
397            FERRO_RUNTIME_JS.contains("data-numpad-target"),
398            "bundle must contain data-numpad-target attribute"
399        );
400        assert!(
401            FERRO_RUNTIME_JS.contains("data-numpad-input"),
402            "bundle must contain data-numpad-input attribute"
403        );
404        assert!(
405            FERRO_RUNTIME_JS.contains("data-numpad-display"),
406            "bundle must contain data-numpad-display attribute"
407        );
408        // D-04: every key tap dispatches a bubbling input event
409        assert!(
410            FERRO_RUNTIME_JS.contains("bubbles: true"),
411            "bundle must dispatch bubbling input event on numpad key tap (D-04)"
412        );
413        // Filter attribute contract
414        assert!(
415            FERRO_RUNTIME_JS.contains("data-filter-tokens"),
416            "bundle must contain data-filter-tokens for token matching"
417        );
418        assert!(
419            FERRO_RUNTIME_JS.contains("data-filter-search"),
420            "bundle must contain data-filter-search for text filtering"
421        );
422        assert!(
423            FERRO_RUNTIME_JS.contains("data-filter-text"),
424            "bundle must contain data-filter-text as the universal tile marker"
425        );
426    }
427}