luci-theme-footstrap: keep the reader's place on every engine - #8978
luci-theme-footstrap: keep the reader's place on every engine#8978VizzleTF wants to merge 1 commit into
Conversation
A LuCI poll refreshes a section with dom.content(), which empties the
container before it refills it. For that moment the document is shorter
than the offset the reader sits at, the engine clamps the offset, and
what happens on the way back is the engine's business: Chromium lands
where it started, WebKit overshoots by 60px on every tick. On a live
Safari that is the page creeping while you read it.
The theme decided whether to help by asking CSS.supports('overflow-
anchor'), which answers "does this engine anchor at all" — a different
question, and one every current engine now answers yes to. It measures
instead: two frames after the mutation, the reference it was already
holding is asked where it ended up. An engine that got it right reports
zero drift and nothing happens; what is left over is what nobody put
back. A synthetic probe was tried and rejected — it performs the collapse
itself, calls Firefox broken because a real page puts layout and a frame
between the collapse and the refill, and cost Chromium and Firefox 15px
of drift they did not have.
Three more faults sat behind the same symptom, all in which element the
reference is taken on. elementFromPoint answers with #view wherever the
hit lands in a gap between sections, and the host's own top never moves
when a poll changes something inside it, so the drift was zero on every
tick. A point above the first section answers with .fs-content, outside
the host, which ended the search with no reference at all. And a page
that is one table — Processes, Routes, the realtime lists — has that
table as a direct child of #view, so the climb out of it (data tables are
excluded as anchors, since the fit pass falsifies their layout) landed on
the host and gave up, leaving nothing holding the reader on any engine.
The search now walks the element stack, steps down the viewport, refuses
the host, anchors on the table itself where the climb would reach it, and
keeps the nearest surviving ancestor for when a tick replaces the element
the reference was taken on.
The two corrections are kept apart. Where the engine anchors, only the
residual check runs: the immediate one reads its reference at mutation
time, and after a scroll WebKit reports the new offset against the old
layout, so the correction undid the reader's own move — 591 put back to
0, measured at 1440 in the top layout at Compact density. Where the
engine does not anchor, only the immediate correction runs, and it now
refuses to land in a page that is moving.
Also in this sync, each reported and each fixed on a luci-base class
rather than on the page it was seen on:
* a meter's value sat on its own label once its column became a card
(luci-mod-dashboard's Wireless list on a phone, 6-9px at every
density) — in a card the value goes back over the bar's far edge;
* a page-title button row touched the heading above it (Status ->
Channel Analysis, 0px measured);
* the per-section Delete button touched the tab bar below it, which is
every named section: SQM's queues, the firewall's zones;
* a block a view builds itself fused with the card below it. A view may
return a bare widget where a section is expected — luci-app-irqbalance
does this for its /proc/interrupts snapshot — and nothing gave that
block the gap a section carries. Stock bootstrap measures the same
0px there; the difference is that a section is a stretch of page in
bootstrap and a card here.
And two from before: the login page prints the hostname it belongs to
(openwrt#8961) and carries a top-level heading, and the Appearance
tab builds its own range control where ui.RangeSlider is missing, which
is 23.05 — the whole tab died there on one missing widget.
Signed-off-by: Ivan Kvashonkin <vizzlef@gmail.com>
openwrt-ai
left a comment
There was a problem hiding this comment.
Reviewed 1 new commit (71cedff). CI is green on the head SHA, so nothing below is CI-grounded.
Commit checks
- 71cedff "luci-theme-footstrap: keep the reader's place on every engine" — the message covers the anchoring rework, the four luci-base spacing fixes, the login page and the 23.05
RangeSlider, but three further changes in the same commit are not described anywhere in it or in the PR body: the statistics-graph SVG text restyle, the Overview port-card rework (including removal of the up/down status dot), and the newnameTooltips()infs-overview.js. Details inline.
The substantive findings are on the ENGINE_ANCHORS path in fs-fit.js: lateDrift() skips the sec/secTop fallback that this PR added for the common "the poll replaced the section's children" case, and it corrects the offset without bringing _rest forward — the double-payment that scheduleAnchor() already documents on the other path. There is also a scope widening in noteUser() that makes every click and keystroke read as "the page is moving".
Generated by Claude Code
| let _userUntil = 0; | ||
| function noteUser() { | ||
| _userUntil = Date.now() + SCROLL_IDLE; | ||
| noteMotion(); | ||
| } | ||
|
|
||
| (function watchMotion() { | ||
| const opts = { passive: true, capture: true }; | ||
| window.addEventListener('scroll', noteMotion, opts); | ||
| window.addEventListener('wheel', noteMotion, opts); | ||
| window.addEventListener('touchstart', noteMotion, opts); | ||
| window.addEventListener('touchmove', noteMotion, opts); | ||
| for (const name of [ 'wheel', 'touchstart', 'touchmove', 'mousedown', 'keydown' ]) | ||
| window.addEventListener(name, noteUser, opts); |
There was a problem hiding this comment.
Routing mousedown and keydown through noteUser() also routes them into noteMotion(), which unconditionally sets _movingUntil = Date.now() + SCROLL_IDLE. So scrolling() now answers "yes" for 400ms after any click and after every keystroke, and that answer gates a lot more than lateDrift():
holdFloor()— the content floor stops being re-measuredrememberRest()— the reference stops being refreshedanchorRef()and the new gate inapplyAnchor()— no correction at all- every fitter that consults
scrolling()and callsdeferMeasurement()
Typing into a form re-arms the 400ms window on each keypress, so on a page with inputs the fit passes are deferred for as long as the user is typing and only land in one burst afterwards — which is the "whole deferred pass in one burst" failure the comment above SCROLL_IDLE describes.
The comment's justification for the two new events is the scrollbar thumb and Page Down, but both of those produce real scroll events, and scroll is already wired to noteMotion on the line above. Only _userUntil needs them:
| let _userUntil = 0; | |
| function noteUser() { | |
| _userUntil = Date.now() + SCROLL_IDLE; | |
| noteMotion(); | |
| } | |
| (function watchMotion() { | |
| const opts = { passive: true, capture: true }; | |
| window.addEventListener('scroll', noteMotion, opts); | |
| window.addEventListener('wheel', noteMotion, opts); | |
| window.addEventListener('touchstart', noteMotion, opts); | |
| window.addEventListener('touchmove', noteMotion, opts); | |
| for (const name of [ 'wheel', 'touchstart', 'touchmove', 'mousedown', 'keydown' ]) | |
| window.addEventListener(name, noteUser, opts); | |
| let _userUntil = 0; | |
| function noteIntent() { | |
| _userUntil = Date.now() + SCROLL_IDLE; | |
| } | |
| function noteUser() { | |
| noteIntent(); | |
| noteMotion(); | |
| } | |
| (function watchMotion() { | |
| const opts = { passive: true, capture: true }; | |
| window.addEventListener('scroll', noteMotion, opts); | |
| for (const name of [ 'wheel', 'touchstart', 'touchmove' ]) | |
| window.addEventListener(name, noteUser, opts); | |
| for (const name of [ 'mousedown', 'keydown' ]) | |
| window.addEventListener(name, noteIntent, opts); |
Generated by Claude Code
| * reader driving (a gesture), and is the offset STREAMING (moving repeatedly, which a | ||
| * one-shot compensation never does but a scroll always does). Either one means hands off. */ | ||
| if (!anchorEnabled() || Date.now() < _userUntil || (scrolling() && _steps > 1)) return; | ||
| if (!ref.el.isConnected || _restPage !== pageStamp()) return; |
There was a problem hiding this comment.
lateDrift() bails out on !ref.el.isConnected, but ref is _rest, which this PR just taught to carry a surviving fallback — and the reason it was added is the case that happens here. anchorRef()'s own comment says dom.content() replaces a section's children, "so the element the hit landed on is usually gone by the time the correction runs", and anchorFor() accordingly falls back to _rest.sec / _rest.secTop.
anchorFor() is now only reachable on the non-anchoring path (see the ENGINE_ANCHORS ? null : anchorFor() call site), so the sec fallback never runs on any engine that anchors — i.e. on every current engine, per the commit message. On the WebKit overshoot this PR is fixing, the poll tick that replaces the section's children is exactly the tick where ref.el is disconnected and the residual correction returns without doing anything.
Should this take the same fallback, e.g. use ref.sec/ref.secTop when ref.el is gone but ref.sec is still connected?
Generated by Claude Code
| if (sc) sc.scrollTop = at + drift; else window.scrollTo(0, at + drift); | ||
| /* the write may have been clamped short; the reader is where they are now */ | ||
| _restAt = scrollTop(); |
There was a problem hiding this comment.
The write moves the offset by drift, but only _restAt is brought forward — _rest.top and _rest.at still describe the page as it stood before the correction. run() re-took _rest two frames earlier (via rememberRest() in run()),`` so the object the next tick captures as settled is off by exactly the amount that was just paid, and `lateDrift()` measures it again.
That is the failure scheduleAnchor() explicitly guards against on the other path:`` "AFTER the correction, never before … or the next tick measures a drift that has already been paid and pays it twice." The ENGINE_ANCHORS path has no equivalent step.
Note that a plain rememberRest() call here would not work either — the write itself starts the motion sampler, so rememberRest() would return early on its scrolling() guard. Refreshing _rest.top/_rest.at in place from the post-write rects would, if you want to keep the reference rather than drop it.
Generated by Claude Code
| const settled = _rest; | ||
| const ref = ENGINE_ANCHORS ? null : anchorFor(); |
There was a problem hiding this comment.
nit: this is now the only call site of anchorFor(), and it guards on ENGINE_ANCHORS before calling — which makes anchorFor()'s own first line, if (ENGINE_ANCHORS) return anchorRef();,`` unreachable. Worth dropping it (and adjusting the function's doc comment, which still describes returning "the post-mutation one everywhere else"), otherwise the next reader has to re-derive that the branch is dead.
Generated by Claude Code
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child{flex:0 0 auto;width:100%;display:flex;align-items:center;justify-content:space-between;gap:var(--fs-space-1-5);font-size:var(--fs-type);font-weight:var(--fs-weight);color:var(--fs-text)} | ||
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child::after{content:"";width:8px;height:8px;border-radius:50%;background:var(--fs-good);flex:0 0 auto} | ||
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3){order:1;flex:0 0 100%;width:100%;margin:var(--fs-space-0-5) 0 var(--fs-space-1)} | ||
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child{flex:0 0 auto;width:100%;min-width:0;overflow:hidden;text-overflow:ellipsis;line-height:var(--fs-leading);font-size:var(--fs-type);font-weight:var(--fs-weight);color:var(--fs-text)} |
There was a problem hiding this comment.
text-overflow:ellipsis does nothing here: the box has no white-space:nowrap and is not a -webkit-box with a line clamp, so the name wraps and is silently cut off by overflow:hidden with no ellipsis and no line limit at all.
This also contradicts the justification for the new code in fs-overview.js, whose comment states "styles/pages/20-overview.css clamps a port card's name to two lines with an ellipsis" — the shipped rule clamps to nothing. The theme's own idiom for this is at .fs-wordmark, cascade.css:344:`` display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:N;overflow:hidden.
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child{flex:0 0 auto;width:100%;min-width:0;overflow:hidden;text-overflow:ellipsis;line-height:var(--fs-leading);font-size:var(--fs-type);font-weight:var(--fs-weight);color:var(--fs-text)} | |
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child{flex:0 0 auto;width:100%;min-width:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;line-height:var(--fs-leading);font-size:var(--fs-type);font-weight:var(--fs-weight);color:var(--fs-text)} |
Generated by Claude Code
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4){order:2;text-align:end;flex:1 0 auto} | ||
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container{font-family:var(--fs-font-mono);font-size:var(--fs-type-xs) !important;color:var(--fs-dim);line-height:var(--fs-leading);white-space:nowrap;text-align:end !important} | ||
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4){order:2;text-align:end;flex:1 0 100%} | ||
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container{font-family:var(--fs-font-mono);font-size:calc(round(10px * var(--fs-density-type),1px)) !important;color:var(--fs-dim);line-height:var(--fs-leading-tight);white-space:nowrap;text-align:end !important} |
There was a problem hiding this comment.
round() is used unguarded here, which deviates from the convention this file sets for itself: every other round() in the sheet lives inside @supports (width:round(1px,1px)) at cascade.css:13,`` with a plain calc(Npx * var(--fs-density-type)) definition preceding it as the fallback.
On an engine without round() the whole declaration is invalid and dropped — including the !important — so the port-card tooltip inherits its size instead. That is exactly the older-WebKit audience this PR is otherwise widening support for. A preceding plain declaration restores the pattern:
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container{font-family:var(--fs-font-mono);font-size:calc(round(10px * var(--fs-density-type),1px)) !important;color:var(--fs-dim);line-height:var(--fs-leading-tight);white-space:nowrap;text-align:end !important} | |
| body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container{font-family:var(--fs-font-mono);font-size:calc(10px * var(--fs-density-type)) !important;font-size:calc(round(10px * var(--fs-density-type),1px)) !important;color:var(--fs-dim);line-height:var(--fs-leading-tight);white-space:nowrap;text-align:end !important} |
Generated by Claude Code
| const node = E('div', { class: 'cbi-range-slider' }, [ | ||
| this.sliderEl, | ||
| this.valueEl, | ||
| this.options.calcunits ? E('span', { class: 'cbi-range-slider-calc-units' }, this.options.calcunits) : null |
There was a problem hiding this comment.
The polyfill renders the units span whenever calcunits is set, but the real widget renders it only when calculate produced a value — see ui.js:2904, where calcunitsEl is gated on this.calculatedvalue, which is null unless options.calculate is a function (ui.js:2887).
Every sliderCtl() caller passes unit and no calculate (radius px, tint %, pattern size px, pattern strength %, photo dim %), so the Appearance tab shows a unit suffix on 23.05 and no unit suffix on 24.10/master — the opposite of the "same DOM, same class names … nothing below this line knows which of the two it got" the comment above promises. .cbi-range-slider-calc-units is styled at cascade.css:802, so it is visible on the polyfill path.
Worth deciding which behaviour is intended: either drop the span so the two paths match, or (if the unit really should show) put it in a class the theme owns and render it on both paths, so a router that upgrades 23.05 → 24.10 does not lose it.
Generated by Claude Code
| [data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img{filter:invert(100%) hue-rotate(180deg)} | ||
| #view div[style] > svg{background-color:var(--fs-panel)} | ||
| #view div[style] > svg line[style]{stroke:var(--fs-text) !important} | ||
| #view div[style] > svg text[style]{fill:var(--fs-text) !important;text-shadow:none !important;font-weight:var(--fs-weight-bold)} |
There was a problem hiding this comment.
nit: this hunk appears in neither the commit message nor the PR body. The message enumerates its scope carefully (anchoring, meter value in a card, page-title button row, per-section Delete, view-built block gap, login hostname + heading, RangeSlider on 23.05), and a global restyle of SVG text in statistics graphs — fill and text-shadow forced, plus a weight change — is a separate, user-visible change from all of them.
Two other undocumented hunks in the same commit:
- the port-card rework further down (
cascade.css:983-994): the up/down status dot::afteris removed from.ifacebox-head:first-child,max-widthmoves fromnoneto200px, the grid minimum becomes density-scaled, and the speed line goes fromflex:1 0 autotoflex:1 0 100%. Dropping the status indicator in particular looks like a decision worth a line in the message. nameTooltips()infs-overview.js— a new native tooltip on every port card.
Per the patch-hygiene convention, either mention these in the commit message or split them out.
Generated by Claude Code
| node.addEventListener('widget-change', () => apply(w.getValue())); | ||
| return node; | ||
| }; | ||
| /* THE ONE WIDGET THIS THEME CANNOT ASSUME, and the reason 23.05 is a supported release again. |
There was a problem hiding this comment.
"23.05 is a supported release again" means "23.05 is a supported release again by the theme".
It means you can install it to 23.05.
There was a problem hiding this comment.
I don't think we should add 23.05 support here.
The Footstrap theme was merged only five days ago (6f08de7 by #8903) to the master branch. 23.05 is EOL, so I don't see a good reason why we should now deliberately extend a brand-new upstream theme to an unsupported release.
We are upstream here, so I don't think we should add extra compatibility code and take on the maintenance burden for old releases unless there is a really compelling reason to do so. 🤷
There was a problem hiding this comment.
I got many request for that from users.
I think its be too complicated to maintain 2 versions: upstream here and separate extended in main repo.
Also i don't see problem with that small adaptation.
There was a problem hiding this comment.
This commit is already doing a lot of things at once. Most of the commit is code changes and fixes, with a lot of detailed comments explaining individual cases. I don't think we should make it even more complicated just to accommodate an EOL release.
We are the upstream project. We don't need to keep adding compatibility code for old releases just because it happens to be possible. If a release is EOL, I think the default should be that new code targets supported releases unless there is a strong reason otherwise.
So unless there is a specific requirement that makes 23.05 support necessary, I'd rather keep the theme as it was originally merged and not add this compatibility layer.
There was a problem hiding this comment.
Okay. i will drop it.
|
Yeah, do not complicate things by trying to support the 23.05 release that has been end-of-life for some time now. |
A LuCI poll refreshes a section with
dom.content(), which empties the container before it refills it. For that moment the document is shorter than the offset the reader sits at, the engine clamps the offset, and what happens on the way back is the engine's business: Chromium lands where it started, WebKit overshoots by 60px on every tick. On a live Safari that is the page creeping while you read it.The theme decided whether to help by asking
CSS.supports("overflow-anchor"), which answers "does this engine anchor at all" — a different question, and one every current engine now answers yes to. It measures instead: two frames after the mutation, the reference it was already holding is asked where it ended up. An engine that got it right reports zero drift and nothing happens; what is left over is what nobody put back.Three more faults sat behind the same symptom, all in which element the reference is taken on —
elementFromPointanswering with#viewitself wherever the hit lands in a gap, a point above the first section answering with.fs-contentoutside the host, and a page that is one table (Processes, Routes, the realtime lists) having that table as a direct child of#view, so the climb out of it landed on the host and gave up. On that last shape nothing was holding the reader on any engine.Also in this sync, each reported and each fixed on a luci-base class rather than on the page it was seen on:
/proc/interruptssnapshot). Stock bootstrap measures the same 0px there; the difference is that a section is a stretch of page in bootstrap and a card here.And two from before this sync: the login page prints the hostname it belongs to (#8961) and carries a top-level heading, and the Appearance tab builds its own range control where
ui.RangeSlideris missing, which is 23.05 — the whole tab died there on one missing widget.Measured on OpenWrt 25.12 and 24.10 containers, chromium/firefox/webkit, both layouts, 390 and 1440, all three density settings: 216 anchoring runs with the reader staying put, and the page audit reporting no new findings.