⛄ Sync 2026-08-16 09:02:12

This commit is contained in:
github-actions[bot] committed 2026-08-16 09:02:12 +08:00
1 parent 570286daf3
commit 4a9528feed
23 files changed
+214 -76

No files matched your search

@@ -4,6 +4,7 @@
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
'require fs-version as version';
/* The chrome AROUND the content: the mode menu, the section tabs, the rail toggle, and the
* measurements that decide how much room any of it gets. The MAIN menu is not here — it is injected
@@ -277,6 +278,40 @@ function publishBarHeight(bar) {
root.style.setProperty('--fs-bar-live', `${h}px`);
}
/* ---- the theme names itself in the footer, beside LuCI and the distribution ----
*
* The footer's sentence is about what renders this page: LuCI's version, the distribution's, and —
* since the markup is the theme's — the theme's. Each of the other two is a link to where that
* software lives, so this one is too.
*
* WRITTEN FROM THE CLIENT, and that is the only reason it is here rather than in footer.ut: the
* version is stamped into fs-version.js by file name — the Makefile, tools/stage.sh and dev-sync.sh
* all sed that one literal — so a second copy in a template would be a second stamp site to keep in
* step, and a footer that quietly disagrees with the Appearance tab is worse than no version at all.
* The label is the one fs-version already prints there, dev builds included.
*
* Once per document: the footer survives a client navigation (navigate() sweeps the children of
* .fs-content, and the footer is its sibling), and the guard makes a second call a no-op rather
* than a second copy. No request, no translated string: a proper noun and a number. */
function nameThemeInFooter() {
const host = document.querySelector('footer.fs-footer span');
if (!host || host.querySelector('.fs-footer-self')) return;
const self = E('a', {
class: 'fs-footer-self',
href: version.REPO_URL,
target: '_blank',
rel: 'noreferrer',
}, version.label());
/* AFTER THE DISTRIBUTION, not at the end. The sentence lists the software that renders the page
* — LuCI, the distribution, the theme — and footer.ut appends one more fragment after them that
* is not a component but a state: "Lua compatibility mode active". Landing behind it read as if
* the theme were part of that notice. Two links means the second is the distribution's; with
* anything else there, appending is still correct. */
const links = host.querySelectorAll('a');
if (links.length >= 2) links[1].after(document.createTextNode(' / '), self);
else host.append(document.createTextNode(' / '), self);
}
/* ---- 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
@@ -459,6 +494,7 @@ function wireIndicatorCounts() {
return baseclass.extend({
setRenderMain,
renderChrome,
nameThemeInFooter,
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' */
@@ -125,10 +125,14 @@ function warnPollUnreadable(msg) {
* itself to L's prototype when the FIRST requirer compiles it, so this sees the instance the pages
* actually use, and a router that required it would both bind it to its own prototypal L (the two-L
* trap, docs/spa-router.md) and pull uci.js onto pages that never touch uci. No instance means no
* cache to flush. */
* cache to flush.
*
* Returns the refill below as a promise the caller must wait on, or null when there is nothing to
* wait for. It never rejects — a failed refill is reported and swallowed, because a navigation is
* not the place to lose a page over a config the incoming view may not even read. */
function flushUciCache() {
const uci = window.L ? window.L.uci : null;
if (!uci || typeof uci.unload !== 'function') return;
if (!uci || typeof uci.unload !== 'function') return null;
/* `state.values` and `loaded` are private, so the same rule as L.Poll.timer applies: a shape we
* do not recognise is a reason to do nothing, once, loudly — never to guess. The two hold
* different halves of the cache (a package whose load is still in flight is in `loaded` alone),
@@ -141,14 +145,60 @@ function flushUciCache() {
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
+ 'this luci-base.');
}
return;
return null;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (names.length) uci.unload(names);
if (!names.length) return null;
uci.unload(names);
/* `state.reorder` is the one half unload() does not clear, and it is deliberately left alone
* rather than reset by hand: with `values` gone, reorderSections() finds no sections to order,
* emits no call and clears the map itself on the next save. Writing to that field would be us
* editing another module's private state, for a difference nothing can observe. */
/* …and put back the three packages luci-base's network.js reads but will never load again.
*
* initNetworkState() loads `network`, `wireless` and `luci` ONCE, fills its own `_state`, and
* from then on answers every caller with `return (_state != null ? Promise.resolve(_state) : _init)`
* — no uci call, ever again, for the life of the document. What it answers WITH, though, is the
* uci cache: `getWifiDevices()` is `uci.sections('wireless', 'wifi-device')`, and a view like
* network/switch reads `uci.sections('network', 'switch')` in its own render(). So dropping those
* packages does not make network.js refetch them — it makes every consumer read an EMPTY config
* until the next full load.
*
* Measured on 24.10, one SPA navigation away from Interfaces: `uci.state.values` {}, and
* `network.getWifiDevices()` 2 devices -> 0. That is Status -> Channel Analysis painting its title
* and its button with no band tabs under them, and Network -> Switch painting its description and
* Save/Apply with no VLAN sections — both correct again after F5, which is exactly how it was
* reported upstream ("only work after reloading the page"). tools/spa-parity.mjs reproduces it in
* one command, and tools/upstream-contract.mjs is what notices if that list of three ever moves.
*
* Refilling them is what a full load hands the incoming view, so navigate() WAITS for it (see the
* Promise.all below): a cached module resolves within a microtask, well before this request lands,
* and the view would read the empty cache we just left it. Only when network.js is really in the
* document — no module, no derived state to keep in step, and no request to spend. The ubus half
* of `_state` stays as stale as upstream leaves it; a view that needs it fresh calls
* network.flushCache() itself, and that is not ours to decide.
*
* WHAT IT COSTS, and why the cheaper shape was not taken. Awaiting this puts one uci `get` in
* front of the render on a navigation whose module is already cached — measured on the stand over
* 12 alternating navigations between two warm views: 136 ms median without the wait, 159 ms with
* it. `uci.load()` also dispatches `uci-loaded`, which luci-base wires to the change indicator, so
* a second, unawaited `uci.changes()` rides along that `unload()` alone never triggered.
*
* The free alternative is to leave these three OUT of the unload rather than drop and refetch
* them, and it was rejected on what it does to the pages people edit: `network.flushCache()` — the
* one call a view makes when it wants fresh data — reloads its ubus half but calls `uci.load()`
* for the uci half, which is a no-op while the package is still cached. Interfaces and Wireless
* would then render fresh device state over config values from whenever the document first
* touched them, which a full load never does. Dropping and refilling keeps both halves as fresh as
* a full load, and keeps pending local edits behaving as they do everywhere else: discarded by the
* navigation, exactly like the poll queue and the view's intervals above. */
if (!window.L.network) return null;
const refill = [ 'network', 'wireless', 'luci' ].filter((p) => names.indexOf(p) !== -1);
if (!refill.length) return null;
return uci.load(refill).catch((e) => {
console.error('footstrap: reloading uci ' + refill.join(', ') + ' after a navigation failed', e);
});
}
let _uciCacheWarned = false;
@@ -676,8 +726,9 @@ function navigate(pathname, push, kbd) {
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* and drop uci's document-scoped config cache, which a full load would not have carried into the
* incoming page either (see flushUciCache) */
flushUciCache();
* incoming page either (see flushUciCache). What it hands back is the refill of the packages
* network.js will never load again — awaited below, before the view renders. */
const uciWarm = flushUciCache();
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* run every registered navigation callback — today the search palette's recent-pages record and
@@ -819,7 +870,10 @@ function navigate(pathname, push, kbd) {
* and the wait introduces a window in which we may never get there. Marking it up front would
* make the NEXT navigation take the cached branch — `new view.constructor()` on a class whose
* require() is what renders it — i.e. two renders and two pollers for one page. */
warmedThen(className).then(() => {
/* `uciWarm` rides alongside the prefetch wait rather than after it: both are in flight already, and
* a view must not be constructed until the uci cache it reads is back (see flushUciCache). It never
* rejects — the refill catches its own failure — so it cannot cost the navigation a full reload. */
Promise.all([ warmedThen(className), uciWarm ]).then(() => {
/* superseded while waiting: never start the require. On a first visit the require IS the
* render, so starting it here would paint a page the user has already left and hand
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
@@ -591,7 +591,7 @@ const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cb
* `grid`/`treegrid`), so overwriting it with `group` drops every cell to generic — a screen reader
* would then read a flat run of text with no rows and no columns, which is the very structure the
* SC 1.4.10 exception exists to preserve. `aria-label` works on either, so the name and the tab stop
* are unaffected. Reported in review.
* are unaffected.
*
* WHAT IS REMOVED IS WHAT WAS WRITTEN, remembered per element rather than inferred from the value:
* an app that had its own `tabindex="0"` or `role="group"` on that table got them taken away when the
@@ -639,18 +639,12 @@ function fitScrollables() {
if (scroll === was) { if (was) t.classList.add('fs-xscroll'); return; }
if (scroll) {
t.classList.add('fs-xscroll');
/* A SCROLL BOX THE KEYBOARD CANNOT REACH IS CONTENT THE KEYBOARD CANNOT READ.
/* A SCROLL BOX THE KEYBOARD CANNOT REACH IS CONTENT THE KEYBOARD CANNOT READ, and the
* tab stop, the name and the conditional role that say so are all reach()'s business —
* its header above carries the reasoning.
*
* Firefox has made scrollers focusable for years and Chrome since 132, but only when they
* hold no focusable child — a table row full of buttons disqualifies itself — and WebKit
* has not shipped it at all (bug 190870, open since 2018). So say it in the markup: a
* tab stop (SC 2.1.1), a role and a name so what receives focus can be announced
* (SC 4.1.2). `group`, not `region`: a region is a landmark, and a status page with four
* scrolling tables would put four of them in the landmark list.
*
* The name comes from the heading the table sits under, which is what a sighted user
* reads it as; only the fallback is ours to translate. Never overwritten: an app that
* labelled its own table knows better. */
* `group`, not `region`: a region is a landmark, and a status page with four scrolling
* tables would put four of them in the landmark list. */
reach(t);
}
else if (was) unreach(t);
@@ -30,7 +30,7 @@ function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(
return baseclass.extend({
VERSION: FS_VERSION,
/* the project's page, for the link at the foot of the Appearance tab. One string, used once —
* it was assembled from a separate FS_REPO constant that the retired updater needed to build
* it was assembled from a separate FS_REPO constant that the retired self-update script needed to build
* API paths from, and nothing has needed the bare `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
isReal,
@@ -58,6 +58,8 @@ return baseclass.extend({
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* once per document — the footer outlives every client navigation */
chrome.nameThemeInFooter();
/* Both of these ADD to a stock page rather than owning a route: each watches
* body[data-page] and does nothing anywhere else. A theme may not register a
* dispatcher node — it outlives the theme that registered it. */