update 2026-10-22 17:05:51

This commit is contained in:
action committed 2026-10-22 17:05:51 +08:00
1 parent 06b74cb314
commit 3f38c69f75
303 files changed
+7299 -130543

No files matched your search

@@ -1,569 +0,0 @@
@layer theme {
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
body {
background-color: var(--fs-bg);
font-family: var(--fs-font-sans);
}
/* One stroke for every chrome icon (header.ut, svgIcon()): 5 attributes per <svg> were 300 B of each admin page */
.fs-ico, .fs-chevron { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
/* placement only — the look lives in 10-chrome.css */
.fs-brand { flex: 1 1 auto; min-width: 0; }
/* Hostname handling, two rules for two failures — chrome.md, "Hostname handling". */
.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: rotate var(--fs-dur-move); }
/* base `ul` gives a 24px inline-start margin and a disc; padding is already 0 (reset) */
.fs-modemenu { list-style: none; margin: 0; }
.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()`: individual transform properties compose across rules,
* so the RTL mirror below sets `scale` without either rule restating the other's value. */
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
/* The two chevrons are DIRECTION, not decoration, so with the document flipped (head.ut
* stamps dir) they must be mirrored explicitly — logical properties flip the box sides
* (margin, padding, inset, radius), but a glyph needs 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-logout shares the .fs-themerow button rule below (display, colour, box); only its
* LAYOUT (square button vs full-width row) differs per layout block. */
/* The bar is the default chrome and the only copy of it, unguarded — chrome.md, "The bar:
* fit steps, scroll model, a rail that must match its guard". The vertical sidebar below is
* the single guarded override, winning on SPECIFICITY, never on source order. */
.fs-shell {
display: flex; flex-direction: column;
min-height: 100svh; width: 100%; /* svh not dvh — chrome.md, same heading */
}
.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);
border-inline-end: 0; border-bottom: var(--fs-hairline);
/* the submenu popups anchor to this bar and must escape it */
overflow: visible;
}
/* brand may shrink (min-width: 0) but is capped at 30ch, tracking the text not the viewport;
* the extra line grows the bar's own min-height (50-toplayout.css). */
.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 mode strip opts in at 2+ modes; empty (before renderModeMenu in fs-chrome.js runs: no gap, no brand
* shift) or `.single` stays hidden by the line above. */
.fs-modemenu:not(:empty):not(.single) { display: flex; }
/* 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 {
display: flex;
order: 3; flex: 1 1 100%;
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
}
/* anchors the dropdown to its OWN item, not the bar — issue #19, chrome.md, "A bar dropdown
* anchors to its own <li>". A phone never shows it: `hover: none` disables the rule. */
.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; }
/* popup card under its own item, sized to content and capped to the viewport; clampDropdown
* (fs-chrome.js) nudges one back inside near the inline-end edge, every bar state. */
.fs-sidebar ul.nav ul {
display: none; list-style: 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-panel);
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. Unguarded: every state that can open a panel on
* hover needs it, not just one layout. */
.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, the accordion included. */
.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 Search 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; align-items: center; gap: var(--fs-space-1-5);
order: 2; margin-inline-start: auto; padding: 0;
}
/* the square icon button both far-edge controls are; .fs-themerow adds only what a <button> needs */
.fs-themerow, .fs-logout {
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto; order: 2;
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: var(--fs-hairline); border-radius: var(--fs-radius);
background: transparent; color: var(--fs-dim);
}
.fs-themerow {
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 */
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);
}
/* .fs-ind-compact then .fs-bar-actrow, both by fitCluster (fs-chrome.js) — chrome.md, "Right
* cluster: two fit steps". max-width releases with the flex basis, or the 30ch cap leaves
* the brand short of the row it was just given. */
.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; }
/* 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, guarded POSITIVELY (never `:not([data-layout=
* …])`); everything the bar declared above must be answered here. The `data-narrow`
* measurement and the 521px JS-disabled floor: chrome.md, "`data-narrow`". */
@media (min-width: 521px) {
/* Static, never sticky (issue #7) — chrome.md, "A static sidebar, never position:
* sticky". Confined here; 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;
/* Firefox-only overflow leak past the static sidebar (issue #12) — chrome.md,
* "contain: paint … is the fix for issue #12". */
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);
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-block: 0 var(--fs-space-5); padding-inline: var(--fs-space-2) var(--fs-space-1);
}
: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; }
/* spacing only: whether the strip shows is the bar's :not(:empty):not(.single) rule above,
* and the rail's hide below outranks it (0,5,0 vs 0,3,0). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu { 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 { position: relative; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
gap: var(--fs-space-3);
padding: var(--fs-space-2-5) var(--fs-space-3);
/* Bold is the root level's own signal (lightening children reads as hierarchy instead); only the accordion needs it. */
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,
: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-block: 0; margin-inline: var(--fs-space-7) 0; padding: 0;
background: none;
border: 0; border-radius: 0; box-shadow: none;
overflow: hidden;
}
/* the bar's hover bridge does not follow: static here, its containing block would fall
* back to the <li> and swallow clicks above it. The rail brings its own (::after). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
/* An open section is a GROUP: an indent alone does not say where it ends, and after ten
* children the next root category read as an eleventh (issue #37). No :not([data-rail])
* guard needed — the rail's own rule below restates these at higher specificity. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
display: flex;
margin-block: var(--fs-space-1) var(--fs-space-3);
margin-inline: var(--fs-space-7) 0;
padding-inline-start: var(--fs-space-1);
border-inline-start: var(--fs-hairline);
}
/* Search 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;
/* no background here: at (0,4,0) it would out-rank the (0,2,0) hover and open plates in 10-chrome.css */
}
: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); }
: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 Search */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
order: 0; margin-inline-start: 0;
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
}
/* In the column the indicator is one of a stack of ROWS, not a row of buttons —
* Search and Log out below it run the sidebar's full width, so a pill at its own
* content width read as a stray chip. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
flex: 1 1 100%;
justify-content: center;
}
/* Collapsed sidebar (icon rail): <html data-rail="true">, persisted in localStorage and
* re-applied before paint by head.ut. Labels collapse and a section's submenu becomes a
* flyout anchored at its icon's inline-end. Same block as the vertical sidebar, so the two guards
* cannot drift — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
: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/sticky: static, like the vertical sidebar (issue #7) — its 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,
/* a rail item has no accordion — its children fly out sideways — so the chevron says
* nothing and crowds the icon. Selector kept this long because the vertical override
* turns the chevron back on at (0,6,3), so a plainer form here would only be (0,6,0). */
: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 — fs-chrome.js flyoutMode). Hover 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-block: 0; margin-inline: var(--fs-space-2-5) 0;
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 reuses the bar's hover-bridge pseudo-element (issue #22) —
* chrome.md, same heading as above. */
: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%;
}
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
* narrow phone would paint a bare glyph in the phone bar until the modules land.
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
: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: this is a poll running quietly forever, not something being
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
}
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
* poll was stopped, the one thing the indicator exists to report. */
: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);
}
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
* attribute + :not() past stylelint's selector-max-specificity (1,7,0). `.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. */
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this
* rule: it applies at every width, this one must stay inside the 521px floor. */
: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-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; }
/* fs-router.js builds the incoming page in a second #view here, then moves its children into
* the live one, so the visible page is never emptied while the next one loads. `visibility:
* hidden` (not display: none) keeps the box so a self-sizing view sees no zero width;
* `height: 0` stops the page moving; `overflow: clip` (not hidden) skips a second scrollport. */
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
/* Slow-navigation hairline, shown once a nav outlives 150ms (fs-router.js). Fixed, and a
* child of <body>, not .fs-main (a column flex container shrinks its 2px to zero) or
* .fs-shell (contain: paint would re-base and clip a fixed descendant). transform/opacity
* only, both composited; the one style flush is on activation out of 'done', no layout. */
#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"] {
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, so a
* third-party view emitting its own <footer> stays themed too. */
footer {
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: var(--fs-hairline);
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
/* centre the flex LINE (base declares no justify-content) so text-align has something
* to centre rather than a <span> already shrunk to its content */
justify-content: center;
text-align: center;
/* target-size floor for the two links this sentence carries (WCAG 2.2 SC 2.5.8) —
* chrome.md, "A footer link pair needs 26px". */
line-height: max(26px, calc(var(--fs-type) * 2));
}
footer a { color: var(--fs-dim); }
footer a:hover { color: var(--fs-accent); }
/* Notifications — ui.addNotification prepends .alert-message as the first child of
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
.fs-main > .alert-message {
order: 1; margin: var(--fs-space-4) auto 0;
width: calc(100% - var(--fs-content-pad) * 2);
max-width: calc(var(--fs-content-max) - var(--fs-content-pad) * 2);
}
.fs-main > .fs-content { order: 2; }
.fs-main > .fs-footer { order: 3; }
/* phone tier: content gutter, alert-message width and wrap — merged, none override each
* other (distinct selectors), must stay after the base rules above they narrow */
@media (max-width: 767px) {
.fs-content { padding: var(--fs-space-4); }
/* width: auto, not the base width above: that calc subtracts the DESKTOP gutter while
* .fs-content's own gutter is narrower here — auto plus the side margins tracks the
* content column exactly at any width in this tier. */
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
/* a top-level alert's action buttons were nowrap, so Dismiss was shoved past the panel
* and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main for #view's too. */
.fs-main .alert-message { flex-wrap: wrap; }
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
/* the action row is a nested flex that stayed nowrap and clipped its first label */
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
.fs-main .alert-message .btn,
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
}
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
* overflowed a 320px button). Outside the @media: table-contract refuses overflow-wrap in a width query */
.fs-main .alert-message .btn,
.fs-main .alert-message button { overflow-wrap: anywhere; }
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
#indicators [data-indicator] {
/* wears the row's own --fs-btn-size and --fs-radius, not a pill, or it reads as a
* different kind of object beside Search and Log out */
display: inline-flex; align-items: center;
min-height: var(--fs-btn-size);
text-transform: none;
font-weight: var(--fs-weight); font-size: var(--fs-type);
/* the bar's control surface; ui.js emits a bare `<span data-indicator=…>`, no class to key on */
background: transparent;
border: var(--fs-hairline);
color: var(--fs-dim);
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
}
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
/* nothing else gives a clickable indicator a pointer cursor — base's rule carries geometry
* only, and this theme emits no <header> at all — so "Refreshing" showed a text caret. */
#indicators [data-indicator][data-clickable] { cursor: pointer; }
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so it
* takes the control ring the buttons beside it take (55-buttons.css). :focus-visible, not
* :focus: a pointer press on the pill should not draw a ring. */
#indicators [data-indicator][data-clickable]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
}