ferro_json_ui/runtime/
mod.rs1mod 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
28pub 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 assert!(!FERRO_RUNTIME_JS.contains("data-toast-variant"));
102 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 #[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 #[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 #[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 assert!(FERRO_RUNTIME_JS.contains("'auto'"));
282 assert!(FERRO_RUNTIME_JS.contains("unobserve"));
283 }
284
285 #[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 #[test]
298 fn runtime_exposes_numpad_and_filter_contract() {
299 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 assert!(
318 FERRO_RUNTIME_JS.contains("bubbles: true"),
319 "bundle must dispatch bubbling input event on numpad key tap (D-04)"
320 );
321 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}