IN THE SHARED DOCUMENT, so a third-party app's CSS can style it, and
+ * every one of its declarations is !important for that reason alone. It carries no chrome mark, so
+ * `fs-sheets`'s fence deliberately does not spare it (the fence protects the chrome's own elements),
+ * and even a fenced sheet still matches an unmarked div. Issue #19: an app whose stylesheet carried
+ * `div { min-width: 500px !important }` won every read — all four tokens came back 500 — so the cut
+ * the sidebar was said to take became 500 + 2x500 and `fitShell` folded the sidebar into a bar on a
+ * 1857px desktop. The threshold that produces is exactly 500 + 500 + 1000 = 2000 CSS px, which is
+ * why it was reported as a ZOOM bug: Chrome at 90% gives 2063 CSS px and passed, 100% gives 1857 and
+ * failed. Inline !important is what answers it — a style-attribute declaration outranks any author
+ * rule at the same importance, so there is nothing left for the app to out-rank.
+ * box-sizing is stated for the same reason: getComputedStyle().width is the CONTENT box, so a
+ * foreign `border-box` plus padding would shave the reading (the padding/border resets below are
+ * important, but only a stated box-sizing makes them provably irrelevant). */
+function resolveLen(token, dflt) {
+ if (!_probe) {
+ _probe = document.createElement('div');
+ _probe.setAttribute('aria-hidden', 'true');
+ /* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
+ _probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
+ 'pointer-events:none!important;height:0!important;box-sizing:content-box!important;' +
+ 'min-width:0!important;max-width:none!important;border:0!important;' +
+ 'padding:0!important;margin:0!important;';
+ document.body.appendChild(_probe);
+ }
+ _probe.style.setProperty('width', 'var(' + token + ')', 'important');
+ const v = parseFloat(getComputedStyle(_probe).width);
+ return Number.isFinite(v) ? v : dflt;
+}
+
+/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc —
+ * but keyed on the DENSITY, because that is the one thing that changes these widths at runtime
+ * (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() precisely so they
+ * are re-measured). Reading one attribute is free; the memo without the key meant a density change
+ * re-measured against the widths of the density before it. */
+/* The last resort, stated ONCE so the fallbacks and the sanity net below cannot restate the
+ * stylesheet's widths in two different places. Reaching for these means the measurement failed. */
+const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
+
+let _geom = null, _geomDensity = null;
+function shellGeometry() {
+ const density = document.documentElement.getAttribute('data-density') || '';
+ if (_geom && _geomDensity === density) return _geom;
+ _geomDensity = density;
+ const px = (name, dflt) => resolveLen(name, dflt);
+ const g = {
+ contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
+ sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
+ railW: px('--fs-rail-w', GEOM_DFLT.railW),
+ /* the token is ONE side's padding; the column loses it twice */
+ contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2
+ };
+ /* Plausibility, and it costs one comparison: the rail IS the sidebar collapsed, so
+ * 0 < railW < sidebarW holds by construction. Both known ways this measurement fails destroy
+ * that — a hijacked probe reports ONE foreign width for all four (issue #19), a renamed or
+ * absent token reports 0 for all four (an abs-positioned empty div with `width:auto` shrinks to
+ * 0, and 0 is finite, so the per-read fallback above never fires). Neither can be seen in the
+ * numbers one at a time; the RELATION between them is what gives it away. */
+ _geom = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0) ? g : Object.assign({}, GEOM_DFLT);
+ return _geom;
+}
+
+function fitShell() {
+ const root = document.documentElement;
+ if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
+ root.removeAttribute('data-narrow');
+ return;
+ }
+ const g = shellGeometry();
+ const cut = prefs.currentRail() ? g.railW : g.sidebarW;
+ /* clientWidth, not innerWidth: the column the content actually gets excludes a classic
+ * scrollbar, and innerWidth includes it — 15-17px of phantom room on Linux/Windows, i.e. the
+ * 500px floor really fired at ~484. */
+ const content = document.documentElement.clientWidth - cut - g.contentPad;
+ /* toggleAttribute, NOT setAttribute: a same-value setAttribute still QUEUES a mutation record
+ * (measured in Chromium: 5 identical setAttribute('data-narrow','') -> 5 records; toggleAttribute
+ * on an already-present attribute -> 0). fitShell runs from fitChrome, which fs-fit calls on every
+ * mutation batch inside #view — i.e. once a second on any polled page. menu-footstrap observes
+ * data-narrow and treats each record as a mode CHANGE, so on a phone (390 - 224 - 56 = 110 < 500,
+ * so the attribute is permanently set) every poll tick re-fired closeFlyouts() and the section the
+ * user had just tapped open snapped shut, forever. The bug was one-sided and therefore invisible
+ * on a desktop: the else-branch removeAttribute on an absent attribute already fires 0 records. */
+ root.toggleAttribute('data-narrow', content < g.contentMin);
+}
+
+function fitChrome() {
+ fitShell();
+
+ const bar = document.querySelector('.fs-sidebar');
+ const menu = document.getElementById('topmenu');
+ /* The top bar is MEASURED at every width — no 768 floor. It used to bail below 768 and hand
+ * the job to a phone-bar media query, which left the sub-768 bar pinning its dropdowns to the
+ * left edge and never collapsing "Refreshing"; the shrink/compact/stack escalation below now
+ * runs at any width for the top layout. (The SIDEBAR layout still has its own phone bar,
+ * decided by fitShell's data-narrow, and is untouched here.) */
+ const topBar = !!bar && !!menu && prefs.isTopLayout();
+
+ if (bar) bar.classList.remove('fs-bar-stack', 'fs-ind-compact', 'fs-bar-actrow');
+ fitTabStrips();
+ /* ---- does the main menu fit on the brand's row? ----
+ * Whether it fits depends on how many sections THIS router has (stock 5, a loaded box 11), not
+ * on the viewport — so it is measured, not a breakpoint. `@media (max-width: 1199px)` stacked
+ * it on every laptop: a stock bar's contents come to ~683px, i.e. one row fits down to ~723px.
+ * Measured UNSTACKED (the remove above): a stacked menu owns a whole row and would "fit",
+ * flipping straight back — oscillation.
+ *
+ * The menu's own pills wrapping IS the "does not fit" signal, but only because the unstacked
+ * top bar is flex-wrap: nowrap (50-toplayout.css); otherwise the BAR wraps, hands the menu
+ * a whole row, and it always "fits". Do NOT measure the bar's children by offsetTop instead:
+ * the bar is align-items:center with children of differing heights, so their offsetTop differs
+ * even on one row (that read as "wrapped" for a 5-section menu). */
+ if (topBar && !stripFitsOneRow(menu)) {
+ /* First step before stacking: collapse the poll pill ("Refreshing", ~90px) to an icon
+ * square and re-measure — that width alone is often enough to keep the menu on the
+ * brand's row and skip the second row entirely (styles/theme/50-toplayout.css). */
+ bar.classList.add('fs-ind-compact');
+ fitTabStrips();
+ if (!stripFitsOneRow(menu)) {
+ bar.classList.add('fs-bar-stack');
+ fitTabStrips();
+ }
+ }
+
+ /* The cluster's own escalation, for EVERY bar — the top layout at any width, and the sidebar
+ * layout once fitShell has stamped data-narrow. It runs after the menu's, because on the top
+ * layout .fs-bar-stack has by then given the menu its own row and taken it out of the sum. */
+ if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
+ fitCluster(bar, menu);
+}
+
+/* ---- does the right-hand cluster still fit beside the brand? ----
+ *
+ * The same question as the menu's, one row up, and it cannot be asked the same way. The cluster is
+ * four SIBLINGS — the indicators, Search, Appearance, Log out — so flexbox wraps them ONE AT A
+ * TIME: measured on a 380px phone bar, Log out alone dropped onto a second row and sat at its LEFT
+ * edge under the hostname, while the other three stayed up top. Either the whole cluster shares the
+ * brand's row or it takes a row of its own, right-aligned; there is no useful state in between.
+ *
+ * Two steps, cheapest first: collapse the pills to icon squares (~200px of prose on a bar showing
+ * both), and only if that still overflows give the cluster a row. .fs-ind-compact may already be
+ * set by the menu's escalation above — adding it twice is free, and it must NOT be cleared here:
+ * whoever asked for it still needs it. */
+function fitCluster(bar, menu) {
+ bar.classList.remove('fs-bar-actrow');
+ if (clusterFitsBrandRow(bar, menu))
+ return;
+
+ bar.classList.add('fs-ind-compact');
+ if (clusterFitsBrandRow(bar, menu))
+ return;
+
+ bar.classList.add('fs-bar-actrow');
+}
+
+/* Add the widths up rather than read positions: the bar is align-items:center over children of
+ * differing heights, so offsetTop differs even on ONE row — the same trap the menu measurement
+ * documents above. The menu is excluded because it owns a full-width row of its own in every bar
+ * (`ul.nav { flex: 1 1 100% }`), so it is never what the cluster competes with. */
+function clusterFitsBrandRow(bar, menu) {
+ const cs = getComputedStyle(bar);
+ const gap = parseFloat(cs.columnGap) || 0;
+ const room = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ let need = -gap;
+
+ for (const el of bar.children) {
+ /* offsetParent is null for a display:none child, which is most of them in a bar:
+ * .fs-navlabel, .fs-spacer and #modemenu never show there, and #indicators is empty
+ * (and so hidden) until the first poll pill lands. */
+ if (el === menu || el.offsetParent === null)
+ continue;
+ need += el.offsetWidth + gap;
+ }
+
+ return need <= room;
+}
+/* No observer and no resize listener of our own: fs-fit owns both, and this file used to grow the
+ * second one docs/conventions.md warns against. A view renders its .cbi-tabmenu into #view, which fs-fit's
+ * MutationObserver already watches — and it re-fits SYNCHRONOUSLY (rule 2), where the copy here
+ * deferred through fit.schedule(), i.e. the duplicate was strictly the slower path into the same
+ * work. #tabmenu is a sibling of #view rather than inside it, but nothing writes it except
+ * renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view (with a
+ * window-resize fallback where there is no RO). */
+
+/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
+function renderModeMenu(node, renderMainMenu) {
+ const ul = document.querySelector('#modemenu');
+ const children = ui.menu.getChildren(node);
+
+ children.forEach((child, index) => {
+ const isActive = L.env.requestpath.length
+ ? child.name === L.env.requestpath[0]
+ : index === 0;
+
+ /* the main menu must render even if a template has no #modemenu — only the mode
+ * list itself is skippable chrome */
+ if (ul)
+ ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
+ E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
+ ]));
+
+ if (isActive)
+ renderMainMenu(child, child.name);
+ });
+
+ if (!ul)
+ return;
+ if (children.length <= 1)
+ ul.classList.add('single');
+ if (ul.children.length > 1)
+ ul.style.display = '';
+}
+
+/* rebuild mode menu + main menu + section tabs from the current L.env; on first load and after
+ * every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
+function renderChrome() {
+ const root = tree.tree();
+ const modemenu = document.querySelector('#modemenu');
+ const topmenu = document.querySelector('#topmenu');
+ const tabmenu = document.querySelector('#tabmenu');
+
+ if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
+ if (topmenu) topmenu.innerHTML = '';
+ if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
+
+ renderModeMenu(root, _renderMain);
+
+ if (L.env.dispatchpath.length >= 3) {
+ let node = root, url = '';
+ /* `node.children &&`, exactly as fs-menutree's nodeForSegs() walks it: a node without
+ * children is an ordinary leaf, and reading `.children[…]` off one is a TypeError that
+ * escapes renderChrome() — i.e. it takes out the mode menu, the tabs and, on the init path,
+ * everything menu-footstrap-common wires after it. The walk already tests `node` on each
+ * step; testing only half of what it dereferences is what left the hole. */
+ for (let i = 0; i < 3 && node; i++) {
+ node = node.children && node.children[L.env.dispatchpath[i]];
+ url = url + (url ? '/' : '') + L.env.dispatchpath[i];
+ }
+ if (node)
+ renderTabMenu(node, url);
+ }
+
+ fit.schedule();
+}
+
+/* Sidebar rail toggle: collapse the sidebar to an icon-only strip. The state lives on
+ * (head.ut re-applies it before paint) and in localStorage; everything else —
+ * flyout submenus, hidden labels — is CSS keyed off that attribute. */
+function wireRail() {
+ const btn = document.getElementById('fs-rail-toggle');
+ if (!btn) return;
+
+ function sync() {
+ const on = prefs.currentRail();
+ btn.setAttribute('aria-expanded', on ? 'false' : 'true');
+ const label = on ? _('Expand menu') : _('Collapse menu');
+ btn.setAttribute('aria-label', label);
+ btn.setAttribute('title', label);
+ }
+
+ btn.addEventListener('click', () => {
+ prefs.applyRail(!prefs.currentRail());
+ sync();
+ /* the sidebar's cut just changed by ~156px, so the content column may now clear (or fall
+ * below) --fs-content-min: re-measure rather than wait for a resize that is not coming */
+ fit.schedule();
+ });
+
+ sync();
+}
+
+/* An indicator pill carries its whole meaning as prose — LuCI writes "Unsaved Changes: 2" — and the
+ * collapsed rail is 68px wide. Measured on the router: the pill wants 86px, wraps onto three lines
+ * and hangs 34px past the rail's edge over the content (issue #14). The rail is an icon strip, so
+ * CSS squares the pill there and draws this attribute instead of the label; a text node cannot be
+ * reached by a selector, which is why the badge has to be lifted into one here.
+ *
+ * The COUNT is what it shows — the only part that changes and the only part worth reading at that
+ * size. A pill with no trailing number (a third-party app's "Backup pending") falls back to a
+ * neutral dot: clipping the prose was tried and rendered "up pen", because a centred pill gives an
+ * ellipsis no start to anchor to. Choosing between the two is a decision, so it lives here rather
+ * than as a second CSS rule. The full prose stays in the label either way — a screen reader still
+ * reads it, and `title` keeps it reachable by pointer. */
+const IND_DOT = '•';
+
+function wireIndicatorCounts() {
+ const box = document.getElementById('indicators');
+ if (!box) return;
+
+ function stamp() {
+ box.querySelectorAll('[data-indicator]').forEach((el) => {
+ const txt = el.textContent || '';
+ const m = txt.match(/(\d+)\s*$/);
+ el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
+ /* the rail hides the prose; the tooltip is where it stays reachable by pointer */
+ el.setAttribute('title', txt);
+ });
+ }
+
+ /* ui.showIndicator REPLACES the label's text node on an update ("Unsaved Changes: 1" -> ": 2")
+ * and appends the span on the first change of the session, so both matter — and characterData
+ * too, for the in-place rewrite. Our own attribute writes do not re-enter: attributes are not
+ * observed. */
+ new MutationObserver(stamp).observe(box, { childList: true, subtree: true, characterData: true });
+ stamp();
+}
+
+return baseclass.extend({
+ setRenderMain,
+ renderChrome,
+ wireIndicatorCounts,
+ /* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
+ * measurement rides the same engine as the data tables' */
+ fitChrome,
+ wireRail
+});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
new file mode 100644
index 00000000..b5f2faf2
--- /dev/null
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
@@ -0,0 +1,153 @@
+'use strict';
+'require baseclass';
+
+/* fs-fit — the theme's ONE "does it still fit?" engine; add fit logic here, do not grow a second
+ * observer. No CSS query can ask what the CONTENT needs (media = viewport, container =
+ * container): does the menu fit beside the brand, is a table still readable? Both were once
+ * breakpoints (one @media, five @container thresholds) — guessed numbers that real routers got
+ * wrong, useless for a third-party luci-app-* table of unknown column count.
+ *
+ * THREE RULES, each a bug that was hit:
+ * 1. MEASURE UNCOLLAPSED — a collapsed thing always "fits" (a stacked table is a pile of flex
+ * rows): read it as it stands and it un-collapses, next frame re-collapses. Oscillation.
+ * 2. RE-FIT SYNCHRONOUSLY ON A MUTATION — the poll re-renders content once a second and the
+ * fresh element has lost our class. A MutationObserver callback is a microtask (pre-paint),
+ * rAF runs AT paint: deferring there painted a stacked table one frame at full width —
+ * 19-109px of overflow, once a second, on Firewall/DHCP/Wireless.
+ * 3. COALESCE ON RESIZE — every fit forces a synchronous layout.
+ *
+ * ResizeObserver, not onresize: a rail collapse and a layout toggle change the content width
+ * without resizing the window. */
+
+const _fitters = [];
+let _rafPending = false;
+let _ro = null, _mo = null;
+
+/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
+ * relevant mutation. */
+function run() {
+ for (const fit of _fitters) {
+ try { fit(); }
+ /* one broken fitter must take neither the others nor the poll's MutationObserver
+ * callback with it — that would silently stop ALL re-fitting */
+ catch (e) { console.error('fs-fit: a fitter threw', e); }
+ }
+}
+
+/* Next frame, at most once per frame (rule 3). */
+function schedule() {
+ if (_rafPending) return;
+ _rafPending = true;
+ requestAnimationFrame(() => { _rafPending = false; run(); });
+}
+
+/* Watch an element's size. Any change re-fits everything — the fitters are cheap and few. */
+function watch(el) {
+ if (!el) return;
+ if (!_ro) {
+ if (!window.ResizeObserver) { /* no RO: fall back to the window */
+ window.addEventListener('resize', schedule);
+ return;
+ }
+ _ro = new ResizeObserver(schedule);
+ }
+ _ro.observe(el);
+}
+
+/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
+ * get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
+ * as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
+function observeContent() {
+ if (_mo) return;
+ const host = document.getElementById('view') || document.body;
+ _mo = new MutationObserver(run);
+ _mo.observe(host, { childList: true, subtree: true });
+ watch(host);
+}
+
+return baseclass.extend({
+ /* Register a fitter and run it once. A fitter selects its own elements, strips its class
+ * (rule 1), measures, re-applies. */
+ add(fit) {
+ if (typeof fit !== 'function') return;
+ _fitters.push(fit);
+ observeContent();
+ fit();
+ },
+
+ /* Re-fit on the next frame, coalesced. (There is no exported `run`: everything that changes
+ * the available room — the layout toggle, the rail collapse — schedules. Only the mutation
+ * observer re-fits synchronously, and that is rule 2's whole point.) */
+ schedule,
+
+ /* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
+ * EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
+ * hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
+ * clamp keeps a rAF handle per
so it can CANCEL a pending measure, which a one-flag
+ * coalescer cannot express. */
+ frame(fn) {
+ let pending = false;
+ return () => {
+ if (pending) return;
+ pending = true;
+ requestAnimationFrame(() => { pending = false; fn(); });
+ };
+ },
+
+ /* Did this batch add anything matching `sel`? The poll rewrites content once a second, so a
+ * MutationObserver here needs that cheap question before its document-wide queries. */
+ touches(mutations, sel) {
+ for (const m of mutations)
+ for (const n of m.addedNodes) {
+ if (n.nodeType !== 1) continue;
+ if (n.matches(sel) || n.querySelector(sel)) return true;
+ }
+ return false;
+ },
+
+ /* Room for `el` = its PARENT's content box. Measuring against ITSELF does not work: a
+ * `display: table` box with width:100% still grows past it when min-content needs more (auto
+ * layout beats the declared width), so scrollWidth and clientWidth grow together and the
+ * overflow is invisible. The parent is an ordinary block and does not grow. */
+ roomFor(el) {
+ const p = el && el.parentElement;
+ if (!p) return Infinity;
+ const cs = getComputedStyle(p);
+ return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ },
+
+ /* Does `el` need more width than it has been given? */
+ overflows(el) {
+ return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
+ },
+
+ /* How many LINE BOXES of text does `el` render? The fact behind "this column has been
+ * squeezed into a tower" — a cell that has to break its own words is a column that has run
+ * out of width, which is a thing no viewport query can ask.
+ *
+ * NOT height / line-height: the assoclist's first cell is an `.ifacebadge`, a flex COLUMN
+ * with a 32px icon over the text, so a third of that height is not text at all. Ranges over
+ * the TEXT NODES only, so an
cannot be counted as a line.
+ *
+ * Cluster by the rect's TOP, not by vertical overlap: consecutive lines OVERLAP. Measured on
+ * the router — tops 15-16px apart while each rect is 17-18px tall (the font's box is taller
+ * than the line advance), so an overlap test merged an 8-line tower into ONE line and the
+ * whole check silently never fired. Half a line of tolerance also merges what belongs on one
+ * line: a `` shares the baseline but sits a few px lower, and is not a new line. */
+ textLines(el) {
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ const range = document.createRange();
+ const rects = [];
+ for (let n; (n = walker.nextNode());) {
+ if (!n.nodeValue.trim()) continue;
+ range.selectNodeContents(n);
+ for (const r of range.getClientRects())
+ if (r.width > 0.5 && r.height > 0.5) rects.push(r);
+ }
+ rects.sort((a, b) => a.top - b.top);
+ let lines = 0, top = -Infinity;
+ for (const r of rects)
+ if (r.top - top > r.height * 0.5) { lines++; top = r.top; }
+ return lines;
+ }
+});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js
new file mode 100644
index 00000000..02d23924
--- /dev/null
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js
@@ -0,0 +1,166 @@
+'use strict';
+'require baseclass';
+
+/* The menu tree as a ROUTING TABLE: path <-> node, with LuCI's alias/firstchild resolution.
+ *
+ * Pure lookup — it renders nothing and touches no DOM. Both the chrome (which walks the tree to
+ * draw the mode menu and the section tabs) and the SPA router (which asks "what view does this URL
+ * open?") need it, and a module they both require is the only way to give them one copy without a
+ * cycle: LuCI's require() raises DependencyError on a dependency loop, so the shared half has to
+ * come OUT rather than be reached across. */
+
+/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
+let _tree = null;
+
+function setTree(tree) {
+ _tree = tree;
+}
+
+/* /cgi-bin/luci/admin/status/overview -> ['admin','status','overview'].
+ * The bare base (what build_url() emits for the brand wordmark) yields an EMPTY seg list, NOT null:
+ * the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the overview
+ * exactly as the server does — returning null made the wordmark un-routable and full-reload. null
+ * stays reserved for a path outside LuCI's scriptname. */
+function segsFromPath(pathname) {
+ const base = L.env.scriptname || '';
+ if (base && pathname.indexOf(base) !== 0)
+ return null;
+ const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
+ return rest.length ? rest.split('/') : [];
+}
+
+/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
+function nodeForSegs(segs) {
+ let node = _tree;
+ for (let i = 0; i < segs.length; i++) {
+ node = node && node.children && node.children[segs[i]];
+ if (!node) return null;
+ }
+ return node;
+}
+
+/* ---- alias / firstchild resolution ----
+ *
+ * 7 of the 27 menu links are redirects, not pages: 4 `alias` (Firewall, System Log, Realtime
+ * Graphs) and 3 `firstchild` (Administration, Terminal, Attended Sysupgrade) — i.e. the
+ * most-clicked entries were the ones still doing a full load.
+ *
+ * The server does not redirect them: a full GET of /admin/status/logs answers 200 at that URL and
+ * stamps the RESOLVED leaf into requestpath/dispatchpath/nodespec, keeping `pathinfo` as requested.
+ * The client must resolve EXACTLY as dispatcher.uc does, or a click and an F5 on the same URL would
+ * open different pages — nodeWeight() and firstChildOf() are ports, not approximations. Only the
+ * ACL check is skipped: the tree from /admin/menu is already ACL-filtered for this session.
+ *
+ * `rewrite` is deliberately NOT followed: the tree has none, and a wrong guess at its splice
+ * semantics would silently open the WRONG page — worse than the full load it falls back to. */
+
+/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
+function nodeWeight(node) {
+ return Math.min(node.order ?? 9999, 9999) + (node.auth && node.auth.login ? 10000 : 0);
+}
+
+/* resolve_firstchild() from dispatcher.uc: the eligible child of lowest weight. Ties go to tree
+ * order (the comparison is strict, as upstream's is, and JSON.parse preserves key order). A
+ * `firstchild` child is eligible only if it resolves to something itself — recursively. */
+function firstChildOf(node) {
+ let bestName = null, best = null;
+ const kids = node.children || {};
+ for (const name in kids) {
+ const child = kids[name];
+ if (!child.satisfied || !child.title || !child.action || typeof child.action !== 'object')
+ continue;
+ if (child.action.type === 'firstchild') {
+ if ((!best || nodeWeight(best) > nodeWeight(child)) && firstChildOf(child)) {
+ best = child; bestName = name;
+ }
+ } else if (!child.firstchild_ineligible) {
+ if (!best || nodeWeight(best) > nodeWeight(child)) {
+ best = child; bestName = name;
+ }
+ }
+ }
+ return best ? { name: bestName, node: best } : null;
+}
+
+/* Follow alias/firstchild to the real page: {segs, node} of the leaf the dispatcher would have
+ * rendered, or null when nothing resolves (the server would 404 — let it). The hop cap is a cycle
+ * guard: an alias loop in some app's menu.d must not hang the UI. */
+function resolveSegs(segs) {
+ let node = nodeForSegs(segs);
+ for (let hops = 0; node && node.action && hops < 8; hops++) {
+ const type = node.action.type;
+ if (type === 'alias') {
+ segs = String(node.action.path).split('/');
+ node = nodeForSegs(segs);
+ } else if (type === 'firstchild') {
+ const pick = firstChildOf(node);
+ if (!pick) return null;
+ segs = segs.concat([ pick.name ]);
+ node = pick.node;
+ } else {
+ return { segs, node };
+ }
+ }
+ return null;
+}
+
+/* ---- readonly is a property of the PATH, not of the leaf ----
+ *
+ * The dispatcher decides it twice over, from two different inputs. apply_tree_acls()
+ * (dispatcher.uc:442) walks the menu JSON handed to the client and marks a NODE readonly when its
+ * OWN `depends.acl` resolves to read-without-write. The request path instead accumulates every
+ * ancestor's acls into ctx.acls and stamps the leaf from the accumulation
+ * (`resolved.node.readonly = !perm`, :1003). So a leaf that declares no acl of its own still comes
+ * back readonly from a real GET, while its node in the tree carries nothing at all.
+ *
+ * Reading the leaf alone therefore loses it, and did: measured on the stand, a full load of
+ * admin/status/logs/syslog reports nodespec.readonly true — the flag sits on `logs`, two levels up —
+ * against false on an SPA arrival, and the same for dmesg and all four realtime graphs. luci.js
+ * implements hasViewPermission() as `!env.nodespec.readonly`, which is what views and luci.js's own
+ * Save/Apply footer key their disabled state off, so on a session with narrower ACLs than root's
+ * this is any page whose SECTION is read-only.
+ *
+ * OR down the path, exactly as check_acl_depends() folds the accumulated list: one read-only acl
+ * anywhere on it is enough. Feed it the RESOLVED segments, since that is the path the dispatcher
+ * accumulates over. */
+function readonlyForSegs(segs) {
+ let node = _tree;
+ if (node && node.readonly === true)
+ return true;
+ for (let i = 0; i < segs.length; i++) {
+ node = node && node.children && node.children[segs[i]];
+ if (!node)
+ return false;
+ if (node.readonly === true)
+ return true;
+ }
+ return false;
+}
+
+/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
+ * `template` node maps to view.status.index (its server template just instantiates that — see
+ * ensureOverviewHelpers in fs-router.js). Shared by navigate() and the hover prefetch. */
+function viewClassFor(node) {
+ if (!node || !node.action || node.satisfied === false)
+ return null;
+ if (node.action.type === 'view')
+ return 'view.' + String(node.action.path).replace(/\//g, '.');
+ if (node.action.type === 'template' && node.action.path === 'admin_status/index')
+ return 'view.status.index';
+ return null;
+}
+
+/* The node the CURRENT full-load landed on, i.e. what L.env.dispatchpath points at. */
+function currentNode() {
+ return nodeForSegs(L.env.dispatchpath || []);
+}
+
+return baseclass.extend({
+ setTree,
+ tree: () => _tree,
+ segsFromPath,
+ currentNode,
+ resolveSegs,
+ readonlyForSegs,
+ viewClassFor
+});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js
new file mode 100644
index 00000000..c1fbd50d
--- /dev/null
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js
@@ -0,0 +1,334 @@
+'use strict';
+'require baseclass';
+'require dom';
+'require network';
+'require fs-fit as fit';
+
+/* Footstrap overview LAYOUT-only module: renders NOTHING of its own, only re-arranges the STOCK
+ * sections — wrapping System / Memory / Storage in a grid so Memory and Storage sit in a right
+ * column beside System. Content, data and styling stay luci-mod-status's. (Do not go back to the
+ * old 05_footstrap_dashboard.js: re-rendering a custom tree every poll flickered and reset mobile
+ * scroll.) The stock poll updates each section IN PLACE via dom.content() and never rebuilds the
+ * .cbi-section wrapper, so once moved into our grid the wrappers stay put across polls.
+ *
+ * IT USED TO LIVE IN LuCI'S GLOBAL INCLUDE DIR (view/status/include/05_footstrap_overview_layout.js)
+ * and that was a real defect, not a filing preference: luci-mod-status loads EVERY *.js in that
+ * directory, so this file was fetched, parsed and evaluated on the overview of every router running
+ * a DIFFERENT theme. Measured with a headless browser against the dev container with `bootstrap`
+ * active: the request is right there beside 10_system.js and 20_memory.js. The `L.env.media` gate
+ * silenced it, but only after it had already been downloaded and run — a theme package reaching
+ * into another module's namespace, which is exactly what this project refuses to do to third-party
+ * apps (docs/conventions.md, the three zones). It is a chrome module now, loaded by the chrome, so a router on
+ * another theme never sees it at all.
+ *
+ * WHAT THAT COST, and why the code below looks the way it does: the old location bought two timing
+ * guarantees for free, because LuCI evaluated the file INSIDE index.load(). Both had to be paid for
+ * explicitly — see ensureOverviewHelpers() and patchOverview() at the bottom. */
+function isFootstrapTheme() {
+ return String(L.env.media || '').indexOf('footstrap') >= 0;
+}
+
+/* section title -> grid role. _() with NO msgctxt on purpose: these must resolve to exactly what
+ * luci-mod-status resolves to, or the titles stop matching. Built once — it used to cost an
+ * allocation plus three _() lookups per poll tick. */
+const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
+
+function sectionTitle(sec) {
+ /* TWO title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10
+ * emits a bare `` as the section's first child. Matching only the wrapped one meant the
+ * grid never applied on 24.10 at all — measured on the dev container: every section tagged
+ * `none`, `.fs-ovl` never built, silently. */
+ const h = sec.querySelector('.cbi-title h3, :scope > h3');
+ return (h && h.firstChild) ? String(h.firstChild.nodeValue || '').trim() : '';
+}
+
+/* the wrapper we built, so the poll-tick fast path costs one property read */
+let _wrapEl = null;
+
+function arrange() {
+ /* the SPA nav can leave this _observer wired while another page renders into #view — detach as
+ * soon as the route stops being the overview. Both the server template and the SPA router
+ * stamp body[data-page] with the DISPATCH path, so /admin/status (firstchild -> overview)
+ * matches too. */
+ if ((document.body.getAttribute('data-page') || '') !== 'admin-status-overview') {
+ stopWatch();
+ return;
+ }
+ const view = document.getElementById('view');
+ if (!view) return;
+
+ /* Fast path — the poll lands here once a second, forever. The stock poll never rebuilds the
+ * .cbi-section wrappers, so the grid survives and there is nothing to do; proving that used
+ * to cost a querySelectorAll over #view plus a sectionTitle() dig per section, every tick.
+ * Deliberately NOT a disconnect(): if a future luci-mod-status ever DOES rebuild a section,
+ * the wrapper loses its children and the slow path below rebuilds the grid — self-healing. */
+ if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
+ return;
+
+ const found = {};
+ view.querySelectorAll(':scope > .cbi-section').forEach((sec) => {
+ const r = ROLES[sectionTitle(sec)];
+ if (r && !found[r]) found[r] = sec;
+ });
+ /* wait until all three stock sections exist */
+ if (!(found.sys && found.mem && found.sto)) return;
+ /* already wrapped? (first tick after a rebuild re-finds the existing grid) */
+ if (found.sys.parentElement && found.sys.parentElement.classList.contains('fs-ovl')) {
+ _wrapEl = found.sys.parentElement;
+ return;
+ }
+ const wrap = document.createElement('div');
+ wrap.className = 'fs-ovl';
+ found.sys.parentNode.insertBefore(wrap, found.sys);
+ found.sys.classList.add('fs-ovl-sys'); wrap.appendChild(found.sys);
+ found.mem.classList.add('fs-ovl-mem'); wrap.appendChild(found.mem);
+ found.sto.classList.add('fs-ovl-sto'); wrap.appendChild(found.sto);
+ _wrapEl = wrap;
+}
+
+/* Stock sections render async and repaint every poll, so watch #view and re-run arrange()
+ * (coalesced, ONE _observer per #view node — a per-poll _observer leak would slow the page down).
+ * The SPA router may REPLACE the #view element between visits, so re-attach when the node we
+ * observed is no longer the current one: a singleton bound to the first #view would silently
+ * watch a detached tree and the grid would never apply on a later SPA visit. */
+let _observer = null, _observedView = null, _routeObserver = null;
+function stopWatch() {
+ if (_observer) _observer.disconnect();
+ _observer = null;
+ _observedView = null;
+ _wrapEl = null; /* the grid belongs to the #view we are leaving */
+}
+function watch() {
+ const view = document.getElementById('view');
+ if (_observer && _observedView !== view)
+ stopWatch();
+ arrange();
+ /* The route check is what the stock include's render() used to provide implicitly — it only ran
+ * when luci-mod-status rendered THIS page. A chrome module is alive on every page, so without
+ * this an observer would be attached to #view on, say, the firewall page and re-run arrange()
+ * for every mutation of a table it has no business watching. */
+ if (_observer || !view ||
+ (document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
+ return;
+ _observedView = view;
+ /* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
+ * theme's shared coalescer, fs-fit.js) */
+ _observer = new MutationObserver(fit.frame(arrange));
+ _observer.observe(view, { childList: true, subtree: true });
+}
+
+/* WHAT REPLACED render(). The include was instantiated by luci-mod-status once per overview render,
+ * which is how it knew a visit had happened; a chrome module is instantiated once per PAGE LOAD and
+ * then has to notice SPA navigation itself. `body[data-page]` is the signal — both the server
+ * template and fs-router stamp it with the dispatch path — so one attribute observer covers arriving
+ * at the overview, leaving it, and coming back.
+ *
+ * The empty `.cbi-section` wrapper stock used to build around this include is gone with it, and so
+ * is the `.fs-ovl-marker` element that existed only to let CSS hide that wrapper. */
+function wire() {
+ if (_routeObserver || !isFootstrapTheme() || !document.body)
+ return;
+ _routeObserver = new MutationObserver(() => {
+ if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
+ onOverview();
+ else
+ stopWatch();
+ });
+ _routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
+ if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
+ onOverview();
+}
+
+/* Everything that must happen on ARRIVAL at the overview, from either direction: a full page load
+ * that lands here, or an SPA navigation that restamps data-page. patchOverview() is idempotent
+ * (the __fsProgressive flag), so the two paths cannot double-patch. */
+function onOverview() {
+ patchOverview();
+ watch();
+}
+
+/* ---- progressive paint -----------------------------------------------------
+ *
+ * Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over EVERY
+ * include's load(), and render() does not return the tree until it resolves — so #view stays
+ * EMPTY for as long as the slowest include takes. Measured on the dev router (warm SPA nav):
+ * 182 ms of blank page, of which System/CPU/Memory/Storage/DHCP/Network were ready at 88 ms and
+ * were simply waiting on 29_ports and 60_wifi (180 ms each).
+ *
+ * Replacing poll_status does two things:
+ * 1. Each section paints when ITS OWN data lands: first content halves, 182 -> ~90 ms. Nothing
+ * jumps — the frames are already in the DOM (built before poll_status is called), a section
+ * just goes hidden -> filled, exactly as on a stock poll tick.
+ * 2. Kills the redundant re-fetch: stock adds the poller only after the first load completes
+ * and Poll.add() steps at once, so the overview re-fetched EVERYTHING (~250 ms of ubus)
+ * right after the first paint. The in-flight guard joins that to the run already going.
+ *
+ * NOT a re-implementation — frames, toggles, includes and their render() stay upstream's.
+ * fillSection() transcribes stock's own loop in the same order so it can be diffed against
+ * index.js when luci-mod-status changes; if that shape is gone, the patch is skipped and the
+ * page runs stock. */
+function fillSection(inc, container, res) {
+ if (inc.failed)
+ return;
+ let content = null;
+ if (typeof inc.render === 'function')
+ content = inc.render(res);
+ else if (inc.content != null)
+ content = inc.content;
+ if (typeof inc.oneshot === 'function') {
+ inc.oneshot(res);
+ inc.oneshot = null;
+ }
+ if (content != null) {
+ container.parentNode.style.display = '';
+ container.parentNode.classList.add('fade-in');
+ if (!inc.hide)
+ dom.content(container, content);
+ }
+}
+
+let _inflight = null;
+
+function pollProgressive(includes, containers, first_load) {
+ /* A run is already fetching exactly this data — join it instead of starting a second
+ * stampede of the same RPCs. This is what kills the duplicate load. */
+ if (_inflight)
+ return first_load ? Promise.resolve() : _inflight;
+
+ const run = network.flushCache().then(() => Promise.all(
+ includes.map((inc, i) => {
+ if (inc.hide && !first_load)
+ return null;
+ const loaded = (typeof inc.load === 'function')
+ ? Promise.resolve(inc.load()).catch(() => { inc.failed = true; })
+ : Promise.resolve(null);
+ /* the point of the patch: fill THIS section the moment ITS data is here,
+ * not at the end of a Promise.all over all of them */
+ return loaded.then((res) => {
+ try { fillSection(inc, containers[i], res); }
+ catch (e) { console.error('footstrap: overview section failed', e); }
+ });
+ }).filter(Boolean)
+ )).then(() => {
+ const ssi = document.querySelector('div.includes');
+ if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
+ });
+
+ _inflight = run.finally(() => { _inflight = null; });
+ /* NOBODY AWAITS THIS ON THE FIRST LOAD — the line below hands the caller a fresh
+ * Promise.resolve() so index.render() can return at once — so a rejection here has no handler
+ * and surfaces as an unhandled rejection in the console. `run` rejects for one ordinary reason:
+ * network.flushCache() failing on an expired session, i.e. exactly when the user is already
+ * being redirected to the login page and the noise is least useful. The sections' own failures
+ * cannot reach it (fillSection is called inside a try/catch and inc.load() has its own .catch),
+ * so there is nothing to report that the page has not reported already. */
+ _inflight.catch(() => {});
+
+ /* First load: resolve NOW so index.render() returns its tree and the frames reach #view
+ * immediately; the sections fill themselves. A poll tick resolves when the data is in —
+ * that is what the poller expects. */
+ return first_load ? Promise.resolve() : _inflight;
+}
+
+/* Patch the stock overview view: replace poll_status so each section paints when its own data lands.
+ *
+ * TIMING, AND WHAT MOVING THE FILE COST. As an include this ran at module eval — which LuCI performs
+ * inside index.load(), i.e. after the view instance exists and before render() calls poll_status:
+ * the exact window the patch needs, for free, on both a full load and an SPA nav.
+ *
+ * A chrome module evaluates much earlier, so that window has to be aimed at rather than inherited,
+ * and it is called from the ROUTE (see wire()) instead of at eval. Two reasons it is not called at
+ * module eval any more: `L.require('view.status.index')` would pull the whole stock overview view
+ * into memory on every page, including pages that are not the overview; and on a full page load the
+ * require would race index.load() — which is why the patch is idempotent, guarded by the
+ * `__fsProgressive` flag on the prototype, and why failing to land is harmless. If it misses, the
+ * page simply renders the stock way: one Promise.all, ~90 ms later. Never broken, sometimes slower.
+ */
+function patchOverview() {
+ /* `window.L`, NEVER the bare `L` this factory was handed — and the reason is NOT this module's
+ * own convenience, it is what the STOCK view ends up holding. `require()` passes the object it
+ * was called on into the loaded module's factory (`const L = this` in luci.js), and
+ * `view/status/index.js` then loads its own includes with that same `L` inside `load()`
+ * (`L.require('view.status.include.' + …)`), so whichever `L` reaches index.js reaches
+ * 30_network.js too — which calls `L.itemlist(...)` directly. `ui` hangs itemlist/showModal/…
+ * on the RUNTIME INSTANCE that the dispatcher builds (`window.L = new LuCI()`), and a chrome
+ * module like this one is loaded as a dependency, i.e. through `LuCI.prototype.require`, so our
+ * `L` is the PROTOTYPE and has none of them.
+ *
+ * `require()` caches by class name, so the FIRST caller decides this for everybody: patching
+ * through the bare `L` cached view.status.index bound to the prototype, and the overview then
+ * died mid-render on "L.itemlist is not a function" with the page stuck on "Loading view…"
+ * (issue #22 follow-up). It only bit on an SPA arrival — a full page load has the dispatcher
+ * require the view through `window.L` before this observer ever fires — and only when this
+ * patch won the race against fs-router's own `RT.require`, which is exactly the "sometimes,
+ * coming from another page" the report described. Same trap as the itemlist calls below
+ * (docs/spa-router.md), reached from the other side. */
+ window.L.require('view.status.index').then((idx) => {
+ const proto = idx ? Object.getPrototypeOf(idx) : null;
+ if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
+ return;
+ proto.__fsProgressive = true;
+ proto.poll_status = function(includes, containers, first_load) {
+ return pollProgressive(includes, containers, first_load);
+ };
+ }).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
+}
+
+/* Status→Overview is a `template` node whose server template (admin_status/index.ut) defines 3
+ * globals the stock status includes use (18_cpu/20_memory/25_storage/…) and then instantiates
+ * view.status.index. Arriving by the theme's SPA router never runs that inline
+
+ {% endif %}
+
+