Files
small-packages/luci-theme-footstrap/docs/chrome.md
T
2026-09-20 01:22:33 +08:00

483 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The chrome: sidebar, rail, bar
The spec for what `header.ut` and `menu-footstrap.js` draw. Tokens and palettes:
[design-system.md](design-system.md). The stylesheet build: [css.md](css.md). Client navigation:
[spa-router.md](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** in `partials/head.ut`; an inline script overrides it from `localStorage` `fs-layout`
before 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 being `top` — 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
`MutationObserver` on `data-layout` in `menu-footstrap.js` folds the accordion into dropdowns
and back.
- **The router may carry a default**: `luci.main.footstrap_layout=top` makes the bar the layout a
browser starts on, `head.ut` stamps it, and the reader's own choice overrides it forever. A
shell script cannot write `localStorage`, 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](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-shell` flex 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
the `complementary` role, and no landmark jump reaches the menu through it.
- `.fs-brandrow`: brand (gradient square + wifi SVG on `currentColor` + hostname wordmark) and
the `#fs-rail-toggle` button;
- `<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](design-system.md).
- **Main** — `<main class="fs-main" id="maincontent" tabindex="-1">`: `.fs-title.fs-sr` with 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.
- `renderMainMenu` fills `#topmenu`: an item is
`<li><a><icon><span class="fs-label">title</span><chevron></a></li>`, active by
`L.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](conventions.md)).
Everything shared — tabs, modes, the rail, the SPA router, chrome
measurement — lives in the `fs-*.js` modules that `menu-footstrap-common.js` wires together. Only
`renderMainMenu` is layout-specific, and it is passed into `common.init()`: composition, not
inheritance, because LuCI makes a singleton of every baseclass.
- The top-level `admin/logout` node 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 not `role="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.
- **`.open` has two meanings**: in the expanded sidebar it is an accordion (several sections at
once, the set remembered in `localStorage` `fs-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 plain `closeFlyouts()` was not enough, because it
stripped `.open` from everything while the markup is not rebuilt on a rail toggle, so "Keep open"
stopped meaning anything.
- `clampDropdown` pushes a dropdown back into the viewport at the right edge. Whenever the chrome is
a BAR (`barDropdown()` — the top layout at any width, or a `data-narrow` sidebar), 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 scheduled `rAF` **per `<li>`** so it can cancel an
unfinished measurement when the pointer has moved on; the shared `fit.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](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/menu` blob 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 `localStorage` `fs-recent`, one key each.** A page's key is
its menu path; a row from a companion source carries its own `key`, because what it points at has
no dispatcher node — the sections source keys one `admin/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-common` owns the writer (`remember()`, exported for a
source that has to record its own row) and `warmRecent()` prefetches the page half of each key —
see [spa-router.md](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](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 one `scrolling()` 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](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](conventions.md).