36 KiB
The chrome: sidebar, rail, bar
The spec for what header.ut and menu-footstrap.js draw. Tokens and palettes:
design-system.md. The stylesheet build: css.md. Client navigation:
spa-router.md.
Layout is a CLIENT axis, not a theme entry
luci.themes holds one entry, Footstrap → /luci-static/footstrap. The sidebar and the top
bar are the same markup and the same renderer; CSS morphs them by :root[data-layout].
- The attribute always carries an explicit value (
sidebar|top) and is stamped by the server inpartials/head.ut; an inline script overrides it fromlocalStoragefs-layoutbefore first paint. The explicit value is not cosmetic: every layout rule matches positively, so a future third layout must opt into rules rather than inherit the sidebar's by merely not beingtop— which is what:not([data-layout="top"])would do, and why that guard must not be written. It also keeps the chrome correct with JS disabled. - Switching layout re-renders nothing: the DOM serves both, CSS changes the chrome, and a
MutationObserverondata-layoutinmenu-footstrap.jsfolds the accordion into dropdowns and back. - The router may carry a default:
luci.main.footstrap_layout=topmakes the bar the layout a browser starts on,head.utstamps it, and the reader's own choice overrides it forever. A shell script cannot writelocalStorage, which is why the server side of the axis exists at all. Layout is the axis this pattern started with; every axis now records its choice explicitly, for the same reason — see the three layers in design-system.md.
The bar is the base; the vertical sidebar is the exception. The bar is written once with no
guards (styles/theme/20-shell.css) and serves the top layout at any width and the sidebar
layout on a phone. The vertical column is the single guarded override, winning on specificity
(0,4,0 against 0,1,0) rather than on order. 50-toplayout.css is a pure desktop-bar delta.
Target layout
┌──────────────────────────────────────────────────┐
│ .fs-sidebar 224px │ .fs-main (flex:1) │
│ (rail: 68px) │ ┌──────────────────────────┐ │
│ [logo] hostname ◂│ │ .fs-content: │ │
│ #indicators │ │ warnings │ │
│ MENU │ │ #tabmenu │ │
│ ▸ Status ● │ │ #view │ │
│ ▸ System │ ├──────────────────────────┤ │
│ ▸ Network │ │ footer.fs-footer │ │
│ [spacer] │ └──────────────────────────┘ │
│ [search] │ │
│ ▸ Log out │ │
└──────────────────────────────────────────────────┘
The widths are quoted at normal density: --fs-sidebar-w and --fs-rail-w are calc() over
--fs-density-box, which the Density axis sets, so Compact and Large move them — and fitShell()
follows for free because it reads the computed token rather than a copy.
There is no separate topbar inside main: the page title is a visually hidden <h1> inside
<div class="fs-title fs-sr">, and #indicators (the "Refreshing" poll pill, unsaved changes)
lives in the sidebar. The ◂ button (#fs-rail-toggle) collapses the sidebar to a 68 px icon rail
with flyouts.
.fs-sr clips, it does not hide. This was hidden once — that is display:none, which drops
the element out of the accessibility tree: the <h1> effectively did not exist while the SPA
router dutifully updated the title inside it. Clipping (as .fs-skip does) keeps it in the tree.
Beside it sits #fs-nav-status (role="status", aria-live="polite"), where the router writes
the page title after every SPA navigation — without it, a page change without a reload does not
happen at all for a screen reader.
LuCI container mapping
| LuCI container | In bootstrap | In footstrap |
|---|---|---|
#topmenu |
horizontal top-level menu | ul.nav in .fs-sidebar: vertical list / bar / rail flyouts — one markup |
#modemenu |
admin/status breadcrumb switch | .fs-modemenu in the sidebar; empty or single mode → display:none |
#tabmenu |
tabs under the header | stays in main, above #view (emitted by partials/notices.ut) |
#indicators |
header's right corner | .fs-indicators in the sidebar under the brand; empty → hidden |
#view |
content | content (main column) |
The set of top-level sections comes from the menu tree and depends on the installed packages — do not hard-code it.
data-narrow: "the sidebar became a bar", and it is a MEASUREMENT
The sidebar gives way to the bar when the remaining content column has nothing left to show. A viewport breakpoint cannot say that: the slice is not a constant (224 px expanded, 68 px rail), so one breakpoint gave both states the same answer and the rail collapsed at the same width as the full sidebar.
fitShell() (fs-chrome.js) subtracts the slice from the root's clientWidth and compares the
remainder with --fs-content-min; it reads the widths from the CSS tokens (--fs-sidebar-w,
--fs-rail-w, --fs-content-min, --fs-content-pad) through getComputedStyle rather than
keeping copies — otherwise narrowing the rail in the styles would leave the measurement subtracting
the old number, and no gate would notice. The result is data-narrow on :root, read by both the
CSS and flyoutMode() in the JS.
The subtraction itself is columnWidth(), and there is exactly one of it. Two callers need the
width of the content column — fitShell(), deciding whether the sidebar still fits beside it, and
contentWidth(), which answers fitTables() mid-scroll where reading layout is forbidden — and
while the arithmetic was written twice it drifted twice: --fs-content-pad is ONE side's gutter and
shellGeometry() already doubles it, so one copy subtracted it twice, and the top-BAR layout has no
sidebar to subtract although it carries no data-narrow either (fitShell() removes it on the way
out). Both errors are invisible in a screenshot and both are worth enough to push a table across
fitTables()'s cramped threshold. The gutter it subtracts is MEASURED off .fs-content rather than read from
--fs-content-pad: the same sheet re-paddings that element to 16px a side below 767px while the
token stays 28, and a media query has no data-* to key on — nor may its breakpoint be copied into
the JS. So the model asks the element what it actually got.
The arithmetic is unit-tested per combination (tests/chrome-geometry.test.mjs); whether its inputs
still describe the real page is checked on a stand, where live-audit compares contentWidth()
against the live .fs-content box. That check is what caught the gutter, on every page at 320, 390
and 568.
contentWidth()'s width is compared fresh on every call, not just cached from the last fitter.
_shellOuter (the window width columnWidth() subtracts from) is written only by measureShell(),
which only fitShell() calls, and fitChrome() steps aside for the whole SCROLL_IDLE window
(400 ms, fs-fit.js) whenever fit.scrolling() answers yes — a resize starts that window the same
way a flick does. A caller landing inside it — fitTables()'s, most of all, since it asks mid-scroll
by design — got the PREVIOUS viewport's width: a resize from 568 to 390 still answered 568 for up to
220 of the 400 ms, and the same shape held at every step of live-audit's width list (−70 at
320→390, −178 at 568→768 read backwards, −256 at 1024→1440 — always exactly the gap between the two
adjacent widths). contentWidth() now compares document.documentElement.clientWidth against
_shellOuter on every call and re-runs measureShell() when they differ, rather than only when
_shellOuter was still unset. That comparison is the one layout read the function's "no layout
read" promise was already conditional on (the bootstrap branch made the same call); doing it on
every call costs nothing when nothing moved, since nothing invalidated layout between one read and
the next.
The measurement, the observer and the coalescing live in fs-fit.js, the theme's one "does it
still fit?" engine (also used by fitTables in fs-select.js). Add fit logic there; do not grow
a second observer.
The one literal is @media (min-width: 521px) around the vertical override in 20-shell.css: the
floor below which no slice leaves a readable 500 px, and simultaneously the safety net with JS
disabled — no JS means no data-narrow, and a phone would otherwise draw the desktop sidebar.
The top bar measures too: it first squeezes the pills (.fs-dense1/2), and only if the menu still
wraps at the tightest step does it move to a second row (.fs-bar-stack). Whether it fits
depends on the number of sections on that particular router, not on the screen.
And it pins its own height while it measures. Rule 1 says take the class off and ask the engine,
but the bar is the one fitter whose element sits ABOVE the reader on every page: fs-bar-stack is
what gives the menu its own row, so for the widths where the menu does not need one the bar answers
a row shorter than it stands — 98px against 65px at 767px, 123px against 115px at 480px, on
owrt2512. That height is what the engine lays the rest of the page out against for the length of the
pass. Chromium and Firefox put the reader back afterwards; Safari implements no scroll anchoring on
any platform, and an iPhone reported the Overview creeping upward once per poll tick, bisected there
to this pass. So fitChrome() writes min-height at the settled height before it strips the
classes and takes it off after — the measurement is unchanged, and the box the page is laid out
against does not move. tools/fit-quiet.mjs (npm run quiet, in live) hands the bar a
classList of its own and reads the height back at the instant the classes come off: 0px of dip
with the pin, 8px and 33px without it. The symptom itself does not reproduce headless — the pass is
one synchronous task and both engines restore the offset once the bar is back, measured at half the
page and at its bottom — which is why the gate watches the cause.
header.ut
- Shared parts live in
partials/(head,brand,logout,notices,notice,search,icon,footer). There is no second template directory. <body>→ a.fs-shellflex wrapper. Before it, the skip link.fs-skip("Skip to content" →#maincontent), the first tab stop on the page.- Sidebar —
<nav class="fs-sidebar" aria-label="Menu">, deliberately<nav>:<aside>gives thecomplementaryrole, and no landmark jump reaches the menu through it..fs-brandrow: brand (gradient square + wifi SVG oncurrentColor+ hostname wordmark) and the#fs-rail-togglebutton;<div id="indicators">;.fs-navlabel+<ul class="nav" id="topmenu">(filled by the menu JS) +<ul id="modemenu">;.fs-spacer(flex:1);- the search button (
#fs-search-btn, the command palette below) and Log out. An Appearance button used to sit between them; the axes are their own menu entry now (/admin/system/footstrap, previously a tab stapled onto System → System), so the chrome carries one control fewer — see design-system.md.
- Main —
<main class="fs-main" id="maincontent" tabindex="-1">:.fs-title.fs-srwith the<h1>,#fs-nav-status, and.fs-content(warnings,#tabmenu,#view, footer). - Must be preserved:
http.prepare_content,cbi.js, the translations script,node.css,css,blank_page,noscript, and the no-root-password / initramfs warnings.
partials/footer.ut emits <footer class="fs-footer" role="contentinfo"> — the role is explicit
because <footer> only gets contentinfo implicitly when its nearest ancestor is <body>, and
this one sits inside <main>. It hard-loads L.require('menu-footstrap') and then
L.require('fs-select'); the menu_module parameter that used to pick a renderer went away with
the second renderer.
menu-footstrap.js — the ONLY renderer
There is no menu-footstrap-top.js. A second layout was never a second design: the sidebar
renderer already produced markup its own CSS turns into a horizontal bar on a phone, and it already
had a flyout mode where a section behaves exactly like a top-menu dropdown. The top layout is
that mode at desktop width. The one piece of unique logic the deleted file carried, clampDropdown,
moved here.
renderMainMenufills#topmenu: an item is<li><a><icon><span class="fs-label">title</span><chevron></a></li>, active byL.env.dispatchpath. Icons are mapped by section name with a regex fallback and a generic SVG, inlined as strings in the JS (E()cannot build SVG — see conventions.md). Everything shared — tabs, modes, the rail, the SPA router, chrome measurement — lives in thefs-*.jsmodules thatmenu-footstrap-common.jswires together. OnlyrenderMainMenuis layout-specific, and it is passed intocommon.init(): composition, not inheritance, because LuCI makes a singleton of every baseclass.- The top-level
admin/logoutnode is dropped from the tree — the chrome draws its own Log out (partials/logout.ut), otherwise it appears twice. - A section with children is a W3C APG disclosure pattern, not a link:
role="button",aria-expanded,aria-controls, Enter/Space, and Escape closes the flyout and returns focus. Deliberately notrole="menu"— APG explicitly says site navigation should not take menubar semantics.aria-current="page"goes on the leaf only; a section header is a button, not a link to the current page. .openhas two meanings: in the expanded sidebar it is an accordion (several sections at once, the set remembered inlocalStoragefs-menu-open); in the rail, in the bar, or on a narrow screen it is an exclusive flyout.flyoutMode()decides, and it reads exactly what the stylesheet reads:data-rail/data-layout=top/data-narrow. Leaving flyout mode restores the accordion (restoreAccordion()) — a plaincloseFlyouts()was not enough, because it stripped.openfrom everything while the markup is not rebuilt on a rail toggle, so "Keep open" stopped meaning anything.clampDropdownpushes a dropdown back into the viewport at the right edge. Whenever the chrome is a BAR (barDropdown()— the top layout at any width, or adata-narrowsidebar), because that is when a panel hangs off its own<li>; the rail is excluded, where a flyout is anchored sideways instead. It keeps one scheduledrAFper<li>so it can cancel an unfinished measurement when the pointer has moved on; the sharedfit.frame()coalescer cannot express that, and this is a documented exception.- Hover opening is pure CSS.
Dark mode and outward compatibility
Mode, palette and layout are all client settings; the pre-paint blocks in partials/head.ut stamp
:root before the first frame, one block per axis.
Dark mode: a stored value beats the OS, otherwise prefers-color-scheme. The change subscription
is registered always (it re-reads storage), so Auto keeps following the system if the user
switches to it after load.
set() stamps THREE attributes: data-darkmode (which this theme's CSS reads), plus
data-theme and data-bs-theme as outbound compatibility for third-party apps that sniff
them — luci-app-justclash hangs 21 rules on data-theme, ssclash checks data-bs-theme
first. Nothing inside styles/ may read the latter two. tools/axes.mjs holds the set.
<meta name="darkreader-lock"> stops the Dark Reader extension repainting the theme into mush.
More on how foreign apps detect dark mode: third-party-apps.md.
The command palette
fs-search.js finds a page by name instead of by remembering which section owns it. A loaded
router carries ~200 reachable menu nodes across 11 sections, and some pages appear in no menu list
at all until you are already there — "Port Forwards" is a tab of Network → Firewall.
- It costs no request. The index is built from the same ACL-filtered
/admin/menublob the chrome already loaded (fs-menutree), so the palette knows exactly the pages this session may open: nothing to leak, nothing to 403 on. It is built on the first open, not at init — a user who never searches pays nothing, and only a full load can change the tree. - It indexes tabs, to
admin/<section>/<page>/<tab>— four levels, every path the dispatcher renders. - It does not call the router. Every result is a real
<a href>, so a click bubbles to the router's own document-level handler and takes the SPA path (or falls back to a full load when the node is not SPA-able) with no second copy of that decision. Enter synthesises the same click. - Recently visited rows are kept in
localStoragefs-recent, one key each. A page's key is its menu path; a row from a companion source carries its ownkey, because what it points at has no dispatcher node — the sections source keys oneadmin/system/system#Footstrap, the page plus the section's own heading. Only the key is stored: the title is resolved through the pool on every render, so it follows the UI language and a row nothing produces any more is dropped instead of shown dead.menu-footstrap-commonowns the writer (remember(), exported for a source that has to record its own row) andwarmRecent()prefetches the page half of each key — see spa-router.md.
Trap it was built around: do not index through ui.menu.getChildren(). On an alias node it
returns a copy whose children are the alias target's. That is right for drawing a menu and
wrong for indexing: Network → Firewall is an alias onto the firewall/zones view, a leaf, so its
five tabs came back as an empty list and "port" found nothing on a router that plainly has a Port
Forwards page. Measured on the dev router: 78 indexed nodes through getChildren(), with every tab
of every aliased page missing.
Keeping the reader's place when the engine will not
Every mechanism, what each is for and what the sweep measures without it, is in anchoring.md. What follows here is the chrome's own half: which element a correction may take, and why.
A poll tick changes the height of things above the reader — a station joins the associated list, a lease expires, an interface box grows a line — and everything below it moves. Chromium and Firefox hide that with scroll anchoring: they pick an element the reader can see and compensate the scroll offset so it stays put. WebKit did not implement it until recently, so on an older Safari and on every iPhone of that vintage the same tick moves the page under the reader's thumb. That is what "the Overview jitters" was, and it is why it was reported from Safari and an iPhone while the same router was still in Chrome. A current WebKit does anchor — and gets a DIFFERENT half of this wrong; see "What an engine that anchors still gets wrong" below.
fs-fit.js therefore does the job only where nobody else is doing it: ENGINE_ANCHORS asks the
platform (CSS.supports('overflow-anchor', 'auto') — an engine that does not know the property does
not have the feature) rather than a browser name, because correcting an offset that the engine also
corrects means two corrections and a page that jumps the other way.
Three details carry it, and each one was a measured failure first:
- The reference is what sits at the top of the CONTENT, not the frame the fold cuts through. A
tick that grows something inside that frame leaves the frame's own top exactly where it was —
drift 0, measured — while everything after it moves. One hit test below
[data-fs-chrome](the bar is sticky and owns the first rows of the viewport; a test at y=1 returns the chrome and the page gets no anchor at all) gives the same element the engine's own algorithm would pick. - It is captured while the page is still, not in the mutation callback. That callback runs after the DOM changed, so a reference taken there has already moved with it: right for the fitters, which have not run yet, and blind to the tick itself. The resting reference is refreshed after every settled pass, after every correction, and when a scroll stops.
- It carries the offset it was taken at. The reader scrolling and the page growing look the same from the element alone; correcting against a reference from another offset would drag the page back to where the reader had scrolled from.
- …and an offset that DROPPED with nobody scrolling, on the page it was taken on, is the engine.
dom.content()— what every LuCI poll calls to refresh a section — empties the container before it refills it, and for that moment the document is shorter than the offset the reader is at. The engine clamps the offset into what is left, the section fills again and nothing puts it back. Three conditions separate that from a reader who moved: a clamp only ever moves the offset down; a reader who moved is onescrolling()still answers for (their scroll starts the sampler, while the clamp's own scroll event arrives in the rendering step after the mutation callback); and the page stamp has not changed, because the router resets both scrollers on a navigation and replays them on a Back, and neither is a clamp to undo. The clamped amount is also what raises the one-viewport ceiling on a correction — a drift that big normally means the view replaced its subtree, and a clamp is the one that comes with a receipt. - The correction does not need the reference to have survived.
dom.content()replaces a section's children with new nodes, so the element that happened to sit at the top of the content area usually does not survive the tick that moved the reader — measured on a 24.10 router, where it was disconnected on exactly the tick that mattered and the fallback was left measuring a fresh reference against nothing. With no element there is no drift, but there is still a number known exactly: the offset dropped by this much and nothing else happened, so giving that back is the correction. It cannot run away with the page either — if the document really did get shorter, the browser clamps the write back and the reader keeps the offset they already had.
Two halves: the document does not shrink, and the reader is put back if it did. Every container
a poll empties carries a floor — min-height at the height it had at the last settled moment, held
until the next one (holdFloor(), over .cbi-section > div, .table > .tbody, .table: what
dom.content() is called on and nothing wider). A container emptying takes nothing off the document,
so there is no shorter document for the engine to clamp into and the tick is invisible; the floors
are re-measured after every settled batch, so a page that genuinely got shorter is shorter one frame
later. Measured on a 25.12 stand with the correction switched off, a real poll tick clamped 1882px
away without the floor and 0px with it; on 24.10, where the theme cannot reach the poll at all, the
same park went from a 1206px clamp to no offset change at all.
The floor is on the containers because it may not be on the column. min-height on an ancestor
of the engine's own anchor is a scroll-anchoring suppression trigger — css-scroll-anchoring-1 §2.2.2
lists it, and Blink's list (css_properties.json5, invalidate: [..., "scroll-anchor"]) is wider
still — so a floor on .fs-content, where this used to live, turned the engine's anchoring off in
exchange for holding the document up. Measured with it there: 120px grew above the reader and the
page moved all 120px under them, on Chromium and Firefox alike; and the 15px this file used to quote
for the same configuration was taken before 47e636d gave anchoring engines a live reference, and
was never re-taken. The suppression walks only the path from the anchor to the scroller, and a
container that empties is never on it — either the anchor was inside it, in which case the engine has
lost the anchor anyway, or the anchor is elsewhere and this container is its sibling. So the floor
now runs on every engine, and the engine's own anchoring keeps working beside it.
A pin on the container itself does not work, and the shape that does was reported from the field.
fs-overview.js briefly pinned each container across dom.content() and released it in the same
statement sequence — dom.content() performs no layout, so nothing ever observed the pin (measured:
1882px still clamped away with it in place). The report that named it wraps dom.content() itself
and releases two frames later, which does work — at the price of patching a luci-base API every app
on the router shares, and up to seven read/write pairs per call. One element the theme owns, measured
once per settled batch, is the same protection: on a live router with the correction off, the wrapper
and the floor both held the reader at 0px against a 337px drift without either, at 16 measurements
against 154 wrapped calls.
The router owns the offset when it navigates. fs-router resets both scrollers for an incoming
page and stamps body[data-page] a require later, so between the two there is a window in which a
poll tick from the OUTGOING page still fires — an offset of 0, a remembered offset from where the
reader was, nobody scrolling and the old stamp: every term of "the engine clamped this" is true. The
router calls fit.forgetRest() at the reset rather than leaving the memo to be inferred from a stamp
written afterwards.
What an engine that anchors still gets wrong. ENGINE_ANCHORS asks whether the platform supports
overflow-anchor, and every current engine answers yes, WebKit included — so the theme steps aside
from the growth case. Anchoring is not the same promise as "a section can vanish and come back",
which is what dom.content() does on every tick: the container empties, the offset is clamped into a
document that is briefly shorter, and what the engine does on the way back is its own business.
Chromium lands where it started. WebKit OVERSHOOTS — measured on both stands, both layouts and both
widths, a swap that grew a section by 120px moved the offset by 180, leaving the reader 60px up the
page on every tick.
The memo is a pair, and a clamped correction splits it. putBack() is the one write either
correction makes: it moves the offset by the drift it measured, and it used to update _restAt
alone, on the reasoning that the element is now back at the top it was remembered at. It is not,
whenever the write was clamped short — a page already at its end cannot scroll the last pixels — and
then _rest.top names a position nothing can reach while _restAt has already moved on. The next
tick corrects to that impossible top and takes the reader with it: measured on webkit/Overview at
390 wide in the top layout, _rest claiming 93.02 for an element standing at 105, and the reader
12px off after the tick (52px on the density beside it). Where the element actually landed is
re-read after the write — for the reference and for the section it falls back to — which is two
rects on a layout the write has already forced. _rest.at is left alone: it is read only by
anchorFor(), on the path where the engine does no anchoring, and the sweep is green on all 18
cells of the failing axis without touching it. This only became visible when WebKit
gained anchoring: on the engine-anchoring path the theme corrects from a memo it keeps across
ticks, where before it took a fresh reference on every mutation.
And the pages where nothing held the reader at all. Two faults sat behind the same symptom, both
of them in which ELEMENT the theme takes as its reference. elementFromPoint answers with #view
itself wherever the hit lands in a gap between two sections — and the host's own top does not move
when a poll changes something inside it, so the drift measured against it is zero on every tick, for
ever. And a page that is ONE TABLE (Processes, Routes, the realtime lists) has that table as a direct
child of #view: the climb out of the table — data tables are excluded as anchors, 30-tables.css
explains why — landed on the host and gave up, leaving no reference at all. Since the stylesheet also
(correctly) tells the engine not to anchor inside those tables, nobody was holding the reader there on
ANY engine: measured at 390px, 120px of growth above the fold moved the page 120px on chromium,
firefox and webkit alike. The host is now refused as a reference and the hit is retried down the
viewport; where the climb would reach the host, the TABLE ITSELF is the reference — its height is
falsified by the fit pass, its top is not.
lateDrift() closes the overshoot without asking who the engine is. Two frames after a mutation — long enough
for the engine to have finished its own correction — the reference the theme was holding is asked
where it is now. An engine that got it right reports zero and nothing happens; what is left over is
what nobody put back, and that is what is given back. It answers GROWTH only: the collapse is the
floor's, and the page it still matters on is the one that is a single data table, where the
stylesheet has told the engine not to anchor and nobody else is holding the reader. Two things it is
deliberately NOT: a browser test (CSS.supports can no longer separate the two behaviours), and a
synthetic probe that performs the collapse itself (it calls Firefox broken, because a real page puts
layout and a frame between the collapse and the refill; gating on it cost Chromium and Firefox 15px
of drift they did not have).
The price is a resting reference on every engine — a hit test and a rect per settled pass, measured
on the stands at 0.2 ms typical and 6 ms worst on a poll-dirtied WebKit layout, never during a
flick, and skipped entirely while the page is at offset 0 (nothing to be put back to). It fires only
where THE OFFSET HAS NOT MOVED since the reference was taken — scrollTop() !== ref.at and it sits
the tick out — plus a user gesture keeping it out, so a correction can never land inside a scroll.
Asking scrolling() instead does not work here and asking the sampler's step count is not enough:
the engine's own compensation moves the offset and starts the sampler, and in WebKit a programmatic
scroll's event arrives up to 1.2 s late, so the sampler is often not running at all while a flick is.
Both of those let a correction through mid-flick — 161px on @390 side, 320px on @1440 side, both
on webkit/Overview. ref.at and not _restAt, because run() re-remembers between the mutation and
this frame, and where the sampler has not started that re-take records the offset the reader has
already flicked to.
The theme's own put-off pass moves the page too, and nothing was looking. A tick landing while
the offset is in motion leaves its measurements to the pass that runs once the page holds still, and
that pass re-lays the tables and rewrites every floor. min-height is itself a suppression trigger
on the path to the anchor, so the engine's compensation can be off in exactly the frame that pass
needs it: on ImmortalWrt 24.10/WebKit the pass's 88 min-height writes and a 58px jump of the offset
land in the SAME frame, 429 ms after the mutation — @390, top layout, large density, Overview, one
cell out of 48, and the offset moved with no write of the theme's own (every programmatic scroll
write was intercepted and none came). lateDrift() is blind to it: scheduled from the mutation on
the same SCROLL_IDLE, it read a drift of zero three milliseconds before the page moved.
settleDrift() measures around that pass instead, and SYNCHRONOUSLY — the reader cannot scroll
between two statements, so what it sees is the pass's own doing, and getBoundingClientRect() is the
operation an engine must flush a pending adjustment before (css-scroll-anchoring-1 §2.2: the
suppression window ends at the end of the event loop iteration, or before the next operation whose
result would differ, whichever is sooner), so the read after the pass is a flush rather than a race.
Measured two frames later instead, it closed that cell and corrected INSIDE A FLICK on three others,
160px each.
tools/scroll-anchor.mjs holds all of it: it grows 120px above the reader and requires the page to
stay within two pixels, once with the engine's anchoring suppressed and once without, on both
scrollers; it refills a section the way a poll does — emptied, then filled again, with the router's own
poll held for the duration — and requires the same; and it flicks the page up and down to prove the
theme corrects nothing while the reader is moving. With the fallback removed the Safari case reports
exactly the reported symptom — 120px of page moved under the reader, 255px on the swap measured in
WebKit with the engine's own anchoring off, and 690px on a real Overview on the stands. It runs on
chromium,firefox,webkit in CI now, because the fault above lives on the path a stand-in engine does
not take, and it waits on fit.restAt() rather than on a stopwatch: WebKit starts its motion sampler
late enough that a flat wait measured the theme before it had a reference at all, which reported a
jump on every WebKit run with the theme identical on all three engines.
Findings print as they are found, and one cell can be run alone. A --full sweep is 144 cells
and tens of minutes, so a run that only speaks at the end costs a whole sweep per fix attempt — five
attempts at the fault above were measured that way before the flags existed. Each finding is printed
the moment it is made (FINDING …), --bail ends the run at the first one, and --width 390 --layout top narrows the sweep to the cell a finding names: the same measurement then takes a
minute. CI passes none of them — how WIDE a fault is is half of what the sweep says.
The sweep crosses three page shapes and two scrollers, and both sets are what a measurement left
standing rather than what looked thorough. The shapes: the Overview refills section BODIES, the
realtime Connections table has its ROWS replaced under a poll, and Processes is one table that is a
direct child of #view — the climb out of that one used to land on the host and give up. Processes
proves the climb and never the swap: its single block is 6397px against 6108px of scroll on
owrt2512, so it can never sit entirely above the reader, which is what a swap needs. Wireless was
measured and dropped (812px of scroll at 390px wide, 0 at 1440). The scrollers: of 1440 side,
1440 top, 390 side and 390 top, only the first scrolls #maincontent and the other three
scroll the window, so the sweep keeps the first and the narrowest. Density is one value for the same
reason — across 72 Overview cells, normal, compact and large reported the same two outcomes and
nothing else. --full crosses all of them anyway, and CI does that on a push and a tag.
fs-select.js
Turns every stock <select> into a styled ui.Dropdown, because a native <select> popup cannot
be styled with CSS. The native <select> remains the form field and must stay
frameEl.firstChild — ui.Select.getValue() returns this.node.firstChild.value.
It also owns fitTables(), which folds data tables into cards. Why config tables are handled
differently, and why that difference is not an unfinished job: css.md.
Translation
Every label on the Footstrap tab carries the footstrap msgctxt; the chrome (Menu, Logout,
Skip to content) and the login/warning strings deliberately do not. The reasoning — msgid is a
global name shared with every app on the router — is in conventions.md.