update 2026-10-25 16:53:21

This commit is contained in:
action committed 2026-10-25 16:53:21 +08:00
1 parent 91f217be10
commit 7fca8e9a3a
74 files changed
+1429 -918

No files matched your search

+5 -8
View File
@@ -30,8 +30,8 @@ LUCI_MINIFY_CSS:=0
#
# JS: the released package is built by owfeed (tools/stage.sh), not by this Makefile through the
# SDK, so LUCI_MINIFY_JS keeps luci.mk's jsmin. jsmin's one-character lookback does not know `n` or
# `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299); eslint's
# `wrap-regex` bans the shape and tools/jsmin-verify.mjs proves the output token-identical.
# `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299): wrap a
# regex literal there in parentheses.
# Apache-2.0, not a free choice: styles/base began as a fork of luci-theme-bootstrap's cascade.css,
# the ucode templates derive from LuCI's and a few JS helpers are verbatim copies; the notices
@@ -119,16 +119,14 @@ define Build/Prepare/luci-theme-footstrap
# htdocs/ root/ ucode/; the copy is metadata only, luci.mk never installs it.
$(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/
$(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css
# Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates and probes strips, which
# take the comments it reads.
# Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates strip, which takes the
# comments it reads.
$(SHELL) $(CURDIR)/mangle-tokens.sh \
$(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources $(PKG_BUILD_DIR)/ucode
# Strips `{# … #}`, whole-line `/* … */` and the indentation from the templates (-27.2 KB of 47.2).
# dev-sync.sh does not run this, so a router being debugged keeps its comments and indentation.
$(SHELL) $(CURDIR)/strip-build.sh templates $(PKG_BUILD_DIR)/ucode
# The gate-only `/* fs:probe */` exports: the functions stay, the export line goes.
$(SHELL) $(CURDIR)/strip-build.sh probes $(PKG_BUILD_DIR)/htdocs/luci-static/resources
# The same for the shell under root/ (about half of uci-defaults and the CGI are comments);
# whole-line `#` only.
$(SHELL) $(CURDIR)/strip-build.sh shell $(PKG_BUILD_DIR)/root
@@ -144,6 +142,5 @@ include $(TOPDIR)/feeds/luci/luci.mk
# Load-bearing, not boilerplate: include/scan.mk finds packages by GREPPING Makefiles for
# `call (Build/DefaultTargets|BuildPackage|KernelPackage)`. luci.mk calls BuildPackage, which the
# grep cannot see, so without the literal line below the package gets no CONFIG_PACKAGE_* symbol and
# `make package/<name>/compile` fails with "No rule to make target". tools/scan-marker.sh re-derives
# the grep and fails the build if it goes missing.
# `make package/<name>/compile` fails with "No rule to make target".
# call BuildPackage - OpenWrt buildroot signature
+9 -11
View File
@@ -11,9 +11,7 @@ Internal name: `footstrap`. Media path: `/luci-static/footstrap`.
by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent,
the three status colours, the four surfaces — are **client** axes on the System → Footstrap page:
`localStorage` plus attributes and inline properties on `:root`, and nothing is written to the
router until "Save as default" is pressed. There are no per-layout or per-mode themes; every legacy
name (`FootstrapSidebar`, `FootstrapOnTop`, `…-dark`/`…-light`) is deleted by
`root/etc/uci-defaults/30_luci-theme-footstrap`.
router until "Save as default" is pressed. There are no per-layout or per-mode themes.
There is one menu renderer too, `renderMainMenu` in `fs-chrome.js`. The top bar is its own markup, morphed by CSS
through `:root[data-layout]`. No second template, no second renderer, no `footstrap-top` symlink.
@@ -26,11 +24,11 @@ and only re-arranges the stock overview sections.
```
Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip,
version, po2lmo)
version)
styles/ CSS SOURCE: layers tokens / base / theme / pages
build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css
mangle-tokens.sh shorten the private --fs-* names in a built sheet, then join it to one line
strip-build.sh build-tree strip, one mode each: templates, shell, probes, assets
strip-build.sh build-tree strip, one mode each: templates, shell, assets
po/ translation catalogue (Weblate translates this)
ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/
htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfonts, and no
@@ -38,14 +36,14 @@ htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfo
is one the admin installs (repo: fonts/set-font.sh)
htdocs/luci-static/resources/ menu-footstrap.js (footer entry), menu-footstrap-common.js,
fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search,
select,appearance,version,overview}.js
root/etc/uci-defaults/ registration and legacy-name migration
root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background upload
select,appearance,assets,axes,version,overview}.js
root/etc/uci-defaults/ registration and the upload symlinks
root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background and pattern uploads
```
There is no update checker: the theme is installed from the package feed (`install.sh` adds it), so
`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The Footstrap
tab shows the version it is running and makes no network call to do it.
`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The System → Footstrap
page shows the version it is running and makes no network call to do it.
**Do not edit `cascade.css`** — it is generated by `build-css.sh` from `styles/` and is gitignored.
Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`.
@@ -62,6 +60,6 @@ ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit lu
ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back
```
Before pushing, run `npm run check` from the repository root. The rules and the traps are in
Before pushing, see [`../docs/verifying.md`](../docs/verifying.md). The rules and the traps are in
[`../docs/conventions.md`](../docs/conventions.md); packaging is in
[`../docs/package.md`](../docs/package.md).
@@ -632,8 +632,4 @@ function build() {
return baseclass.extend({
/* the dispatched page's whole render(), called by view/footstrap/appearance.js */
renderStandalone: () => Promise.resolve().then(build),
/* test-only: the observer's lifetime is a navigation, which a test can fire and cannot reach
* through build() */
watchColours, /* fs:probe */
});
@@ -288,7 +288,4 @@ return baseclass.extend({
removePattern: PATTERN.remove,
uploadLoginBg: LOGIN_BG.upload,
removeLoginBg: LOGIN_BG.remove,
/* test-only: the SVG cleaner and the CSS-escape decoder */
_sanitizeSvg, /* fs:probe */
_cssUnescape, /* fs:probe */
});
@@ -7,13 +7,12 @@
/* fs-axes — the Appearance axes and the Save-as-default machinery.
*
* Split out of fs-prefs.js because only `fs-appearance` (the form) and `fs-assets` (the uploads)
* need it, so the router fetches it on the Appearance page and nowhere else: 6.6 KB off what every
* need it, so the router fetches it on the Appearance page and nowhere else: 5.9 KB off what every
* page downloads. fs-prefs keeps what the chrome applies live: the localStorage wrappers and sd(),
* dark mode (guardDarkStamp defends it on every page), layout, rail and auto-collapse.
*
* head.ut's pre-paint has already stamped every axis before the first frame, so nothing here is
* needed to paint a page correctly, only to change one from the form. The axis list is read from the
* whole resources directory, so an axis may live in a second file. */
* needed to paint a page correctly, only to change one from the form. */
/* An axis whose values are a list, with one of them stamped as nothing.
*
@@ -50,8 +49,8 @@ function listAxis(key, attr, values, dflt, after) {
}
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
* with a list of one — kept as its own name because the call is matched by name, and because
* "two-valued" is what most of these axes are. */
* with a list of one — kept as its own name because "two-valued" is what most of these
* axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
}
@@ -657,13 +657,4 @@ return baseclass.extend({
rail: prefs.currentRail()
});
},
/* test-only: the arithmetic driven directly covers every combination of layout, rail and width
* without a browser */
columnWidth, /* fs:probe */
/* test-only: the active-leaf rule and the key-repeat guard, and the indicator observer without the
* rest of start() */
renderMainMenu, /* fs:probe */
wireIndicatorCounts, /* fs:probe */
});
@@ -6,17 +6,12 @@
* content needs (media = viewport, container = container). Three rules, each a bug that was hit:
* rule 1, measure uncollapsed, since a collapsed thing always fits; rule 2, re-fit synchronously on
* a mutation, since deferring a poll tick to rAF paints one frame of a stacked table at full width
* (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. The reader's
* place is docs/anchoring.md, "The corrections". */
* (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. */
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
* without it left every data table invisible. */
function armGate() {
if (!fittersEnabled()) return;
/* Written as the literal `dataset.fsFit`, never through a helper: the theme's own
* repository checks this file for exactly that spelling to prove the gate rule is still armed,
* and an indirection hides the write from it. The behaviour survives being factored out; the
* contract does not. */
document.documentElement.dataset.fsFit = '1';
}
@@ -30,7 +25,7 @@ function runAll(list, what) {
}
}
/* dev switch — docs/anchoring.md, "The dev switches" */
/* dev switch */
function fittersEnabled() {
try { return localStorage.getItem('fsFit') !== 'off'; }
catch (e) { return true; }
@@ -49,7 +44,7 @@ function run(records) {
* where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of
* growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a
* table's body — the third was missing and a poll emptying it took 58px from under the reader on
* 24.10's Overview. docs/anchoring.md, "The document may not get shorter: `holdFloor()`". */
* 24.10's Overview. */
const SHRINKS = '.cbi-section > div, .table';
const FLOORED = '[data-fs-floor]';
@@ -88,14 +83,14 @@ function holdFloor(records) {
dirty.forEach((box) => { box.style.minHeight = ''; });
/* The box's own rect height, not `offsetHeight`'s rounding: 22 floors half a pixel too tall made
* the document 2px taller, and the next clear handed that back as a clamp — 6 of 12 refills
* corrected against 12 of 12. docs/anchoring-log.md, "The floor is written half a pixel too tall". */
* corrected against 12 of 12. */
const hs = dirty.map((box) => box.getBoundingClientRect().height);
dirty.forEach((box, i) => {
if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('data-fs-floor', ''); }
else box.removeAttribute('data-fs-floor');
});
/* And where the scroller stayed shorter the shrink is real: the clamp gave back only the 60px the
* document lost at its bottom. docs/anchoring-log.md, "A clamp is not the reader". */
* document lost at its bottom. */
const landed = scrollTop();
if (landed >= at) return;
if (page.scrollHeight >= tall) writeOffset(at); else _clampedTo = landed;
@@ -104,8 +99,7 @@ function holdFloor(records) {
/* Is the page moving? asked of the position, never of the events: on iOS momentum carries the page
* long after the finger has gone, and one offset read per frame sees it. SCROLL_IDLE is how long the
* page must hold still before put-off work may run: 137-256px of roughness at 200ms against 59px —
* one pixel of rounding per frame, the floor — at 250ms and above.
* docs/anchoring.md, "Is the page moving: `scrolling()`". */
* one pixel of rounding per frame, the floor — at 250ms and above. */
const SCROLL_IDLE = 400;
let _deferred = false;
function deferMeasurement() { _deferred = true; }
@@ -120,7 +114,7 @@ function sawOwnWrite(y) {
return true;
}
/* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()`
* keeps answering for the whole theme. docs/anchoring-log.md, "A clamp is not the reader". */
* keeps answering for the whole theme. */
let _clampedTo = null;
function sawClamp() {
if (scrollTop() === _clampedTo) return true;
@@ -134,7 +128,7 @@ function writeOffset(value) {
}
/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
* stylesheet decides it. docs/anchoring-log.md, "Which element scrolls, asked once per width". */
* stylesheet decides it. */
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
function layoutKey() {
const root = document.documentElement;
@@ -168,16 +162,16 @@ function sampleMotion() {
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
_sampling = false;
/* The offset's own response to the write below, not a reference element's drift: one re-established
* on wrong ground shows none. docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
const target = _deferredFloor;
_deferredFloor = null;
const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
* on wrong ground shows none. */
/* Only a box wholly above the viewport moves the reader when it gives its floor up. */
const floorsAbove = [..._deferredFloors].filter((b) => b.isConnected && b.getBoundingClientRect().bottom <= 0)
.map((b) => [ b, parseFloat(b.style.minHeight) || 0 ]);
_deferredFloors.clear();
const offsetBefore = scrollTop();
holdFloor();
if (target && target.isConnected) {
const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
}
let shrink = 0;
for (const [ b, h ] of floorsAbove) shrink += h - (parseFloat(b.style.minHeight) || 0);
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
rememberRest();
if (_deferred) {
_deferred = false;
@@ -202,9 +196,16 @@ function noteUser() {
noteMotion();
}
/* Read on the next frame, never in the scroll event: a layout forced while WebKit dispatches one
* skips its anchoring adjustment and leaves the scroller marked as queued, so the engine anchors
* nothing for the rest of the document's life (ScrollAnchoringController::
* adjustScrollPositionForAnchoring returns before it clears the mark). Any sticky or fixed box —
* `.th`, `#modal_overlay`, the bar — leaves layout dirty there, so one `scrollY` read is enough. */
const noteScroll = frame(noteMotion);
(function watchMotion() {
const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteMotion, opts);
window.addEventListener('scroll', noteScroll, opts);
for (const name of [ 'wheel', 'touchmove' ])
window.addEventListener(name, noteUser, opts);
for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
@@ -222,7 +223,7 @@ function frame(fn) {
const schedule = frame(run);
/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
* steps rewrote 1054 class attributes. docs/anchoring-log.md, "Width only: the URL bar is a resize". */
* steps rewrote 1054 class attributes. */
let _resizeSeq = 0;
const _lastWidth = new WeakMap();
function onResize(entries) {
@@ -243,38 +244,20 @@ function watch(el) {
}
/* ---- scroll anchoring, where the engine has none ----
* An older WebKit absorbs none of a tick's growth and the page moved on every one, measured on the
* reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the
* offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px).
* Whether there is one to leave anything behind is asked of the platform, never of a browser name —
* with anchoring suppressed, a 120px growth moves the reader 120px.
* docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
* Safari and iOS before 27 have no `overflow-anchor`, and a tick's growth above the reader moves
* them by all of it. Whether the engine anchors is asked of the platform, never of a browser name;
* where it does, the observer leaves the reader to it. Elsewhere the drift is computed from a
* reference element, never from the offset. */
const ENGINE_ANCHORS = (() => {
/* dev switch — docs/anchoring.md, "The dev switches" */
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
catch (e) { /* no storage, no switch */ }
try { return CSS.supports('overflow-anchor', 'auto'); }
catch (e) { return true; }
})();
/* A load-time check cannot see an engine that declines on a given refill: two residuals written
* back 419-420ms late move the observer to the 7-36ms path until trust returns. Never a browser
* name, only a count. docs/anchoring.md, "When the platform check is not enough: `_engineTrusted`". */
const LATE_MISS_LIMIT = 2;
let _lateMisses = 0;
let _engineTrusted = ENGINE_ANCHORS;
/* The witness's own rounding on a 32-36 row lease table at 390 wide is 8-12.25px, none of them a
* residual. docs/anchoring-log.md, "The witness is not safe to write on its own". */
const LATE_ROUND_TOLERANCE = 16;
/* Trust returns after this many refills where `_rest.el` itself held: the two cheaper witnesses
* tried recovered on an engine still 48px wrong. docs/anchoring-log.md, "Trust that comes back". */
const TRUST_RECOVERY_LIMIT = 2;
let _lateHits = 0;
let _rest = null;
let _restAt = null, _restPage = null;
let _deferredFloor = null;
/* Every floored box a tick refilled while the reader scrolled: one tick refills several, and keeping
* one lost the lease table's 172px. */
const _deferredFloors = new Set();
function pageStamp() {
return (document.body && document.body.getAttribute('data-page')) || '';
}
@@ -285,8 +268,7 @@ function forgetRest() {
}
/* `force`: only for the caller that has just written the offset itself, since the engine's own
* compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without,
* `0, 0, 0` with it. docs/anchoring-log.md, "The reference after this file's own write must be
* re-taken forced". */
* `0, 0, 0` with it. */
function rememberRest(force) {
if (scrolling() && !force) return;
if (ENGINE_ANCHORS && scrollTop() <= 0) {
@@ -351,124 +333,30 @@ function anchorRef() {
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
}
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
* the late drift correction further down) — same shape twice: the last reason recorded, and the
* last 8 with a timestamp. They are read back only by the theme repository's
* development build; the package exports none of it. One factory, so the cap and the timestamp
* format cannot drift between the two. */
const TRAIL_MAX = 8;
function makeTrail() {
let last = null;
const entries = [];
return {
record(w) {
last = w;
entries.push(w + '@' + Math.round(performance.now()));
if (entries.length > TRAIL_MAX) entries.shift();
},
last: () => last,
entries: () => entries
};
}
let _anchorPending = null;
const _anchor = makeTrail();
function awhy(w) { _anchor.record(w); }
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
catch (e) { return true; }
}
/* ---- what the engine's own anchoring leaves behind ----
* It keeps a reference still; it does not promise that a section can vanish and come back. An
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
let _lateFrame = 0;
const _late = makeTrail();
function why(w) { _late.record(w); }
function lateDrift(ref, grow, floorShrink) {
if (_lateFrame) return why('busy');
if (!ref) return why('no-reference');
why('armed');
_lateFrame = requestAnimationFrame(() => {
why('frame');
const seen = scrollTop();
const settle = () => {
_lateFrame = 0;
why('settle');
if (!anchorEnabled()) return why('anchoring-off');
if (Date.now() < _userUntil) return why('reader-intent');
if (_restPage !== pageStamp()) return why('page-changed');
/* The offset, not the event stream: the engine's own compensation starts the motion sampler, and
* in WebKit the event arrives up to 1.2s late. `ref.at`, since run() has re-taken `_restAt` and the
* comparison becomes a value against itself (320px through, @1440 side). */
if (scrollTop() !== seen) return why('offset-moved');
let el = ref.el, was = ref.top;
if (!el || !el.isConnected) {
if (!ref.sec || !ref.sec.isConnected) return why('reference-gone');
el = ref.sec; was = ref.secTop;
}
let drift = el.getBoundingClientRect().top - was;
/* The witness can be blind: the fold's element need not sit below the container this tick refilled,
* 13 of 13 refills reading 0px with the reader 120px off, and a gap under the tolerance is `grow`'s
* own rounding. docs/anchoring-log.md, "A witness at the fold can be blind". */
if (Math.abs(drift) < 1 && grow > 1) {
const compensated = seen - ref.at;
if (Math.abs(compensated) < 1) drift = grow - compensated;
else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
rememberRest(true);
return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
}
}
if (Math.abs(drift) < 1) {
if (grow > 1 || floorShrink > 1) rememberRest(true);
return why('no-drift-grow-' + Math.round(grow)); /* the engine put it back */
}
if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
writeOffset(scrollTop() + drift);
why('wrote-' + Math.round(drift));
rememberRest(true);
if (floorShrink > 1) return;
if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
};
/* How long to wait is a question about the reader (docs/anchoring.md): where nothing is driving,
* the engine is the only mover and its window is 7-36ms — 404-422ms became 8-61ms. */
if (!scrolling() && Date.now() >= _userUntil) { why('now'); settle(); }
else { why(scrolling() ? 'wait-idle-moving' : 'wait-idle-intent'); _lateFrame = window.setTimeout(settle, SCROLL_IDLE); }
});
}
/* A floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching: its
* own frame slot, and the offset's own response to the write below as the check. Not gated on
* `scrolling()` — the belated write is what the engine reacts to, and the reaction re-arms the
* motion window, still reading true one rAF later on all three cells.
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
let _floorLateFrame = 0;
/* Floors `holdFloor()` could not clear while the reader scrolled, cleared once they let go: written
* in the frame the floors came off, so no frame is painted with the reader moved. The offset as
* laid out after the clear tells whether the engine already gave it back. */
function settleDeferredFloor(offsetBefore, shrink) {
if (_floorLateFrame) return;
_floorLateFrame = requestAnimationFrame(() => {
const seen = scrollTop();
_floorLateFrame = requestAnimationFrame(() => {
_floorLateFrame = 0;
if (!anchorEnabled() || Date.now() < _userUntil) return;
if (_restPage !== pageStamp()) return;
if (scrollTop() !== seen) return; /* still settling, or the reader has moved */
const wanted = offsetBefore - shrink;
const gap = wanted - seen;
if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return; /* the engine already gave it back */
if (Math.abs(gap) > (window.innerHeight || 800)) return;
writeOffset(scrollTop() + gap);
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
_restAt = scrollTop();
});
});
if (!anchorEnabled() || Date.now() < _userUntil) return;
const seen = scrollTop();
const gap = offsetBefore - shrink - seen;
/* 16: a 32-36 row lease table rounds by 8-12.25px at 390 wide */
if (Math.abs(gap) <= 16) return; /* the engine already gave it back */
/* no cap at the viewport: four leases stacked at 390px give up 1032px, all of it above */
writeOffset(seen + gap);
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
_restAt = scrollTop();
}
function scheduleAnchor(ref) {
if (!ref) return awhy('no-reference');
if (!anchorEnabled()) return awhy('anchoring-off');
if (_anchorPending) return awhy('pending-kept-first');
if (!ref) return;
if (!anchorEnabled()) return;
if (_anchorPending) return;
_anchorPending = ref;
requestAnimationFrame(() => {
const pending = _anchorPending;
@@ -479,23 +367,20 @@ function scheduleAnchor(ref) {
}
function applyAnchor(ref) {
/* Not into a moving page — unless the motion is the clamp this correction is for, which held a
* correct -60px correction unwritten. docs/anchoring-log.md, "A clamp is not the reader". */
if (scrolling() && !sawClamp()) return awhy('refused-moving');
* correct -60px correction unwritten. */
if (scrolling() && !sawClamp()) return;
const at = scrollTop();
if (ref.by != null) {
if (ref.by < 1) return awhy('by-under-1');
if (ref.by < 1) return;
writeOffset(at + ref.by);
awhy('wrote-by-' + Math.round(ref.by));
return true;
}
if (at <= 0) return awhy('at-top');
if (!ref.el.isConnected) return awhy('reference-gone');
if (at <= 0) return;
if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return awhy('no-drift'); /* nothing needed correcting here */
_lateHits = 0;
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
writeOffset(at + drift);
awhy('wrote-' + Math.round(drift));
return true;
}
@@ -505,56 +390,28 @@ function observeContent() {
.filter(Boolean);
const viewHost = hosts[0];
_mo = new MutationObserver((records) => {
const settled = _rest;
const trustEngine = _engineTrusted;
const ref = trustEngine ? null : anchorFor();
/* The record's own target, read before run() rewrites its floor: the growth in pixels, rather than
* whichever element the fold happened to hit, and read on every tick so recovery can tell a tick
* that tested the engine from one that did not. The floored box the record sits in, since
* `dom.content()` refills a node inside the pinned box on 1 of 12 nodes (docs/anchoring-log.md,
* "A witness that cannot see the box it is measuring"). */
let box = null;
for (const m of records) {
if (m.type !== 'childList') continue;
const b = m.target.closest(FLOORED);
if (b) { box = b; break; }
}
const before = box && (parseFloat(box.style.minHeight) || 0);
let grew = before ? box.offsetHeight - before : 0;
/* only growth above the reader's reference: counting a box below it wrote the whole growth back,
* offset +120 and reader -120 (docs/anchoring-log.md, "Growth below the reader") */
if (grew > 1 && settled && settled.el.isConnected
&& box.getBoundingClientRect().top >= settled.el.getBoundingClientRect().top) grew = 0;
if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
&& !scrolling() && _restPage === pageStamp()
&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
_engineTrusted = true;
_lateMisses = _lateHits = 0;
}
const ref = ENGINE_ANCHORS ? null : anchorFor();
const wasScrolling = scrolling();
if (before && wasScrolling) _deferredFloor = box;
/* every floored box a record sits in, since `dom.content()` refills a node inside the pinned box
* on 1 of 12 nodes, and one batch refills several: only the first lost the lease table's.
* Only a box already floored before run(): one run() floors now has nothing to settle later. */
if (wasScrolling)
for (const m of records) {
const b = m.type === 'childList' && m.target.closest(FLOORED);
if (b && (parseFloat(b.style.minHeight) || 0) > 0) _deferredFloors.add(b);
}
run(records);
if (!wasScrolling) _deferredFloor = null;
const floorShrink = before ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
if (!wasScrolling) _deferredFloors.clear();
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
* out and written over a correct restoreScroll(). docs/anchoring.md, "The commit is not a refill". */
let gone = 0, came = 0, took = 0, gave = 0;
for (const m of records) {
* out and written over a correct restoreScroll(). */
let gone = 0, came = 0;
for (const m of records)
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
}
if (gone && came) {
forgetRest();
return;
}
/* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding
* with the later batch cost 12 findings. docs/anchoring-log.md, "The later batch must not win". */
if (trustEngine) {
if (took && !gave && grew <= 0 && floorShrink <= 1) why('emptying');
else lateDrift(settled, grew, floorShrink);
}
else scheduleAnchor(ref);
scheduleAnchor(ref);
});
for (const host of hosts) {
_mo.observe(host, { childList: true, subtree: true });
@@ -564,9 +421,9 @@ function observeContent() {
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
/* A tab switch, a closing fold and a `depends()` row mutate no node, and `min-height` beats the
* `height: 0` they collapse with: 1299px of blank left standing (docs/anchoring-log.md, "A tab
* switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes
* every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms. */
* `height: 0` they collapse with: 1299px of blank left standing. A same-value write is not a
* change, though: the fitters re-apply classes every pass, and an unguarded `class` watch froze
* the tab, 391 callbacks in 432ms. */
const _moTabs = new MutationObserver((records) =>
records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName)
&& (r.attributeName !== 'class' || r.target.dataset.field)) && run());
@@ -585,20 +442,8 @@ return baseclass.extend({
},
scrolling,
/* test-only: the two trails of `makeTrail()` above */
lateWhy: () => _late.last(), /* fs:probe */
lateTrail: () => _late.entries().slice(), /* fs:probe */
anchorWhy: () => _anchor.last(), /* fs:probe */
anchorTrail: () => _anchor.entries().slice(), /* fs:probe */
deferMeasurement,
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
* of the anchor. No probe marker: packaging strips those, and the sweep reads the installed package. */
restAt: () => _restAt,
/* -> whether the engine is still trusted with a refill; unmarked, like `restAt` above */
engineTrusted: () => _engineTrusted,
forgetRest,
armGate,
@@ -317,9 +317,4 @@ function patchOverview() {
return baseclass.extend({
/* called by menu-footstrap-common's loader on every data-page stamp that maps here */
wire,
/* test-only: the percent parse and the clamp/threshold split, driven directly rather than through
* a live `.cbi-progressbar[title]` walk */
parseMeterPercent, /* fs:probe */
annotateMeter, /* fs:probe */
});
@@ -50,8 +50,6 @@ function currentMode() {
/* Dark mode is announced in three dialects because apps sniff for it: `data-theme="dark"`
* (luci-app-justclash keys 21 rules off it), Bootstrap's `data-bs-theme` and `data-darkmode`. */
function stampDark(root, dark) {
/* the names stay literal: a gate reads this function's source, and a hoisted const would
* look like no attribute at all */
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
@@ -110,7 +110,6 @@ function markAlive() {
_expired = false;
console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
}
function sessionExpired() { return _expired; }
function watchSession() {
const req = window.L.Request;
if (req && typeof req.addInterceptor === 'function')
@@ -809,22 +808,5 @@ return baseclass.extend({
seed,
wire: wireRouter,
onNavigate,
/* test-only, driven against a hand-broken `L`: the one way to see the off branch */
contractBreaks, /* fs:probe */
/* test-only: the navigation sweep around a visibilitychange, and the session verdict the
* interceptors reached */
clearViewIntervals, /* fs:probe */
sessionExpired, /* fs:probe */
/* test-only: the deferred `poll-stop` listener's own check, called directly so a test need not
* fake a microtask tick */
hidePollIndicatorIfEmpty, /* fs:probe */
/* test-only: the body-litter classifier and counter, and navigate() to prove the early return
* reaches them */
strayBodyNode, /* fs:probe */
bodyLittered, /* fs:probe */
navigate, /* fs:probe */
/* test-only: the progress bar's start/end pair, to drive the fade without a navigation */
progressStart, /* fs:probe */
progressEnd, /* fs:probe */
prefetchSegs
});
@@ -571,11 +571,6 @@ function wireTypeahead() {
}
return baseclass.extend({
/* test-only: the slot-cache key, driven directly */
shapeOf, /* fs:probe */
/* test-only: the token-cell predicate */
markTokenCells, /* fs:probe */
__init__() {
wireTypeahead();
@@ -411,7 +411,10 @@ function reassertLayerOrder() {
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
head.insertBefore(_layerStmt, head.firstChild);
/* before the first sheet, not into <head>: a document_start script (an extension) appends its
* <style> to <html> ahead of <head>, and Firefox then laid Overview out 2125px short at 390px */
const first = document.querySelector('style, link[rel~="stylesheet"]');
first.parentNode.insertBefore(_layerStmt, first);
}
function sweepViewSheets() {
@@ -449,10 +452,4 @@ return baseclass.extend({
documentPoisoned,
scopeToCurrentPage,
watchViewSheets,
/* test-only: the duplicate sweep, the fence rewrite, the owner stamp and the re-host, driven
* directly instead of through a live document walk */
dedupeViewSheets, /* fs:probe */
fenceRules, /* fs:probe */
_owner, /* fs:probe */
rehostIntoThemeLayer, /* fs:probe */
});
@@ -93,9 +93,9 @@ function wireSearch() {
/* The three template globals Status -> Overview needs (progressbar, renderBox, renderBadge), which
* stock includes call bare and an SPA arrival never defines. */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
they can be diffed against upstream when it changes. Modernising the `var`s would break
that property, which is what makes carrying the copies safe. */
/* Adapted copies of LuCI's admin_status/index.ut (window.L, an array child for E(), the title
string hoisted into `reading`); keep the shape, `var`s included, so a diff against upstream
stays readable when it changes. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
@@ -127,7 +127,6 @@ function ensureOverviewHelpers() {
window.L.itemlist(E('span'), [].slice.call(arguments, 2))
]);
};
/* eslint-enable no-var */
}
ensureOverviewHelpers();
+7 -9
View File
@@ -1,15 +1,13 @@
# Single source of the openwrt/luci commit whose two build tools we download and run as gates, and
# of their checksums. Sourced as shell (`. luci-upstream.pin`) by update-po.sh and CI.
# - jsmin.c compiled; its regex-vs-division lookback can silently eat a shipped JS file
# - i18n-scan.pl run by perl; decides whether the translation catalogue is complete
# Off a moving `master` the gate would be whatever upstream pushed last.
# To bump: change LUCI_PIN, re-fetch both files at the new commit, update their sha256s.
# Single source of the openwrt/luci commit whose i18n-scan.pl update-po.sh downloads and runs by
# perl to decide whether the translation catalogue is complete, and of its checksum. Sourced as
# shell (`. luci-upstream.pin`) by update-po.sh, build-apk.sh and tools/build-usign.sh.
# Off a moving `master` the check would be whatever upstream pushed last.
# To bump: change LUCI_PIN, re-fetch the file at the new commit, update its sha256.
LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3
JSMIN_SHA256=0568b14873f27f339cf66a9c43603d0bc8ca501ff62522ea924c6e8daf1be215
I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3
# No ucode pin, deliberately: the `verify` job runs the router's own interpreter over the installed
# templates on both release lines (docs/ci.md), so nothing is pinned, built or stubbed here.
# No ucode pin, deliberately: the `e2e` job renders every installed template with the router's own
# ucode on both release lines (tools/e2e.mjs), so nothing is pinned, built or stubbed here.
# usign, OpenWrt's ed25519 signer: owfeed signs each release package with it, and build-apk.sh
# builds this copy (via tools/build-usign.sh, not run in CI) to check the SDK it downloads. This is
+1 -2
View File
@@ -6,8 +6,7 @@
# 14% of the sheet: 18283 of 134210 bytes (measured 2026-10-14), wire bytes too: uhttpd serves /www
# uncompressed.
# The same map rewrites the .js and .ut under each <rewrite-dir> in place, BEFORE `strip-build.sh
# templates` and the JS minifier. Verify with `npm run computed-diff` (tools/computed-diff.mjs),
# mangled against plain.
# templates` and the JS minifier.
# Safe because (each checked):
# * `--fs-*` is the private tier; the export tier `--*-color-*` is untouched, and no installed
# luci-app reads a `--fs-` name.
+1 -1
View File
@@ -169,7 +169,7 @@ msgctxt "footstrap"
msgid "File"
msgstr "Archivo"
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake,
# Product name, kept identical on purpose: translating a brand name is a mistake,
# not a missing string.
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
msgid "Footstrap"
+1 -1
View File
@@ -164,7 +164,7 @@ msgctxt "footstrap"
msgid "File"
msgstr "Файл"
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake,
# Product name, kept identical on purpose: translating a brand name is a mistake,
# not a missing string.
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
msgid "Footstrap"
@@ -13,9 +13,10 @@ set_opt() {
set_opt themes.Footstrap /luci-static/footstrap
# A fresh install may pick the theme; an upgrade must never move a router off its theme. `changed`
# tells the two apart (on an upgrade the entry exists) and carries the decision alone: apk exports
# no PKG_UPGRADE.
# set_opt never overwrites, and luci-base ships main.mediaurlbase, so on a stock router this line
# sets nothing on install or upgrade: the admin picks Footstrap in Language and Style. An upgrade
# must never move a router off its theme; on an upgrade the themes entry exists, so `changed` is 0
# (apk exports no PKG_UPGRADE).
if [ "${PKG_UPGRADE:-}" != 1 ] && [ "$changed" = 1 ]; then
set_opt main.mediaurlbase /luci-static/footstrap
fi
+3 -43
View File
@@ -1,10 +1,9 @@
#!/bin/sh
# Strip build-time-only bytes from a BUILD TREE, one mode per kind of file:
# ./strip-build.sh templates|shell|probes|assets <dir>
# ./strip-build.sh templates|shell|assets <dir>
#
# templates `{# … #}`, whole-line `/* … */` and the indentation out of the .ut files
# shell whole-line `#` out of the shell under root/ (uci-defaults, the CGI, keep.d)
# probes the `/* fs:probe */` export lines out of the JS
# assets the SVG favicon's comment and the whitespace in the manifest JSON
#
# Run from the Makefile over $(PKG_BUILD_DIR) and from tools/stage.sh over the staged payload, never
@@ -14,11 +13,11 @@
# executes it. Reasons per mode sit above each branch below; docs/package.md has the rest.
set -eu
usage() { echo "usage: strip-build.sh templates|shell|probes|assets <dir>" >&2; exit 2; }
usage() { echo "usage: strip-build.sh templates|shell|assets <dir>" >&2; exit 2; }
MODE="${1:-}"
DIR="${2:-}"
case "$MODE" in templates|shell|probes|assets) ;; *) usage ;; esac
case "$MODE" in templates|shell|assets) ;; *) usage ;; esac
[ -n "$DIR" ] && [ -d "$DIR" ] || usage
LIST=$(mktemp)
@@ -29,7 +28,6 @@ trap 'exit 130' INT TERM
# The list goes through a file: `for f in $(find …)` splits on whitespace.
case "$MODE" in
templates) find "$DIR" -type f -name '*.ut' ;;
probes) find "$DIR" -type f -name '*.js' ;;
assets) find "$DIR" -type f \( -name '*.svg' -o -name '*.json' \) ;;
shell) find "$DIR" -type f ;;
esac | sort > "$LIST"
@@ -50,11 +48,6 @@ want() {
echo "strip-build: $1:$(grep -n '<<' "$1" | head -1 | cut -d: -f1): a heredoc — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
fi ;;
# a few module-private functions are also exported for one reason: a gate here calls them.
# The function stays, the export line goes, with the comment block directly above it. Only a
# complete export entry may go; anything else fails the build, since a half-removed object
# literal is a module that does not parse.
probes) grep -q '/\* fs:probe \*/' "$1" ;;
# acl.d is excluded: rpcd skips a malformed ACL SILENTLY, so the grant would go to nobody, for ~200 B
assets) case "$1" in */rpcd/acl.d/*.json) return 1 ;; esac ;;
esac
@@ -222,36 +215,6 @@ strip() {
echo "strip-build: $1 lost its shebang — refusing" >&2
exit 1
fi ;;
probes)
pre=$(grep -c '/\* fs:probe \*/$' "$1" || true)
awk '
# a comment block directly above a probe entry describes that export, so it is held back
# and goes with it; above anything else it is released untouched
function flush() { if (nb) { printf "%s", buf; lastblank = 0 } buf = ""; nb = 0 }
incom { buf = buf $0 "\n"; nb++; if ($0 ~ /\*\//) { incom = 0 } ; next }
/^[ \t]*\/\*/ && !/\/\* fs:probe \*\/$/ {
flush()
buf = $0 "\n"; nb = 1
if ($0 !~ /\*\//) incom = 1
else if ($0 !~ /\*\/[ \t]*$/) flush()
next
}
/\/\* fs:probe \*\/$/ {
line = $0
sub(/[ \t]*\/\* fs:probe \*\/$/, "", line)
# a complete entry, in either form the modules use: `name,` or `name: expression,`
if (line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*,[ \t]*$/ ||
line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*:.*,[ \t]*$/) { buf = ""; nb = 0; skipblank = lastblank; next }
print "strip-build: " FILENAME ":" NR ": not a whole export entry: " line > "/dev/stderr"
bad = 1
}
# a drop between two blank lines would leave a run the source did not have
skipblank && /^[ \t]*$/ { skipblank = 0; next }
{ flush(); skipblank = 0; lastblank = ($0 ~ /^[ \t]*$/); print }
END { flush(); exit bad }
' "$1" > "$CUR"
post=$(grep -c '/\* fs:probe \*/$' "$CUR" || true)
dropped=$((dropped + pre - post)) ;;
assets)
case "$1" in
*.svg)
@@ -315,7 +278,6 @@ strip() {
before=0
after=0
found=0
dropped=0
while IFS= read -r f; do
want "$f" || continue
found=$((found + 1))
@@ -333,11 +295,9 @@ done < "$LIST"
case "$MODE" in
templates) [ "$found" -gt 0 ] || { echo "strip-build: no .ut under $DIR" >&2; exit 1; } ;;
shell) [ "$found" -gt 0 ] || { echo "strip-build: no shell file under $DIR" >&2; exit 1; } ;;
probes) [ "$found" -gt 0 ] || { echo "strip-build: no marked export in $DIR" >&2; exit 1; } ;;
assets) [ "$found" -gt 0 ] || { echo "strip-build: no .svg or .json in $DIR" >&2; exit 1; } ;;
esac
case "$MODE" in
probes) echo "strip-build: probes: $dropped export(s) dropped from $found file(s)" ;;
assets) echo "strip-build: assets: $found file(s)" ;;
*) echo "strip-build: $MODE: $found file(s), $before -> $after bytes (-$((before - after)))" ;;
esac
+3 -5
View File
@@ -8,8 +8,7 @@
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs
* (`selected-row/paint`), docs/css.md "@mirror" */
/* opaque, not a tint (4.21:1 in dark) */
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* every separator is this one line, stated once rather than in 69 longhand declarations */
@@ -41,7 +40,7 @@
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
* tokens, not fetched". */
--fs-icon-refresh: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'><path d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/><path d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/><path fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/><path fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/></svg>");
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */
/* a glyph inlined once, sized at two call sites — keep them in step by hand */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
@@ -154,7 +153,7 @@
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md
/* outbound only: styles/ never reads one (nothing checks it). docs/design-system.md
* "The export tier is a RAMP" */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
@@ -171,7 +170,6 @@
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* tools/export-tier.mjs proves the whole tier across palette x mode */
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
+1 -1
View File
@@ -20,7 +20,7 @@
--fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base);
/* deliberately under AA — the "nothing here" ink, held by `npm run placeholder`.
/* deliberately under AA — the "nothing here" ink.
* docs/design-system.md "The fourth ink is the one". */
--fs-placeholder-mix: 55%;
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
@@ -50,7 +50,7 @@
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
}
/* Physical: border-block-start/-end need a css-floor.mjs support decision first. */
/* Physical: border-block-start/-end need a browser-floor support decision first. */
.tr.drag-over-above {
border-top: 2px solid var(--fs-accent);
}
@@ -36,8 +36,7 @@
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict
* reports. */
* (same selectors), so a copy left behind would be a dead declaration. */
.cbi-tab-disabled[data-errors]::after {
content: attr(data-errors);
@@ -203,7 +203,7 @@
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, chosen instead of :is(): .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor (npm run css-floor) */
* forgiving below the browser floor */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
+4 -13
View File
@@ -165,10 +165,8 @@
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
background: var(--fs-dim);
@@ -180,10 +178,8 @@
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
@@ -412,10 +408,8 @@
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
/* Static, deliberately: this is a poll running quietly forever, not something being
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
}
@@ -427,9 +421,8 @@
}
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
* attribute + :not() past stylelint's selector-max-specificity (1,7,0). `.fs-sidebar` is
* deliberately absent: #indicators is an id. */
* collapsed-rail indicator badges its count". `.fs-sidebar` is deliberately absent:
* #indicators is an id. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits)
* and a fixed box would clip it. */
@@ -437,13 +430,11 @@
padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this
/* .fs-ind-compact above paints the same badge but cannot SHARE this
* rule: it applies at every width, this one must stay inside the 521px floor. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
}
@@ -536,7 +527,7 @@
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
}
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
* overflowed a 320px button). Outside the @media: table-contract refuses overflow-wrap in a width query */
* overflowed a 320px button). */
.fs-main .alert-message .btn,
.fs-main .alert-message button { overflow-wrap: anywhere; }
@@ -40,33 +40,25 @@
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
* meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead
* of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the
* whole rule, form row included. @mirror pins the shared declarations. */
* whole rule, form row included. */
.cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2);
}
.cbi-value-field > .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */
--fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
@@ -114,7 +114,7 @@
}
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
* (18 jumps/1500 frames measured) — anchoring.md, "What the engine is told". */
* (18 jumps/1500 frames measured). */
.table.fs-dt { overflow-anchor: none; }
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
@@ -306,7 +306,7 @@
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
* 1-line neighbour's (52px) */
/* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list,
* losing display:flex on every card. Under css-dup's floor — keep both in step by hand. */
* losing display:flex on every card. Keep both in step by hand. */
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked tfoot:not(:has(> .tr)),
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
@@ -315,14 +315,13 @@
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
overflow-wrap: anywhere; white-space: normal;
}
/* the column label, lifted above its value. PINNED: the config table needs this under its
* own @container guard, which cannot share a block with a class. */
/* the column label, lifted above its value. A copy sits in theme/65-dropdown.css: the config
* table needs it under its own @container guard, which cannot share a block with a class. Keep
* the two identical by hand. */
.table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
.table.fs-stacked .td.cbi-section-actions::before,
@@ -330,26 +329,20 @@
.table.fs-stacked .td[data-title=""]::before,
.table.fs-stacked tfoot .td[data-title]::before,
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
/* row buttons are not a label/value pair — full width below the pairs. PINNED, see above. */
/* row buttons are not a label/value pair — full width below the pairs. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
* the tail button. PINNED to the config table's copy. */
* the tail button. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
}
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
* it does not scroll to an over-wide child */
@@ -273,9 +273,7 @@
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
.cbi-value-field select option:checked {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
}
@@ -271,9 +271,7 @@
/* after hover on purpose: hovering the selected row must still read as selected — docs/css.md
* "Dropdown: the open list's rail and the selected-row's contrast". */
.cbi-dropdown[open] > ul.dropdown > li[selected] {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
* INSIDE it so a focused+selected row keeps both */
@@ -282,8 +280,8 @@
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
}
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; the same declarations as
* `select option:checked` in theme/60-inputs.css, which has no checkbox (kept identical by hand) */
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
@@ -352,36 +350,28 @@
.table.cbi-section-table .td[data-widget="dvalue"],
.table.cbi-section-table .td[data-widget="fvalue"],
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
/* PINNED to the data table's copy (theme/30-tables.css, tools/mirror.mjs): needed under a
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
/* a copy of the data table's rule (theme/30-tables.css), needed under a CLASS there and
* under this @container here, and CSS cannot OR the two guards. Keep them identical by hand. */
.table.cbi-section-table .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */
/* the row's buttons are not a label/value pair: full width, sharing the line. Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
* div is a single flex item. PINNED to the data table's copy, same trap on Startup. */
* div is a single flex item. Copy in theme/30-tables.css; same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
}
.table.cbi-section-table .td .zone-forwards { width: 100%; }
}
@@ -20,11 +20,9 @@
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
#modal_overlay .modal label.btn {
/* @mirror btn-wrap/anywhere */
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
/* @endmirror */
}
#modal_overlay .modal li { overflow-wrap: anywhere; }
/* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */
@@ -4,11 +4,9 @@
@container fs-view (max-width: 360px) {
#view .cbi-button, #view .btn,
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
/* @mirror btn-wrap/anywhere */
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
/* @endmirror */
}
}
@@ -1,7 +1,7 @@
@layer theme {
/* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules,
* and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below
* (.stylelintrc.json holds the allowlist). .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
* .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
@@ -67,7 +67,7 @@
* 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked
* one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused
* keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at the stylelint ceiling (1,7,0) and still win by file order. */
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at (1,7,0) and still win by file order. */
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"],
input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
@@ -127,8 +127,7 @@
/* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live
applier in fs-prefs.js (no shared code: this runs before the module loader exists).
It carries no interpolation, unlike the data blob above: an interpolated body is not
JS until rendered, so it is exempt from the inline-script lint and anything merged
into it would go unchecked.
JS until rendered.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */
(() => {
@@ -144,8 +144,7 @@
{#
The two server values the script below needs, handed over as DATA: a `{{ … }}` block is not JS
until the server renders it, so this line carries no logic at all and eslint's .ut processor
enforces that. The script itself is plain JS and is linted.
until the server renders it, so this line carries no logic at all. The script itself is plain JS.
#}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script>
+3 -3
View File
@@ -4,9 +4,9 @@
#
# ./update-po.sh rescan, merge, report what is still untranslated
# ./update-po.sh --check change nothing; fail if the .pot is stale or a string is
# untranslated. The CI gate.
# untranslated.
#
# An untranslated _() falls through to its English msgid and nothing reports it, so --check is a gate.
# An untranslated _() falls through to its English msgid and nothing else reports it.
# `po/` is what LUCI_LANGUAGES globs and Weblate translates (docs/package.md): do not rename it.
# Not run on the buildbot (luci.mk calls po2lmo); needs perl and gettext.
# The scanner is LuCI's own build/i18n-scan.pl, not a grep: it lexes .ut into JS before xgettext and
@@ -16,7 +16,7 @@ set -eu
cd "$(dirname "$0")"
# Pinned by commit and sha256 in luci-upstream.pin: a fetched perl script is EXECUTED, and off a
# moving branch the completeness gate would be whatever upstream pushed last.
# moving branch the completeness check would be whatever upstream pushed last.
. ./luci-upstream.pin
SCANNER_URL="https://raw.githubusercontent.com/openwrt/luci/${LUCI_PIN}/build/i18n-scan.pl"
SCANNER_SHA256="$I18N_SCAN_SHA256"