ferro_json_ui/runtime/
mod.rs1mod 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
27pub 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 assert!(!FERRO_RUNTIME_JS.contains("data-toast-variant"));
99 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 #[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 #[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 #[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 assert!(FERRO_RUNTIME_JS.contains("'auto'"));
277 assert!(FERRO_RUNTIME_JS.contains("unobserve"));
278 }
279
280 #[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 #[test]
293 fn runtime_exposes_numpad_and_filter_contract() {
294 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 assert!(
313 FERRO_RUNTIME_JS.contains("bubbles: true"),
314 "bundle must dispatch bubbling input event on numpad key tap (D-04)"
315 );
316 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}