Files
op-packages/luci-theme-footstrap/styles/theme/10-chrome.css
T
github-actions[bot] 410d762592
Merge-upstream / merge (push) Canceled after 0s
🎄 Sync 2026-08-27 02:13:09
2026-08-27 02:13:09 +08:00

205 lines
11 KiB
CSS

@layer theme {
/* ---- the chrome's floor: pin what a foreign rule can reach by INHERITANCE ----
*
* fs-sheets.js fences the chrome out of a third-party selector, so nothing foreign MATCHES a
* chrome element. Inheritance is the way in a fence cannot close: a rule on `html` or `body` is
* an ancestor, and excluding it is meaningless. Measured against a hostile flagged rule on
* `html`: font-style reached 166 of the menu's 169 elements, word-spacing and text-align 157,
* letter-spacing and text-transform 156, cursor 46 — while font-family, color, font-size,
* line-height and font-weight reached none,
* because the chrome already states those. The fix is to state the rest too.
*
* Inheritance is not a cascade competitor: it supplies a value only when no declaration matches
* the element, so any declaration of ours — unflagged, at zero specificity — beats an inherited
* `!important`. Hence no `!important` here.
*
* It keys off `[data-fs-chrome]`, the mark a chrome root carries in the markup (header.ut), not
* off `.fs-sidebar`: the chrome is not one element, and a pin naming the menu leaves the skip
* link and the search overlay inheriting straight from `html`. The fence (fs-sheets.js) reads
* the same mark, so the two halves of zone 1's defence cannot cover different sets of elements,
* and `npm run chrome-fence` derives the mark from the markup and holds both to it.
*
* It goes on the ROOT alone, and says so in the SELECTOR (`:not([data-fs-chrome] *)`) rather
* than trusting whoever adds the next marker to have read this: the markers live in a template
* with conditional blocks, so "is this one inside that one" is not a question a text scanner can
* answer, and CSS can. Pinning descendants too is worse than useless — a direct declaration
* beats an inherited one even when the inherited one is ours, so `.fs-label` loses the `nowrap`
* it inherits and `text-align` is forced from `start` to `left` on 302 elements, which alone
* breaks every RTL language LuCI ships. Hence `text-align: start`, never `left`.
*
* `:where()` keeps it at 0,0,0 so every existing chrome rule outranks it: the pin is a floor,
* never a ceiling. Only inherited properties belong here. */
:where([data-fs-chrome]:not([data-fs-chrome] *)) {
font-style: normal;
font-variant: normal;
letter-spacing: normal;
word-spacing: normal;
text-transform: none;
text-indent: 0;
text-align: start;
cursor: auto;
}
/* Shared chrome — brand, logo, wordmark, logout, indicators. Same in both layouts;
* only their PLACEMENT differs. Written twice once (here and under `.fs-topnav`) and
* the copies had drifted: 32 vs 30px logo, 11 vs 10px brand gap, 10 vs 8px indicator
* gap. The top-bar values won. A layout file may set placement on these (flex, order,
* the rail collapse) — never their look. */
.fs-brand {
display: flex; align-items: center; gap: var(--fs-space-2-5);
color: var(--fs-text); font-weight: var(--fs-weight-bold);
}
.fs-logo {
width: var(--fs-btn-size); height: var(--fs-btn-size); border-radius: var(--fs-radius);
/* No accent fill — a bordered box, same look as the square buttons in the right
* cluster (.fs-logout, .fs-themerow: 1px var(--fs-border)). The glyph is the OpenWrt
* favicon mark (brand.ut), baked-colour, so color/currentColor is unused here. */
border: var(--fs-hairline);
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto;
}
.fs-logo svg { width: 20px; height: 20px; }
/* The OpenWrt ring flips with the theme mode like the SVG favicon does under
* prefers-color-scheme — dark ring on the light UI, light ring on dark. The cyan arcs
* stay baked (read on both). */
.fs-logo-ring { fill: #002b49; }
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
/* 700, not 800: the theme ships no faces, so 800 is whatever the reader's Manrope or system
* stack has — 700 for most of them, synthesised for the rest. It already resolved to 700 back
* when the 18 KB face was bundled and dropped for drawing six elements. Say so. */
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
/* Skip link — the first tab stop: a keyboard/SR user jumps the whole menu instead of
* tabbing through every section on every page. Off-screen until focused, never
* display:none / visibility:hidden — that removes it from the tab order, which is the
* one thing it exists for. Sits above everything, incl. an open popover. */
.fs-skip {
position: fixed; top: 8px; inset-inline-start: 8px; z-index: var(--fs-z-dropdown);
padding: var(--fs-space-2-5) var(--fs-space-4); border-radius: var(--fs-radius);
background: var(--fs-accent); color: var(--fs-on-accent);
font-weight: var(--fs-weight-bold); font-size: var(--fs-type); text-decoration: none;
box-shadow: var(--fs-shadow-pop);
}
/* Clipped, not translated off-screen: --fs-shadow-pop is offset 12px / blur 34px, so a
* box parked at -68px still bled ~16px of DROP SHADOW into the viewport and smudged the
* top-left corner of every page. clip-path clips the shadow too. Never display:none /
* visibility:hidden — see above. */
.fs-skip:not(:focus) {
clip-path: inset(50%);
width: 1px; height: 1px;
padding: 0; overflow: hidden; white-space: nowrap;
}
.fs-skip:focus {
outline: 2px solid var(--fs-panel); outline-offset: 2px;
}
/* Visually hidden but PRESENT in the a11y tree — for the document <h1> and the live
* region. hidden/display:none/visibility:hidden take the element out of the a11y tree
* too: the h1 would fix no heading outline and the live region would never announce.
* Clipping leaves a node readable to assistive tech while occupying no space.
* nowrap: a long title clipped into a 1px column is read out line by line by some SRs. */
.fs-sr {
position: absolute;
clip-path: inset(50%);
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden; white-space: nowrap;
}
/* the skip link's target <main> carries tabindex="-1" so the browser actually MOVES
* focus there (Safari/Chrome refuse on a non-focusable target: they scroll but leave
* focus in the menu) — but it must not then ring the whole page. */
#maincontent:focus { outline: none; }
.fs-logout { display: flex; align-items: center; color: var(--fs-dim); }
.fs-logout:hover { color: var(--fs-danger); }
.fs-indicators { display: flex; align-items: center; gap: var(--fs-space-2); }
/* The 34px square icon button (.fs-themerow / .fs-logout collapsed) is NOT declared
* here: it only exists where the chrome is a bar or a rail, and those blocks
* (20-shell.css, 50-toplayout.css) already give it its border, surface and size — an
* unconditional copy here was a second definition they had to re-state anyway. */
/* Density steps for the main menu once it is a horizontal row —
* fs-chrome.js (fitTabStrips) adds .fs-dense1/2 when the row would wrap (and strips them
* in the vertical sidebar, so no media guard is needed). Padding is trimmed hard
* first, the font only at the d2 floor. */
.fs-sidebar ul.nav.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
.fs-sidebar ul.nav.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-1-5); font-size: var(--fs-type); }
/* Menu primitives, once for both layouts (both render the menu as `ul.nav > li > a`):
* the 19px line-height, `display: block` on the items and the `position: relative`
* every submenu popup anchors to — all three used to be inherited from a navbar rule
* set the theme no longer carries. */
ul.nav { position: relative; margin: 0; list-style: none; }
ul.nav > li { display: block; }
ul.nav a { line-height: 19px; text-decoration: none; }
/* `.dropdown-menu` is emitted by nobody here — not by this theme, whose menu is a plain nested
* `ul`, and not by luci-base. It stays styled under the coverage contract: it is theme
* vocabulary (stock luci-theme-bootstrap carries 14 rules for it), so a third-party luci-app-*
* is entitled to emit it and expect a themed result. Each item anchors its own nested popup,
* hence position: relative.
*
* This comment used to claim ui.js emits it for the split Save & Apply button. It does not —
* that button is a `.cbi-dropdown`, a different widget. */
.dropdown-menu li { display: block; position: relative; }
/* wins over `ul.nav a` above: the popup sets its own denser rhythm */
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; }
/* Menu hover / active — one look, every layout. One markup (`ul.nav > li > a` + a
* nested `ul`) serves the main list, the submenu (accordion / bar dropdown / rail
* flyout), the mode menu and the Appearance row, so there are no per-layout copies to
* keep in step. */
.fs-sidebar ul.nav > li > a:hover,
.fs-sidebar ul.nav ul a:hover,
.fs-themerow:hover {
background: var(--fs-panel2); color: var(--fs-text); opacity: 1;
}
.fs-sidebar ul.nav > li > a.active,
.fs-sidebar ul.nav ul a.active,
.fs-themerow[aria-expanded="true"],
.fs-modemenu li.active a {
background: var(--fs-accent-soft); color: var(--fs-accent);
}
/* ---- the fill marks the PAGE, and a category is not a page ----
*
* `li.active` is set on the SECTION containing the current page as well as on the page's own
* row, so `li.active > a` hands the same accent plate to both — two identical plates, one above
* the other, on System -> System (issue #37): measured before this rule, the open category and
* the current leaf carried the same fill, the same ink and the same 13px/600, differing in 2px
* of padding.
*
* The markup already says which is which: menu-footstrap.js puts `aria-current="page"` on the
* LEAF alone, a section header being a disclosure control rather than a link to where you are.
* So the plate stays with the page and the ancestor keeps the accent INK, while an ancestor
* that is also the page is matched by `a.active` above and keeps the plate.
*
* Unscoped, like the rule above it: the accordion, the bar's dropdown and the rail's flyout are
* one markup. */
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
/* Keyboard focus for the chrome controls, which otherwise have only the UA default.
* --fs-focus-ring-solo, not --fs-focus-ring: none of these flips a border on focus, so the ring
* is the entire indicator, and the tinted ring measures 1.15-1.29:1 against the sidebar — i.e.
* tabbing the whole menu shows nothing at 3:1 (the two-ring contract in 02-tokens.css). */
.fs-railtoggle:focus-visible,
.fs-themerow:focus-visible,
.fs-logout:focus-visible,
.fs-sidebar ul.nav a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* The page-wide accent signals no widget rule covers, all following the palette:
* ::selection the UA default (opaque blue) ignores palette and dark mode
* caret-color inherits from :root into every field, in one declaration
* accent-color the theme's own checkboxes are appearance:none drawings and never see it,
* but a third-party app's native checkbox/radio/progress does */
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
}