ferro-json-ui 0.3.5

JSON-based server-driven UI schema types for Ferro
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
pub(super) const SOURCE: &str = r#"
    // ── Instant navigation runtime ────────────────────────────────────────
    //
    // Intercepts same-origin GET <a> clicks on the dashboard, fetches the
    // destination page, and swaps only the #ferro-json-ui content region.
    // The sidebar, header, toast container, and SSE EventSource are never
    // re-rendered (persistent frame — NAV-01).
    //
    // Prefetch on pointerdown / hover-dwell so warm navigations apply
    // immediately with no visible loading state (NAV-02).
    //
    // History, scroll position, and focus are managed explicitly:
    //   - history.scrollRestoration = 'manual'
    //   - scroll state stored in history.state (main.scrollTop)
    //   - focus moves to PageHeader h2 after forward navigation (NAV-03)
    //
    // SSE EventSource is never torn down. Scripts in swapped content are
    // re-executed by cloning into fresh <script> nodes (same-origin only).
    // fjui:navigated fires after re-execution; fjui:before-navigate fires
    // before the swap so page-scoped EventSources can close (NAV-04).
    //
    // POST forms, modified clicks, target=_blank, download, hash-only, and
    // cross-origin links are never intercepted (D-14).

    // ── Progress hairline ─────────────────────────────────────────────────

    function setupProgressHairline() {
        if (document.querySelector('.fjui-nav-progress')) return;
        var bar = document.createElement('div');
        bar.className = 'fjui-nav-progress';
        bar.setAttribute('aria-hidden', 'true');
        document.body.appendChild(bar);

        function showHairline() {
            bar.classList.add('fjui-nav-progress--active');
        }
        function doneHairline() {
            bar.classList.remove('fjui-nav-progress--active');
            bar.classList.add('fjui-nav-progress--done');
            setTimeout(function() {
                bar.classList.remove('fjui-nav-progress--done');
            }, 300);
        }
        function resetHairline() {
            bar.classList.remove('fjui-nav-progress--active');
            bar.classList.remove('fjui-nav-progress--done');
        }

        // Expose helpers for setupNav (same closure scope).
        window.__fjuiHairline = {
            show: showHairline,
            done: doneHairline,
            reset: resetHairline
        };
    }

    // ── Navigation runtime ────────────────────────────────────────────────

    function setupNav() {
        if (!document.getElementById('ferro-json-ui')) return;

        // Disable browser scroll restoration — we manage it manually (D-07).
        history.scrollRestoration = 'manual';

        // The actual scroll container (HIDE_SCROLLBARS_CSS pins html/body to
        // overflow:hidden; only <main> scrolls).
        var mainEl = document.querySelector('body > div.flex.flex-col > main');

        // In-memory prefetch cache. Each entry: { promise, controller, ts }.
        var prefetchCache = {};
        var inflight = 0;
        var MAX_INFLIGHT = 2;
        var PREFETCH_TTL = 5000;
        var HOVER_DWELL = 80;

        // Track the URL that a click actually intends to navigate to, so a
        // late-arriving prefetch for a superseded URL cannot be applied (Pitfall 4).
        var intendedUrl = null;

        function evictStale(url) {
            var entry = prefetchCache[url];
            if (entry && (Date.now() - entry.ts) > PREFETCH_TTL) {
                delete prefetchCache[url];
                return true;
            }
            return false;
        }

        function prefetch(url) {
            if (prefetchCache[url]) {
                evictStale(url);
                if (prefetchCache[url]) return; // still fresh
            }
            if (inflight >= MAX_INFLIGHT) return;
            var controller = new AbortController();
            inflight++;
            var entry = {
                promise: fetch(url, {
                    credentials: 'same-origin',
                    headers: { 'X-FJUI-Nav': '1', 'X-FJUI-Target': 'ferro-json-ui' },
                    signal: controller.signal
                }).then(function(r) {
                    inflight--;
                    return r;
                }).catch(function() {
                    inflight--;
                    delete prefetchCache[url];
                }),
                controller: controller,
                ts: Date.now()
            };
            prefetchCache[url] = entry;
        }

        function abortAllPrefetchesExcept(keepUrl) {
            for (var k in prefetchCache) {
                if (k !== keepUrl && prefetchCache[k] && prefetchCache[k].controller) {
                    try { prefetchCache[k].controller.abort(); } catch (_) {}
                    delete prefetchCache[k];
                }
            }
        }

        function shouldIntercept(a, event) {
            if (!a || !a.href) return false;
            if (event.defaultPrevented) return false;
            if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
            if (event.button !== 0) return false;
            if (a.target && a.target !== '' && a.target !== '_self') return false;
            if (a.hasAttribute('download')) return false;
            var href = a.href;
            // Hash-only: same page, only fragment changes.
            if (href.indexOf('#') !== -1) {
                try {
                    var u = new URL(href);
                    if (u.pathname === window.location.pathname && u.search === window.location.search) {
                        return false;
                    }
                } catch (_) { return false; }
            }
            try {
                var urlObj = new URL(href);
                if (urlObj.origin !== window.location.origin) return false;
                if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') return false;
            } catch (_) {
                return false;
            }
            return true;
        }

        function findAnchor(el) {
            var node = el;
            while (node && node !== document.body) {
                if (node.tagName === 'A') return node;
                node = node.parentElement;
            }
            return null;
        }

        function navigate(url, isPopstate) {
            var hairline = window.__fjuiHairline;
            var timer = null;

            // Only show hairline (after 150ms delay) for navigations that aren't
            // already resolved in the prefetch cache.
            var hasCached = prefetchCache[url] && !evictStale(url);
            if (!hasCached && hairline) {
                timer = setTimeout(function() { hairline.show(); }, 150);
            }

            // Abort all other in-flight prefetches — their responses must not
            // overwrite this navigation's result (D-05, Pitfall 4).
            abortAllPrefetchesExcept(url);

            var responsePromise;
            if (prefetchCache[url] && !evictStale(url)) {
                responsePromise = prefetchCache[url].promise;
                delete prefetchCache[url];
            } else {
                var controller = new AbortController();
                responsePromise = fetch(url, {
                    credentials: 'same-origin',
                    headers: { 'X-FJUI-Nav': '1', 'X-FJUI-Target': 'ferro-json-ui' },
                    signal: controller.signal
                });
            }

            responsePromise.then(function(response) {
                var isFragment = response.headers.get('X-FJUI-Fragment') === '1';
                var fragTitle = isFragment ? response.headers.get('X-FJUI-Title') : null;
                var fragBodyClass = isFragment ? response.headers.get('X-FJUI-Body-Class') : null;

                // Swappable check (D-03).
                var contentType = response.headers.get('content-type') || '';
                if (!response.ok || contentType.indexOf('text/html') === -1) {
                    if (timer) clearTimeout(timer);
                    if (hairline) hairline.reset();
                    clearBusy();
                    window.location.assign(url);
                    return;
                }
                // Response-URL correlation: check origin is still same-origin
                // (guards against unexpected redirects to external sites).
                try {
                    var responseUrl = new URL(response.url);
                    if (responseUrl.origin !== window.location.origin) {
                        if (timer) clearTimeout(timer);
                        if (hairline) hairline.reset();
                        clearBusy();
                        window.location.assign(url);
                        return;
                    }
                } catch (_) {
                    if (timer) clearTimeout(timer);
                    if (hairline) hairline.reset();
                    clearBusy();
                    window.location.assign(url);
                    return;
                }

                response.text().then(function(html) {
                    // Concurrency guard: if another click superseded this one, discard.
                    if (!isPopstate && url !== intendedUrl) {
                        if (timer) clearTimeout(timer);
                        if (hairline) hairline.reset();
                        clearBusy();
                        return;
                    }

                    var doc = new DOMParser().parseFromString(html, 'text/html');
                    var newEl = doc.getElementById('ferro-json-ui');
                    if (!newEl) {
                        if (timer) clearTimeout(timer);
                        if (hairline) hairline.reset();
                        clearBusy();
                        window.location.assign(url);
                        return;
                    }

                    // Capture the departing page's scroll position BEFORE the
                    // swap mutates the DOM: replacing tall content with shorter
                    // content makes the browser clamp mainEl.scrollTop, so reading
                    // it after replaceChildren would save 0 (B-03).
                    var departScrollTop = mainEl ? mainEl.scrollTop : 0;

                    // Before-swap cleanup hook (D-13): page scripts can listen
                    // to this event to close transient EventSources.
                    try {
                        document.dispatchEvent(new CustomEvent('fjui:before-navigate', {
                            detail: { url: url }
                        }));
                    } catch (_) {}

                    // Swap inner content of #ferro-json-ui (swap target only — never
                    // an ancestor node; sidebar/header node identity preserved).
                    var target = document.getElementById('ferro-json-ui');
                    if (target) {
                        var children = Array.prototype.slice.call(newEl.childNodes);
                        target.replaceChildren.apply(target, children);
                    }

                    // Sync body class from destination page (NAV-05: fill_viewport
                    // toggling — ferro-fill must be added/removed so the CSS chain
                    // activates on POS pages and deactivates on standard pages).
                    if (fragBodyClass !== null) {
                        document.body.className = fragBodyClass;
                    } else if (doc.body) {
                        document.body.className = doc.body.className;
                    }

                    // Update document title.
                    if (fragTitle !== null) {
                        document.title = fragTitle;
                    } else {
                        var titleEl = doc.querySelector('title');
                        if (titleEl) document.title = titleEl.textContent;
                    }

                    // Sidebar active-item update (D-03, Pitfall 6).
                    var oldActive = document.querySelector('.fjui-sidebar__nav-item--active');
                    if (oldActive) {
                        oldActive.classList.remove('fjui-sidebar__nav-item--active');
                    }
                    try {
                        var newPath = new URL(url).pathname;
                        var navLinks = document.querySelectorAll('.fjui-sidebar__nav-item[href]');
                        for (var j = 0; j < navLinks.length; j++) {
                            if (navLinks[j].getAttribute('href') === newPath) {
                                navLinks[j].classList.add('fjui-sidebar__nav-item--active');
                                break;
                            }
                        }
                    } catch (_) {}

                    // History + scroll management (D-07).
                    if (!isPopstate) {
                        // Save current scroll position into the current history entry
                        // before pushing the new state.
                        try {
                            history.replaceState(
                                { scrollTop: departScrollTop },
                                document.title
                            );
                            history.pushState({ scrollTop: 0 }, document.title, url);
                        } catch (_) {}
                        if (mainEl) mainEl.scrollTop = 0;
                    } else {
                        // popstate: restore scroll from event state (set by caller).
                        // Caller passes the state value as the third arg via closure.
                    }

                    // Hairline done + clear busy state on the initiating link.
                    if (timer) clearTimeout(timer);
                    if (hairline) hairline.done();
                    clearBusy();

                    // Script re-execution (D-12, B-02): scripts set via innerHTML/
                    // replaceChildren are inert; clone into fresh <script> nodes.
                    // Same-origin guard: never execute scripts from external origins.
                    // Type guard: skip non-executable script types (data islands,
                    // JSON-LD, templates, module scripts) — only classic JS
                    // (no type, or type="text/javascript") is re-executed.
                    var pendingLoads = 0;
                    function dispatchNavigated() {
                        try {
                            document.dispatchEvent(new CustomEvent('fjui:navigated'));
                        } catch (_) {}
                        // D-16: re-initialize page-scoped components after every
                        // content swap. All setup functions are idempotent via their
                        // own guards, so re-invoking ferroRuntime() is always safe.
                        // Do NOT call ferroRuntime() from inside a setup function
                        // (infinite-loop anti-pattern).
                        try { if (typeof ferroRuntime === 'function') ferroRuntime(); } catch (_) {}
                    }
                    if (newEl) {
                        var scripts = newEl.querySelectorAll('script');
                        for (var i = 0; i < scripts.length; i++) {
                            var srcType = scripts[i].type;
                            // Skip non-executable script types.
                            if (srcType && srcType !== '' && srcType !== 'text/javascript') {
                                continue;
                            }
                            var s = document.createElement('script');
                            if (scripts[i].src) {
                                try {
                                    if (new URL(scripts[i].src).origin !== window.location.origin) {
                                        continue;
                                    }
                                } catch (_) {
                                    continue;
                                }
                                // External-src scripts load asynchronously.
                                // Track pending loads so fjui:navigated fires only
                                // after all same-origin external scripts have executed
                                // (or failed). A 5 s timeout ensures a hung script
                                // cannot block the event indefinitely.
                                s.src = scripts[i].src;
                                pendingLoads++;
                                (function(node) {
                                    var settled = false;
                                    function settle() {
                                        if (settled) return;
                                        settled = true;
                                        pendingLoads--;
                                        if (pendingLoads === 0) dispatchNavigated();
                                    }
                                    node.onload = settle;
                                    node.onerror = settle;
                                    setTimeout(settle, 5000);
                                }(s));
                            } else {
                                s.textContent = scripts[i].textContent;
                            }
                            document.head.appendChild(s);
                        }
                    }

                    // Fire navigated event after all inline scripts have executed
                    // (synchronous) and all same-origin external-src scripts have
                    // loaded (or timed out after 5 s). If there are no external-src
                    // scripts, fires immediately here (pendingLoads === 0).
                    // NOTE: fjui:navigated fires after all inline scripts in the
                    // swapped content have executed. External-src scripts (same-origin
                    // only) are appended and awaited via onload; a 5 s fallback
                    // prevents a hung script from blocking the event indefinitely.
                    // Phase-249 init hooks may be inline or same-origin external-src.
                    if (pendingLoads === 0) {
                        dispatchNavigated();
                    }

                    // Focus PageHeader h2 on forward navigation (D-09).
                    if (!isPopstate) {
                        try {
                            var h2 = document.querySelector('#ferro-json-ui h2.fjui-text--display');
                            if (h2) {
                                h2.setAttribute('tabindex', '-1');
                                h2.focus({ preventScroll: true });
                            }
                        } catch (_) {}
                    }

                }).catch(function() {
                    if (timer) clearTimeout(timer);
                    if (hairline) hairline.reset();
                    clearBusy();
                    window.location.assign(url);
                });

            }).catch(function() {
                if (timer) clearTimeout(timer);
                if (hairline) hairline.reset();
                clearBusy();
                window.location.assign(url);
            });
        }

        // pointerdown: start prefetch early (D-04).
        document.addEventListener('pointerdown', function(event) {
            var a = findAnchor(event.target);
            if (a && shouldIntercept(a, event)) {
                prefetch(a.href);
            }
        }, true);

        // mouseover with dwell timer (D-04 secondary trigger).
        var hoverTimer = null;
        var hoverUrl = null;
        document.addEventListener('mouseover', function(event) {
            var a = findAnchor(event.target);
            if (a && shouldIntercept(a, { button: 0 })) {
                if (a.href !== hoverUrl) {
                    if (hoverTimer) clearTimeout(hoverTimer);
                    hoverUrl = a.href;
                    hoverTimer = setTimeout(function() {
                        prefetch(hoverUrl);
                    }, HOVER_DWELL);
                }
            } else {
                if (hoverTimer) clearTimeout(hoverTimer);
                hoverTimer = null;
                hoverUrl = null;
            }
        });
        document.addEventListener('mouseout', function() {
            if (hoverTimer) clearTimeout(hoverTimer);
            hoverTimer = null;
            hoverUrl = null;
        });

        // Track the link currently showing a busy state so it can be cleared
        // after the swap completes or on failure. Only one link is ever busy
        // at a time (the most recent intercepted click).
        var busyAnchor = null;

        function setBusy(a) {
            if (busyAnchor && busyAnchor !== a) {
                try { busyAnchor.removeAttribute('aria-busy'); } catch (_) {}
            }
            busyAnchor = a;
            try { a.setAttribute('aria-busy', 'true'); } catch (_) {}
        }

        function clearBusy() {
            if (busyAnchor) {
                try { busyAnchor.removeAttribute('aria-busy'); } catch (_) {}
                busyAnchor = null;
            }
        }

        // click: intercept same-origin GET <a> clicks (D-14).
        document.addEventListener('click', function(event) {
            // D-12 / T-249-04-04: never intercept clicks while an inline editor is active.
            try {
                if (document.querySelector('[data-inline-edit-active]')) return;
                if (event.target && event.target.closest && event.target.closest('[data-inline-edit-active]')) return;
            } catch (_) {}
            var a = findAnchor(event.target);
            if (!a) return;
            if (!shouldIntercept(a, event)) return;
            event.preventDefault();
            intendedUrl = a.href;
            setBusy(a);
            navigate(intendedUrl, false);
        }, true);

        // popstate: re-fetch the page and restore scroll (D-08).
        window.addEventListener('popstate', function(event) {
            var url = window.location.href;
            var scrollTop = event.state && typeof event.state.scrollTop === 'number'
                ? event.state.scrollTop : 0;
            intendedUrl = url;
            navigate(url, true);
            // Scroll is restored after the swap completes; pass via closure.
            // Override: patch scroll restoration into the navigate callback.
            // Since navigate is async, we defer the scroll restore via a
            // listener on fjui:navigated (fired after the swap).
            var restoreOnce = function() {
                if (mainEl) mainEl.scrollTop = scrollTop;
                document.removeEventListener('fjui:navigated', restoreOnce);
            };
            document.addEventListener('fjui:navigated', restoreOnce);
        });
    }
"#;

#[cfg(test)]
mod nav_source_tests {
    use super::SOURCE;

    #[test]
    fn nav_sends_fjui_target_header() {
        assert!(
            SOURCE.contains("'X-FJUI-Target': 'ferro-json-ui'"),
            "nav.js must send X-FJUI-Target header"
        );
    }

    #[test]
    fn nav_reads_fragment_headers() {
        assert!(
            SOURCE.contains("X-FJUI-Fragment"),
            "nav.js must read X-FJUI-Fragment response header"
        );
        assert!(
            SOURCE.contains("X-FJUI-Title"),
            "nav.js must read X-FJUI-Title response header"
        );
        assert!(
            SOURCE.contains("X-FJUI-Body-Class"),
            "nav.js must read X-FJUI-Body-Class response header"
        );
    }

    #[test]
    fn nav_still_sends_fjui_nav_header() {
        // Backward compat: both headers sent.
        assert!(
            SOURCE.contains("'X-FJUI-Nav': '1'"),
            "nav.js must still send X-FJUI-Nav for backward compat"
        );
    }
}