@layer theme {
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
body {
padding: 0;
background-color: var(--fs-bg);
font-family: var(--fs-font-sans);
}
/* placement only — the look lives in 10-chrome.css */
.fs-brand { flex: 1 1 auto; min-width: 0; }
/* A hostname is user-supplied and can outgrow any bar or column. `overflow-wrap: anywhere`
* wraps it mid-word only when no better break exists (unlike word-break: break-all, a
* normal hostname still breaks at its dots), and it lets the flex item's min-content size
* shrink — which is what stops a long name pushing the menu out of the bar. It used to be
* nowrap + ellipsis, which truncated the one string that identifies the router. */
/* AND A CEILING ON THE LINES, because the width cap alone bounds the wrong axis. Measured in
* the top bar at 1280px by growing the string: 8 characters give the ordinary 46px bar, 63 (the
* kernel's nodename limit, i.e. the realistic worst case) give 73px, and a hand-set 4000
* character hostname gave a 4273px tall bar with the page content starting below it. Three lines
* is past every real hostname and still a bar; the rest is clipped rather than allowed to push
* the page down. `-webkit-line-clamp` is the only cross-engine spelling of this today and needs
* its two companions, which is why they are here rather than a plain max-height: a max-height cut
* would slice a line in half instead of ending it. */
.fs-wordmark {
overflow-wrap: anywhere; min-width: 0;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
/* collapse the sidebar to an icon rail. The toggle only EXISTS in the vertical sidebar,
* so its `display` is declared there, not here, or it would just be shadowed. */
.fs-railtoggle {
align-items: center; justify-content: center;
width: calc(28px * var(--fs-density-box)); height: calc(28px * var(--fs-density-box));
padding: 0; flex: 0 0 auto;
border: 1px solid transparent; border-radius: var(--fs-radius-sm);
background: transparent; color: var(--fs-faint); cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
.fs-railtoggle:hover { color: var(--fs-text); background: var(--fs-panel2); border-color: var(--fs-border); }
.fs-railtoggle .fs-ico { width: 16px; height: 16px; transition: transform var(--fs-dur-move); }
.fs-sidebar ul.nav,
.fs-sidebar #modemenu {
list-style: none;
margin: 0; padding: 0;
display: flex;
}
.fs-sidebar ul.nav > li > a .fs-label { flex: 1 1 auto; }
/* collapse chevron for sections with children */
.fs-chevron {
width: 14px; height: 14px; flex: 0 0 auto;
color: var(--fs-dim);
transition: rotate var(--fs-dur-move);
}
/* `rotate`, not `transform: rotate()`: the INDIVIDUAL transform properties compose across
* rules, so the RTL mirror below can set `scale` without either rule having to restate the
* other's value. The fixed order is translate -> rotate -> scale, so a mirrored chevron still
* points down when it opens. */
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
/* ---- the two chevrons are DIRECTION, and direction is what mirrors ----
* A disclosure marker and the rail's collapse arrow both point ALONG the reading axis: `›` means
* "opens away from the menu" and the rail arrow means "the sidebar folds towards its own edge".
* With the document flipped (head.ut stamps dir), both pointed back at themselves. Everything
* else in the chrome mirrors from the logical properties; these two are glyphs, so they need the
* one transform. */
:root[dir="rtl"] .fs-chevron,
:root[dir="rtl"] .fs-railtoggle .fs-ico { scale: -1 1; }
.fs-sidebar ul.nav > li.has-sub > a { cursor: pointer; }
.fs-sidebar ul.nav ul {
list-style: none;
}
/* .fs-logout's display/align/colour are shared chrome (10-chrome.css) — do not restate
* them; only its LAYOUT (square button vs full-width row) belongs to a layout block. */
/* ==================================================================
* THE BAR — the default chrome, and the ONLY copy of it.
*
* The chrome is a bar unless something says otherwise: the TOP layout at every width,
* and the SIDEBAR layout collapsed onto a phone. The vertical sidebar is the exception,
* written once below as a single guarded override.
*
* Why this way round: the bar is needed when
* (viewport <= 767px) OR (:root[data-layout="top"])
* and CSS cannot OR a media query with an attribute selector in ONE selector — so
* writing the bar under both guards meant writing it TWICE (measured: 55 of ~75
* declarations identical, i.e. free to drift in silence). Inverted, each of the three
* chrome states is stated exactly once.
*
* The override wins on SPECIFICITY, never on source order:
* :root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar (0,4,0) beats .fs-sidebar (0,1,0)
* (:root, [data-layout], :not([data-narrow]) — a :not() contributes its argument's
* specificity — and .fs-sidebar.)
* ================================================================== */
.fs-shell {
display: flex; flex-direction: column;
min-height: 100dvh; width: 100%;
}
.fs-sidebar {
display: flex;
width: 100%; height: auto;
position: sticky; top: 0; z-index: var(--fs-z-sticky);
flex-direction: row; flex-wrap: wrap; align-items: center;
gap: var(--fs-space-2-5); padding: var(--fs-space-2-5) var(--fs-space-4);
background: var(--fs-bar-bg);
backdrop-filter: var(--fs-blur);
-webkit-backdrop-filter: var(--fs-blur);
border-inline-end: 0; border-bottom: 1px solid var(--fs-border);
/* the submenu popups anchor to this bar and must escape it */
overflow: visible;
}
/* A hostname can be enormous. Three things must be true at once:
* flex: 0 1 auto + min-width: 0 — the brand may SHRINK (0 0 auto would let it push the
* menu and the right cluster out of the bar);
* max-width — it must also be CAPPED: the bar is flex-wrap, so instead of squeezing the
* brand, flexbox wraps the OTHER items away and lets the brand sit alone on a 600px
* line. The cap is what actually forces the wrap onto .fs-wordmark;
* and the bar grows in height (min-height, 50-toplayout.css) to hold the extra line.
* 30ch tracks the text, not the viewport: a wide screen licenses more MENU, not a wider
* hostname. */
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
/* the bar has no section caption, no column to collapse, and its height follows its
* content — so the flex filler has nothing to fill */
.fs-navlabel, .fs-spacer, .fs-railtoggle, .fs-modemenu { display: none; }
/* the menu on its own full-width row beneath the brand. The DESKTOP bar lifts it back
* onto the brand's row whenever it fits — MEASURED, not a breakpoint
* (fs-chrome.js fitChrome adds .fs-bar-stack when it does NOT fit). */
.fs-sidebar ul.nav {
order: 3; flex: 1 1 100%;
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
}
/* the dropdown hangs off its OWN item — in EVERY bar state, which is what this used to get
* wrong (issue #19). `position: static` + a panel pinned at `left: 16px` was the bar's base,
* and only the top layout's delta re-anchored it per item; so the sidebar layout collapsed
* onto a bar (data-narrow, i.e. a desktop window narrowed past --fs-content-min) opened a
* hover panel at the bar's LEFT EDGE while its trigger sat mid-bar. Measured at 760px: item
* at x=253, panel at x=32 — travelling down to the panel leaves the
, nothing bridges
* the diagonal and the CSS hover closes it, so no submenu entry could be reached at all.
* A phone never showed it: `hover: none` disables the hover rule outright. */
.fs-sidebar ul.nav > li.has-sub { position: relative; }
.fs-sidebar ul.nav > li > a {
display: flex; align-items: center;
padding: var(--fs-space-1-5) var(--fs-space-3); border-radius: var(--fs-radius);
color: var(--fs-dim);
font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
}
/* a bar item is its label: no icon, no chevron */
.fs-sidebar ul.nav > li > a .fs-ico,
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
/* submenu = a popup card under its own item: sized to its content rather than stretched
* across the bar, and capped to the viewport so a long label wraps instead of overflowing.
* `left: 0` = the item's left edge (the
above is the containing block); an item near
* the right edge is nudged back inside by menu-footstrap.js clampDropdown, in every bar
* state — the JS gate is the same question this rule asks, not a layout test. */
.fs-sidebar ul.nav ul,
.fs-sidebar ul.nav > li.open > ul {
display: none;
position: absolute; inset-inline: 0 auto; top: calc(100% + 8px); z-index: var(--fs-z-flyout);
width: max-content; min-width: 200px; max-width: calc(100vw - 32px);
margin: 0; padding: var(--fs-space-2);
background: var(--fs-glass);
backdrop-filter: var(--fs-blur); -webkit-backdrop-filter: var(--fs-blur);
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
}
/* an invisible bridge across the 8px gap, so travelling from the item down into the panel
* does not drop the hover and close it. Belongs to the BAR, not to one layout: the hover
* rule below is unguarded, so every state that can open a panel on hover needs it. */
.fs-sidebar ul.nav > li.has-sub > ul::before {
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
}
/* hover only on a real pointer: a tap leaves :hover stuck on mobile browsers, which
* would block tap-to-close */
@media (hover: hover) {
.fs-sidebar ul.nav > li.has-sub:hover > ul { display: block; }
}
.fs-sidebar ul.nav > li.has-sub.open > ul { display: block; }
/* a submenu link is the same link in every layout — only its PADDING differs (denser
* inside a popup than in the accordion), which is all the vertical override restates. */
.fs-sidebar ul.nav ul a {
display: block; padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm);
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
}
/* right cluster: the poll pill, then Appearance and Log out as square icon buttons.
* LuCI's #indicators (poll "Refreshing", unsaved changes) collapses when empty, so it
* only shows when there is something to report. */
.fs-sidebar #indicators {
display: flex; flex-wrap: wrap; gap: var(--fs-space-1-5);
order: 2; margin-inline-start: auto; padding: 0;
}
.fs-themerow {
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto; order: 2; margin-inline-start: auto;
/* see the :not(:empty) rule below — two auto margins would SPLIT the free space between
* them instead of pushing one cluster to the bar's far edge */
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
background: var(--fs-panel2); color: var(--fs-dim);
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
text-align: start; cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
/* ==================================================================
* WHEN THE RIGHT CLUSTER RUNS OUT OF ROOM — .fs-ind-compact, then .fs-bar-actrow
*
* Both are set by fs-chrome.js (fitCluster) by MEASUREMENT, and both belong to the BAR rather
* than to a layout: the top layout is a bar at every width, and the sidebar layout becomes one
* on a phone (data-narrow). They used to sit in 50-toplayout.css under a data-layout="top"
* guard, so a phone in the DEFAULT sidebar layout — the common case — got neither, and its bar
* showed the full "Refreshing" and "Unsaved Changes: 1" pills with Log out wrapped alone onto a
* second row at the LEFT edge (measured at 380px).
*
* Step 1, .fs-ind-compact: the pills lose their prose and become icon squares matching
* Appearance / Log out — "Refreshing" alone is ~90px, "Unsaved Changes: 2" another ~110px, and
* on the top layout freeing that is often enough to keep the MENU on the brand's row as well
* (fitChrome's own escalation sets the same class for that reason).
* Step 2, .fs-bar-actrow: the cluster still does not fit, so the brand row takes the full width
* and the whole cluster moves down together. It keeps the right edge for free — the first
* rendered cluster member already carries the auto inline-start margin (above). max-width must be released
* along with the flex basis, or the 30ch cap leaves the brand short of the row it was just
* given and the cluster rides back up beside it.
* ================================================================== */
.fs-sidebar.fs-bar-actrow .fs-brandrow { flex: 1 1 100%; max-width: 100%; }
/* the poll pill: label off, masked glyph on. Static green arrows, grey when the poll is paused —
* the same --fs-icon-refresh as the rail, and the same reason it does not spin (a permanently
* spinning glyph makes an idle router look busy, and a moving target cannot be clicked). */
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"] {
width: var(--fs-btn-size); padding: 0; font-size: 0; justify-content: center; position: relative;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
background: var(--fs-dim);
}
/* every OTHER indicator collapses to the same box and shows the badge fs-chrome.js lifted out of
* its label — the count, or a dot when there is no number — exactly as the collapsed rail does.
* min-width rather than width: three digits must not be clipped, and here there is room to grow. */
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"]) {
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
* FIRST one actually rendered. #indicators is earlier in the DOM, so when the poll pill
* shows IT opens the gap and .fs-themerow's auto must stand down — otherwise flexbox
* SPLITS the free space between the two and the pill drifts away from the buttons.
* .fs-themerow keeps its own auto for when the pill is empty and collapsed. */
.fs-sidebar #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
/* …and there are TWO .fs-themerow buttons now (Search, then Appearance), so the same split
* would happen between THEM whenever the pill is collapsed: the second one stands down too. */
.fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
.fs-logout {
flex: 0 0 auto; order: 2; justify-content: center;
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
background: var(--fs-panel2);
}
/* on a square button the BORDER carries the state — the fill and the ink come from the
* shared hover/active groups in 10-chrome.css */
.fs-themerow:hover,
.fs-themerow[aria-expanded="true"] { border-color: var(--fs-accent); }
.fs-logout:hover { border-color: var(--fs-danger); }
.fs-themerow .fs-label, .fs-logout .fs-label { display: none; }
.fs-themerow .fs-ico,
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
/* ==================================================================
* THE VERTICAL SIDEBAR — the single override, the ONLY thing that is not a bar. Guarded
* positively (never write a `:not([data-layout=…])` guard). Everything the bar declared
* above must be answered here — which is what makes it checkable: cssdiff proves the
* column is byte-identical to the pre-merge theme.
*
* WHEN it applies is NOT "viewport >= 768px": what matters is how much room is left for
* the CONTENT once the sidebar takes its cut, and that cut is not constant — 224px
* expanded, 68px as a rail — so the two must give way at DIFFERENT widths, which one
* breakpoint cannot express. `data-narrow` is set by fs-chrome.js (fitShell) when the
* content column would fall below --fs-content-min (02-tokens.css), measured from the
* sidebar's ACTUAL cut, so the rail keeps its column ~156px further down than the
* expanded sidebar does — exactly the room it gave back.
*
* `min-width: 521px` is not a second guess: it is a floor keeping the chrome correct
* with JS DISABLED (no JS = no data-narrow, so a phone would render the desktop
* sidebar). Below 521 no cut leaves 500px anyway — it states the measurement's
* conclusion in advance. */
@media (min-width: 521px) {
/* SCROLL MODEL for the desktop sidebar layout: the shell is pinned to the viewport and the
* CONTENT column scrolls inside itself, so the sidebar is a STATIC element that simply
* stays put — it is NOT position:sticky. This is the fix for issue #7: a sticky sidebar is
* a composited GPU layer, and with overflow:visible (needed so the rail flyouts escape
* sideways) every hover repaint of the column left a 1px seam of the flyout's
* buttons/headers at the layer's edge that only a full repaint cleared — visible on the
* live collapse and on hover, never on a fresh load. A static sidebar is not a layer, so
* there is nothing to leave stale. It also drops the z-index juggling: sticky ALWAYS makes
* a stacking context (which is why the sidebar needed z-index to sit above .fs-main at
* all), whereas a static sidebar is none, so the flyouts resolve their own --fs-z-flyout in
* the ROOT stacking context and sit above the content for free. Confined to the desktop
* sidebar: the top layout and the phone bar keep window scrolling and their sticky bar. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
flex-direction: row; height: 100dvh; overflow: hidden;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
height: 100dvh; overflow-y: auto;
/* `contain: paint` — the fix for #12. .fs-shell is height:100dvh + overflow:hidden and
* .fs-main is its own overflow:auto scroller, so Chrome never grows the document past the
* viewport. Firefox does: on Обзор (where the .fs-ovl grid makes the column ~2800px tall)
* it propagates ~158px of an out-of-flow descendant's scrollable overflow PAST both
* overflow ancestors up to the initial containing block, painting a SECOND, whole-page
* scrollbar beside .fs-main's own (measured: html.scrollHeight 1058 vs 900 viewport;
* System, a short page, stays 900). overflow:hidden on the shell/main/root does NOT stop
* it — it only hides the extra scrollbar while the document still scrolls 158px, dragging
* the fixed sidebar up off-screen. Paint containment makes .fs-main the containing block
* for that descendant and clips it, so the overflow is captured here and html.scrollHeight
* drops to 900 (proven in Firefox). LuCI modals are position:fixed on , not inside
* .fs-main, so the new containing block does not re-base them. No-op in Chrome. */
contain: paint;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
width: var(--fs-sidebar-w); height: 100dvh;
flex: 0 0 auto;
position: static;
flex-direction: column; flex-wrap: nowrap; align-items: normal;
gap: normal; padding: var(--fs-space-5) var(--fs-space-3-5);
background: var(--fs-panel);
backdrop-filter: none; -webkit-backdrop-filter: none;
border-inline-end: 1px solid var(--fs-border); border-bottom: 0;
box-shadow: none;
/* `overflow: auto`, not overflow-y alone: the bar sets `overflow: visible` for its
* popups, and a lone overflow-y here would leave overflow-x visible — which the
* spec forces back to auto anyway. Say both. */
overflow: auto;
z-index: auto;
}
/* the column is already 224px wide — that IS the cap, so the bar's 30ch one must go
* or the brand is capped twice, at the wrong value */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-brandrow {
display: flex; align-items: center; gap: var(--fs-space-1-5);
flex: 0 0 auto; order: 0; min-width: auto; max-width: none; padding: 0 var(--fs-space-1) var(--fs-space-5) var(--fs-space-2);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-navlabel {
display: block;
color: var(--fs-dim);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); letter-spacing: .1em;
text-transform: uppercase;
padding: var(--fs-space-1-5) var(--fs-space-2-5) var(--fs-space-2);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-spacer { display: block; flex: 1 1 auto; min-height: 12px; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-railtoggle { display: inline-flex; }
/* :not(:empty):not(.single) in the SHOW rule, not a separate hide pair: the bar's base
* rule (top of this file) already hides .fs-modemenu, so an empty/single-mode strip
* simply never opts in. The hide pair existed twice (here and 50-toplayout.css),
* byte-identical and unpinned — the drift shape @mirror exists for, folded instead. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu:not(:empty):not(.single) { display: flex; margin-top: var(--fs-space-1); }
/* vertical main nav */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #modemenu {
order: 0; flex: 0 1 auto;
flex-direction: column; flex-wrap: nowrap; align-items: normal; gap: var(--fs-space-0-5);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub { position: relative; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
display: flex; align-items: center; gap: var(--fs-space-3);
padding: var(--fs-space-2-5) var(--fs-space-3);
/* BOLD is the root level's own signal, and it is the one the type scale can promise:
* `--fs-weight-bold` has a face everywhere, while `--fs-weight-normal` is documented in
* 02-tokens.css as having none of its own — so lightening the CHILDREN instead would
* look like hierarchy on a system stack and like nothing at all on a router whose admin
* installed a 600/700 subset. Only the accordion is deep enough to need the distinction;
* the bar keeps one level of items and is left alone. */
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); white-space: normal;
line-height: 1.1;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-ico { display: block; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-chevron { display: block; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-ico { width: 17px; height: 17px; }
/* nested submenu -> an inline accordion, not a popup */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul {
display: none;
/* the bar anchors this panel under itself (absolute + top/left); an accordion is
* just the next block in the column, so the offsets must go back to auto or they
* linger in the computed style */
position: static; top: auto; inset-inline: auto; z-index: auto;
width: auto; min-width: 0; max-width: none;
flex-direction: column; gap: var(--fs-space-0-5);
margin: 0 0 0 var(--fs-space-7); padding: 0;
background: none; backdrop-filter: none; -webkit-backdrop-filter: none;
border: 0; border-radius: 0; box-shadow: none;
overflow: hidden;
}
/* and the bar's hover bridge goes with the popup. The panel is static here, so the
* bridge's containing block falls back to the
— an invisible 10px strip sitting
* ABOVE the section, i.e. over the previous menu item, swallowing its clicks. The rail
* (below) is a popup again but flies its panel out sideways, and brings its own
* horizontal bridge (li.has-sub::after). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
/* ---- THE OPEN SECTION IS A GROUP, AND IT HAS TO LOOK LIKE ONE ----
*
* An indent alone did not say where a long section ended: after ten children, the next root
* category read as an eleventh child (issue #37). A hairline down the group's reading edge
* draws that boundary, and the wider bottom margin separates the last child from the next
* category — the two cues cost nothing but a border and 6px.
*
* No `:not([data-rail="true"])` guard: in the rail this same `ul` becomes a flyout card,
* whose rule (below) restates `border` and `margin` as shorthands at higher specificity, so
* neither declaration here survives into it. Checked in place rather than assumed. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
display: flex;
margin: var(--fs-space-1) 0 var(--fs-space-3) var(--fs-space-7);
padding-inline-start: var(--fs-space-1);
border-inline-start: 1px solid var(--fs-border);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul a { padding: var(--fs-space-2) var(--fs-space-3); }
/* the Appearance trigger and Log out are full-width text rows down here */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout {
order: 0; margin-inline-start: 0;
width: 100%; height: auto;
justify-content: normal;
gap: var(--fs-space-3); padding: var(--fs-space-2-5) var(--fs-space-3);
border: 0; border-radius: var(--fs-radius);
background: transparent;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow { text-align: start; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout { font-weight: var(--fs-weight); font-size: var(--fs-type); }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout:hover { background: var(--fs-panel2); border-color: transparent; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow:hover,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow[aria-expanded="true"] { border-color: transparent; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-label,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-label { display: block; flex: 1 1 auto; }
/* the poll pill sits in the sidebar footer, above Appearance */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
order: 0; margin-inline-start: 0;
flex-wrap: wrap; gap: var(--fs-space-1-5);
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
}
/* In the column the indicator is not one of a row of buttons but one of a stack of
* ROWS — Appearance and Log out below it run the sidebar's full width. A pill floating
* at 87px in a 195px column read as a stray chip; give it its neighbours' width. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators .label,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
flex: 1 1 100%;
justify-content: center;
}
}
/* THE APPEARANCE CONTROLS USED TO LIVE HERE, as a popover on plus the segmented and
* slider controls it was built from. They are a PAGE now (System -> Appearance) and their CSS
* moved with them, to styles/pages/80-appearance.css — a page's styles belong in the page layer,
* and the shell no longer draws any of it. */
/* ==================================================================
* Collapsed sidebar (icon rail) — , persisted in localStorage and
* re-applied before paint by head.ut. Labels collapse; a section's submenu becomes a
* flyout anchored right of its icon, opened on hover (desktop) or on focus — which is
* what a tap on the section link produces on touch.
*
* THE GUARD MUST BE EXACTLY THE VERTICAL SIDEBAR'S — same media floor, same
* :not([data-narrow]). It once said `min-width: 768px` against the vertical block's 521:
* between 521 and 767 with the rail collapsed the VERTICAL rules applied but the RAIL
* rules did not, so the sidebar sprang back to its full 224px — it EXPANDED as the window
* shrank — while the JS measurement was subtracting the rail's 68px and believed the
* content still fitted. The rail is a MODE of the vertical sidebar: it can never be
* visible under conditions the vertical sidebar is not.
* ================================================================== */
@media (min-width: 521px) {
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
* which would clip the flyouts; the rail is short enough not to scroll */
overflow: visible;
/* NO z-index / no sticky: the sidebar is a static element (see the scroll-model note
* above), so it is not a composited layer and its overflow:visible flyouts leave no
* stale seam (issue #7). The flyouts still cover the content on their own --fs-z-flyout
* in the root stacking context. */
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-railtoggle .fs-ico { rotate: 180deg; }
/* only the rail's own labels collapse — a `.fs-label` also exists on every
* submenu link, and those live inside the flyout where they must stay */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-wordmark,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-navlabel,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-modemenu,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
/* The chevron is an affordance for a section you can UNFOLD; a rail item has no label
* and no accordion (its children fly out to the side), so it says nothing and crowds
* the icon.
*
* The selector must be this long: the vertical override turns the chevron back on at
* (0,6,3), so a plainer `.fs-sidebar .fs-chevron` here is only (0,6,0) and LOSES —
* which is exactly why the chevrons reappeared in the rail. Match its shape and add
* the rail attribute: (0,7,3). */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout {
justify-content: center; gap: 0; padding: var(--fs-space-2-5) 0;
}
/* leaf sections have no flyout — show their name as a tooltip bubble */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a::after {
content: attr(data-label);
position: absolute; inset-inline-start: 100%; top: 50%; transform: translateY(-50%);
margin-inline-start: var(--fs-space-2-5); z-index: var(--fs-z-flyout);
padding: var(--fs-space-1-5) var(--fs-space-2-5); border-radius: var(--fs-radius-sm);
background: var(--fs-panel); border: 1px solid var(--fs-border);
box-shadow: var(--fs-shadow-pop);
color: var(--fs-text); font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a:hover::after { opacity: 1; }
/* the flyout: opened by hover on a mouse, or by .open which the menu JS toggles on tap
* (exclusively — menu-footstrap.js flyoutMode). The hover rule is gated on a real
* pointer: mobile browsers leave :hover stuck on the tapped element, which would block
* tap-to-close. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
display: none;
position: absolute; inset-inline-start: 100%; top: -8px; margin: 0; margin-inline-start: var(--fs-space-2-5);
min-width: 196px; padding: var(--fs-space-2); z-index: var(--fs-z-flyout);
background: var(--fs-panel); border: 1px solid var(--fs-border); border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
animation: fs-fade var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
@media (hover: hover) {
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub:hover > ul { display: flex; }
}
/* the flyout's heading, so the collapsed icon still names its section.
*
* IT MUST UNDO THE BAR'S HOVER BRIDGE, and forgetting to was issue #22. This same
* ::before is the bar's invisible 10px hover bridge above the panel (the unguarded rule
* near the top of this file): `position: absolute; left: 0; right: 0; top: -10px;
* height: 10px`. Re-pointing `content` at the title left all four of those in force, so
* the heading rendered as a 10px-tall absolute box ten pixels ABOVE the flyout — out of
* the flow, and clipped in half by the panel's own `overflow: hidden` (measured:
* `top: -10px`, `height: 12px`, and the top of every letter cut off). The rail brings its
* own bridge as `li.has-sub::after`, so nothing here needs the bar's. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
content: attr(data-title);
position: static; inset: auto; height: auto;
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
}
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
width: 12px; height: 100%;
}
/* Collapsed rail: swap the "Refreshing" word for a green refresh glyph — a masked SVG
* tinted with --fs-good, so it follows the palette. Only poll-status becomes an icon;
* other indicators (unsaved changes …) keep their pill.
*
* These two lived OUTSIDE this media block, carrying only the [data-rail] half of the
* guard — the surviving instance of the bug this block's header legislates against. It
* was reachable, not theoretical: head.ut stamps data-rail from localStorage inline and
* pre-paint, while data-narrow is written later by fitShell() in an async module. So on a
* <521px phone a rail user had every OTHER rail rule excluded by the floor while these
* two matched, and the "Refreshing" pill painted as a bare 30px green glyph inside the
* phone bar until the modules landed — permanently if they failed.
*
* The box is --fs-ctl-h-sm and not --fs-btn-size: one step under the bar's compact pill,
* because the rail is 68px wide and the glyph sits in a column of icons rather than in a
* row of equal squares. It was a bare 30px, which is the same size at the default density
* and no size at all at the other two — every other chrome box scales with the Density
* axis, and a literal silently opts out of it (02-tokens.css). */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
/* STATIC, deliberately. A spinner promises something you are WAITING for; this is a
* poll running quietly in the background forever, and a permanently spinning glyph
* makes an idle router look busy. The spin also broke the click that pauses the poll —
* a target that never stops moving cannot be hit. */
}
/* paused: the same glyph gone quiet. Without this the rail kept saying GREEN while the poll
* was stopped — the one thing the indicator exists to tell you, and it was lying. (The pill
* gets this for free: its --fs-good only applies to [data-style="active"].) */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
background: var(--fs-dim);
}
/* EVERY OTHER indicator — LuCI's "Unsaved Changes: N" and whatever a third-party app shows.
* Its label is prose and the rail is 68px: measured on the router, that pill wants 86px,
* wraps onto three lines and hangs 34px past the rail's edge over the content (issue #14).
* The pill is not ours to shorten, so it becomes a square in the poll glyph's box and shows
* the COUNT alone — the only part that changes, lifted into data-fs-badge by fs-chrome.js
* because a text node cannot be reached by a selector. `font-size: 0` kills the prose
* without removing it: the label stays in the DOM for a screen reader, and in the `title`
* for the pointer.
*
* What the badge SAYS — the count, or a dot when the label has no number — is decided in the
* JS, so this stays one rule per box: written as two CSS rules it needed an extra attribute
* and a `:not()` to tell them apart, which pushed the sheet's widest selector past the
* css-metrics ratchet for a choice that is not a cascade question.
*
* `.fs-sidebar` is deliberately absent from these two: #indicators is an id, and the class
* beside it only widened the selector. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits) and
* a fixed box would clip it. It grows from the poll glyph's square and stops at the
* rail's inner width. */
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
box-sizing: border-box; padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* The top bar's compact step paints the same badge (50-toplayout.css) and CANNOT share this
* rule: that one applies at every width, while this one must stay inside the 521px floor —
* below it a rail user has data-rail stamped pre-paint but data-narrow not yet written, so a
* merged rule would strip the phone bar's indicator to a bare digit until the modules land. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
}
/* ---------- main column ---------- */
.fs-main {
flex: 1 1 auto;
min-width: 0;
display: flex; flex-direction: column;
background: var(--fs-bg);
overflow-x: clip;
}
.fs-sidebar #indicators:empty { display: none; }
.fs-indicators { flex-wrap: wrap; }
.fs-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
/* ---------- footer ----------
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright (absorbed
* from base, which keeps only the flex skeleton), so a third-party view emitting its own
*