⛄ Sync 2026-08-12 20:59:19

This commit is contained in:
github-actions[bot] committed 2026-08-12 20:59:19 +08:00
1 parent d5f9df4a19
commit 5c244874df
62 files changed
+2165 -809

No files matched your search

@@ -11,8 +11,8 @@
*
* WHERE IT LIVES. It used to be a popover hanging off a button in the chrome; it is now a TAB on
* System -> System (admin/system/system), beside General Settings / Logging / Time Synchronization
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — eighteen of them, nine
* carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — twenty-one of
* them, nine carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
* place itself against a viewport edge and stay inside a 320px column is the wrong container for
* that. Keeping BOTH would have meant every axis rendered twice, which is the failure this file's
* own history is made of.
@@ -46,9 +46,12 @@
* the current look to the ROUTER for other browsers. That distinction is the whole model
* (docs/design-system.md) and it is why this page has no Save/Reset footer of LuCI's own. */
function render() {
/* still a promise: the view awaits it, and keeping the shape means a future asynchronous step
* (or the caller) needs no change. */
return Promise.resolve(build());
/* A promise, and the build runs INSIDE it rather than as its argument: `Promise.resolve(build())`
* evaluates build() synchronously, so a throw in it unwound out of render() before mount() could
* attach .catch/.finally — leaving mount()'s _building flag set for the life of the document. The
* tab then never built again on that page and nothing was logged, which is both channels silent
* at once. Deferred like this, the same throw lands in the .catch that exists for it. */
return Promise.resolve().then(build);
}
function build() {
@@ -219,15 +222,15 @@ function build() {
/* the STRENGTH half of the Tint — how strong the hue reads. Only meaningful in hue mode: a
* hex canvas IS the colour asked for, with no chroma of ours to scale. CSS hides it in the
* other two states (no tint at all, or a hex one). */
/* NOT "Density": that is the UI-density segment above, and this string is both the visible
/* NOT "Density": that is the UI-density select above, and this string is both the visible
* caption AND the control's aria-label, so two rows would read "Density" and a screen reader
* would announce "Density, radio group" and "Density, slider" with nothing to tell them
* would announce "Density, combo box" and "Density, slider" with nothing to tell them
* apart. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
step: 5,
fmt: (v) => v + '%'
}), { cls: 'fs-ap-tint fs-ap-density' }),
unit: '%'
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
/* recolours the accented CONTROLS (buttons/toggles/sliders/focus rings), not the canvas the
* way Tint does. Measured as TEXT on a card, which is the use that fails first: as a fill it
@@ -487,8 +490,21 @@ function build() {
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
/* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled
* "Save as default". Called after every axis change (via bump). */
* "Save as default". Called after every axis change (via bump).
*
* Unless this browser refuses storage, in which case the status would be a lie in both halves:
* nothing was written, so every current*() reads the ROUTER default back and the comparison is
* true however far the page has been dragged from it. Say what is actually true instead — the
* axes apply and are forgotten on reload — and leave the button enabled, because pushing this
* browser's look to the router is the one thing that still works here. */
function refreshSave() {
if (prefs.storageBroken()) {
saveBtn.disabled = false;
saveBtn.textContent = _('Save as default', 'footstrap');
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap');
saveErr.hidden = false;
return;
}
const saved = prefs.matchesSavedDefault();
saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
@@ -615,8 +631,9 @@ function build() {
open = !open;
prefs.lsSet(key, open ? 'on' : 'off');
paint();
/* the colour controls read the COMPUTED cascade, and a hidden element computes nothing
* useful — so the readouts are filled when the group becomes visible, not before */
/* Refreshed on OPEN because the axes below it may have moved while it was collapsed —
* a palette switch or a preset changes what every readout says. Cheap and skipped
* while closed: nothing in that fold is on screen to be wrong. */
if (open) refreshColours();
});
paint();
@@ -637,10 +654,11 @@ function build() {
section(_('Defaults', 'footstrap'), defaults)
]);
/* The colour controls read the COMPUTED cascade, which needs them in the document — so the first
* fill cannot happen while the tree is still being assembled above. The view appends this
* element synchronously on return, so a microtask is late enough and early enough: late enough
* that the probe resolves, early enough that nothing has painted a blank field. */
/* The first fill, deferred one microtask so the tree above is finished being assembled. It does
* NOT wait for the form to be in the document, and does not need to: every value a readout shows
* comes back through widgets.probeColor(), which keeps its own hidden probe attached to <body>
* and resolves the cascade there — so this runs while the form is still detached (mount() appends
* it a microtask later) and still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
}
@@ -666,7 +684,9 @@ function takeReturn() {
return true;
} catch (e) { return false; }
}
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
const MARK = 'fs-ap';
/* how long the stock view gets to render its tabs before a missing group counts as a failure */
const TAB_DEADLINE = 5000; /* the built form's own class; also how mount() knows it is already there */
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
let _routeObserver = null, _viewObserver = null, _observedView = null, _building = false;
@@ -692,11 +712,31 @@ function stopWatch() {
* yet is a page still rendering, not a page without tabs.
*
* The flag and the sibling are the whole test, deliberately: the panes themselves are NOT required
* to be found here. On the stock System page they are not direct children of the marked element at
* all — the map nests them a level deeper — so a `:scope > .cbi-tabcontainer` check found none and
* the tab was never added, silently, on a page that plainly has tabs. */
* to be found here, and a check for them by CLASS is what failed. A modern pane carries no class at
* all — form.js gives it `data-tab` and `data-tab-title` and nothing else, and `.cbi-tabcontainer`
* is luci-compat vocabulary from the Lua CBI — so `:scope > .cbi-tabcontainer` matched nothing on a
* page that plainly has tabs, and the tab was never added, silently. ui.tabs itself marks
* `panes[0].parentNode`, so the panes ARE this element's children; they are simply not identifiable
* that way. */
/* Tab groups that belong to the page we just LEFT. The router stamps body[data-page] before the
* incoming view renders, and on a warm route #view still holds the outgoing page's DOM at that
* moment — so mount() found ITS tab strip and appended the whole Appearance form, plus a live,
* clickable "Footstrap" <li>, to another page's tabs. Measured arriving at System -> System from
* Network -> DHCP: two builds for one arrival, and for 66 ms on localhost (an RTT or more on a real
* router, since the window is the incoming view's load()) the tab sat on the DHCP strip and opened
* all 24 Appearance rows when clicked. Every group present at the moment of the stamp is therefore
* disqualified; the incoming view's own group is a fresh element and is not in this set. */
const _staleGroups = new WeakSet();
function disqualifyCurrentGroups() {
const view = document.getElementById('view');
if (!view) return;
for (const g of view.querySelectorAll('[data-initialized="true"]'))
_staleGroups.add(g);
}
function tabGroup(view) {
for (const g of view.querySelectorAll('[data-initialized="true"]')) {
if (_staleGroups.has(g)) continue;
const menu = g.previousElementSibling;
if (menu?.classList.contains('cbi-tabmenu'))
return { group: g, menu };
@@ -739,8 +779,10 @@ function mount() {
const title = 'Footstrap';
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
* opened on before, and a theme has no business taking that over. */
/* The same shape a stock pane has, which is `data-tab` + `data-tab-title` and no class:
* ui.tabs.switchTab reads the attributes, and the `cbi-tabcontainer` class this used to
* carry is luci-compat's, styled by no rule this theme ships. */
t.group.appendChild(E('div', {
'class': 'cbi-tabcontainer',
'data-tab': TAB,
'data-tab-title': title
}, [ form ]));
@@ -764,13 +806,32 @@ function watch() {
_viewObserver = new MutationObserver(mount);
_viewObserver.observe(view, { childList: true, subtree: true });
mount();
/* A DEADLINE on the one failure that is otherwise perfectly silent. tabGroup() reads three
* private ui.tabs facts — the `data-initialized` marker, the `cbi-tabmenu` class on the menu it
* inserts, and `cbi-tab-disabled` on the items — and one of those has already moved between
* 24.10 and 25.12 (`data-tab-group` was dropped with no announcement). If any of the three we do
* read goes the same way, mount() simply returns early on every mutation: the stock page renders
* perfectly, nothing throws, and every Appearance axis becomes unreachable. Say it once, after
* the page has had time to render. */
window.setTimeout(() => {
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK) || _building) return;
console.error('footstrap: the Appearance tab could not be attached — this page has tabs, but '
+ 'ui.tabs no longer marks them the way fs-appearance.js looks for. Every Appearance axis '
+ 'is unreachable until that is updated.');
}, TAB_DEADLINE);
}
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
* observer inside. */
function wire() {
if (_routeObserver || !document.body) return;
_routeObserver = new MutationObserver(() => (onPage() ? watch() : stopWatch()));
_routeObserver = new MutationObserver(() => {
/* BEFORE deciding anything: whatever is in #view at the moment data-page changes belongs to
* the page being left (see _staleGroups). */
disqualifyCurrentGroups();
return onPage() ? watch() : stopWatch();
});
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
if (onPage()) watch();
}
@@ -151,7 +151,7 @@ function resolveLen(token, dflt) {
* 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;
let _geom = null, _geomDensity = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
if (_geom && _geomDensity === density) return _geom;
@@ -170,7 +170,19 @@ function shellGeometry() {
* 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);
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
/* SAY SO when it fires. The fallback keeps the chrome laid out, which is right — but it lays it
* out on the literals CLAUDE.md forbids copying into JS, so from there on the sidebar folds at a
* width nobody chose while the page still LOOKS correct. Silent, that is how a renamed token
* ships green; one console line is the difference between a mystery and a grep. Once per
* document — this runs on every resize and every mutation. */
if (!sane && !_geomWarned) {
_geomWarned = true;
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
+ JSON.stringify(g) + ') — falling back to the built-in defaults. A --fs-* width token was '
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
}
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
return _geom;
}
@@ -236,8 +248,8 @@ function fitChrome() {
}
/* 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. */
* layout once fitShell has stamped data-narrow. It runs after the menu's, so that when the menu
* did NOT fit, .fs-bar-stack has already given it a row of its own. */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
}
@@ -268,8 +280,12 @@ function fitCluster(bar, menu) {
/* 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. */
* documents above. The menu is excluded either way. Where it wraps to a row of its own it plainly
* is not competing (`ul.nav { flex: 1 1 100% }` — the sidebar-layout bar, and the top bar once
* .fs-bar-stack is set); where it does share the brand's row, un-stacked at `flex: 1 1 auto`
* (theme/50-toplayout.css), it is the one child that SHRINKS, so counting its current width would
* report the cluster as not fitting whenever the menu happened to be wide. What the cluster has to
* fit beside is the brand and the other actions. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
@@ -292,8 +308,7 @@ function clusterFitsBrandRow(bar, menu) {
* 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). */
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view. */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
@@ -44,13 +44,11 @@ function schedule() {
/* 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);
}
/* No feature test: the shipped CSS needs :has() and container queries, both years younger than
* ResizeObserver in every engine, so a browser that can render this theme at all has it. The
* window-resize fallback that used to sit here was worse than nothing anyway — it cannot see a
* rail collapse or a layout toggle, which is the pair this file uses an observer FOR. */
if (!_ro) _ro = new ResizeObserver(schedule);
_ro.observe(el);
}
@@ -138,8 +138,8 @@ function readonlyForSegs(segs) {
}
/* 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. */
* `template` node maps to view.status.index (its server template just instantiates that — the
* globals that template also defines are fs-overview.js's, see ensureOverviewHelpers there). Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
@@ -19,15 +19,13 @@
* 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.
* another theme never sees it at all — which is why nothing below re-checks `L.env.media`: the file
* cannot be reached except through this theme's own footer partial, and a gate for that is a gate
* for a state that cannot occur.
*
* 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. */
@@ -39,7 +37,17 @@ function sectionTitle(sec) {
* 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() : '';
if (!h) return '';
/* The first non-empty TEXT node, not `firstChild`. That heading is not text-only any more: 25.12
* appends a hide/show <span> inside the same <h3>, and `firstChild` lands on the title today only
* because upstream happened to put the words first. One reordering there and every section reads
* as an empty title — the grid silently stops being built, with the page still rendering fine. */
for (const n of h.childNodes) {
if (n.nodeType !== 3) continue;
const t = String(n.nodeValue || '').trim();
if (t) return t;
}
return '';
}
/* the wrapper we built, so the poll-tick fast path costs one property read */
@@ -126,7 +134,7 @@ function watch() {
* 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)
if (_routeObserver || !document.body)
return;
_routeObserver = new MutationObserver(() => {
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
@@ -188,11 +196,21 @@ function fillSection(inc, container, res) {
}
let _inflight = null;
/* WHICH containers the in-flight run is filling. The guard below is module-level because the
* duplicate load it kills is module-level, but the frames are per RENDER — so joining a run blindly
* joined one that fills SOMEBODY ELSE'S frames. Reproduced: double-click Status → Overview 100 ms
* apart, or leave and return inside the first-load window, and the second arrival's sections stayed
* at their birth `display:none` for a full poll interval (measured 5.9 s against 0.4 s on a single
* arrival) — it had "joined" a run that was filling the frames the content swap had just detached.
* Stock LuCI has no such guard and each render fetches its own data, so the blank page is ours. */
let _inflightFor = 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)
/* A run is already fetching exactly this data, FOR THESE FRAMES — join it instead of starting a
* second stampede of the same RPCs. This is what kills the duplicate load. A run for older
* frames is left to finish into the detached nodes it owns, which costs nothing and is simpler
* than cancelling RPCs that are already in flight. */
if (_inflight && _inflightFor === containers)
return first_load ? Promise.resolve() : _inflight;
const run = network.flushCache().then(() => Promise.all(
@@ -214,7 +232,11 @@ function pollProgressive(includes, containers, first_load) {
if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
});
_inflight = run.finally(() => { _inflight = null; });
_inflight = run.finally(() => {
/* only if it is still OURS: a newer render may have replaced it while this one was running */
if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
});
_inflightFor = containers;
/* 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:
@@ -27,9 +27,19 @@
* tint back off). Every axis therefore records the chosen value, including the off/default one, the
* way `layout` always has. lsDel is reserved for resetToSaved(), which drops back to the router
* default on purpose. */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
/* A browser can REFUSE storage outright — "block all cookies" for this address in Chrome/Safari,
* dom.storage.enabled=false in Firefox, a partitioned WebView — and then every access throws. The
* three helpers below still swallow it, because an axis that cannot be remembered must still be
* allowed to APPLY; what they no longer do is keep quiet about it. Without this flag the page told a
* flat lie: each control took effect, nothing was written, current*() then read null and fell back
* to the router default, so matchesSavedDefault() was true and the Save button sat there DISABLED
* reading "Saved as default" while the page was painted in three axes the router default does not
* carry — and a reload dropped all of them. The Appearance tab asks this and says so once. */
let _lsBroken = false;
function storageBroken() { return _lsBroken; }
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { _lsBroken = true; } }
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
/* A stored JSON ARRAY, or [] — the shape the two REMEMBERED LISTS use (the search palette's recent
* paths, the menu's open sections). lsGet above owns the try/catch around localStorage itself; this
@@ -45,7 +55,7 @@ function lsGetArr(k) {
}
/* the router-wide defaults the server stamped (head.ut). Read at RUNTIME so current*() reports the
* effective default when this browser has no localStorage — the popover's controls then show what
* effective default when this browser has no localStorage — the Appearance tab's controls then show what
* the page is actually painted as, not a phantom "auto". */
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
@@ -371,7 +381,7 @@ function applyPalette(val) {
* The list is what VALIDATES a stored value, so a value added to the CSS and not to this array is
* one head.ut pre-paints and the live applier then rejects: the page would paint it and the first
* touch of any other control would take it away. Adding one means this line, the head.ut whitelist,
* the segmented control in fs-appearance.js and the rules in 15-wallpaper.css.
* the Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css.
*
* A router upgrading from a version with the downloaded `cats`/`dinos` doodles reads its stored
* value here, finds it is not in the list, and falls to 'off' — the files those named are not in
@@ -576,8 +586,8 @@ function applyAutoCollapse(val) {
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
}
/* The sidebar rail's collapsed flag. The BUTTON that flips it is chrome (fs-chrome.js): only the
* stored state belongs to the preference layer, and fs-update.js needs the ls* helpers anyway.
/* The sidebar rail's collapsed flag. The BUTTON that flips it is chrome (fs-chrome.js); only the
* stored state belongs to the preference layer.
* NOT part of the router-wide defaults — it is a transient chrome collapse, not an appearance
* choice, so it is absent from snapshotAxes()/resetToSaved() below. */
function applyRail(on) {
@@ -609,7 +619,10 @@ const AXIS_KEYS = [
/* Tint density: the STRENGTH of the router-identity Tint (the hue washed onto --fs-bg), a per-browser
* axis paired with the Tint hue — the hue picks the colour, this picks how strong it reads.
* --fs-tint-strength is a multiplier on the tint chroma (03-palettes.css): 100% = the designed
* strength, 0% = none, up to 200%. It only bites while a Tint hue is set (data-tint), and it is
* strength, up to 200%. 0 is not quite "no tint": the palette's canvas carries a slight cast of its
* own and the relative colour that applies the tint replaces chroma outright, so 0 leaves a neutral
* canvas at the same lightness rather than the untinted one (03-palettes.css measures it). Clearing
* the Tint hue is the real off. It only bites while a Tint hue is set (data-tint), and it is
* hidden and moot under the File wallpaper, where the tint resets to neutral (the photo covers the
* canvas). A normal per-browser axis: localStorage ?? router default ?? built-in; head.ut pre-paints
* it; stored explicitly (incl. the 100 default) so it can override a router default, like the hues.
@@ -700,7 +713,7 @@ function snapshotAxes() {
};
}
/* The RESOLVED router default (UCI value if set, else the built-in), in snapshotAxes() string form,
* so the popover can grey the Save button out when this browser already shows exactly it. Seeded
* so the Appearance tab can grey the Save button out when this browser already shows exactly it. Seeded
* from window.__fsSD at load and replaced with the just-saved snapshot after saveAsDefault(), so a
* save flips the match to true without a reload.
*
@@ -745,7 +758,7 @@ function matchesSavedDefault() {
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
}
/* ---- /etc/config/footstrap is written by Save-as-default AND BY NOTHING ELSE -------------------
/* ---- no AXIS reaches /etc/config/footstrap except through Save-as-default ----------------------
* EVERY axis is per-browser and reaches the router only through this button. Two of them used to
* write through the moment they changed — `wallpaper` on every pick, `photo_dim` on every drag —
* on the argument that the File photo is router-side, so "which wallpaper shows it" and "how dim"
@@ -789,10 +802,11 @@ function resetToSaved() {
/* The built-in defaults, written through the ordinary appliers so each one validates its own value
* and stamps :root the way it always does. Stated here rather than derived: a "default" is only a
* default because it is the value a bare :root paints, and the three that already have a named
* const (rounding, tint strength, photo dim) use it, so the numbers cannot drift from the CSS. */
* default because it is the value a bare :root paints, and the five that already have a named
* const (rounding, tint strength, photo dim, pattern size, pattern strength) use it, so the numbers
* cannot drift from the CSS. */
function resetToBuiltin() {
/* TOP, not sidebar: the bar is what a bare :root paints (header.ut stamps it when uci says
/* TOP, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci says
* nothing), so it is what "the theme as it ships" means. This said 'sidebar' after the default
* flipped and nothing caught it — the button quietly reset to a layout that is no longer the
* default, which is the one thing this button must not do. */
@@ -955,6 +969,7 @@ function uploadPattern(file) {
* as making every other device paint it, which is the wallpaper axis and Save-as-default. */
.then(() => _uciSet('footstrap', 'settings', { pattern: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(PAT_PATH, e))
.then(() => {
/* switch THIS browser onto it — the ordinary axis path, localStorage only */
applyWallpaper('pattern');
@@ -967,8 +982,7 @@ function uploadPattern(file) {
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
* set/commit only), clear the tile live. */
function removePattern() {
return _fileRemove(PAT_PATH)
.catch(() => null) /* already gone is success — still blank the token below */
return _removeServed(PAT_PATH)
.then(() => _uciSet('footstrap', 'settings', { pattern: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { _applyPattern(''); });
@@ -990,11 +1004,33 @@ const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink to BG_PATH (
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
const BG_QUALITY = 0.9;
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
const _fileRemove = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ], reject: true });
/* NO `reject: true` here, unlike every other declare in this file, and that is the whole point: with
* it the caller gets an Error whose only account of WHY is a sentence with the ubus code inside it,
* so "the file was already gone" and "the router refused to delete it" arrive indistinguishable —
* and both callers below used to swallow the rejection whole and report success. Without it the
* promise resolves with the ubus status as a NUMBER (rpc.js hands back `msg.result[0]`), which is a
* signal this code can actually branch on. */
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
/* Delete, treating "not found" as done. Anything else is a real refusal — a read-only or full
* overlay, an immutable flag, a path replaced by a non-empty directory — and it must NOT be reported
* as a removal: the file stays on flash and stays fetchable WITHOUT A SESSION through the /www
* symlink, which is precisely what an admin removing a background for privacy reasons believes they
* have just stopped. */
const UBUS_NOT_FOUND = 4;
function _removeServed(path) {
return _fileRemoveStatus(path).then((res) => {
const code = (typeof res === 'number') ? res : parseInt(res, 10);
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
return Promise.reject(new Error(
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
});
}
/* cgi-upload writes the file mode 0600, and uhttpd refuses to SERVE a file that is not
* world-readable (measured: 0600 -> 403, 0644 -> 200), so make it 0644 before it can be fetched. The
* rpcd ACL grants exec on exactly `/bin/chmod 644 /etc/footstrap/login-bg` — one fixed command, no
* argument the caller controls. */
* rpcd ACL grants exec on exactly two fixed commands — `/bin/chmod 644 /etc/footstrap/login-bg` and
* `/bin/chmod 644 /etc/footstrap/pattern.svg`, the two files this module uploads — with no argument
* the caller controls. */
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
/* …and the ubus status is only half of it: `file.exec` reports the COMMAND's exit status inside the
* payload, so a chmod that ran and failed still comes back as a successful call. Unchecked, the
@@ -1015,7 +1051,7 @@ function _chmodServeable(path) {
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
/* the token the server last saved (window.__fsSD.login_bg), validated to the same hex charset the
* head.ut sanitiser and pre-paint use — so the popover shows the current background and builds a
* head.ut sanitiser and pre-paint use — so the Appearance tab shows the current background and builds a
* cache-busted preview src. '' = none. */
function currentLoginBg() {
const t = sd('login_bg');
@@ -1041,10 +1077,10 @@ function _applyLoginBg(tok) {
* sits in — it escapes as an uncaught error and leaves the promise pending FOREVER. Two real ways
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed (out of memory
* on a low-RAM box is the case this decodes a 25 MB source on), and drawImage/toBlob can throw on
* their own. The caller is fs-appearance's file picker, which disables "Choose image" and relabels
* their own. The caller is the Appearance tab's file picker, which disables "Choose image" and relabels
* it "Uploading…" before the call and restores both in a `.finally()` — so a pending promise means
* that button stays disabled and lying for the life of the page. The popover is built ONCE, in
* init(), so nothing short of a reload gets it back. */
* that button stays disabled and lying until the form is rebuilt, which mount() does only when the
* stock view re-renders: the next arrival at System -> System, not this one. */
function _downscale(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
@@ -1069,6 +1105,23 @@ function _downscale(file) {
});
}
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
* below write the file first (cgi-upload) and the token second (uci), and the second half can fail
* on its own: no `settings` section yet, a narrowed uci ACL, ubus busy. The page then showed the
* rpc error — honest as far as it went — while the image sat in /etc/footstrap at mode 0644 and was
* served to ANYONE at /luci-static/footstrap/bg, because the /www symlink does not depend on the
* token. Worse, Remove is hidden exactly when the token is empty, so the page offered no way to
* delete what it had just published. Roll the file back instead, and report the failure that
* started it — a rollback that itself fails is appended, because at that point the admin has to
* know the file is there. */
function _rollbackUpload(path, cause) {
return _removeServed(path).then(
() => Promise.reject(cause),
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
);
}
/* Upload flow: validate -> canvas re-encode -> multipart POST to cgi-io's cgi-upload (the same
* endpoint L.ui.uploadFile uses; session carried in the `sessionid` FIELD, path in `filename`, bytes
* in `filedata`) -> take the md5 `checksum` from the JSON reply as the cache-bust token -> save it in
@@ -1100,6 +1153,7 @@ function uploadLoginBg(file) {
* here would re-point every other device's default from one admin's upload, silently. */
.then(() => _uciSet('footstrap', 'settings', { login_bg: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(BG_PATH, e))
.then(() => {
/* switch THIS browser to the photo — the ordinary axis path, localStorage only */
applyWallpaper('file');
@@ -1112,8 +1166,7 @@ function uploadLoginBg(file) {
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
* set/commit only), clear the background live. */
function removeLoginBg() {
return _fileRemove(BG_PATH)
.catch(() => null) /* already gone is success — still blank the token below */
return _removeServed(BG_PATH)
.then(() => _uciSet('footstrap', 'settings', { login_bg: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { _applyLoginBg(''); });
@@ -1122,7 +1175,7 @@ function removeLoginBg() {
return baseclass.extend({
/* the storage helpers */
lsGet, lsSet, lsDel, lsGetArr,
lsGet, lsSet, lsDel, lsGetArr, storageBroken,
currentMode, applyMode, guardDarkStamp,
currentPalette, applyPalette,
@@ -47,9 +47,34 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
};
})();
function clearViewIntervals() {
const keep = (L.Poll && L.Poll.timer) || null;
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/
* `start`/`stop`/`active` are the documented surface, and upstream has already marked the whole
* `L.Poll` alias deprecated (`'require poll'` is its replacement, and neither 24.10 nor 25.12
* ships poll.js yet, so the alias is still the only way in). If that field ever goes, `keep`
* becomes null and this sweep would clear LuCI's tick along with the view's timers — every poll
* on every later page silently dead, from a rename we did not notice. So the missing field is
* not a null: it is a reason to do NOTHING, once, loudly. A view's leftover interval outliving
* its page costs a wasted RPC; killing the global tick costs the router's live data. */
/* Asked through the DOCUMENTED half first: active() says whether LuCI's tick is running at all,
* and `timer` is deleted by stop() — so an absent field is the ordinary "nothing to protect"
* case on a page with no pollers, not a sign that upstream moved it. The anomaly worth reporting
* is the pair disagreeing: a tick that is running while the id it runs on has no name we know.
* Then do NOTHING, once, loudly: a view's leftover interval outliving its page costs a wasted
* RPC, whereas clearing LuCI's own tick costs every live value on every later page. */
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
if (running && L.Poll.timer == null) {
if (!_pollTimerWarned) {
_pollTimerWarned = true;
console.error('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
+ 'luci-base.');
}
return;
}
const keep = running ? L.Poll.timer : null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
}
let _pollTimerWarned = false;
let _wired = false;
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
@@ -64,7 +89,7 @@ let _navGen = 0;
* The two layouts scroll different elements: the sidebar layout pins .fs-shell to 100dvh and gives
* overflow-y to .fs-main (#maincontent), the top layout lets the document scroll. A browser restores
* an INNER scrollable region only across full loads, never on a same-document traversal — measured
* (docs/spa-router.md §2): Back opened the incoming page at 0, because the swap empties #view,
* (docs/spa-router.md, "Scroll"): Back opened the incoming page at 0, because the swap empties #view,
* scrollHeight collapses and the browser clamps scrollTop.
*
* The DOCUMENT scroller was left to the browser's own scrollRestoration ('auto') on the grounds that
@@ -116,28 +141,75 @@ function saveScroll() {
}
/* Put the scrollers back where the entry left them — but only once the incoming view has grown that
* much height (docs/spa-router.md §5: restoring before the content exists is clamped to 0 and reads as
* much height (docs/spa-router.md, "Scroll": restoring before the content exists is clamped to 0 and reads as
* "worked"). The view renders behind an RPC, so poll by frame; a newer navigation cancels via the
* generation, and a page that never reaches the old height again is simply left at the top. Each
* offset is waited for on its OWN scroller, so a layout switched between the two entries restores
* whichever half it can rather than blocking on the half that no longer scrolls. */
function restoreScroll(pos, gen) {
if (!pos || (!pos.win && !pos.main)) return;
let tries = 300; /* ~5 s at 60 fps — outlasts a slow RPC without polling forever */
/* A DEADLINE, not a frame count: this was 300 frames called "~5 s", which holds only at 60 Hz —
* the same budget is 10 s on a 30 Hz panel. Frames stay the tick (they are when a paint could
* have changed the height); time decides when to stop waiting for an RPC that is not coming. */
const until = Date.now() + 5000;
/* THE USER OUTRANKS THE SAVED POSITION. Waiting up to five seconds for a slow view to grow means
* the reader may have started using the page in the meantime — and jumping them somewhere else
* two seconds after they began reading is worse than opening at the top, which is what a full
* load does anyway. So any sign that the scroll is THEIRS cancels the restore for good.
*
* Two kinds of sign, because neither covers the other. The three input events are intent even
* when nothing moves yet (a wheel tick on a page too short to scroll, a touch that becomes a
* drag); `scroll` is the catch-all for everything they cannot see — a scrollbar drag, a
* trackpad fling, Find-in-page, an anchor jump, assistive tech. `scroll` also fires for OUR
* OWN writes, asynchronously, so a flag around the write would still be false by the time it
* arrives: the position we last wrote is remembered instead, and a scroll that lands exactly
* there is ours. Landing there by hand is indistinguishable and costs nothing — it is the
* position we were restoring to.
*
* Passive listeners: this must never sit in front of the scroll it is watching for. */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
const onScroll = (ev) => {
const t = ev.target;
const now = (t === document || t === document.documentElement || t === document.body)
? Math.round(window.scrollY) : (t && t.scrollTop);
if (now === wroteWin || now === wroteMain) return; /* our own write coming back */
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
window.removeEventListener('wheel', stop, opts);
window.removeEventListener('touchstart', stop, opts);
window.removeEventListener('keydown', onKey, opts);
window.removeEventListener('scroll', onScroll, opts);
}
window.addEventListener('wheel', stop, opts);
window.addEventListener('touchstart', stop, opts);
window.addEventListener('keydown', onKey, opts);
/* capture, so the inner scroller (#maincontent in the sidebar layout) is seen too: `scroll`
* does not bubble from an element, but it does travel down the capture phase. */
window.addEventListener('scroll', onScroll, opts);
(function tick() {
if (gen !== _navGen || --tries < 0) return;
if (cancelled) return;
if (gen !== _navGen || Date.now() > until) { off(); return; }
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) sc.scrollTop = pos.main;
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) { wroteMain = pos.main; sc.scrollTop = pos.main; }
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) window.scrollTo(0, pos.win);
if (de.scrollHeight - de.clientHeight >= pos.win) { wroteWin = pos.win; window.scrollTo(0, pos.win); }
else pending = true;
}
if (pending) requestAnimationFrame(tick);
else off();
})();
}
@@ -276,38 +348,43 @@ function pragmaDeps(src) {
return out;
}
/* SIX CLASS NAMES HAVE NO FILE, and fetching one is a guaranteed 404 in the user's console. luci.js
* seeds its class registry with them at load — `const classes = { baseclass: Class, dom: DOM,
* poll: Poll, request: Request, session: Session, view: View }` — so require() answers them from
* memory and never asks the network, while `ls /www/luci-static/resources` has no baseclass.js,
* dom.js, poll.js, request.js or session.js at all. Every view file's pragmas name `view` and
* `baseclass`, so a dependency walk hits this on its very first step: measured, warming the recents
* at idle put 404s for view.js and poll.js in the console of every page load, and the first hover
* added baseclass.js. A require() that 404s is noise this theme refuses to make, and the same
* standard applies here.
/* WHICH CLASS NAMES ARE WORTH A PREFETCH, and the answer is the DOTTED ones — that is the whole
* rule now, and it used to be a hardcoded list of the six names luci.js seeds its registry with
* (`baseclass`, `dom`, `poll`, `request`, `session`, `view`). Those have no file, so fetching one is
* a guaranteed 404 in the user's console — measured before the list existed: warming the recents at
* idle put 404s for view.js and poll.js into every page load, and the first hover added baseclass.js.
* The list worked, and it was a literal copied out of luci.js rather than a guess about it, but it
* was still a copy: a seventh built-in would cost one 404 per name per session until this file
* caught up with a release of somebody else's software.
*
* A future LuCI adding a seventh built-in would cost one 404 per session — `_prefetched` makes it
* at most one — and the list is a literal from luci.js, not a guess about it. */
const BUILTIN_CLASSES = new Set([ 'baseclass', 'dom', 'poll', 'request', 'session', 'view' ]);
/* Is this class already instantiated? require() attaches its singleton to the LuCI prototype
* (`ptr[parts[idx]] = instance`), so a SINGLE-segment name reads back as L[name] — which covers the
* libs a loaded page has already pulled in (ui, form, uci, rpc, fs, validation, and network/firewall
* once some network page has been open). Skipping them spends no request on a cache hit nobody needs.
* `instanceof L.Class` rather than a truthiness test: L.env, L.url and L.get are members too, and a
* dep whose name collided with one of those would otherwise be skipped without ever being loaded —
* which is why this cannot be the guard for the six above either: they are seeded as CONSTRUCTORS
* (and under other names — L.Poll, L.Request, L.Class), so no `instanceof` probe sees them. A DOTTED
* name (tools.network) is not attached unless its parent already exists, so it is simply fetched —
* and those are the ones the win comes from. */
* The shape of the names answers it without the copy. A LuCI class name is a path — `tools.widgets`
* is tools/widgets.js — so a name with no dot is either one of those six virtual classes or one of
* the flat libraries (`ui`, `form`, `network`, `uci`, `rpc`, `fs`, `validation`), and those are
* already loaded by the time any prefetch runs: this theme's own chrome requires `network` for the
* overview grid, which drags in firewall/uci/rpc/validation, and `ui` comes with the widgets.
* Measured on the stand from three different landing pages, including the lightest one there is
* (System -> Reboot): all eight flat libraries were already instances on arrival, and driving the
* prefetch walk over seven pages fetched 10 files, every one of them nested. So the flat half of the
* namespace is worth nothing to prefetch and can never be worth a 404 — declining it outright costs
* nothing measurable, needs no list, and a future built-in is covered before it ships.
*
* The dotted half is still asked properly: require() attaches a class at its path (`ptr[parts[i]] =
* instance`), so `tools.widgets` reads back as L.tools.widgets once some form page has pulled it,
* and a second navigation there spends no request at all. `instanceof L.Class` rather than a
* truthiness test: L.env, L.url and L.get are members too. */
function classLoaded(name) {
if (BUILTIN_CLASSES.has(name)) return true;
try { return name.indexOf('.') < 0 && window.L[name] instanceof window.L.Class; }
if (name.indexOf('.') < 0) return true;
try {
let ptr = window.L;
for (const part of name.split('.')) {
ptr = ptr[part];
if (ptr == null) return false;
}
return ptr instanceof window.L.Class;
}
catch (e) { return false; }
}
/* view classes already required, i.e. the ones LuCI has an instance cached for. A class NOT in
* here is rendered by the require() itself (see navigate). */
const _seen = new Set();
const _prefetched = new Set();
/* className -> the promise of ITS OWN body being in the HTTP cache; navigate() waits on that.
@@ -490,7 +567,7 @@ function navigate(pathname, push, kbd) {
* when View.__init__ replaces it there is nothing to see, and the string arrives already
* translated in the ~40 languages this theme ships no catalogue for (the chrome rule — a
* context-free msgid is how we inherit luci-base's translation). */
if (!_seen.has(tree.viewClassFor(node))) {
if (!_seen.has(className)) {
const vp = document.getElementById('view');
if (vp) {
const s = document.createElement('div');
@@ -541,7 +618,11 @@ function navigate(pathname, push, kbd) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
try { if (typeof ui.hideModal === 'function') ui.hideModal(); } catch (e) {}
/* ui hard-requires into this module and ui.js defines hideModal unconditionally, so there is no
* feature to test; what is caught is a modal's own teardown throwing, which must not take the
* navigation with it. Reported, not swallowed — the same rule as the loop above. */
try { ui.hideModal(); }
catch (e) { console.error('footstrap: hideModal threw during a navigation', e); }
/* point the runtime env at the new node so views, tabs and highlighting read the right
* path. For a fully-matched leaf, request == dispatch path. */
@@ -579,8 +660,8 @@ function navigate(pathname, push, kbd) {
* correctly returns — one dead Back press per stray click. A full load has no such trap. */
if (push) {
const same = pathname === window.location.pathname;
if (_curId == null) _curId = newEntryId();
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id */
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id
* (seed() adopted one before wire() made this function reachable, so there is always one) */
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
}
@@ -601,7 +682,7 @@ function navigate(pathname, push, kbd) {
*
* A popstate replay resets nothing on purpose: BOTH scrollers are restored there from _scrollMem —
* see restoreScroll(). scrollRestoration is left at 'auto': the UA's own attempt lands before the
* swap and is undone by it (§2), so it neither helps nor hurts, and 'manual' would only take away
* swap and is undone by it (see restoreScroll above), so it neither helps nor hurts, and 'manual' would only take away
* the case that does work — a genuine full load. */
if (push) {
window.scrollTo(0, 0);
@@ -613,7 +694,8 @@ function navigate(pathname, push, kbd) {
* renderChrome() has just done `#topmenu.innerHTML = ''`, so the very <a> the user activated with
* Enter no longer exists: focus falls back to <body>, the next Tab restarts at the skip link, and
* nothing says the page changed — URL, title and #view all moved in silence. So do what a real
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md §3): a KEYBOARD
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md,
* "Accessibility of a route change"): a KEYBOARD
* activation (ev.detail === 0) moves focus to the skip link — a small target whose :focus overlay
* tells a sighted keyboard user where they are, with Enter jumping straight to the content; its
* text differs from the live region's announcement below, so the double announcement complements
@@ -664,7 +746,15 @@ function navigate(pathname, push, kbd) {
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
if (gen !== _navGen) return null;
_seen.add(className);
return RT.require(className);
/* NAME THE OWNER for the length of this require. On a first visit the require IS the render,
* so any <style> the module injects belongs to THIS page — even if a newer navigation has
* stamped data-page by the time it lands, which is exactly the case repairStaleRender() below
* exists for. Without it fs-sheets credited such a sheet to the page that superseded this one
* and bound it there for the life of the document (measured: luci-app-filemanager's
* `.cbi-button-save { display: none !important }` disabled on its own page and live on
* System -> System, across return visits). */
sheets.attributeTo(rsegs);
return RT.require(className).finally(() => sheets.attributeTo(null));
}).then((view) => {
if (view == null) return;
if (!(view instanceof RT.view))
@@ -818,7 +908,8 @@ function wireRouter() {
* take back what that one just painted. */
document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); } catch (e) {}
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); }
}
});
@@ -844,7 +935,7 @@ function wireVisibility() {
else if (wasActive) {
L.Poll.start();
}
} catch (e) {}
} catch (e) { console.error('footstrap: the poll pause/resume threw', e); }
});
}
@@ -24,7 +24,11 @@
/* ---- the index ---------------------------------------------------------- */
/* admin/<section>/<page>/<tab> — four levels is every path LuCI's dispatcher renders. */
/* How deep below the MODE the walk goes. `depth` counts recursion levels and the walk starts at 1
* on a node that already has two segments, so a node at depth N carries N+1 of them: 4 admits
* admin/<section>/<page>/<tab>/<subtab>. That is one more than LuCI's own dispatcher renders, and
* deliberately so — a third-party node nested deeper is worth finding, and the depth term in the
* score below already ranks it last. */
const MAX_DEPTH = 4;
let _index = null;
@@ -99,6 +103,13 @@ function buildIndex() {
return out;
}
/* Built once per DOCUMENT, and deliberately not re-derived per open: it is a projection of the
* client menu tree, which `ui.menu.load()` itself caches for the life of the document. So the
* palette is exactly as fresh as the sidebar beside it — a package installed or removed without a
* reload is missing from, or still listed in, BOTH. Invalidating only this half would make the two
* disagree, which is worse than either being stale; the reload the package manager already prompts
* for is what refreshes them together. (Cheap enough to rebuild if that ever changes: 238 nodes on
* the dev router.) */
function index() {
if (!_index) _index = buildIndex();
return _index;
@@ -190,9 +201,9 @@ function warmRecent() {
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
/* Nothing waits on this, so it belongs after the page has settled — at idle, with a timeout for a
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout, unlike
* fs-appearance's ~1 ms one: that wires a button the user may click immediately, this competes
* with the view's own module fetches and RPCs and must lose that race on purpose. */
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout: nothing
* on screen waits for this, so it competes with the view's own module fetches and RPCs and must
* lose that race on purpose. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
@@ -379,8 +390,7 @@ function wire() {
/* Back and Forward are navigations no listener here can see: every close above is a user act on
* the document (Escape, the scrim, the trigger, picking a result). An open palette therefore rode
* a popstate onto the next page, aria-modal and Tab-trapped, while the router moved focus behind
* it. Same reasoning as the Appearance popover's — see fs-appearance.js. returnFocus=false because
* the router places focus itself on a navigation. */
* it. returnFocus=false because the router places focus itself on a navigation. */
router.onNavigate(() => close(false));
/* Ctrl/Cmd+K is the shortcut every command palette has taught users, and `/` is the one every
@@ -87,7 +87,7 @@ function enhance(sel) {
if (sel.dataset.fsSelect || sel.disabled) return; /* disabled: NOT marked — it may be enabled later */
/* `multiple` and "not in a CBI field" are permanent, so mark it and stop re-testing on
* every scan */
if (sel.multiple || !sel.closest('.cbi-value-field, .td.cbi-value-field, .cbi-value')) {
if (sel.multiple || !sel.closest('.cbi-value-field, .cbi-value')) {
sel.dataset.fsSelect = 'skip';
return;
}
@@ -100,7 +100,14 @@ function enhance(sel) {
sort: false,
optional: Object.prototype.hasOwnProperty.call(choices, '')
});
} catch (e) { return; }
} catch (e) {
/* Marked, not merely returned from: without the mark the same select is re-selected by
* scan()'s :not([data-fs-select]) on every mutation frame and throws again, forever and
* silently. One loud failure, then left as the stock <select> it already is. */
sel.dataset.fsSelect = 'skip';
console.error('footstrap: a select could not be enhanced', e);
return;
}
const node = dd.render();
const ac = new AbortController();
@@ -210,12 +217,19 @@ function labelCells(t, head) {
for (const row of t.querySelectorAll('.tr, tbody tr')) {
if (row === head) continue;
const cells = row.children;
/* COLUMN cursor, not the cell index: a cell that spans N columns occupies N of the header's
* slots while advancing the cell index by one, so keying titles off `i` captioned every cell
* AFTER a spanning one with the heading of the column to its left — "Hostname" over an IP
* address, and nothing to say the mapping was guessed. Only the spanning cell itself is left
* uncaptioned, because it has no single heading to take. (A rowspan reaching down from an
* earlier row would shift this too; no LuCI table emits one, and a wrong caption is worse
* than none, so that shape stays unhandled rather than approximated.) */
let col = 0;
for (let i = 0; i < cells.length; i++) {
if (i >= titles.length || !titles[i]) continue;
if (cells[i].hasAttribute('data-title')) continue;
/* a cell that spans columns has no single heading to take */
if (cells[i].colSpan > 1) continue;
cells[i].setAttribute('data-title', titles[i]);
const span = (cells[i].colSpan > 1) ? cells[i].colSpan : 1;
if (span === 1 && col < titles.length && titles[col] && !cells[i].hasAttribute('data-title'))
cells[i].setAttribute('data-title', titles[col]);
col += span;
}
}
}
@@ -243,8 +257,11 @@ const STACKABLE = '#view .table.fs-dt';
* unreadable ribbon. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an
* overflow: tried, and it carded the firewall's zone table at 1420px and still overflowed by
* 39px once carded — a floor big enough to force the overflow is big enough to break the card. */
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px
* tier .fs-content pads 16px a side, so 600 -> 568 of room */
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px tier
* .fs-content pads var(--fs-space-4) a side, 16px at the default density,
* so 600 -> 568 of room. A fixed number and not a re-read of that token on
* purpose: the threshold is the DESIGN judgement above, and Compact density
* shrinking the gutter to 10px is not a reason to keep a table wider. */
/* The ribbon has one more shape, and CRAMPED cannot see it: the table has room by the number
* above and still shreds its FIRST column, because auto table layout hands width out by what
@@ -23,7 +23,8 @@
*
* `invasiveSheet()` is that test; its universe is read back from cascade.css itself (same-origin,
* so `cssRules` is readable) rather than a hand-written list, so it tracks the theme. 0.3 ms per
* nav. Exempt: `[data-fs-shell]` (the one <style> the server emits — marked, not guessed at) and
* nav. Exempt: anything the server marked `[data-fs-shell]` — the shell's own two links and two
* styles, marked rather than guessed at (partials/head.ut) — and
* anything inside `#view` (dies with the content swap); LuCI core injects no <style> at runtime at
* all (checked: luci.js, ui.js, cbi.js). If cascade.css cannot be read, EVERY view sheet counts as
* invasive: fail to the slow path, never the broken one. */
@@ -328,7 +329,8 @@ function outlivesPage(el) {
* is real.
*
* WHOLE PATH, NOT A SUFFIX. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…`, and the base in
* that line is the SAME value the runtime holds: header.ut hands `resource` to `new LuCI({…})`, and
* that line is the SAME value the runtime holds: luci-base's own header.ut hands `resource` to
* `new LuCI({…})`, and
* `L.resource()` joins it back exactly, so the server's href is reconstructable rather than guessable
* — only the cache key has to come off. A suffix match is what a guess costs: anchored at nothing but
* a `/`, `custom.css` matches any sheet ending in that filename, and two in-tree apps append exactly
@@ -339,9 +341,13 @@ function outlivesPage(el) {
* through Adblock → Feeds, and the router would swap into a page whose stylesheet was never linked —
* the one outcome the guard exists to prevent.
*
* Equality also keeps the failure safe: L.path() drops a part that leaves its charset, so a malformed
* `css` yields the bare base and matches no href at all — a full load, which is the correct answer for
* a value nobody can serve. */
* Equality also keeps the failure safe, in the direction that costs only speed. L.path() keeps a part
* only if it matches /^(?:[a-zA-Z0-9_.%,;-]+\/)*[a-zA-Z0-9_.%,;-]+$/ — no `+ ~ ( ) @ ! ' $ &`, nothing
* non-ASCII, no leading `/` — and drops it otherwise, while head.ut interpolates `css` raw and uhttpd
* serves those names without complaint. So a third-party `"css": "view/foo/style(dark).css"` does get
* linked and rendered; `want` collapses to the bare base, matches no href, and every entry into that
* page is a full load. A suffix compare covered those names, and losing them is what closing the false
* positive above costs. No in-tree node sets `css` at all, so the shape is third-party only. */
function documentCarries(path) {
const want = L.resource(String(path));
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
@@ -456,8 +462,14 @@ function documentPoisoned() {
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
/* the getter always normalises a pseudo-element to `::`, incl. legacy `:before` */
const i = part.indexOf('::');
/* The getter always normalises a pseudo-element to `::`, incl. legacy `:before` — and the split
* point is found on the MASK and sliced out of the ORIGINAL, exactly as selectorParts() does and
* for the same reason: `::` is legal inside a quoted attribute VALUE (an IPv6 literal is the real
* case — `[data-addr*="::"]`, `a[href*="[::1]"]`). Read raw, the fence went INSIDE the quotes and
* the app's rule came back matching a value it never wrote — still valid CSS, so the setter
* reported success and nothing looked wrong afterwards, which this file's header calls the worse
* half of the two failures. */
const i = maskStrings(part).indexOf('::');
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
}
@@ -481,8 +493,14 @@ function fenceRules(rules, names) {
/* An @import's rules live in a sheet that is fetched separately, so they are not there the moment
* the shim is inserted — retry until they are, then fence. Bounded: a sheet that never becomes
* readable (404, cross-origin) simply stays unfenced, which is where we already were. */
function fenceImported(styleEl, names, tries) {
* readable (404, cross-origin) simply stays unfenced, which is where we already were.
*
* The bound is a DEADLINE in ms, not a frame count. It was 60 frames described as "~1 s", which is
* only true at 60 Hz: the same 60 frames is 2 s on a 30 Hz panel and longer still on a throttled
* clock, so the budget moved with the display. Frames remain the retry TICK (they are what the
* browser gives for free and a cache hit lands on the first one); only the giving-up point is
* measured in time. */
function fenceImported(styleEl, names, until) {
/* no initialiser: every path below assigns (the try, or the catch's null), so `= null`
* here was a dead store — eslint 10 puts no-useless-assignment in recommended and said so. */
let rules;
@@ -491,7 +509,15 @@ function fenceImported(styleEl, names, tries) {
rules = first && first.styleSheet && first.styleSheet.cssRules;
} catch (e) { rules = null; }
if (rules) { fenceRules(rules, names); return; }
if (tries > 0) requestAnimationFrame(() => fenceImported(styleEl, names, tries - 1));
if (Date.now() < until) { requestAnimationFrame(() => fenceImported(styleEl, names, until)); return; }
/* GIVING UP IS A REPORTABLE EVENT. The irreversible half already happened — the app's original
* is silenced, the shim owns the page — so a fence that never lands leaves that app's rules
* reaching the chrome while every later pass skips the sheet as handled and documentPoisoned()
* calls the document clean. It is also not only the slow-router case: rAF does not fire in a
* background tab, so a page opened in one and left there passes the deadline without a single
* retry. Rare, silent and it looks like a CSS bug, which is exactly what a console line is for. */
console.error('footstrap: could not read the re-hosted @import within the deadline — the sheet '
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
}
/* What a sheet IS, as text: the rules that are APPLYING, not the markup that may or may not have
@@ -553,11 +579,7 @@ const _silenced = new WeakSet();
*/
function silence(el) {
_silenced.add(el);
el.disabled = true;
if (el.sheet) { el.sheet.disabled = true; return; }
/* no sheet yet: re-assert once there is one. `once` — the element is marked fsLayered, so this
* never re-arms, and a sheet that never loads has nothing to silence. */
el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
setEnabled(el, false);
}
/* ---- PAGE OWNERSHIP: contain an invasive sheet instead of spending the document ----
@@ -590,7 +612,7 @@ function silence(el) {
* Disabling is reversible, which is the whole difference.
*
* OWNER = body[data-page] when the sheet was re-hosted. The order that makes this sound is in
* fs-router.js: it stamps data-page (line ~371) BEFORE require()ing the view class (~450), so at the
* fs-router.js: navigate() stamps data-page BEFORE it require()s the view class, so at the
* moment a view module evaluates and appends its <style>, the attribute already names ITS page. On a
* full load the server stamped it. Either way "now" is the sheet's own page.
*
@@ -643,6 +665,28 @@ function appKey(segs) {
* /admin/status renders the card as `inline-block 240px 96px`; System -> General and back leaves
* it `block 966px` — every interface card on the Overview stacked full-width. Keyed on the
* dispatch path: 240px before and after. */
/* WHO A SHEET INJECTED RIGHT NOW BELONGS TO, when that is not the page the chrome is showing.
*
* On a FIRST visit the require() of a view IS its render, and a require in flight cannot be
* stopped: click a page whose module injects CSS, click away before it lands, and the app's <style>
* appears after the router has already stamped data-page for the page that superseded it. Credited
* to currentKey() that sheet is bound to the wrong page for the life of the document — disabled on
* its own page and ENABLED on one it has no business painting, which for luci-app-filemanager means
* `.cbi-button-save { display: none !important }` on somebody else's config form (reproduced on the
* stand: the System page came back with no Save button, and stayed that way across return visits).
*
* So the router names the owner for the duration of such a require (see fs-router.js), and this is
* that hint. It is a plain value rather than a stack: two cold requires cannot overlap, because the
* second navigation supersedes the first before it starts one. */
let _ownerHint = null;
function attributeTo(segs) {
_ownerHint = (segs == null) ? null : appKey(segs);
}
function ownerKey() {
return (_ownerHint !== null) ? _ownerHint : currentKey();
}
function currentKey() {
if (_curKey !== null) return _curKey;
const dp = L.env && L.env.dispatchpath;
@@ -652,9 +696,14 @@ function currentKey() {
return appKey(p ? p.split('/') : []);
}
/* Both halves, for the reason silence() documents: el.disabled is the ELEMENT's flag and
* el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has no
* .sheet for the assignment to reach. */
/* BOTH HALVES, for the reason the paragraph above silence() measured: el.disabled is the ELEMENT's
* flag and el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has
* no .sheet for the assignment to reach — so a switch-off also re-asserts once the bytes arrive.
* `once` — the element is marked fsLayered by then, so this never re-arms, and a sheet that never
* loads has nothing to silence.
*
* silence() is this with the element remembered, and says so by calling it: the two used to write
* the rule out separately, which is one copy of a JUDGEMENT more than this file allows itself. */
function setEnabled(el, on) {
el.disabled = !on;
if (el.sheet) el.sheet.disabled = !on;
@@ -686,8 +735,8 @@ function rehostIntoThemeLayer(el, universe) {
el.dataset.fsLayered = '1';
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
silence(el);
_owner.set(s, currentKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, 60); /* ~1s of frames; a cache hit lands on the first */
_owner.set(s, ownerKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, Date.now() + 1000); /* a cache hit lands on the first frame */
return;
}
@@ -703,7 +752,7 @@ function rehostIntoThemeLayer(el, universe) {
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
* that says the work is done, so it has to be set for every path below, wrapped or not. */
el.dataset.fsLayered = '1';
_owner.set(el, currentKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
_owner.set(el, ownerKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
@@ -887,6 +936,7 @@ function watchViewSheets() {
}
return baseclass.extend({
attributeTo,
documentCarries,
documentPoisoned,
scopeToCurrentPage,
@@ -34,6 +34,6 @@ return baseclass.extend({
* API paths from, and nothing has needed the bare `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
isReal,
/* what the popover's version row prints */
/* what the Appearance tab's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -1,11 +1,15 @@
'use strict';
'require baseclass';
'require fs-prefs as prefs';
/* The theme's own UI primitives: the disclosure pair the menu is built on, the two controls the
* Appearance popover is built on, and the popup placement both hand-placed popups share. Nothing
* here knows what it is being used FOR — that is the point, and it is why the menu and the popover
* can each take what they need without either requiring the other. */
/* The theme's own UI primitives: the one inline-SVG wrapper, the disclosure pair the menu is built
* on, and the colour control the Appearance tab's nine colour axes are built on. Nothing here knows
* what it is being used FOR — that is the point, and it is why the menu and the Appearance tab can
* each take what they need without either requiring the other.
*
* A popup PLACEMENT helper lived here too, shared by the menu's dropdown clamp and the Appearance
* panel when that was a floating popover on <body>. The panel is a tab on System -> System now
* (fs-appearance.js) and places nothing, so the helper had one caller left and went with it; the
* edge gap it clamped against belongs to the menu, which is the only thing still clamping. */
/* The chrome's ONE inline-SVG wrapper. Every icon this theme draws is the same 24x24 stroked
* outline and differs only in its path data, but the wrapper was written out per call site — in the
@@ -112,15 +116,47 @@ function probeColor(expr) {
return getComputedStyle(_probe).color;
}
/* A computed colour string -> [r,g,b] 0..255, or null. Engines do not agree on the notation: both
* `rgb(9, 105, 218)` and the space-separated `rgb(9 105 218 / .5)` are current, and a colour outside
* sRGB (which oklch() readily produces) comes back from some engines as `color(srgb .03 .41 .85)`,
* i.e. 0..1 floats. Take the first three numbers and scale by whether the form is the 0..1 one —
* the alpha after the slash is deliberately ignored, since every token this reads is opaque. */
/* A computed colour -> [r,g,b] 0..255, or null. RASTERISED, never parsed out of the string, and that
* is not a preference: a computed `color` keeps the SPACE it was authored in, so an axis in hue mode
* comes back as `oklch(0.54 0.19 300)`. Read as numbers that is [0.54, 0.19, 300] — three values in
* the wrong units, clamped into a colour nobody chose — and every consumer here believed it: the hex
* field, the `<input type="color">` swatch beside it and the WCAG readout. Measured on the stand with
* `tint=120, accent=300`: a near-white green canvas whose real text contrast is ~16:1 reported #010078 and "Too
* faint to read", and confirming the swatch would have written that navy over the admin's own hue.
*
* Painting one pixel and reading it back asks the engine to do the conversion instead. It is the
* same method tools/export-tier.mjs uses, and for the same reason its header gives: "a color-mix()
* computes to whatever space it was written in, and `oklch(L C H)` has three numbers that parse
* perfectly well — silently and wrongly — as an rgb() triple". The parse survives only as the
* fallback for an engine with no 2D context, where the legacy `rgb()`/`color(srgb …)` forms are all
* that can appear anyway. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
return _cx;
}
function parseColor(s) {
const nums = String(s || '').match(/[\d.]+/g);
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle KEEPS the last value it could parse, so a colour this engine rejects would report
* the previous one as a fresh reading — the same trap probeColor() clears its own style for. */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(String(s)) ? 255 : 1;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
@@ -258,36 +294,6 @@ function colorControl(current, onPick, label, opts) {
return wrap;
}
/* How close a popup may come to the viewport edge before it is nudged back in. Read by BOTH popups
* the theme places by hand — the Appearance popover (fs-appearance.js) and the menu's dropdown
* edge-clamp (menu-footstrap.js) — which had each written their own `8`. */
const EDGE_GAP = 8;
/* Place the popover next to its trigger and keep it inside the viewport. It is position:fixed on
* <body> because the sidebar is `overflow-y: auto` (which computes overflow-x to `auto` too), so
* an absolutely-positioned popover parented to the Appearance row was clipped off the sidebar
* edge. The top bar opens downward from the button's right edge, the sidebar sideways out of the
* rail; both are then clamped. */
function placePopover(btn, pop) {
const gap = EDGE_GAP, r = btn.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const top_layout = prefs.isTopLayout();
let left = top_layout ? (r.right - w) : (r.right + gap);
let top = top_layout ? (r.bottom + gap) : (r.bottom - h);
/* sidebar: if there is no room to the right, fall back above the trigger */
if (!top_layout && left + w > vw - gap) {
left = r.left;
top = r.top - h - gap;
}
pop.style.left = Math.max(gap, Math.min(left, vw - w - gap)) + 'px';
pop.style.top = Math.max(gap, Math.min(top, vh - h - gap)) + 'px';
}
return baseclass.extend({
svgIcon,
setOpen,
@@ -295,7 +301,5 @@ return baseclass.extend({
wireDismiss,
colorControl,
probeColor,
toHex,
EDGE_GAP,
placePopover
toHex
});
@@ -16,7 +16,7 @@
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
* fs-widgets disclosure primitives, the seg/slider controls, popup placement
* fs-widgets the inline-SVG wrapper, the disclosure primitives, the colour control
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
* fs-router the SPA client router (docs/spa-router.md)
* fs-sheets the guard against a view's injected CSS repainting every later page
@@ -76,7 +76,7 @@ return baseclass.extend({
router.wire();
router.wireVisibility();
/* fs-chrome's renderTabMenu warns about exactly this, and the root chain was left bare: a
* throw anywhere in the calls above took out the menu, the router and the Appearance popover
* throw anywhere in the calls above took out the menu, the router and the Appearance tab
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
@@ -9,7 +9,7 @@
/* The theme's ONE menu renderer: a vertical #topmenu the CSS also turns into the top bar and the
* rail flyouts — same markup, no second renderer (docs/chrome.md). The disclosure primitives it builds
* sections on come from fs-widgets and the auto-collapse preference from fs-prefs; the rest of the
* chrome (mode menu, tabs, rail, router, popover) is bootstrapped by menu-footstrap-common, which
* chrome (mode menu, tabs, rail, router, Appearance tab) is bootstrapped by menu-footstrap-common, which
* this file composes with by injecting renderMainMenu into common.init — a callback, not an
* override, because a required LuCI module is a singleton and cannot be subclassed (docs/conventions.md).
* Spec: docs/chrome.md */
@@ -67,9 +67,8 @@ const OPEN_LI = '#topmenu > li.open';
* item near the right edge would push its panel past the viewport. Nudge it back inside. Runs at
* ANY width now that the top bar is measured (no 768 floor); the rail flies panels out sideways
* and needs a different placement, so it is excluded below. */
/* the viewport edge gap, defined once in fs-widgets.js — the Appearance popover keeps a popup off
* the edge by the same amount, and the two used to state it separately */
const EDGE_GAP = widgets.EDGE_GAP;
/* how close the panel may come to the viewport edge before it is nudged back in */
const EDGE_GAP = 8;
/* Is this panel a BAR dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Ask what the STYLESHEET asks, exactly as flyoutMode() does: `data-narrow` is what
* turns the sidebar layout into a bar, and it also disables the rail (the rail's rules are all