',
- _('The access control list (ACL) specifies which IP addresses and ports can be mapped.') + ' ' +
+ _('The access control list (ACL) specifies which IPv4 addresses and ports can be mapped.') + ' ' +
_('ACL entries are checked in order, then rejected if not matched and not accepted by access defaults. (To do: should be part of access control tab)'));
s.anonymous = true;
s.addremove = true;
diff --git a/luci-app-upnp/root/usr/share/rpcd/ucode/luci.upnp b/luci-app-upnp/root/usr/share/rpcd/ucode/luci.upnp
index b9bf7280..bd6fe158 100644
--- a/luci-app-upnp/root/usr/share/rpcd/ucode/luci.upnp
+++ b/luci-app-upnp/root/usr/share/rpcd/ucode/luci.upnp
@@ -19,9 +19,12 @@ const methods = {
const leasefile = open(uci.get('upnpd', 'settings', 'lease_file') || '/var/run/miniupnpd.leases', 'r');
if (leasefile) {
for (let line = leasefile.read('line'); length(line); line = leasefile.read('line')) {
- const record = split(line, ':', 6);
- if (length(record) == 6) {
- let descr = trim(record[5]);
+ const record = split(line, ':', 7);
+ if (length(record) == 6 || length(record) == 7) {
+ // Daemon version >2.3.11 changes lease file format
+ let recshift = 0;
+ if (length(record) == 7) recshift = 1;
+ let descr = trim(record[5 + recshift]);
let m = match(descr, /^PCP [A-Z]+ ([0-9a-f]{24})$/);
if (m) descr = 'PCP (nonce ' + m[1] + ')';
else if (match(descr, /^NAT-PMP \d+ \w+$/)) descr = 'NAT-PMP';
@@ -30,9 +33,9 @@ const methods = {
push(leases, {
proto: uc(record[0]),
extport: +record[1],
- intaddr: arrtoip(iptoarr(record[2])),
- intport: +record[3],
- expires: record[4] - timelocal(localtime()),
+ intaddr: arrtoip(iptoarr(record[2 + recshift])),
+ intport: +record[3 + recshift],
+ expires: record[4 + recshift] - timelocal(localtime()),
descr: descr
});
}
diff --git a/luci-app-wwand/Makefile b/luci-app-wwand/Makefile
index c9b093aa..ac76affb 100644
--- a/luci-app-wwand/Makefile
+++ b/luci-app-wwand/Makefile
@@ -15,11 +15,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand
-PKG_RELEASE:=14
+PKG_RELEASE:=15
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
-PKG_SOURCE_VERSION:=8ac66dc263c430b6f81c55dd97133d83000c750a
+PKG_SOURCE_VERSION:=d7ca5f7f253507540730d3285f112f2d204f50a3
PKG_SOURCE_DATE:=2026-09-09
PKG_MIRROR_HASH:=skip
diff --git a/luci-lib-linkeaseauth/Makefile b/luci-lib-linkeaseauth/Makefile
index c2165cfe..9fa81e64 100644
--- a/luci-lib-linkeaseauth/Makefile
+++ b/luci-lib-linkeaseauth/Makefile
@@ -4,9 +4,10 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI shared OpenWrt auth bridge for LinkEase apps
LUCI_PKGARCH:=all
+PKG_PROVIDES:=luci-lib-openwrtauth
PKG_VERSION:=1.0.1
-PKG_RELEASE:=2
+PKG_RELEASE:=3
include $(TOPDIR)/feeds/luci/luci.mk
diff --git a/luci-proto-wwand/Makefile b/luci-proto-wwand/Makefile
index c96d2c98..67a06429 100644
--- a/luci-proto-wwand/Makefile
+++ b/luci-proto-wwand/Makefile
@@ -18,12 +18,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-proto-wwand
-PKG_RELEASE:=7
+PKG_RELEASE:=8
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git
-PKG_SOURCE_VERSION:=b2cbfa2150034e7037a966e4676f301fcea6ed9e
-PKG_SOURCE_DATE:=2026-09-06
+PKG_SOURCE_VERSION:=badbbc482790f1f79724196b6515746ccb942706
+PKG_SOURCE_DATE:=2026-09-09
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile
index 37b939c7..5e66cc09 100644
--- a/luci-theme-footstrap/Makefile
+++ b/luci-theme-footstrap/Makefile
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
-PKG_RELEASE:=63
+PKG_RELEASE:=64
endif
LUCI_TITLE:=Footstrap Theme
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
index ca2efff1..dd39d65f 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
@@ -182,18 +182,20 @@ function shellGeometry() {
return _geom;
}
-/* The window's width and the column's gutter are read here, from a fitter, and nowhere else.
+/* The window's width and the column's gutter are read here, from a fitter or from
+ * `contentWidth()`'s own staleness check below — nowhere else.
*
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
* what these reads exist to avoid — so the element is asked what it actually got.
*
- * Only a fitter calls this because `clientWidth` is a layout read and `getComputedStyle` resolves
- * style, while `contentWidth()` below must answer mid-scroll without either (fs-select's pass, for
- * a table the poll brought in under the reader's thumb). A fitter runs on every resize and content
- * mutation and defers during a flick, so a mid-scroll answer is the geometry as of the last still
- * moment.
+ * `clientWidth` is a layout read and `getComputedStyle` resolves style, so a fitter is the one
+ * caller allowed to reach BOTH unconditionally: it defers the whole pass during a flick
+ * (`fit.scrolling()`, fs-fit.js) rather than pay either mid-scroll. `contentWidth()` reaches only
+ * the first (comparison, not the write) on every call, and this function's own body only when
+ * that comparison says the width moved — its answer is otherwise the geometry as of the last still
+ * moment, exactly what a fitter last measured.
*
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
@@ -270,21 +272,33 @@ function fitChrome() {
* fitShell's data-narrow, and is untouched here.) */
const topBar = !!bar && !!menu && prefs.isTopLayout();
- /* THE BAR MAY NOT GET SHORTER WHILE IT IS BEING MEASURED. The three classes below are taken off
- * so the menu can be asked whether it fits on one row (fs-fit rule 1), and on a narrow bar that
- * makes the whole chrome one row instead of two for that layout — every pixel of it above the
- * reader, who is moved by exactly as much and moved back a moment later. Chromium and Firefox
- * hide it behind their scroll anchoring; Safari implements none, on any platform, so on an
- * iPhone this is the Overview creeping up once per poll tick. Reported from one, and bisected to
- * this pass on the reporter's own router: `?off=chromefit` stopped it, `?off=measure` (the
- * tables' own re-measure) did not.
+ /* THE BAR MAY NOT CHANGE HEIGHT WHILE IT IS BEING MEASURED, IN EITHER DIRECTION. The three
+ * classes below are taken off so the menu can be asked whether it fits on one row (fs-fit
+ * rule 1), and while they are off the bar's OWN box is free to answer any height its content
+ * currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
+ * grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
+ * measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
+ * 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
+ * — each step landing between two of the poll's own separate section refreshes, so the browser
+ * paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
+ * as Safari (`tools/fit-quiet.mjs`, `../tmp/task-toplayout/pass-probe.mjs`). `min-height` alone
+ * was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
+ * looked like the whole story only because it is the one engine with no anchoring at all to hide
+ * this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
+ * change, which is not the same as not producing it.
*
- * `min-height`, not `height`: the pass may legitimately need MORE room a moment later — that is
- * what `fs-bar-stack` is for — and a floor lets it grow while refusing the shrink. It comes off
- * before `publishBarHeight()`, which must measure the bar the reader actually gets. */
+ * So both `min-height` AND `height` are pinned to the SAME value for the whole decision — a hard
+ * pin, not a floor — because nothing this pass measures (`stripFitsOneRow()`'s `offsetTop`,
+ * `clusterFitsBrandRow()`'s widths) reads the BAR's own height; `overflow: visible`
+ * (`theme/20-shell.css`) means a row the pin is too short for still lays out and measures
+ * correctly, it only paints past the pinned box's edge, which is invisible for the one
+ * synchronous pass before the pin comes off. The pin is released only once the final class set
+ * is decided — after `fitCluster()`, before `publishBarHeight()` — which must measure the bar
+ * the reader actually gets. */
const pinned = bar ? Math.round(bar.getBoundingClientRect().height) : 0;
const hadMinH = bar ? bar.style.minHeight : '';
- if (pinned > 0) bar.style.minHeight = pinned + 'px';
+ const hadH = bar ? bar.style.height : '';
+ if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
if (bar) bar.classList.remove('fs-bar-stack', CLASS_IND_COMPACT, 'fs-bar-actrow');
fitTabStrips();
@@ -316,7 +330,7 @@ function fitChrome() {
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
- if (pinned > 0) bar.style.minHeight = hadMinH;
+ if (pinned > 0) { bar.style.minHeight = hadMinH; bar.style.height = hadH; }
publishBarHeight(bar);
}
@@ -572,18 +586,32 @@ return baseclass.extend({
* data tables' */
fitChrome,
- /* The width a page's content column has, without reading layout: the sidebar or rail eats a
- * known amount of the window and the shell adds a known padding, all memoised for fitShell().
- * Exported because a pass answering mid-scroll (fs-select's, for a table the poll just brought
- * in) otherwise has only the window width, which in the sidebar layout is wrong by exactly the
- * sidebar — at 800px the column is 520px, so a table judged to have room overflows.
+ /* The width a page's content column has: the sidebar or rail eats a known amount of the
+ * window and the shell adds a known padding, all memoised for fitShell(). Exported because a
+ * pass answering mid-scroll (fs-select's, for a table the poll just brought in) otherwise has
+ * only the window width, which in the sidebar layout is wrong by exactly the sidebar — at
+ * 800px the column is 520px, so a table judged to have room overflows.
*
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
contentWidth() {
- /* no layout read: the window width and gutter are whatever the last fitter measured, and
- * the three attributes below are style. The bootstrap read serves a caller arriving
- * before any fitter has run, which cannot happen mid-scroll. */
- if (!_shellOuter) measureShell();
+ /* `_shellOuter` is refreshed only by measureShell(), which only fitShell() calls, and
+ * fitChrome() steps ASIDE FOR THE WHOLE SCROLL_IDLE WINDOW (400ms, fs-fit.js) whenever
+ * fit.scrolling() answers yes — including for a resize that lands mid-flick, since a
+ * resize is exactly what starts that window (fs-fit.js's resize observer feeds the same
+ * motion sampler `scrolling()` reads). A caller landing in that window, most of all
+ * fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
+ * stayed 568 for up to 220ms of the 400 (measured: −178px, exactly 568−390;
+ * ../tmp/task-vnstat/probe2.mjs, probe3.mjs; live-audit's `geometry|fs-content` finding on
+ * owrt2410, CI run 34364446910).
+ *
+ * So the window's own width is compared fresh on every call, not only when `_shellOuter`
+ * is still zero. `clientWidth` is the one read the old "no layout read" promise here was
+ * already conditional on: the bootstrap branch (no fitter has run yet) made this exact
+ * call through measureShell(). A plain `!==` compares against the cached width for free —
+ * nothing invalidated layout since the last read, so this costs nothing when nothing
+ * moved — and measureShell()'s own further reads (the resolved gutter) only run when the
+ * comparison says the width actually did. */
+ if (document.documentElement.clientWidth !== _shellOuter) measureShell();
const root = document.documentElement;
return columnWidth(shellGeometry(), {
outerW: _shellOuter,
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
index bd0f1c0a..8dac07a0 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
@@ -27,7 +27,12 @@
* arming is exported and the module that clears the rule is the one that raises it. */
function armGate() {
if (!fittersEnabled()) return;
- try { document.documentElement.dataset.fsFit = '1'; } catch (e) { /* no document, no gate */ }
+ /* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
+ * reads 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. */
+ try { document.documentElement.dataset.fsFit = '1'; }
+ catch (e) { /* no document, no flag to write */ }
}
const _fitters = [];
@@ -187,6 +192,51 @@ function deferMeasurement() { _deferred = true; }
let _movingUntil = 0;
let _lastOffset = null;
let _sampling = false;
+/* THE THEME'S OWN CORRECTION WRITE IS A SCROLL EVENT TOO, and this sampler cannot otherwise tell it
+ * from the reader's: `lateDrift()` and `applyAnchor()` write `scrollTop` directly, the browser
+ * dispatches `scroll` for that the same as for a finger, and `noteMotion()` below used to treat
+ * either as SCROLL_IDLE (400ms) of fresh motion — which then blocks `holdFloor()`, `rememberRest()`
+ * and `applyAnchor()` itself for that whole window, since all three refuse while `scrolling()`. On
+ * REPEAT's own back-to-back refills of one section a correction's write routinely landed inside the
+ * 700ms gap the next refill starts in: measured live (`../tmp/task-refill2/probe.mjs`,
+ * owrt2512b/chromium/Overview), a correction's write at t=6530 opened a motion window to t=6930, the
+ * next refill's mutation arrived at t=6811 — INSIDE it — and both `holdFloor()` and `rememberRest()`
+ * were silently refused for that refill: the floor never picked up the new content's real height and
+ * `_rest` kept describing the position from before it, so the correction two ticks later measured a
+ * fabricated 59px drift against that stale reference and wrote a real one, the exact 47-60px "never
+ * corrected" shape the gate reports. Two such self-inflicted writes are two misses by `lateDrift()`'s
+ * own count, which is what tripped `_engineTrusted` false in the same run though the engine had
+ * anchored correctly throughout. The write's own resulting offset is remembered here and consumed by
+ * `sawOwnWrite()` below, held (not consumed on the first look) until the offset actually leaves that
+ * pixel, so both watchers agree; a reader who scrolls to a different pixel in the meantime is
+ * unaffected. */
+let _ownWrite = null;
+/* -> true if `y` is new motion the sampler must count; false if it still reads as this file's own
+ * last write settling. NOT single-shot — task refill2: `noteMotion()` (the `scroll` event) and
+ * `sampleMotion()`'s own frame loop both ask this of the SAME settling write when the sampler was
+ * already running before the write (an earlier, genuine motion still winding down), and a version
+ * that cleared `_ownWrite` on the first of the two to ask left the second — whichever it was — with
+ * nothing to recognise, reading the already-explained pixel as fresh motion and re-extending
+ * `_movingUntil` right back over the write's own settle. Measured live
+ * (`../tmp/task-refill2/probe.mjs`, owrt2512b/webkit/Overview @390 top, 3/3 reps): `scrolling()` never
+ * came back false at all between refills, `holdFloor()`/`rememberRest()` refused every one of them,
+ * and the reader drifted 59px on the second and third with no correction ever landing — the same
+ * shape a stale `_rest` produces, from a different cause. The marker now clears only once the offset
+ * moves to something ELSE, so however many places ask, the answer for THIS pixel stays consistent. */
+function sawOwnWrite(y) {
+ if (_ownWrite === null) return true;
+ if (Math.abs(y - _ownWrite) < 1) return false;
+ _ownWrite = null;
+ return true;
+}
+/* The one place either correction may write the scroll position, so `_ownWrite` cannot go stale by a
+ * write skipping it. Reads the offset back rather than trusting the argument: a write near either
+ * end of the document is clamped by the browser, and the pixel that actually lands is the one the
+ * next `scroll` event will report. */
+function writeOffset(sc, value) {
+ if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
+ _ownWrite = sc ? sc.scrollTop : window.scrollY;
+}
/* Which element scrolls, asked once per width rather than once per frame.
*
@@ -229,14 +279,34 @@ function scrolling() { return Date.now() < _movingUntil; }
function sampleMotion() {
const y = scrollTop();
if (_lastOffset === null || y !== _lastOffset) {
+ const own = !sawOwnWrite(y);
_lastOffset = y;
- _movingUntil = Date.now() + SCROLL_IDLE;
+ if (!own) _movingUntil = Date.now() + SCROLL_IDLE;
}
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
_sampling = false;
+ /* THE BOX, NOT THE REFERENCE — second attempt, also measured live and also wrong: a check
+ * against `_rest.el`'s own rect never sees this fault, because `_rest` is exactly what gets
+ * RE-ESTABLISHED, at whatever the offset currently is, by the very next successful
+ * `rememberRest()` — which may already have run, on a FRESH reference picked at the fold of an
+ * already-wrong position, before this ever gets to check anything. A reference cannot catch a
+ * fault in the ground it is itself read off. See `settleDeferredFloor()` for what holds instead:
+ * the OFFSET's own response to the ONE write below, measured directly, the same way
+ * `lateDrift()`'s `compensated` already checks the offset against `grow` — just for a shrink
+ * `holdFloor()` is about to (or, silently, already did) apply rather than one a mutation just
+ * grew. Both taken before `holdFloor()` runs it down: a box already settled through the ordinary
+ * mutation path in the meantime shows no shrink here, and costs one `parseFloat` to learn that. */
+ const target = _deferredFloor;
+ _deferredFloor = null;
+ const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
+ const offsetBefore = scrollTop();
/* the reader has stopped, so the floor and the reference both belong to where the page now
* stands */
holdFloor();
+ if (target && target.isConnected) {
+ const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
+ if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
+ }
rememberRest();
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */
if (_deferred) {
@@ -253,6 +323,7 @@ function sampleMotion() {
}
function noteMotion() {
+ if (!sawOwnWrite(scrollTop())) return;
_movingUntil = Date.now() + SCROLL_IDLE;
if (_sampling) return;
_sampling = true;
@@ -281,14 +352,23 @@ function noteUser() {
(function watchMotion() {
const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteMotion, opts);
- /* a gesture that IS the scroll: the reader is driving and the page is moving */
- for (const name of [ 'wheel', 'touchstart', 'touchmove' ])
+ /* A gesture that IS the scroll, from the second event on: `touchmove` and `wheel` fire only once
+ * the page has already moved, so unlike `touchstart` they mean motion, not just presence.
+ * `scroll` (above) and momentum still start the sampler for whatever the first frame of a flick
+ * misses. */
+ for (const name of [ 'wheel', 'touchmove' ])
window.addEventListener(name, noteUser, opts);
- /* Intent only. A scrollbar drag and a Page Down move the page and say so themselves, through
+ /* Intent only: says the reader is present, not that the page is moving. `touchstart` used to sit
+ * above and feed `noteMotion` too, so a stationary tap on a tab declared the page moving for
+ * SCROLL_IDLE (400ms) and gated `fitChrome()` with it — the freshly drawn tab strip painted at
+ * full padding and only shrank once the sampler saw the page still, ~400ms after the tap. Real
+ * motion is read from the scroll POSITION by `sampleMotion`, not the event: `touchmove` and
+ * `scroll` above, plus momentum, all still start it, so nothing that actually moves the page
+ * loses its guard. A scrollbar drag and a Page Down move the page and say so themselves, through
* `scroll`. Feeding them to `noteMotion` too would make `scrolling()` answer yes for 400ms after
* any click and every keystroke, which gates every layout-reading pass in this file: while
* typing into a form, 9 of 10 passes were skipped and landed in one burst afterwards. */
- for (const name of [ 'mousedown', 'keydown' ])
+ for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
window.addEventListener(name, noteIntent, opts);
})();
@@ -359,21 +439,105 @@ function watch(el) {
* It never fights the user: a page at the top has no offset to give back, and a drift under a pixel
* is rounding. */
/* Does the engine anchor at all? Chromium and Firefox do — measured with their anchoring
- * suppressed, a 120px growth above the fold moves the reader 120px, and 0px with it on. An older
- * WebKit does not, and a current one anchors but gets the COLLAPSE case wrong instead (lateDrift()
- * below). Correcting the offset in an engine that also corrects it means two corrections and a
- * page that jumps the other way, so this is asked of the platform rather than of a browser name —
- * `overflow-anchor` is the property that turns the feature off, and an engine that does not know it
- * does not have it. */
+ * suppressed, a 120px growth above the fold moves the reader 120px, and 0px with it on. So this is
+ * asked of the platform rather than of a browser name — `overflow-anchor` is the property that
+ * turns the feature off, and an engine that does not know it does not have it. Defended against an
+ * engine — or a stub, in a node-run test — with no `CSS` object at all; unreadable answers `true`
+ * (assumed handled rather than fought).
+ *
+ * Task wkanchor shipped a second question here, `ENGINE_MISANCHORS`
+ * (`-webkit-hyphenate-limit-before`), reasoning WebKit 26's own anchoring got a real correction
+ * wrong: +21-41px on the Overview, reader parked, real poll ticks, nothing above the fold growing
+ * by even a pixel. Task barpin found the real mover instead — `fitChrome()` (fs-chrome.js) pinned
+ * the bar against SHRINKING during its own measurement pass but not against GROWING, so the bar
+ * itself walked up to 107px taller than its settled height and back inside that one pass, on every
+ * engine; WebKit was never mis-anchoring, it was the one engine with no scroll anchoring of its own
+ * to absorb what the bar was actually doing. With `fitChrome()` pinned both ways, the same probe
+ * that measured +21-41px reads 0px on WebKit at 390/top with NO suppression at all
+ * (`../tmp/task-toplayout/top-probe.mjs --unsuppress`, 26s of real ticks) — `ENGINE_MISANCHORS` and
+ * the `data-fs-anchor-suppress` write it drove are gone with it; see `docs/anchoring.md`. */
const ENGINE_ANCHORS = (() => {
/* dev switch: `localStorage.fsEngineAnchor = 'off'` makes any engine take the non-anchoring
* path, which is otherwise only reachable on a machine with Safari on it */
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
catch (e) { /* no storage, no switch */ }
- try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('overflow-anchor', 'auto'); }
- catch (e) { return true; } /* unreadable: assume it is handled rather than fight it */
+ try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
+ ? CSS.supports('overflow-anchor', 'auto') : true; }
+ catch (e) { return true; }
})();
+/* Support for the property is not proof it is doing the job on THIS page: CI showed a real engine
+ * decline to anchor a container refill on two separate passes, `overflow-anchor` still reading
+ * `true` throughout — a case `ENGINE_ANCHORS` above cannot see, because it is asked once, at load,
+ * of the platform. `lateDrift()` below already computes the residual after every refill the theme
+ * did not itself correct, so the evidence is left to accumulate rather than guessed at up front:
+ * two residuals it actually had to write back — task latenet's four-way ablation measured that
+ * write landing 419-420ms after the refill — and the observer stops trusting this engine, moving to
+ * the anchorFor()/scheduleAnchor() path instead, measured 7-36ms on the same refill, UNTIL
+ * TRUST_RECOVERY_LIMIT below says otherwise — not for the rest of the session unconditionally: a
+ * fork with no way back paid the full 7-36ms path forever after two ticks a loaded runner or a
+ * momentary layout hiccup could produce just as easily as a standing fault. One residual is left as
+ * headroom for a single one-off rather than tripping on the first. Never a browser name, only a
+ * count: an engine that keeps the reference itself never reaches the write this counts — Chromium
+ * and Firefox measure 0 residuals today — so the switch cannot trip for them.
+ * `docs/anchoring.md`, "Who is responsible", carries the numbers. */
+const LATE_MISS_LIMIT = 2;
+let _lateMisses = 0;
+let _engineTrusted = ENGINE_ANCHORS;
+/* How much of the growth witness's own rounding a correctly-anchoring engine may leave unaccounted
+ * for before that gap reads as the engine declining rather than as the witness rounding — task
+ * missrule. Measured on a 32-36 row DHCP lease table at 390 wide, the one shape in the tree that
+ * rounds this large: 8px (compact), 12px (normal/large, webkit), 12.25px (large, firefox) — three
+ * points clustered tight against a 120px growth pad (`GROWTH`, tools/scroll-anchor.mjs), none of
+ * them a residual, all of them the engine having already done the whole correction
+ * (docs/anchoring.md, "The witness is not safe to write on its own"). 16 leaves 3.75px (30%) over
+ * the largest of the three — the same margin-over-the-worst-measured-cluster shape the gate's own
+ * `LATE_MS` uses — while staying far under half the pad (60px), so a genuine partial failure that
+ * leaves the reader in the middle of it is never read as rounding. */
+const LATE_ROUND_TOLERANCE = 16;
+
+/* Trust that was never allowed back — task trust. Once `_engineTrusted` went false the fork above
+ * stayed on the `anchorFor()`/`scheduleAnchor()` path for the rest of the session even where the
+ * two misses that tripped it were the engine having a bad ten seconds, not a standing fault.
+ *
+ * TWO SHAPES TRIED HERE WERE WRONG, BOTH MEASURED — worth keeping the failures, not only the fix,
+ * since a maintainer three lines below reasoning "just read the same drift `lateDrift()` reads" or
+ * "just compare the offset to the growth" is about to reproduce one of them.
+ *
+ * First shape: `applyAnchor()` already reads `ref.el`'s own rect against the remembered top before
+ * deciding whether to write — the same measurement `lateDrift()` uses to call a miss on the trusted
+ * path — so a hit was counted there whenever that drift read under a pixel. Live against a real,
+ * correctly-anchoring engine (`../tmp/task-trust/probe.mjs`, chromium/owrt2410, the Overview's own
+ * poll-sized section) that branch never ran at all: `anchorFor()`'s OWN offset read forces the
+ * layout the engine's scroll-anchoring resolves against, so by the time it asks "did the reader
+ * move", the engine has ALREADY moved the offset to absorb the growth — and `anchorFor()`, built for
+ * an engine that does none of that, reads any offset change that is not a downward clamp as the
+ * READER having scrolled and returns null. `scheduleAnchor()` then never runs, so `applyAnchor()`
+ * never sees the one tick that would prove the engine right — 0 hits recorded across 5 genuinely
+ * successful refills, measured directly (a debug build exporting `_lateHits` read 0 throughout).
+ *
+ * Second shape: read the offset itself instead, in the SAME microtask `grew` (below) is already
+ * read in, before `run()` can overwrite `_restAt` — `compensated = scrollTop() - _restAt` against
+ * `grew`, the identical comparison `lateDrift()` makes on the trusted path (`seen - ref.at`), just
+ * without a rAF plus `SCROLL_IDLE` to wait out. This one DOES see the engine work, but it is not
+ * strict enough: measured against a genuinely PARTIAL correction (an offset that moved by about the
+ * right amount overall), `compensated` matched `grew` within `LATE_ROUND_TOLERANCE` while the
+ * gate's own independent mark still sat 48px off, uncorrected — the container growing by roughly the
+ * right amount is not the same fact as the READER'S OWN reference holding, and task blindref already
+ * proved a container-based witness can agree with a bad tick.
+ *
+ * What holds: `_rest.el` itself, read directly rather than through `anchorFor()` — the identical
+ * reference and the identical comparison `lateDrift()` trusts on the OTHER path, just made here,
+ * before `run()`, instead of a rAF plus `SCROLL_IDLE` later, since the compensation is already done
+ * by the time anything in this callback reads geometry. A false negative here only delays recovery;
+ * this shape has no cheaper approximation that does not risk a false positive instead. Proven the
+ * same way as the two failures: the identical 5-refill run holds `_rest.el`'s drift under a pixel on
+ * every one, `_engineTrusted` returns `true` on the second, and the reader's own mark never moved
+ * (`moved: 0` throughout, both phases) — recovery costs no visible correction of its own, because
+ * there is nothing left for one to do. */
+const TRUST_RECOVERY_LIMIT = 2;
+let _lateHits = 0;
+
/* What the reader was looking at, captured while the page was still. `anchorRef()` runs from the
* mutation observer, i.e. after the DOM changed: right for the FITTERS, which have not run yet, and
* blind to the mutation itself. An anchoring engine covers that other half; where none does, the
@@ -384,6 +548,22 @@ let _rest = null;
* both scrollers on a client navigation and replays them on a Back, and neither is a clamp to
* undo. */
let _restAt = null, _restPage = null;
+/* The floored box a mutation found `holdFloor()` refused for (`scrolling()` was true) — its own
+ * shrink or grow still happens eventually, once `holdFloor()` finally does run, and until now that
+ * later call was `sampleMotion()`'s own bare `holdFloor(); rememberRest();`, wired to neither
+ * `lateDrift()` nor `scheduleAnchor()` — task wkrefill. Stashed by the mutation callback below,
+ * cleared the moment any tick's `holdFloor()` stops being refused, consumed once by
+ * `sampleMotion()`/`settleDeferredFloor()` (the write, and why it checks the OFFSET rather than a
+ * reference element, are there). Measured live (`../tmp/task-wkrefill/run-probe2.mjs`,
+ * webkit/owrt2512b @390 top, normal): a growth-then-shrink refill landing entirely inside one
+ * motion window left `_restAt` 59px higher than it started — `mark`'s own PAGE position never
+ * changed (7441.15625px throughout) while its viewport position read 440 against a `before` of 499
+ * — because the shrink's `min-height` write (a real scroll-anchor invalidation, `holdFloor()`'s own
+ * citation) landed inside `sampleMotion()`'s unwired call, the engine gave back only 61px of the
+ * 120px it owed, and the 59px gap was adopted as truth by the very next `rememberRest()`,
+ * uncounted and uncorrected — the exact shape task refill2's `compensated` check already catches on
+ * the GROWTH side, missing here only because nothing measured the SHRINK side at all. */
+let _deferredFloor = null;
function pageStamp() {
return (document.body && document.body.getAttribute('data-page')) || '';
}
@@ -393,8 +573,19 @@ function forgetRest() {
_restAt = null;
_restPage = null;
}
-function rememberRest() {
- if (scrolling()) return;
+/* `force`: skip the `scrolling()` guard — for the one caller that just wrote the scroll offset
+ * itself and needs the reference to match THAT write, not whatever unrelated motion happens to have
+ * `_movingUntil` still in the future at that instant. `lateDrift()`'s own correction and the
+ * engine's real compensation for the SAME underlying mutation both settle around the same
+ * SCROLL_IDLE window, so the two routinely overlap by a few milliseconds — measured live
+ * (`../tmp/task-refill2/probe.mjs`, owrt2512b/chromium/Overview): an ordinary, unforced
+ * `rememberRest()` right after the write was refused on `scrolling()` still reading true from the
+ * engine's own, unrelated scroll event 1ms earlier, leaving `_rest` stale for the NEXT refill and
+ * reproducing the same fabricated drift this call exists to prevent. Safe here specifically because
+ * the write just performed is a single, deliberate one this file made, not a multi-step animation to
+ * read mid-flight — by the time this call happens, that write has already landed. */
+function rememberRest(force) {
+ if (scrolling() && !force) return;
/* A page at the top has nothing to be put back to, so it does not pay for a reference: at
* offset 0 there is nothing to lose, and anchorRef()'s hit test plus rect costs 0.2ms typical,
* 6ms on a poll-dirtied WebKit layout. The offset is still remembered — one read, and
@@ -566,7 +757,7 @@ function anchorEnabled() {
* scrolls, not across a navigation, never more than a viewport. */
let _lateFrame = 0;
-function lateDrift(ref) {
+function lateDrift(ref, grow, floorShrink) {
/* the reference from BEFORE this tick, captured by the caller: one taken after the mutation
* describes the page as the mutation left it, so its drift is zero by construction */
if (_lateFrame || !ref) return;
@@ -607,22 +798,198 @@ function lateDrift(ref) {
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return;
el = ref.sec; was = ref.secTop;
}
- const drift = el.getBoundingClientRect().top - was;
+ let drift = el.getBoundingClientRect().top - was;
+ /* THE WITNESS CAN BE BLIND. `el`/`ref.sec` is whatever anchorRef() hit-tested at the fold
+ * on the LAST still page — it is not guaranteed to sit below the container this tick
+ * actually refilled, and an element's own top does not move when growth happens somewhere
+ * it is not connected to. Measured live: a compact-density reference at top -362 read 0px
+ * of drift on 13 of 13 refills while the reader sat 120px off (CI, webkit/owrtsnap @1440
+ * side, task blindref). `grow` — the mutation record's OWN target measured by the caller
+ * (observeContent()) against the height `data-fs-floor` pinned it at before this tick —
+ * cannot make that mistake: it IS what the refilled container actually grew by, not a
+ * guess at what moved.
+ *
+ * IT IS NOT A SAFE NUMBER TO WRITE ON ITS OWN, though — task detector, reverting the claim
+ * this comment used to make. `compensated` is what the OFFSET already did since the
+ * reference was taken; where it is exactly zero the engine never touched the offset at
+ * all, the same blindness `drift` above just had, and `grow` is the only witness that saw
+ * it — writing the whole growth back is correct and IS a miss: the engine did nothing.
+ * Where `compensated` is NOT zero the engine already moved the offset roughly by its own
+ * anchoring, and a SMALL gap between the two is not a residual to correct — it is this
+ * witness's own rounding on a many-row table re-laid at a narrow width: a 32-36 row DHCP
+ * lease table clamped by exactly 8px (compact), 12px (normal/large, webkit) or 12.25px
+ * (large, firefox) while `compensated` already matched `grow` to within that same amount,
+ * on BOTH engines, at every density and every layout tested (task detector, 21 CI
+ * findings, /admin/network/dhcp @390 — the overshoot equalled the clamp the gate itself
+ * reported in every one). Writing that gap is a second correction on top of one the engine
+ * already made; instrumented locally against the failing cell, withholding the write reads
+ * `swap moved 0px` on all 21 with no write logged, engine alone.
+ *
+ * BUT a gap that small is not the only shape this branch used to see, and task detector's
+ * own `>= 1` counted every one of them as a miss regardless of size — so on the SAME table,
+ * two ticks (10s) later, `_engineTrusted` went false while the engine was still anchoring
+ * perfectly (task missrule, `../tmp/task-anchor-audit/inventory.md` §2.2): the fast path
+ * then ran ALONGSIDE a live engine, the exact "two corrections throw the page the other
+ * way" `ENGINE_ANCHORS` above warns about. A miss must mean the engine did not do the job,
+ * not that this witness rounded — so only a gap ABOVE `LATE_ROUND_TOLERANCE` counts as one;
+ * within it, the engine did the job and lateDrift() returns having written nothing, same as
+ * the direct `drift < 1` case two lines below. Below the tolerance is not "no drift to
+ * report" — WITHIN it, this file already knows the engine is right, which `drift` alone
+ * (still whatever `anchorRef()`'s possibly-blind reference read) does not. Re-measured live
+ * against a real engine that never anchors this section at all (task missrule,
+ * ../tmp/task-missrule/probe.mjs, webkit and chromium, a table swapped three times running):
+ * `compensated` stays exactly 0 every tick, so this branch is never reached for that case —
+ * it counts the ordinary way, below, and `_engineTrusted` still goes false on the second
+ * one, switch working as built. */
+ 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) {
+ /* fresh distrust starts the recovery count at 0 too — a streak from a PREVIOUS
+ * spell of distrust proves nothing about this one */
+ if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
+ return;
+ }
+ /* else: within table-row rounding — drift is still whatever it was (< 1 per the guard
+ * above), so the plain `drift < 1` return two lines down is what fires, unwritten and
+ * uncounted: the engine did the job. */
+ }
if (Math.abs(drift) < 1) return; /* the engine put it back */
if (Math.abs(drift) > (window.innerHeight || 800)) return;
const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
- if (sc) sc.scrollTop = at + drift; else window.scrollTo(0, at + drift);
- /* The write moves the page by exactly the drift measured, which puts the reference back
- * at the top it was remembered at, so `_rest.top` still holds and the next tick
- * measures zero. Only `_restAt` changes, and the write may have been clamped short, so
- * it is re-read rather than assumed; `rememberRest()` cannot do it, since the write
- * starts the motion sampler and that function returns early while the page moves. */
- _restAt = scrollTop();
+ writeOffset(sc, at + drift);
+ /* A FRESH, FORCED rememberRest(), not just `_restAt = scrollTop()` — task refill2. The
+ * write moves the page by exactly the drift measured, so `_rest.top` still holds FOR THIS
+ * TICK's own `el`, but `_rest` itself was taken by run() at the top of THIS callback,
+ * before the mutation had a settled height and before the engine had done any of its own
+ * compensating scroll for it — a snapshot mid-transition, not the page as it will stay.
+ * Left standing, the NEXT refill's own correction reads its drift against that stale,
+ * mid-transition top instead of the one this write just settled, and computes a fabricated
+ * number: on REPEAT's own back-to-back refills of one section this was the 59px "never
+ * corrected" drift itself, not merely late bookkeeping. `force` (`rememberRest()`'s own
+ * comment) because the engine's real, unrelated compensation for the SAME underlying
+ * mutation routinely settles inside the same SCROLL_IDLE window as this write and leaves
+ * `scrolling()` reading true by a handful of milliseconds — an unforced call here reproduced
+ * the identical fabricated drift on the very next refill (measured,
+ * `../tmp/task-refill2/probe.mjs`, owrt2512b/chromium/Overview: `moved 0, 0, -59` without
+ * `force`, `moved 0, 0, 0` with it, both `trusted true` throughout). */
+ rememberRest(true);
+ /* A DROP IN THIS SAME BOX'S OWN FLOOR IS NOT EVIDENCE ABOUT THE ENGINE — task refill2. Every
+ * `run()` clears and rewrites `r.target`'s `min-height`, and a WRITE to that property is a
+ * scroll-anchor invalidation in its own right (`holdFloor()`'s own citation), independent of
+ * how reliably this engine otherwise keeps a reference: a floored box whose content really
+ * did shrink pays this cost on every engine, every time, structurally — counting it toward
+ * `_lateMisses` measures the theme's own floor bookkeeping, not the engine, and two ordinary
+ * shrinks ten seconds apart used to be enough to distrust an engine that had anchored every
+ * real growth on the same section at 0px throughout (measured, `../tmp/task-refill2/
+ * probe.mjs`, owrt2512b/chromium/Overview: `trustedBefore true, trustedAfter false`, reader
+ * never moved). The correction still runs — P1/P2 do not care why the engine left a
+ * residual — only the bookkeeping is skipped. */
+ if (floorShrink > 1) return;
+ /* A DIRECT DRIFT INSIDE THE SAME TOLERANCE THE BLIND BRANCH ALREADY USES IS THE SAME
+ * ROUNDING, NOT A SECOND FAULT — task refill2. The blind branch above only ever sees this
+ * table's row-rounding when the engine's own compensation left `drift` under 1px; on the
+ * SAME table's GROWTH refill this measured a direct `drift` of exactly 1px against a 132px
+ * growth (`../tmp/task-refill2/probe.mjs`, owrt2512b/chromium, `/admin/network/dhcp @390`
+ * side and top) — one pixel over the `< 1` line above, so neither guard caught it, and a
+ * write this small still counted as a miss. `REPEAT`'s own three-refill window turns that
+ * into two: one from this 1px growth-side rounding, a second from the very next one, and
+ * `_engineTrusted` tripped false on an engine anchoring within a pixel every time. Read
+ * against the SAME LATE_ROUND_TOLERANCE the blind branch already measured this exact page
+ * and width against (8-12.25px) rather than a new number for what is the same table. */
+ if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
+ /* This engine did not keep the reference across a container refill, once more on this
+ * page — see LATE_MISS_LIMIT above for what happens once that has been measured twice. */
+ if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
}, SCROLL_IDLE);
});
}
+/* ---- a floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching ----
+ *
+ * `_deferredFloor`'s own comment has the fault; this is the fix, and it needs a THIRD frame slot,
+ * not either of the two `lateDrift()`/`scheduleAnchor()` already own. Measured live, first attempt:
+ * routing the same information through `lateDrift(ref, 0, floorShrink)` from `sampleMotion()`
+ * collided with the regular mutation's OWN pending call for the SAME tick — `seen` there is
+ * captured one rAF after the ORIGINAL (refused) mutation, well before `sampleMotion()` ever gets to
+ * clear the floor, so by the time this one tried to arm, `_lateFrame` was still occupied, and by
+ * the time the ORIGINAL one's own timeout fired, `holdFloor()`'s belated write had already moved
+ * the offset on its own — read by that timeout as `scrollTop() !== seen`, "the reader is still
+ * moving", and refused too. Two correct guards, aimed at two different questions, defeating each
+ * other on the one tick both fire for.
+ *
+ * NOT A REFERENCE ELEMENT'S OWN DRIFT — second attempt, also measured live and also wrong, and the
+ * one worth explaining because it looks safer than it is. Checking `_rest.el`'s (or `ref.sec`'s)
+ * rect the way `lateDrift()` does cannot see this fault BY CONSTRUCTION: `_rest` is exactly what
+ * gets RE-ESTABLISHED, at whatever the offset happens to be, by the very next successful
+ * `rememberRest()` — which runs a fresh `anchorRef()` hit test at the CURRENT fold, wherever that
+ * now falls. Once one has run on an already-59px-wrong offset, the reference it picks (measured
+ * live: an `H3` a NEW hit test landed on, in a position the fold only reaches because the offset is
+ * already wrong) shows zero drift for ever after — it was established AT the wrong position, not
+ * moved away from a right one. A witness cannot catch a fault in the ground it is itself read off.
+ *
+ * WHAT HOLDS: the OFFSET's own response to the ONE write below, measured directly against how much
+ * the box actually shrank — the same `compensated` vs `grow` comparison `lateDrift()`'s blind branch
+ * already makes for a GROWTH, just made here for a SHRINK `holdFloor()` is about to apply (or,
+ * silently, already did) rather than one a mutation just grew. Neither snapshot depends on `_rest`
+ * staying pure, so neither can be corrupted by the fault this exists to catch. */
+/* NOT GATED ON `scrolling()` — third attempt, also measured live and also wrong, and the one
+ * `lateDrift()`'s own comment already warned this file about once: "an anchoring engine moves the
+ * offset ITSELF … so an offset that merely differs is the engine working, and refusing on that
+ * leaves the engine's own residual uncorrected." `holdFloor()`'s belated write is exactly such a
+ * change — a real `min-height` clear the engine reacts to by moving the offset — and that reaction
+ * dispatches its own `scroll` events, which is what this correction exists to read. Gating on
+ * `scrolling()` therefore refuses on the very motion it is trying to observe: measured live,
+ * `scrolling()` still read true one whole rAF after `holdFloor()` ran, on every one of three cells,
+ * because the reaction itself kept re-arming `_movingUntil`. `lateDrift()` does not make this
+ * mistake either — it checks `scrollTop() !== seen` (STILL, not "not moving"), two reads apart, the
+ * same check used here: two rAFs, not `SCROLL_IDLE` (400ms) — `REPEAT`'s own back-to-back refills
+ * leave under a second between this write's inputs being known and the NEXT refill's mutation
+ * landing, and a 400ms wait routinely lands its own check after that next refill has already
+ * restarted the motion sampler, checking a `now` the reader has already moved past. */
+let _floorLateFrame = 0;
+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 */
+ /* A box that shrinks above the reader must lower the offset by the same amount for them
+ * to stay put — `wanted` is that target, not an estimate: `shrink` is what the box
+ * actually gave up, read fresh by the caller against its own pre-write height, the
+ * identical measurement `grow` already is for the growth side. */
+ 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;
+ const sc = scroller();
+ const at = sc ? sc.scrollTop : window.scrollY;
+ writeOffset(sc, at + gap);
+ /* `_rest` ADJUSTED IN PLACE, NOT `rememberRest(true)` — fourth attempt, also measured
+ * live and also wrong: this write's own `scroll` event can still be in flight when this
+ * line runs, and `rememberRest(true)` calls `anchorRef()` regardless of `force` —
+ * `anchorRef()` has its OWN, unconditional `if (scrolling()) return null;` guard, so a
+ * write landing inside that window leaves `_rest` NULL rather than merely stale. Measured
+ * live (`../tmp/task-wkrefill/run-probe.mjs --cellonly owrt2410b@top`): the very next
+ * mutation's `lateDrift()` then refused with "no ref", `_rest` was re-established fresh
+ * off whatever the fold happened to be two ticks later, and the reader ended up 120px off
+ * in the OTHER direction — worse than doing nothing. `_rest.el` itself did not move; only
+ * the offset it was measured against did, by exactly `gap`, so its remembered screen
+ * position shifts by the same amount and nothing needs re-reading off the page. */
+ if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
+ _restAt = scrollTop();
+ /* NOT COUNTED TOWARD `_lateMisses` — same rule as `lateDrift()`'s own `floorShrink > 1`
+ * skip: this write answers for the theme's own bookkeeping catching up late, not for
+ * anything the engine declined to do, so it says nothing about whether the engine can be
+ * trusted on an ordinary tick. */
+ });
+ });
+}
+
function scheduleAnchor(ref) {
if (!ref || !anchorEnabled()) return;
if (_anchorPending) return;
@@ -632,10 +999,14 @@ function scheduleAnchor(ref) {
_anchorFrame = 0;
const pending = _anchorPending;
_anchorPending = null;
- applyAnchor(pending);
+ const wrote = applyAnchor(pending);
/* after the correction, never before: the reference must describe the page as the reader now
- * sees it, or the next tick pays the same drift twice */
- rememberRest();
+ * sees it, or the next tick pays the same drift twice. Forced only where a write just
+ * happened — task refill2's `force` on `rememberRest()` is for resyncing after THIS file's
+ * own write, not for skipping the guard when `applyAnchor()` did nothing (refused because the
+ * page was moving, at the top, or the reference was gone): forcing unconditionally would read
+ * a reference off a page mid-flick on exactly the refusal this guard exists for. */
+ rememberRest(wrote);
});
}
function applyAnchor(ref) {
@@ -657,22 +1028,28 @@ function applyAnchor(ref) {
* one case to sit out. */
if (ref.by != null) {
if (ref.by < 1) return;
- if (sc) sc.scrollTop = at + ref.by;
- else window.scrollTo(0, at + ref.by);
- return;
+ writeOffset(sc, at + ref.by);
+ return true;
}
if (at <= 0) return;
if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
- if (Math.abs(drift) < 1) return;
+ if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
+ /* A definite write is a definite miss — task trust: any recovery streak counted so far said
+ * nothing about THIS tick, and this tick just proved the engine did not do the job on its own.
+ * (Recovery evidence itself is gathered earlier, in the mutation callback — see TRUST_RECOVERY
+ * _LIMIT above: this path never sees the tick that proves the engine right, because a reference
+ * the engine has already put back is exactly what makes anchorFor() return null instead of
+ * reaching here — measured live, `../tmp/task-trust/probe.mjs`.) */
+ _lateHits = 0;
/* A correction is a scroll the reader did not ask for, so an absurd one is a bug: a view that
* replaced its whole subtree can move a reference by thousands of pixels. One viewport and 200px
* is the most a single tick can honestly account for — where `innerHeight` is unreadable those
* 200px are the whole ceiling — plus whatever the engine is on record for having clamped away
* (`slack`, see anchorFor()). */
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
- if (sc) sc.scrollTop = at + drift;
- else window.scrollTo(0, at + drift);
+ writeOffset(sc, at + drift);
+ return true;
}
/* Rule 2's mutation side. Deliberately not filtered by node type: a filter is a second place to
@@ -686,7 +1063,7 @@ function applyAnchor(ref) {
* constructor, and luci-base instantiates a class once, at the first require. */
function observeContent() {
if (_mo) return;
- _mo = new MutationObserver(() => {
+ _mo = new MutationObserver((records) => {
/* The theme corrects only where the engine will not. Where it anchors, growth above the
* reader is the engine's job and the floor covers the collapse, so there is nothing left for
* a correction to do: one written here would read its reference in the same instant the poll
@@ -701,9 +1078,83 @@ function observeContent() {
* within the frame, so the immediate correction stays, measured against the reference from
* the last still page. */
const settled = _rest;
- const ref = ENGINE_ANCHORS ? null : anchorFor();
+ /* `_engineTrusted`, not `ENGINE_ANCHORS`: the platform check above answers once, at load,
+ * whether the property exists — it cannot see an engine that has it but declines to use it
+ * on a given refill, which is what `lateDrift()`'s own residual count is for (LATE_MISS_LIMIT
+ * above). Once that has happened twice this session the fork moves here too, so the engine
+ * gets no third 420ms-late chance at a correction the fast path already does in 7-36ms. */
+ const trustEngine = _engineTrusted;
+ const ref = trustEngine ? null : anchorFor();
+ /* THE RECORD'S OWN TARGET, read before run() below rewrites its floor for the NEXT tick — the
+ * only place this callback sees which container a poll actually refilled, as opposed to
+ * whichever element anchorRef() happened to hit-test at the fold, which need not be connected
+ * to THIS growth at all (task blindref). A record's target already wearing `data-fs-floor` IS
+ * the box holdFloor() pinned at its last settled height, so its height against that pin, taken
+ * NOW — the mutation already happened, so this is its final height; nothing here waits on the
+ * engine, which only ever moves the SCROLL POSITION, never an element's own size — is the
+ * growth lateDrift() needs, in pixels rather than a live reference to carry forward.
+ *
+ * FOUND EVERY TICK, TRUSTED OR NOT — task trust. Gating this to the trusted branch was fine
+ * while distrust was permanent, since nothing downstream of it would ever read the number
+ * again; recovering trust needs to tell a tick that tested the engine from one that tested
+ * nothing on the distrusted path too, in the recovery check right below, so the read can no
+ * longer be skipped there. One extra `records.find` and an `offsetHeight` per distrusted
+ * tick — the one path that used to pay nothing here at all. */
+ const r = records.find((m) => m.type === 'childList' && m.target.hasAttribute('data-fs-floor'));
+ /* a box freshly wearing its FIRST floor has no "before" to measure against — parseFloat
+ * of an unset `min-height` is NaN, `|| 0` reads as "no growth" rather than false growth
+ * the size of the whole box */
+ const before = r && (parseFloat(r.target.style.minHeight) || 0);
+ const grew = before ? r.target.offsetHeight - before : 0;
+ /* RECOVERY EVIDENCE, task trust — read here and nowhere else; see TRUST_RECOVERY_LIMIT's own
+ * comment for why `applyAnchor()` cannot see it. THE SAME REFERENCE `lateDrift()` trusts on
+ * the other path (`_rest.el`'s own rect against the top it was remembered at), read before
+ * `run()` can overwrite it, instead of a rAF plus `SCROLL_IDLE` later — the compensation is
+ * already done by the time anything here reads geometry (`anchorFor()`'s own read, just
+ * above, forces the layout the engine resolves it in).
+ *
+ * NOT `compensated` vs `grew` (the OFFSET against the CONTAINER'S height, `lateDrift()`'s own
+ * comparison for the case its element-based drift is blind) — measured live and wrong here:
+ * `../tmp/task-trust/probe.mjs` against a genuinely partial correction (an offset that moved
+ * ROUGHLY the growth pad's own size) read `compensated≈grew` and recovered trust while the
+ * probe's own independent mark still sat 48px off, uncorrected. The container growing by
+ * about the right amount is not the same fact as THIS reference holding, and recovery needs
+ * the stricter of the two: a false negative here only delays recovery, a false positive
+ * un-distrusts an engine that is still getting it wrong. */
+ 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;
+ }
+ /* THE SAME BOX'S FLOOR, BEFORE AND AFTER THIS run() — task refill2. `holdFloor()` inside
+ * run() clears and rewrites `r.target`'s own `min-height` every tick, which is a scroll-
+ * anchor-invalidating style write on its own account (css-scroll-anchoring-1 §2.2.2,
+ * `holdFloor()`'s own comment) — so a floored box whose CONTENT genuinely shrinks (a poll's
+ * data losing rows, or in this callback the mutation record ITSELF being a removal) drops the
+ * engine's own anchoring for that write no matter how reliable the engine otherwise is. That
+ * residual reads to `lateDrift()` exactly like an engine that declined a real refill — the
+ * `grow` witness above is already 0 for it (`before` still equals the box's own pre-shrink
+ * offsetHeight at the point `grew` was read, min-height not yet cleared) — and every one of
+ * them used to count as a miss: measured live (`../tmp/task-refill2/probe.mjs`,
+ * owrt2512b/chromium/Overview, `REPEAT_TIMES` back-to-back refills of one section), two such
+ * shrinks ten seconds apart tripped `_engineTrusted` false on an engine that had anchored
+ * every real growth on the same section at 0px throughout. Read AFTER run(), which is what
+ * actually clears and rewrites it for this tick. */
+ /* STASHED BEFORE run(), against the identical `scrolling()` its own `holdFloor()` call is
+ * about to check: a floored box `holdFloor()` is refused for right now still shrinks or grows
+ * eventually — see `_deferredFloor`'s own comment for where and why nothing used to notice.
+ * THE BOX ITSELF, not a height or a reference: `settleDeferredFloor()` re-reads the height
+ * fresh at consumption time, so an intervening successful sweep of the SAME box (this tick's
+ * own `run()`, or another mutation's) leaves nothing stale behind — cleared below the moment
+ * any tick's `holdFloor()` actually runs, for the identical reason. */
+ const wasScrolling = scrolling();
+ if (r && before && wasScrolling) _deferredFloor = r.target;
run();
- if (ENGINE_ANCHORS) lateDrift(settled);
+ if (!wasScrolling) _deferredFloor = null;
+ const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(r.target.style.minHeight) || 0)) : 0;
+ if (trustEngine) lateDrift(settled, grew, floorShrink);
else scheduleAnchor(ref);
});
const hosts = [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]
@@ -725,20 +1176,35 @@ function observeContent() {
_moFlag = new MutationObserver(run);
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
- /* A TAB SWITCH MUTATES NO NODE. ui.tabs writes `data-tab-active` on the panes, so the
- * {childList} registration above never wakes and the floor the pane wore while it was open
- * stays on it — and `min-height` beats the `height: 0` an inactive pane is collapsed with
- * (theme/30-tables.css), so that floor IS blank page above the tab the reader just opened.
- * Measured on 25.12, /admin/network/network, Interfaces -> Devices: 1299px left standing, the
- * document at 2647px against 1720 and the content the reader came for 1559px down, still there
- * 13 s later on a page whose poll never mutates #view (tools/floor-contract.mjs, issue #75).
+ /* A TAB SWITCH — OR A DISCLOSURE CLOSING, OR A depends() ROW HIDING — MUTATES NO NODE. ui.tabs
+ * writes `data-tab-active` on the panes and fs-appearance.js's foldable() closes by writing
+ * `hidden`/`aria-expanded` only, so the `{childList}` registration above never wakes for either
+ * and the floor taken while the content was open/visible just stays — `min-height` beats the
+ * `height: 0` a hidden pane collapses with (theme/30-tables.css), so that floor IS blank page.
+ * Measured on 25.12, /admin/network/network, Interfaces -> Devices: a tab switch left 1299px
+ * standing, the document at 2647px against 1720, still there 13s later on a page whose poll
+ * never mutates #view (tools/floor-contract.mjs, issue #75); the disclosure shape measured
+ * 731/1485/1485 (open/close/still-1485) on /admin/system/footstrap before this observer
+ * covered it, 731/1485/731 with it (docs/anchoring.md).
+ *
+ * STOCK LuCI HIDES A ROW THE SAME WAY, WIDER: form.js's setActive() — what every `depends()`
+ * calls — toggles the CLASS `hidden` on the `[data-field]` element, not the attribute. `class`
+ * cannot join `data-tab-active`/`hidden`/`aria-expanded` in this filter unguarded — the poll
+ * rewrites row classes on every tick, and an unfiltered `class` watch would call run(), a
+ * forced layout, on every one of them (see _moFlag's own comment) — so a `class` record only
+ * counts where the mutated element itself carries `data-field`, a property check with no
+ * forced layout, once per delivered record rather than once per poll tick. Measured: System ->
+ * System -> Time Synchronization, unticking "Enable NTP client", left 258px of empty ground
+ * before this filter existed and 0px with it (../tmp/task-spoilerfloor/probe3.mjs).
*
* A THIRD observer for the reason the second one exists — observe() replaces the options of a
- * registration for the same node. The filter keeps it to the one attribute: `subtree: true` on
- * `class` would wake run() on every row the poll rewrites. */
- _moTabs = new MutationObserver(run);
+ * registration for the same node; ONE registration per host covers all four attributes since
+ * none of this needs `subtree: true` on a different scope than `data-tab-active` already has. */
+ _moTabs = new MutationObserver((records) =>
+ records.some((r) => r.attributeName !== 'class' || r.target.dataset.field) && run());
for (const host of hosts)
- _moTabs.observe(host, { attributes: true, attributeFilter: [ 'data-tab-active' ], subtree: true });
+ _moTabs.observe(host, { attributes: true,
+ attributeFilter: [ 'data-tab-active', 'hidden', 'aria-expanded', 'class' ], subtree: true });
}
@@ -779,6 +1245,15 @@ return baseclass.extend({
* here at all". */
restAt: () => _restAt,
+ /* -> whether this session still lets the engine's own scroll anchoring correct a refill, or has
+ * moved to the anchorFor()/scheduleAnchor() fallback instead (LATE_MISS_LIMIT above) — until
+ * TRUST_RECOVERY_LIMIT measured refills in a row put it back (task trust). Unmarked for the same
+ * reason `restAt` is: a browser sweep against the INSTALLED package needs it, and a probe marker
+ * is what packaging strips. Task missrule — SWAP closes after one refill, so nothing before this
+ * could see the flag go false on the SECOND one; this is what a several-refill case reads instead
+ * of inferring it from timing. */
+ engineTrusted: () => _engineTrusted,
+
/* "the offset is mine now, forget what you remembered": called by fs-router when it resets both
* scrollers for an incoming page. The router resets synchronously and stamps `body[data-page]`
* an await later, so in between a poll tick from the OUTGOING page satisfies every term of "the
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
index 91a94816..792b0ab9 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
@@ -419,14 +419,53 @@ function restoreScroll(pos, gen) {
* asynchronously, so a flag around the write would already be false — the position last written
* is remembered instead, and a scroll landing exactly there is ours.
*
- * Passive listeners: this must never sit in front of the scroll it watches for. */
+ * Passive listeners: this must never sit in front of the scroll it watches for.
+ *
+ * AND ONLY ON AN AXIS THIS `pos` ACTUALLY CARRIES. One `cancelled` flag serves both scrollers, so
+ * an event on the axis this layout does NOT use — a stray write to the other scroller, from
+ * anywhere in the document, while THIS one is still waiting for its content to grow tall enough —
+ * used to read as "the reader scrolled" and cancel the whole restore, killing the axis that WAS
+ * legitimately pending over one that was never being restored at all. Reproduced without a real
+ * router (`../tmp/task-back/repro2.mjs`): a bare `window.scrollTo(0, 111)` from an unrelated
+ * script while the sidebar layout's `#maincontent` restore was still pending left the reader at 0
+ * for the rest of the 5 s window instead of the parked 3000; scoping the check to the axis `pos`
+ * carries fixes it, samples unchanged (3000).
+ *
+ * A SAME-axis false alarm survives that fix: the browser's OWN traversal restore lands on the
+ * scroller BEFORE this handler swaps `#view` (see the comment above the function), and the swap
+ * that follows briefly leaves the incoming page shorter than the saved offset — `commitStage()`
+ * moves the outgoing page's nodes out before the incoming page's have finished growing under
+ * their own RPCs. The engine then clamps the scroller BACK to whatever height exists NOW, firing
+ * an ordinary `scroll` event that looks exactly like a reader's, and it lands before this tick has
+ * ever written anything (`wroteWin`/`wroteMain` still -1), so the "our own write coming back"
+ * check above cannot catch it either. Measured live (owrt2512b @1440, `/admin/status/overview` <-
+ * package-manager, Back): the UA restores `window.scrollY` to the parked 2684 in the same tick
+ * `popstate` fires, `commitStage()` leaves the document ~900px tall for one frame, and the next
+ * native `scroll` event reports `y=0` a whole 5 s before this function's own deadline — cancelling
+ * a restore that had not yet had the height to attempt. A clamp cannot land anywhere but the
+ * scroller's OWN current ceiling, and the reader cannot have scrolled PAST a height that does not
+ * exist yet, so a scroll landing exactly there while that ceiling is still short of the saved
+ * offset is the engine settling, not input — every real gesture that could produce it (a
+ * scrollbar drag past the same limit, in particular) already flows through the direct
+ * wheel/touchstart/keydown listeners below regardless of what onScroll decides. */
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 */
+ const isWin = (t === document || t === document.documentElement || t === document.body);
+ if (isWin) {
+ if (!pos.win) return;
+ if (Math.round(window.scrollY) === wroteWin) return; /* our own write coming back */
+ const de = document.documentElement;
+ const ceiling = Math.max(0, de.scrollHeight - de.clientHeight);
+ if (ceiling < pos.win && Math.round(window.scrollY) === ceiling) return; /* the page settling */
+ }
+ else {
+ if (!pos.main) return;
+ if (t && t.scrollTop === wroteMain) return; /* our own write coming back */
+ const ceiling = t ? Math.max(0, t.scrollHeight - t.clientHeight) : 0;
+ if (ceiling < pos.main && t && t.scrollTop === ceiling) return; /* the page settling */
+ }
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
@@ -607,6 +646,15 @@ function seed() {
/* the served page's entry needs an id too, or the first Back TO it has nothing to look up */
adoptEntry();
+ /* Page-scoped CSS keys off `#view[data-page]` and `.fs-content[data-page]`, not `body`
+ * (commitStage) — but the server stamps only `body`, so a document that never took a client
+ * navigation would otherwise have neither. Copied, not recomputed: the server's value is
+ * already the resolved dispatch path (header.ut, `ctx.path`), and re-deriving it here from the
+ * tree could disagree with what the served markup actually carries. */
+ const curPage = document.body ? (document.body.getAttribute('data-page') || '') : '';
+ const contentHost = document.querySelector('.fs-content');
+ if (contentHost) contentHost.setAttribute('data-page', curPage);
+
/* The document's own first render is the first link in the chain. A navigation waits for the
* previous render because a LuCI view chain resolves `#view` at paint time and would otherwise
* paint into the newer navigation's stage — and the first chain, `view.ut`'s inline
@@ -619,7 +667,10 @@ function seed() {
* keeps a document whose first view never renders from turning every later click into a
* rejected promise. */
const vp = document.getElementById('view');
- if (vp) _inflight = renderedIn(vp).catch(() => {});
+ if (vp) {
+ vp.setAttribute('data-page', curPage);
+ _inflight = renderedIn(vp).catch(() => {});
+ }
}
/* ---- the incoming page is rendered off screen and swapped in when it is ready ----
@@ -711,6 +762,18 @@ function commitStage(stage, contentHost) {
sheets.scopeToCurrentPage();
sweepAround(contentHost);
const live = liveView(contentHost, stage);
+ /* …and the page-scoped CSS identity moves forward with it. `stage.view` carries the incoming
+ * page's name from navigate() (spared there the same way the sheets above were); this is the
+ * commit that finally moves it onto the element the reader is about to see and onto
+ * `.fs-content`, which the same CSS uses for content that sits beside `#view` rather than
+ * inside it (the Overview's stray `
`, styles/pages/20-overview.css). Until
+ * this line the LIVE `#view` and `.fs-content` still carried the OUTGOING page's name, so its
+ * own page-scoped rules kept matching for the whole staging window. */
+ const page = stage.view.getAttribute('data-page');
+ if (page != null) {
+ live.setAttribute('data-page', page);
+ if (contentHost) contentHost.setAttribute('data-page', page);
+ }
const nodes = Array.from(stage.view.childNodes);
const dom = window.L ? window.L.dom : null;
if (live && dom && typeof dom.content === 'function')
@@ -753,9 +816,9 @@ function liveView(contentHost, stage) {
/* Clear what the outgoing page left as a SIBLING of #view inside .fs-content: dom.content()
* replaces only #view's own children, so anything a page emitted next to it rides along — the
- * Overview template's `
Status
` is hidden by a `body[data-page=…]` rule and
- * would show on every later page. Keep only the chrome that legitimately outlives a page (tabs,
- * server notices,