# 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 `
` inside
`
`, 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 `
` 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.
- `` → a `.fs-shell` flex wrapper. Before it, the skip link `.fs-skip`
("Skip to content" → `#maincontent`), the first tab stop on the page.
- **Sidebar** — `