@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, the width cap alone bounding the wrong axis: measured in the top * bar at 1280px, 8 characters give the ordinary 46px bar, 63 (the kernel's nodename limit) give * 73px, and a 4000-character hostname gave a 4273px tall bar. Three lines is past every real * hostname and still a bar. `-webkit-line-clamp` is the only cross-engine spelling today and * needs its two companions, where a max-height would slice a line in half. */ .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, so with * the document flipped (head.ut stamps dir) both point back at themselves. Everything else in * the chrome mirrors from the logical properties; these two are glyphs and need the 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. * * This way round because 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 means writing it twice — 55 of ~75 declarations identical, i.e. free to * drift. 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) */ .fs-shell { display: flex; flex-direction: column; /* ---- `svh`, not `dvh`, and that is the phone's whole problem ---- * * `dvh` tracks the viewport as browser UI comes and goes, and on iOS the URL bar slides * continuously WHILE THE USER SCROLLS, so a shell keyed on `dvh` is re-laid-out on every * frame of that animation — the shaking reported from an iPhone. `svh` is the small viewport * height, the value with the browser UI shown, and it does not move while the UI does. It is * also the right value here: this is a floor, so the shell must be at least one screenful * tall with the bar visible, and when the bar hides the page is free to be taller. * * The whole file follows that rule: a height that must not change under the reader uses * `svh`. `dvh` is for a thing that should GROW as the UI retracts. */ min-height: 100svh; 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: var(--fs-hairline); /* the submenu popups anchor to this bar and must escape it */ overflow: visible; } /* A hostname can be enormous, and three things must be true at once: * flex: 0 1 auto + min-width: 0 — the brand may SHRINK (0 0 auto lets 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 it sit alone on a 600px line; * 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 (issue #19): `position: static` plus a * panel pinned at `left: 16px` anchors it to the bar's edge rather than to its trigger, so a * sidebar layout collapsed onto a bar opened a hover panel at the left edge while its trigger * sat mid-bar — measured at 760px, the item at x=253 and the panel at x=32. Travelling to the * panel leaves the
  • , nothing bridges the diagonal, and * the CSS hover closes it, so no submenu entry could be reached. A phone never shows 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: var(--fs-hairline); 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: var(--fs-hairline); 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. Under a data-layout="top" guard they miss the common case — a phone in the default * sidebar layout, whose bar then shows the full pills with Log out wrapped alone onto a second * row at the left edge. * * Step 1, .fs-ind-compact: the pills lose their prose and become icon squares matching the other * cluster controls, which frees ~200px and on the top layout is often enough to keep the MENU on * the brand's row as well. * 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, keeping the right edge for free from 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. */ .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: var(--fs-hairline); 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. * * 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 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, measured from the sidebar's ACTUAL cut. * * `min-width: 521px` is not a second guess but a floor keeping the chrome correct with JS * DISABLED, where no data-narrow is ever written: below 521 no cut leaves 500px anyway, so 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 stays put — * never position:sticky. That 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 left a 1px seam of the flyout at the layer's edge until a full repaint cleared it. * A static sidebar is not a layer, so there is nothing to leave stale, and it drops the * z-index juggling too: sticky always makes a stacking context, while a static sidebar makes * none, so the flyouts resolve --fs-z-flyout in the root stacking context. Confined to the * desktop sidebar; the top layout and the phone bar keep window scrolling. */ :root[data-layout="sidebar"]:not([data-narrow]) .fs-shell { flex-direction: row; height: 100svh; overflow: hidden; } :root[data-layout="sidebar"]:not([data-narrow]) .fs-main { height: 100svh; overflow-y: auto; /* `contain: paint`, the fix for issue #12: .fs-shell is height:100svh with overflow:hidden and * .fs-main is its own overflow:auto scroller, so Chrome never grows the document past the * viewport — but Firefox 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 on the Overview, where the * .fs-ovl grid makes the column ~2800px tall: html.scrollHeight 1058 against a 900px * viewport, and 900 once contained). overflow:hidden does not stop it: it hides the extra * scrollbar while the document still scrolls those 158px, dragging the fixed sidebar * off-screen. Paint containment makes .fs-main the containing block for that descendant * and clips it. 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: 100svh; 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: var(--fs-hairline); 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 the one the type scale can promise: * `--fs-weight-bold` has a face everywhere, while `--fs-weight-normal` has none of its own * — so lightening the CHILDREN instead looks 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. */ 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 has to look like one ---- * * An indent alone does not say where a long section ends: after ten children the next root * category reads 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. * * 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. */ :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: var(--fs-hairline); } :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 and a section's submenu becomes a flyout anchored right of its icon, opened on * hover 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, and between the two the * vertical rules applied without the rail ones, 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. * The rail is a MODE of the vertical sidebar and 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 sideways — 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. 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: var(--fs-hairline); 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: var(--fs-hairline); 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 (issue #22): this same ::before is the bar's invisible * 10px hover bridge above the panel, so re-pointing `content` at the title while leaving * `position`, `inset` and `height` in force renders the heading as a 10px-tall absolute box * above the flyout, clipped in half by the panel's own overflow. The rail brings its own * bridge as `li.has-sub::after`. */ :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 keep their pill. * * These two must carry the FULL guard, not the [data-rail] half alone: head.ut stamps * data-rail from localStorage pre-paint while data-narrow is written later by an async * module, so on a narrow phone a rail user has every other rail rule excluded by the floor * while these two match — the pill paints as a bare green glyph inside the phone bar until * the modules land, permanently if they fail. * * The box is --fs-ctl-h-sm rather than --fs-btn-size: one step under the bar's compact pill, * the rail being a column of icons rather than a row of equal squares. A literal here would * silently opt out of the Density axis. */ :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 — "Unsaved Changes: N" and whatever a third-party app shows. Its label * is prose and the rail is 68px: 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 showing the COUNT alone, 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 is decided in the JS, so this stays one rule per box: as two CSS rules * it needs an extra attribute and a `:not()` to tell them apart, which pushes the sheet's * widest selector past the css-metrics ratchet. * * `.fs-sidebar` is deliberately absent: #indicators is an id. */ :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 (the .fs-ind-compact rule above, whose * copy of this paint is mirror-pinned to it) 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; } /* ---- the staging area a client navigation renders into ---- * fs-router.js renders the incoming page into a second `#view` inside this wrapper, first in * tree order, and moves its children into the live one when the render completes, so the page on * screen is never emptied while the next one loads. * * Hidden but LAID OUT, and the three declarations are not interchangeable. `visibility: hidden` * takes it off the screen while keeping the box: several views size themselves from the element * they render into, and `display: none` would hand them a zero width they keep for the life of * the instance. `height: 0` stops the page under it from moving, and `overflow: clip` keeps the * content overflowing that zero height from painting — `clip` rather than `hidden`, since a * scroll container would be a new scrollport in the middle of the content column. */ .fs-staging { visibility: hidden; height: 0; overflow: clip; } /* ---- and the hairline that says a slow navigation is under way ---- * Shown only once a navigation outlives 150 ms (fs-router.js), so a warm click never flashes it. * * Fixed, at the top of the viewport, and a child of — all three load-bearing. In the * content column it is a flex item of `.fs-main`, and a column flex container shrinks its 2px to * zero (measured: height 0 in the sidebar layout, and with `margin-bottom: -2px` the content * painted over what was left); and it cannot live inside `.fs-shell` either, that box carrying * `contain: paint`, which * makes a fixed descendant position against the shell and be clipped by it. The top of the * viewport is also where a browser puts its own progress. * * `transform` and `opacity` only, both composited, so this cannot cost the render it is * reporting on. Reduced motion keeps the bar and drops its animation. */ #fs-nav-progress { position: fixed; inset-inline: 0; top: 0; height: 2px; background: var(--fs-accent); transform: scaleX(0); transform-origin: left center; opacity: 0; pointer-events: none; z-index: var(--fs-z-tooltip); } :root[dir="rtl"] #fs-nav-progress { transform-origin: right center; } #fs-nav-progress[data-state="active"] { opacity: 1; transform: scaleX(.8); transition: transform 2s cubic-bezier(.1, .7, .1, 1), opacity 120ms linear; } #fs-nav-progress[data-state="done"] { opacity: 0; transform: scaleX(1); transition: transform 120ms ease-out, opacity 300ms linear 100ms; } @media (prefers-reduced-motion: reduce) { #fs-nav-progress[data-state="active"], #fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); } } /* ---------- 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 *