mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-12 17:47:51 +08:00
update 2026-09-23 23:57:29
This commit is contained in:
1 parent
77395e0373
commit
e22304fb46
172 files changed
+367
-44976
No files matched your search
@@ -1,204 +0,0 @@
|
||||
@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); }
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
@layer theme {
|
||||
/* Wallpaper axis, value `pattern`: an SVG the admin uploads, tiled behind the content and
|
||||
* recoloured by the theme, so the same file reads correctly in light and dark and under any
|
||||
* palette without being edited.
|
||||
*
|
||||
* A dedicated element rather than a `background-image`, because recolouring a rasterised image
|
||||
* takes `mask-image` — the SVG supplies the alpha, the element the colour — and a mask applies
|
||||
* to the element's whole rendering, children included, so it can never go on .fs-shell or
|
||||
* <body>. Hence .fs-pattern, emitted as the first child of <body> by header.ut and sysauth.ut
|
||||
* (the login page has no .fs-shell), whose only job is to be masked.
|
||||
*
|
||||
* z-index: -1 with a transparent body, because inside the root stacking context the paint order
|
||||
* is html's background, then negative-z children, then every block background: the layer sits
|
||||
* above the canvas and below the content, but body's own opaque --fs-bg would bury it. The
|
||||
* canvas therefore moves up to :root for this axis and body goes transparent; .fs-main and the
|
||||
* footer paint their own --fs-bg and follow for the same reason. The sidebar is deliberately
|
||||
* not excluded —
|
||||
* the bar is translucent and the pattern showing behind it is what makes the page read as one
|
||||
* sheet.
|
||||
*
|
||||
* Cards sit on opaque --fs-panel, so the pattern shows through the gaps only. */
|
||||
.fs-pattern { display: none; }
|
||||
|
||||
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
|
||||
:root[data-wallpaper="pattern"] body,
|
||||
:root[data-wallpaper="pattern"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="pattern"] footer.fs-footer { background: transparent; }
|
||||
|
||||
/* `fixed`, so the pattern is one calm backdrop the page scrolls under rather than a strip that
|
||||
* slides with every table — the same argument the File photo's background-attachment makes.
|
||||
* --fs-pattern-url is `none` until something is uploaded (head.ut / fs-prefs.js), so the axis
|
||||
* selected with no file is a bare canvas rather than a broken tile. */
|
||||
:root[data-wallpaper="pattern"] .fs-pattern {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: var(--fs-pattern-strength);
|
||||
}
|
||||
/* THEME INK (the default). The file's own colours are discarded and only its alpha is used, so
|
||||
* an opaque background inside the SVG would mask as a solid sheet — which is exactly why the
|
||||
* admin can switch to the file's own colours below. Line art on transparency is the shape this
|
||||
* mode is for. */
|
||||
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
|
||||
background-color: var(--fs-text);
|
||||
-webkit-mask-image: var(--fs-pattern-url);
|
||||
mask-image: var(--fs-pattern-url);
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-size: var(--fs-pattern-size) auto;
|
||||
mask-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
/* THE FILE'S OWN COLOURS: an ordinary tiled background, no mask. For artwork that carries its
|
||||
* own palette and would be flattened to one colour by the mask. */
|
||||
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
|
||||
background-image: var(--fs-pattern-url);
|
||||
background-repeat: repeat;
|
||||
background-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
|
||||
/* No wash layer behind #view: the pattern shows through the gaps between the cards at
|
||||
* full strength. Every block that carries text paints an opaque --fs-panel of its own,
|
||||
* so nothing has to be dimmed globally. */
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
@layer theme {
|
||||
/* The uploaded photo, the Wallpaper axis's `file` value. The bytes are router-side and shared,
|
||||
* and the served file is referenced by CSS `url()`, never inlined (uhttpd has no gzip). A photo
|
||||
* carries no bakeable opacity, so a scrim is mandatory for legibility: --fs-photo-scrim, a wash
|
||||
* derived from --fs-bg so it follows palette, mode and tint (03-palettes.css), painted as the
|
||||
* TOP background layer over the photo on the SAME element. That keeps it between image and
|
||||
* content with no ::before
|
||||
* and no z-index, and no backdrop-filter — a blur on a full-viewport surface is the most
|
||||
* expensive paint there is on a phone GPU, the scrim already carries the readability, and
|
||||
* reduced transparency turns the scrim opaque anyway (95-a11y-media.css).
|
||||
*
|
||||
* One-element rule, like the pattern: .fs-shell where there is chrome, the bare <body> on the
|
||||
* login page. Content blocks paint their own opaque --fs-panel and float above the scrim, so it
|
||||
* only has to carry the little text sitting directly on the canvas. --fs-login-bg-url is `none`
|
||||
* until a photo is uploaded. */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) {
|
||||
background-image: linear-gradient(var(--fs-photo-scrim), var(--fs-photo-scrim)), var(--fs-login-bg-url);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
/* fixed, so the photo is one calm backdrop the page scrolls under, not a strip that slides
|
||||
* with every table. */
|
||||
background-attachment: fixed;
|
||||
}
|
||||
/* .fs-main's opaque --fs-bg would cover the shell's single background layer — make it transparent
|
||||
* so the photo+scrim shows through the whole content area (15-wallpaper.css does the same for
|
||||
* the pattern). */
|
||||
:root[data-wallpaper="file"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="file"] footer.fs-footer { background: transparent; }
|
||||
}
|
||||
@@ -1,794 +0,0 @@
|
||||
@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);
|
||||
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 <li>, 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 <li> 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-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. 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-bar-ctl-bg); 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-bar-ctl-bg);
|
||||
}
|
||||
/* 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 <body>, 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);
|
||||
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;
|
||||
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 <li> — 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 <body> 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) ----
|
||||
* <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 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 <body> — 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
|
||||
* <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, not just the text: base makes the footer a flex container
|
||||
* (base/50-chrome.css) and declares no `justify-content`, so the initial `flex-start` packs
|
||||
* the single <span> at the start and leaves text-align nothing to centre, the span
|
||||
* shrinking to its content. */
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
/* And space the lines, because the two links become adjacent TARGETS the moment the line wraps.
|
||||
* The footer is one sentence carrying the LuCI link and the OpenWrt link; at the width where
|
||||
* it breaks they land on consecutive lines, and with the inherited leading that is 16px
|
||||
* between their centres — inside the 24px envelope WCAG 2.2 SC 2.5.8 requires around a
|
||||
* target smaller than 24x24, which an inline link always is. Found by tools/live-audit.mjs on
|
||||
* CI's runner and not on the stands: the wrap width depends on text metrics, so the same
|
||||
* 568px viewport breaks the line there and not here. 26px, not 24: the criterion is that the
|
||||
* two circles must not meet, so 24 is the boundary rather than a pass.
|
||||
*
|
||||
* A FLOOR, not a fixed number, and not a bare ratio either: `--fs-type` follows the Density
|
||||
* axis, so a plain `26px` would be the one leading in this file that ignores it, while a
|
||||
* unitless `2` follows the axis straight back under 24px at Compact (11.7px x 2 = 23.4) —
|
||||
* where the wrap this rule exists for is most likely. `max()` is both: 26px at the default
|
||||
* and at Compact, 29.9px at Large. */
|
||||
line-height: max(26px, calc(var(--fs-type) * 2));
|
||||
}
|
||||
footer a { color: var(--fs-dim); }
|
||||
footer a:hover { color: var(--fs-accent); }
|
||||
|
||||
/* the phone bar is the base above; only the content gutter is width-driven */
|
||||
@media (max-width: 767px) {
|
||||
.fs-content { padding: var(--fs-space-4); }
|
||||
}
|
||||
|
||||
|
||||
/* 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; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* width:auto, not the base width above: that calc subtracts the DESKTOP gutter
|
||||
* (--fs-content-pad, 28px a side) while .fs-content's own gutter is 16px here — the
|
||||
* alert came out 24px narrower than the content and hugged the left (measured at 700px:
|
||||
* insets 16/40 where the content sits 16/16). auto + the side margins matches 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; }
|
||||
}
|
||||
|
||||
/* Phone: alerts (e.g. the sysupgrade banner, injected by ui.addNotification as a
|
||||
* `.fs-main > .alert-message` — a SIBLING of #view, not a child) lay their action buttons
|
||||
* in a nowrap flex row, so on a ~344px screen the trailing Dismiss button was shoved past
|
||||
* the panel and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main so it covers
|
||||
* those top-level alerts and any inside #view. */
|
||||
@media (max-width: 767px) {
|
||||
.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, so the first button's label got
|
||||
* clipped when Dismiss sat beside it. Wrap the inner rows; let labels break. */
|
||||
.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%; }
|
||||
}
|
||||
|
||||
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
|
||||
.fs-indicators .label,
|
||||
.fs-indicators [data-indicator],
|
||||
#indicators .label,
|
||||
#indicators [data-indicator] {
|
||||
/* An indicator sits in a ROW of controls — in the bar, followed by Search and Log out — so it
|
||||
* must look like one of them or the cluster reads as three unrelated things. The same
|
||||
* --fs-btn-size height (a box sized by its font's line-box comes out 28px, visibly the odd
|
||||
* one out) and the same --fs-radius corner, not --fs-radius-pill: a capsule beside two
|
||||
* rounded squares announces itself as a different kind of object, which it is not. Width
|
||||
* stays the content's. */
|
||||
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, not the panel: this pill sits in the same row as the square
|
||||
* buttons and wears what they wear. Verified on the live DOM rather than by reading —
|
||||
* ui.js emits the indicator as a bare `<span data-indicator=…>` with NO class, so a rule
|
||||
* written against `.label` (as one here briefly was) matches nothing at all. */
|
||||
background: var(--fs-bar-ctl-bg); background-image: none;
|
||||
border: var(--fs-hairline);
|
||||
color: var(--fs-dim);
|
||||
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
|
||||
}
|
||||
.fs-indicators [data-indicator][data-style="active"],
|
||||
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
|
||||
/* nothing else gives a clickable indicator a pointer cursor (base's `header
|
||||
* [data-indicator]` rule carries geometry only, and this theme emits no <header> at all),
|
||||
* so "Refreshing" showed a text caret. */
|
||||
.fs-indicators [data-indicator][data-clickable],
|
||||
#indicators [data-indicator][data-clickable] { cursor: pointer; }
|
||||
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so
|
||||
* it is a control now and takes the control ring the buttons beside it take (55-buttons.css)
|
||||
* rather than the UA outline it was left with. `:focus-visible`, not `:focus`: a pointer press
|
||||
* on the pill should not draw a ring, the same rule every other control here follows. */
|
||||
.fs-indicators [data-indicator][data-clickable]:focus-visible,
|
||||
#indicators [data-indicator][data-clickable]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
@layer theme {
|
||||
/* One thin meter for every .cbi-progressbar (overview Memory/Storage/Network, Software
|
||||
* disk space, package manager …): slim rounded track, accent fill, and the LuCI title
|
||||
* value floated just above the bar's right edge (mono/dim) instead of stuffed inside a fat
|
||||
* pill. No per-page overrides. */
|
||||
.cbi-progressbar {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
background: var(--fs-track);
|
||||
border: 0;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
overflow: visible;
|
||||
min-width: 120px;
|
||||
margin: 0;
|
||||
}
|
||||
.cbi-progressbar > div {
|
||||
height: 100%;
|
||||
background: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
transition: width var(--fs-dur-fill);
|
||||
}
|
||||
/* Colour carries the reading too, not length alone: `data-fs-level` is set from JS
|
||||
* (menu-footstrap-common.js, FS_METER_WARN/FS_METER_DANGER — 80/92%, a decision, not a
|
||||
* measurement) on every poll tick. Solid tokens, matching the accent fill above — no
|
||||
* color-mix, so no static twin is owed to 04-nocolormix.css. */
|
||||
.cbi-progressbar[data-fs-level="warn"] > div {
|
||||
background: var(--fs-warn);
|
||||
}
|
||||
.cbi-progressbar[data-fs-level="danger"] > div {
|
||||
background: var(--fs-danger);
|
||||
}
|
||||
.cbi-progressbar::after {
|
||||
content: attr(title);
|
||||
/* the gap is a scale step, not a literal 4: every other gap in the theme scales with the
|
||||
* density axis and this one sat outside it. Identical at the default density. */
|
||||
position: absolute; inset-inline: auto 0; bottom: calc(100% + var(--fs-space-1)); top: auto;
|
||||
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim); white-space: nowrap; text-shadow: none;
|
||||
}
|
||||
|
||||
/* The value sits BESIDE the bar wherever the bar already shares its line with a name.
|
||||
*
|
||||
* The default above puts it over the bar's far edge, which assumes the Overview's shape: the
|
||||
* meter owns a full-width line and its name sits on the line above, so the value lands at the
|
||||
* far end of that name's line and the two read as one row. Two shapes are not that, and CSS
|
||||
* cannot ask whether a row carries its own label, so each is named:
|
||||
*
|
||||
* `.cbi-value-field > .cbi-progressbar` the meter IS the field of a CBI form row, where the
|
||||
* value otherwise lands against the row divider rather than beside its own bar
|
||||
* `.table:has(.tr.table-titles) .td …` a meter COLUMN in a multi-column data table, where
|
||||
* the floated value lands on the row divider too
|
||||
*
|
||||
* Both want the same geometry and differ in one number, so the geometry is written once and
|
||||
* `--fs-meter-value` is the knob: a form row holds `-140.0 dBm`, half again as wide as a table
|
||||
* cell's `100% (1.2 GiB)`. The Overview and the package manager keep the value above the bar,
|
||||
* their meters being neither shape. */
|
||||
/* ONE RULE PER SHAPE, not one selector list for both, and the split is not cosmetic: the table
|
||||
* half needs `:has()`, and an engine that cannot parse a selector discards the WHOLE rule —
|
||||
* siblings included. Grouped, the form row (which needs nothing new) lost its placement on such
|
||||
* an engine along with the cell that was unreachable there anyway. The declarations they share
|
||||
* are @mirror-pinned instead, so the two copies cannot drift. */
|
||||
.cbi-value-field > .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
/* @endmirror */
|
||||
--fs-meter-value: calc(var(--fs-space-10) * 2);
|
||||
}
|
||||
.cbi-value-field > .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* @endmirror */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
/* @endmirror */
|
||||
--fs-meter-value: var(--fs-space-10);
|
||||
/* narrower than this it stops reading as a meter at all; the column gives up the rest. */
|
||||
min-width: 60px;
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* @endmirror */
|
||||
}
|
||||
|
||||
/* …and back above the bar once that column becomes a card.
|
||||
*
|
||||
* The rule above puts the value beside the bar because a table column shares its row with other
|
||||
* columns. A carded row is not that: `.fs-stacked` gives each cell half the card, so "beside"
|
||||
* is outside the cell and the value runs across the neighbouring column and over the label
|
||||
* above it (reported twice from a phone, at every density).
|
||||
*
|
||||
* So the card puts it back over the bar's far edge and buys it a line: the bar takes a top
|
||||
* margin of one --fs-type-xs line, so the value sits between the label and the bar instead of
|
||||
* on either. 6-9px of overlap before, none after.
|
||||
*
|
||||
* The selector carries `:has(.tr.table-titles)` for specificity alone — that is what the
|
||||
* beside-rule above matches on, and `:has()` counts its argument, which makes it (0,6,0), so a
|
||||
* plainer selector here loses (measured: `margin-inline-end` still 40px). The title row is
|
||||
* still in the DOM when the table is carded; the card only hides it. */
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
margin-inline-end: 0;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
inset-inline: auto 0;
|
||||
bottom: calc(100% + var(--fs-space-1));
|
||||
top: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Progressbar rows in a section table (luci-app-cpu-status): keep the native label | bar
|
||||
* row, just centre the cells vertically. */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table) .tr:has(.cbi-progressbar) .td { vertical-align: middle; }
|
||||
}
|
||||
@@ -1,834 +0,0 @@
|
||||
@layer theme {
|
||||
/* ---------- panels / cards (cbi) ---------- */
|
||||
.cbi-section, .cbi-map > .cbi-section-node, fieldset.cbi-section, .panel {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); }
|
||||
/* a bare <legend>; the ones inside a .cbi-section are covered below. The ink is the heading
|
||||
* ramp's (45-misc.css) — legend is kept out of that group so this rule stays its one owner;
|
||||
* no line-height here: base's `fieldset legend` sets 1 and every real legend sits in a
|
||||
* fieldset, so stating leading here would silently repaint them all through the layer. */
|
||||
legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
|
||||
/* overview (luci-mod-status) DOM:
|
||||
* .cbi-section > .cbi-title>h3 + div > table.table (.tr/.td) */
|
||||
|
||||
/* section title + collapse control */
|
||||
.cbi-section > .cbi-title { margin-bottom: var(--fs-space-3); }
|
||||
.cbi-section > .cbi-title h3,
|
||||
.cbi-section > h3:first-child,
|
||||
.cbi-section > legend {
|
||||
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
margin: 0; padding: 0; border: 0;
|
||||
}
|
||||
/* A bare section title (no .cbi-title wrapper) sat flush against the content below it —
|
||||
* on System → System the "System Properties" h3 touched the tab strip. Give it the same
|
||||
* 12px the .cbi-title wrapper already carries above. Excludes .cbi-title h3: that variant
|
||||
* gets its gap from the wrapper's own margin-bottom, so flagging the inner h3 too would
|
||||
* double it. */
|
||||
.cbi-section > h3:first-child,
|
||||
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
|
||||
|
||||
/* A second heading inside the same card — the one that follows a table rather than opening the
|
||||
* section — has no air at all: on Status -> Overview the stock includes stack `table + h3 +
|
||||
* table` inside one .cbi-section, and the heading reads as another row of the table above it.
|
||||
* The rule above cannot cover it, `:first-child` being what excludes the one that opens the
|
||||
* card. The two values are the ladder's own: a heading is separated from what came before by
|
||||
* more than from what it introduces. */
|
||||
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
|
||||
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* ---- tables in cards ----
|
||||
* Discriminator: a key/value include (System, Memory) has NO id; a data table has one
|
||||
* (status_leases) or the JS tag .fs-dt. Key/value is not itself a card — no frame, no clip;
|
||||
* a data table keeps the `.table` frame below. Nested (`.table .table`, `.cbi-value .table`)
|
||||
* and the config-diff dialog (`.cbi-modal .table`) go with key/value. */
|
||||
.cbi-section .table:not([id]):not(.fs-dt),
|
||||
.cbi-section-node .table:not([id]):not(.fs-dt),
|
||||
.cbi-modal .table, .table .table, .cbi-value .table {
|
||||
border: 0; border-radius: 0; overflow: visible; background: none;
|
||||
}
|
||||
.cbi-section .table { margin: 0; width: 100%; }
|
||||
/* A data table straight in #view (no section: package-manager, Startup) misses the width above,
|
||||
* so it sized to preferred width and overflowed the content column — which base already fixes
|
||||
* for EVERY `.table` (`width: 100%`, base/40-tables.css), and has for as long as both rules have
|
||||
* existed. Switched off against the 24 tables that match it across eight pages: no computed
|
||||
* style moved, so the rule only restated a value one layer down. Removed. */
|
||||
/* Config-table fields fill their cell — moved from base with the generic field box in
|
||||
* theme/60-inputs.css (in base these beat its 210px in-layer; left behind they would lose
|
||||
* to it by layer), together with the numeric-cell refinement below them. */
|
||||
.table.cbi-section-table input[type="password"],
|
||||
.table.cbi-section-table input[type="text"],
|
||||
.table.cbi-section-table textarea,
|
||||
.table.cbi-section-table select,
|
||||
.table.cbi-section-table .cbi-select {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-table-cell select,
|
||||
.table.cbi-section-table .td.cbi-section-table-cell .cbi-select { width: inherit; }
|
||||
/* The filter row (form.js renderHeaderRows) is the one place a field cannot claim the width it
|
||||
* wants: it lives in a table HEADER cell, so the column is as wide as the DATA under it. The
|
||||
* rule above makes it fill that cell; what does not fit is the TEXT, LuCI writing the
|
||||
* placeholder as `Filter ` plus the column title, so a long translated title is sliced
|
||||
* mid-glyph (issue #12). The flag column is the worse half: form.js writes `width: 30px` inline
|
||||
* there, of which the theme's padding ate 24, leaving less than one glyph.
|
||||
*
|
||||
* A denser box and denser type buy back ~20px per column; the ellipsis changes the FAILURE, a
|
||||
* placeholder too long for its column now ending instead of being cut through a letter. Nothing
|
||||
* is lost to it — the column title says the same word directly above. No !important, so the
|
||||
* inline 30px still stands: it is upstream's sizing for a one-character field. */
|
||||
.table.cbi-section-table .cbi-section-filter {
|
||||
padding: var(--fs-space-1) var(--fs-space-1-5);
|
||||
font-size: var(--fs-type-xs);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* width:100% cannot shrink a table below min-content — one long unbreakable value holds the
|
||||
* floor. Breaking values reflows it into the column instead of scrolling, and that break is the
|
||||
* DEFAULT for every cell (base/40-tables.css), so a copy here would say nothing the cascade does
|
||||
* not.
|
||||
*
|
||||
* A rule on this property is back a few lines down, and it is the INVERSE rather than a copy: a
|
||||
* data table gets `break-word` while it is still a table, so its columns keep a floor and an
|
||||
* overflow can be seen. Read that block before touching either. DHCP leases need finer control
|
||||
* (only DUID/IPv6 may break) and get it from `white-space: nowrap`: pages/50-leases.css. */
|
||||
.cbi-section .table .td,
|
||||
.cbi-section .table .th {
|
||||
padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-2-5) 0;
|
||||
border: 0; border-bottom: var(--fs-hairline);
|
||||
line-height: 1.45; vertical-align: middle;
|
||||
text-align: start;
|
||||
}
|
||||
/* Row actions hug the far edge. Base says so too but loses to the `text-align: start` above (an
|
||||
* earlier layer), so restate it. `.cbi-section-table-cell` is the discriminator:
|
||||
* form.TableSection's action cells carry it (firewall zones, port forwards); the hand-built
|
||||
* ones of Wireless (`.td.middle`) and DHCP leases do not, and stay left. */
|
||||
.cbi-section .table .td.cbi-section-table-cell.cbi-section-actions { text-align: end; }
|
||||
.cbi-section .table .tr:last-child .td { border-bottom: 0; }
|
||||
/* Case is the app's, not the theme's: `text-transform: uppercase` stood here and printed LuCI's
|
||||
* own `IPv4 address` as `IPV4 ADDRESS`, which reads as a typo in a column heading rather than as
|
||||
* a style (issue #39). Everything else that made these read as headings — the smaller size, the
|
||||
* weight, the faint ink, the tracking — stays. Four rules carry the same decision: the title row
|
||||
* below, the card label (mirrored into theme/65-dropdown.css) and the alert's table in
|
||||
* theme/35-alerts.css. */
|
||||
.cbi-section .table .th {
|
||||
color: var(--fs-eyebrow-color); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs);
|
||||
letter-spacing: var(--fs-eyebrow-tracking);
|
||||
}
|
||||
|
||||
/* --- DATA tables ([id] or JS-tagged .fs-dt): leases, wifi, processes, routes … ---
|
||||
* Symmetric padding so cells clear the table's own contour (below).
|
||||
*
|
||||
* `:is([id], .fs-dt)` is one discriminator at ONE specificity: written as two selector lists the
|
||||
* two names for "data table" land at different weights — `.cbi-section .table[id] .td` (0,4,0)
|
||||
* against `table.fs-dt .td` (0,2,1), the latter losing to the key/value default (0,3,0) — so a
|
||||
* table known only by the JS tag keeps the flush-left padding, with no mono face, no row hover
|
||||
* and no header rule. Status -> Routing emits exactly that shape. */
|
||||
.cbi-section .table:is([id], .fs-dt) .td,
|
||||
.cbi-section .table:is([id], .fs-dt) .th,
|
||||
table.fs-dt .td, table.fs-dt .th { padding: var(--fs-space-2-5) var(--fs-space-3); }
|
||||
.cbi-section .table:is([id], .fs-dt) .td { font-family: var(--fs-font-mono); font-size: var(--fs-type); color: var(--fs-text); }
|
||||
|
||||
/* ---- the floor, and it belongs to the one tier a measurement reads ----
|
||||
*
|
||||
* `base/40-tables.css` breaks every value `anywhere`, the one break value that also lowers the
|
||||
* cell's min-content contribution — to a single character. That is what keeps a table nobody
|
||||
* measures inside its card, and it is also a LIE told to the engine: with no floor, auto table
|
||||
* layout is free to starve a column and the browser answers "it fits", truthfully and uselessly.
|
||||
*
|
||||
* So the floor goes back exactly where it is READ: a data table, while it is still a table.
|
||||
* `break-word` breaks the same values in the same places — the two differ only in whether the
|
||||
* break counts towards min-content (css-text-3 §5.4) — so nothing about the rendering changes;
|
||||
* what changes is that the column can no longer be starved below it, and an overflow becomes
|
||||
* visible to `fit.overflows()`.
|
||||
*
|
||||
* `:not(.fs-stacked)` is the whole guard, and it is the state the fitter measures in (it strips
|
||||
* the class first, fs-fit rule 1). Once carded, every cell has the row to itself.
|
||||
*
|
||||
* base/40-tables.css is deliberately NOT touched: `break-word` there was tried and recorded as a
|
||||
* failure (a table stayed 355px wide in a 346px column, and .fs-main clips), a tier with no card
|
||||
* and no scroll having nowhere to put the overflow. */
|
||||
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
|
||||
/* …and the ONE column the fitter decided may be shredded rather than card the whole table. Written
|
||||
* by fs-select.js on the cells of a single column, stripped before every measurement. (0,5,0) — it
|
||||
* beats the floor above on specificity, not on source order. */
|
||||
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---- and the one flag, because the view writes its shredding inline ----
|
||||
*
|
||||
* `luci-mod-status`'s processes.js emits `E('span', {'style': 'word-break: break-word'}, …)`.
|
||||
* `word-break: break-word` is the deprecated alias defined as "`word-break: normal` plus
|
||||
* `overflow-wrap: anywhere`" (css-text-3 §5.1), so it lowers the cell's min-content to a single
|
||||
* character from an INLINE declaration no cascade layer can outrank: measured at 720px, the
|
||||
* Command column sat at 126px against a widest token of 353px and forcing the column's
|
||||
* `overflow-wrap` any of three ways changed nothing, the floor being erased inside the span.
|
||||
* Neutralised, the same table asks for 963px in 688px of room, which is the truth.
|
||||
*
|
||||
* This re-times the view's intent rather than overruling it: the column may still shred, but
|
||||
* when the fitter marks it (`.fs-td-break`, one rung before carding) rather than always. Buttons
|
||||
* and inputs are excluded — base gives them `break-all` deliberately, and a control label is not
|
||||
* a value being read.
|
||||
*
|
||||
* The `!important` is the sanctioned kind: it fights an inline `style=`, the one adversary a
|
||||
* layer cannot reach.
|
||||
*
|
||||
* A CARD IS NOT EXEMPT, though `:not(.fs-stacked)` stood here. The inline alias damages the
|
||||
* MEASUREMENT, and a carded table is not measured — but it is not the only adversary. LuCI's
|
||||
* luci-mod-dashboard ships `.Dashboard … .devices-info .tr .td:first-child { word-break:
|
||||
* break-all }` in view/dashboard/css/custom.css with no layer around it, and `break-all` splits a
|
||||
* token that would have fitted: measured at 390px, `t3e-syp.home.iot` over six lines and the
|
||||
* caption `HOSTNAME` cut as `HOSTNA`/`ME`. A card's own breaking is unchanged — `.td` below keeps
|
||||
* `overflow-wrap: anywhere`, so a token too long for the line still breaks. */
|
||||
.table.fs-dt .td,
|
||||
.table.fs-dt .td :not(button):not(.btn):not(input):not(select):not(textarea) {
|
||||
word-break: normal !important;
|
||||
}
|
||||
|
||||
/* Whether a table cards, breaks one column or scrolls is MEASURED (fs-select.js), at any width. */
|
||||
|
||||
/* ---- the header row stays while the rows go past ----
|
||||
*
|
||||
* Processes renders 114 rows and Connections a few hundred: past the first screenful the column
|
||||
* headings are gone and every value is an unlabelled string of digits. The card view solves that
|
||||
* by printing the heading above each value; a table that FITS had no equivalent.
|
||||
*
|
||||
* On the CELLS, not the row: `position: sticky` needs a box with a background, and in the
|
||||
* separated border model a row's background does not travel with a sticky cell — the heading
|
||||
* would float over the rows with the values showing through. So each `.th` sticks and carries
|
||||
* the header's own fill.
|
||||
*
|
||||
* What it sticks TO depends on which box scrolls. In the vertical sidebar `.fs-main` is its own
|
||||
* scroller and nothing overlaps its top edge, so the offset is 0. In the bar layouts the
|
||||
* DOCUMENT scrolls under a sticky bar whose height is not `--fs-bar-h` — it grows when the brand
|
||||
* wraps, when the menu takes a row or when the cluster does, all decided by measurement — so
|
||||
* fs-chrome.js publishes `--fs-bar-live` and this reads it, with the designed height as the
|
||||
* fallback for the frame before the chrome has been fitted.
|
||||
*
|
||||
* And a dialog is neither, which is why every one of these selectors names its root. The roots
|
||||
* ride in `:where()` so they cost nothing: spelled out they weigh (1,9,0) against the (1,7,0)
|
||||
* ceiling tools/css-metrics.mjs enforces, while the cell rule that has to win is (0,6,0). */
|
||||
:where(:root[data-layout="sidebar"]:not([data-narrow]) #view, #modal_overlay)
|
||||
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
background: var(--fs-panel2);
|
||||
}
|
||||
:where(:root[data-layout="top"] #view, :root[data-narrow] #view)
|
||||
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
|
||||
position: sticky; top: var(--fs-bar-live, var(--fs-bar-h)); z-index: 1;
|
||||
background: var(--fs-panel2);
|
||||
}
|
||||
|
||||
/* ---- a measurement must not throw the reader ----
|
||||
*
|
||||
* `fitTables()` decides a data table's tier by taking its marks OFF and asking the engine
|
||||
* (fs-fit rule 1), and the reading forces layout, so for that pass the document really is laid
|
||||
* out with a table several screens taller than the one on screen.
|
||||
*
|
||||
* Scroll anchoring turns that into a visible bug: the browser keeps the node under the reader
|
||||
* still by adjusting the offset, and re-picks that node whenever the offset changes for any
|
||||
* other reason — which, while the user scrolls, is every frame. The pick can land inside a table
|
||||
* in the un-stacked state the fitter is about to undo, and the next frame the engine faithfully
|
||||
* corrects the offset by the difference. Measured on the Overview at 390x844 with the poll
|
||||
* driving real re-renders: 18 movements nobody asked for in 1500 frames, the worst 1877px,
|
||||
* always a pair.
|
||||
*
|
||||
* So the tables the fitter re-stamps are declared ineligible as anchors — and only they, because
|
||||
* every other node staying eligible is what keeps the good half of anchoring working. NOT on the
|
||||
* scroll container, which is the answer that suggests itself and is worse: with
|
||||
* `html, body, #maincontent { overflow-anchor: none }` the same run still moved 12 times, up to
|
||||
* 788px, having thrown that compensation away as well. */
|
||||
.table.fs-dt { overflow-anchor: none; }
|
||||
|
||||
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
|
||||
.cbi-section .table:is([id], .fs-dt) .tr.table-titles .th { border-bottom-color: var(--fs-border); }
|
||||
/* Contour around the whole data table. A real <table> needs border-collapse:separate or the
|
||||
* radius is silently dropped. Scoped to #view too: Startup renders a .table.fs-dt in a bare
|
||||
* #view wrapper with no section, and drew frameless under a section-scoped selector. */
|
||||
.cbi-section .table[id], #view .table[id],
|
||||
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, table.fs-dt {
|
||||
border-collapse: separate; border-spacing: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* EXPLICIT, not an omission: the generic `.table` rule below still says `overflow: hidden`
|
||||
* — which clipped the last column of a wide table — and not repeating it here does not
|
||||
* stop it applying, which is what kept the bug alive. The corner cells (below) round the
|
||||
* frame instead, and long values wrap so the table shrinks to fit. */
|
||||
overflow: visible;
|
||||
}
|
||||
/* The frame is overflow:visible, so nothing clips the square corner cells to its radius and
|
||||
* they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill.
|
||||
* `#view .table[id]` is listed because the frame rule draws the border there too.
|
||||
*
|
||||
* `:is(.tr.table-titles, .thead)` because a header is any of the shapes fs-select.js adopts:
|
||||
* `.tr.table-titles` is the bare-th-row markup, `.thead` covers a real `<thead>` whether it
|
||||
* holds its `<th>`s directly (E()-built, no `<tr>`) or through one — adoptMarkup() never adds
|
||||
* `.tr` to that inner row, so a class-only selector has to reach the `.th` by descent, not by
|
||||
* child. Without `.thead` the header kept its square top corners under any real `<thead>`: the
|
||||
* DHCP and Wi-Fi dashboard tables measured a first/last `.th` radius of 0 against the frame's
|
||||
* 12px. */
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
table.fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-top-left-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:last-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:last-child,
|
||||
table.fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-top-right-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] .tr:last-child .td:first-child,
|
||||
#view .table[id] .tr:last-child .td:first-child,
|
||||
table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] .tr:last-child .td:last-child,
|
||||
#view .table[id] .tr:last-child .td:last-child,
|
||||
table.fs-dt .tr:last-child .td:last-child { border-bottom-right-radius: var(--fs-radius-lg); }
|
||||
/* Empty table: L.ui.Table adds a single-cell .tr.placeholder. Spanning it across the table is
|
||||
* base's job (base/40-tables.css); what is left here is the FRAMED table's own flavour — the
|
||||
* plate reads as prose, left-aligned like the rows it stands in for, and carries the frame's
|
||||
* bottom corners so the rounding does not cut across it. */
|
||||
.cbi-section .table[id] .tr.placeholder .td,
|
||||
#view .table[id] .tr.placeholder .td,
|
||||
table.fs-dt .tr.placeholder .td {
|
||||
/* the framed flavour pads its cells `--fs-space-2-5` a side (above), and that shorthand is in
|
||||
* THIS layer, so it overrides base's placeholder padding and made the plate 4px taller than
|
||||
* the row reserved for it — measured on Wireless as 67px inside 63px, printing below the
|
||||
* frame's bottom border. Put base's number back for this one cell. */
|
||||
padding-block: var(--fs-space-2);
|
||||
text-align: start;
|
||||
color: var(--fs-dim); font-family: var(--fs-font-sans);
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
/* --- KEY/VALUE tables: System, Memory ---
|
||||
* "no id" is not enough: form.TableSection/GridSection also render `.table.cbi-section-table`
|
||||
* with no id, where a 40%/nowrap first column starves the data columns. So exclude it and .fs-dt
|
||||
* explicitly.
|
||||
*
|
||||
* Also exclude the realtime graph legend: `[style*="table-layout"]` is the one no-id `.table`
|
||||
* LuCI hands an inline `table-layout:fixed`, where a 40% first column would only starve the
|
||||
* other five. A DDNS-style multi-column status table is not excluded and keeps the key/value
|
||||
* first column. */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child {
|
||||
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) {
|
||||
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
|
||||
}
|
||||
/* Below ~560px of container width the 40%/nowrap column above starves the value: measured on
|
||||
* System -> System at 390px content width, Firmware Version wrapped onto 5 lines and Kernel
|
||||
* Version/Architecture onto 2 each, most of the 130px label column sitting empty. Stack it like
|
||||
* the other card idioms in this file — label as an eyebrow above its value — instead of holding
|
||||
* a fixed-fraction column at any width. A width win, not a height one: measured on the gallery's
|
||||
* key/value specimen (a 340px .cbi-map, docs/gallery.html), stacking cost the card +44.9% height
|
||||
* (202.6 -> 293.5px) for the value column widening x1.67 (183.6 -> 306px) and Firmware Version's
|
||||
* line count dropping from two to one. (The five lines above are the live 390px page, where the
|
||||
* value is longer than the specimen carries — the shape is the same, the count is not.)
|
||||
*
|
||||
* 560 is the second threshold named on fs-content; the config table's card (theme/65-dropdown.css)
|
||||
* is the first, at 960, for a row full of widgets that bake in a layout width. This one holds two
|
||||
* plain text cells, so it can stay two-column down to a narrower width before the label needs the
|
||||
* row to itself.
|
||||
*
|
||||
* `:first-child:first-child` / `:last-child:last-child` double the tail pseudo-class to outrank
|
||||
* the two rules above at identical specificity otherwise — the `.hidden.hidden` idiom
|
||||
* (theme/60-inputs.css) — rather than win on source order, which the next edit to either rule
|
||||
* could invert.
|
||||
*
|
||||
* The value's right-flush alignment above is a two-column device: with the label gone from the
|
||||
* row, right-flushing floats the value away from its own caption. Left-aligned instead, so the
|
||||
* card reads top-to-bottom as caption then value, matching every other stacked cell in this file. */
|
||||
@container fs-content (max-width: 560px) {
|
||||
/* Stacked, the row's own `.td, .th` rule above (line 97) still spends a FULL cell padding
|
||||
* on each half of the pair — 10px top + 10px bottom on the caption, the same on the value,
|
||||
* plus a hairline under EACH — 40px of padding-block and two dividers for one caption/value
|
||||
* pair, so the gap inside a pair (caption to its own value) equalled the gap between pairs
|
||||
* and the pair read as two rows, not one. Same fix as the meter row's
|
||||
* (pages/20-overview.css): move the rhythm off the CELLS onto the ROW. `--fs-space-1 0
|
||||
* --fs-space-1-5` — 4px + 6px, a quarter of the 40px it replaces — is the pair's own outer
|
||||
* padding, and the hairline moves with it so a stack outside the Overview page (which zeroes
|
||||
* it separately) draws ONE divider per pair instead of one under the caption as well. With
|
||||
* the cells at `padding: 0`, caption and value sit flush against each other — nothing between
|
||||
* them but line-height — and the record boundary is the one line the reader can see. */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr {
|
||||
display: block;
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
border: 0;
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child:first-child {
|
||||
display: block; width: auto; white-space: normal;
|
||||
padding: 0; border: 0;
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
|
||||
display: block; width: auto; text-align: start;
|
||||
padding: 0; border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the realtime graph legend (status/realtime/*) ---
|
||||
* The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and six cells
|
||||
* per row. `fixed` splits the row into six equal columns, so on a phone each is ~1/6 of the
|
||||
* content width and the labels overflow onto the value beside them. Stock luci-theme-bootstrap
|
||||
* folds the six cells into label/value PAIRS on a narrow screen; do the same. `display:flex` on
|
||||
* the row takes the cells out of the fixed table layout, so the inline `table-layout` no longer
|
||||
* bites. Keyed on that inline attribute so only this legend is touched. */
|
||||
@media (max-width: 767px) {
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr {
|
||||
display: flex; flex-wrap: wrap;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .td {
|
||||
flex: 1 1 50%; min-width: 0; width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* remove double card when a section nests a table-only include */
|
||||
.cbi-section .cbi-section { border: 0; box-shadow: none; padding: 0; margin: 0; background: transparent; }
|
||||
|
||||
/* generic links/labels inside cards */
|
||||
.cbi-section .ifacebox, .cbi-section .zonebadge { border-radius: var(--fs-radius); }
|
||||
/* moved from base with the generic field box (same layer arithmetic as the cell rules above) */
|
||||
.zonebadge input { width: 6em; }
|
||||
/* The device/MAC badge inside the Network status box kept base's flat 4px, reading as square
|
||||
* against the card's --fs-radius corners (issue #5.4). Scoped to the status box: the small
|
||||
* firewall zone .ifacebadge chips want to stay tight. Follows the Rounding axis via the token. */
|
||||
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
|
||||
/* …and the head has to round WITH the box: it sits inside the box's 1px border and carries the
|
||||
* zone colour as an inline background, so its own corners are what you see, and a 3px head
|
||||
* inside a 10px box cuts straight through the rounding with the zone colour spilling past the
|
||||
* corner (issue #7). Minus the 1px border it is inset by; max() because the Rounding axis goes
|
||||
* down to 0, where a bare subtraction is negative — an invalid declaration, silently dropped. */
|
||||
.cbi-section .ifacebox > .ifacebox-head {
|
||||
--fs-head-radius: max(0px, calc(var(--fs-radius) - 1px));
|
||||
border-radius: var(--fs-head-radius) var(--fs-head-radius) 0 0;
|
||||
}
|
||||
|
||||
/* Inactive tab panes. Base's [data-tab-title] rule sets height:0/overflow:hidden, collapsing only
|
||||
* the CONTENT box — padding, border and margin still draw empty bars, and a pane left in flow
|
||||
* inflates scrollHeight (Interfaces scrolled ~585px past the footer, Network -> DNS another
|
||||
* ~750px). Zeroing the box does both jobs, and hiding the pane with `display: none` on top of
|
||||
* that buys nothing (Interfaces scrollHeight 1039 either way).
|
||||
*
|
||||
* It is `visibility`, not `display: none`, because a display:none pane has no width and a view
|
||||
* is entitled to measure one: stock channel_analysis.js builds its channel axis from
|
||||
* `graph.offsetWidth` once, in a rAF, so a pane that starts inactive reads 0 and the 5GHz graph
|
||||
* stays a collapsed blob for the life of the page. `visibility: hidden` keeps the pane laid out
|
||||
* at full width while still taking it out of the a11y tree and the tab order, and
|
||||
* height:0 + overflow:hidden keep it out of the scroll.
|
||||
*
|
||||
* `position: relative` is what makes that overflow hold: an absolutely positioned descendant is
|
||||
* clipped only by an overflow ancestor in its own containing-block chain, and a collapsed pane of
|
||||
* ordinary markup has none — the theme supplies them itself, the toggle switch parking its real
|
||||
* checkbox at `position: absolute` (theme/60-inputs.css). Measured: DHCP and DNS scrolled 1841px
|
||||
* against 1110px of content on 24.10 and 1925 vs 1439 on 25.12, i.e. ~500-700px of empty page
|
||||
* under the footer (issue #10).
|
||||
*
|
||||
* Hence any [data-tab-title], not just .cbi-section ones. */
|
||||
[data-tab-title]:not([data-tab-active="true"]) {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* 6%, not 12%. The list inside is bold --fs-danger on a tint OF --fs-danger, so the tint
|
||||
* eats its own contrast: 12% measured 4.4:1 (axe-core). At 6% every palette clears 4.5
|
||||
* (4.87 / 4.95 / 5.28 / 6.09) and the box still reads red. */
|
||||
.cbi-section-error { border-color: var(--fs-danger); background: color-mix(in srgb, var(--fs-danger) 6%, var(--fs-panel)); color: var(--fs-text); }
|
||||
.cbi-section-error ul li { color: var(--fs-danger); }
|
||||
|
||||
/* denser data tables (dhcp static leases, section-tables) */
|
||||
.cbi-section-table .th, .cbi-section-table .td,
|
||||
/* The `::before` is the GridSection name CELL on 24.10, where form.js renders none and base
|
||||
* generates the column from `data-title` (base/95-luci.css). It is not a `.th`/`.td`, so every
|
||||
* cell rule below has to name it too, or the column it draws lines up with nothing. Listing
|
||||
* `.tr::before` unqualified is safe: base names exactly the rows that get one, and a
|
||||
* pseudo-element whose content is `none` generates no box. */
|
||||
.table.cbi-section-table .th, .table.cbi-section-table .td,
|
||||
.table.cbi-section-table .tr::before { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; }
|
||||
/* The zero padding-left above is right only INSIDE a card, where the .cbi-section supplies that
|
||||
* gutter itself. Outside one it is wrong — Status -> Firewall renders `.table.cbi-section-table`
|
||||
* in a bare <div>, so the table draws its own frame and the header text ends up glued to that
|
||||
* frame's rounded corner (issue #7). A table carrying its own frame pads its own cells. */
|
||||
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .th,
|
||||
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .td { padding: var(--fs-space-2-5) var(--fs-space-3); }
|
||||
.cbi-section-table .tr.cbi-section-table-titles .th,
|
||||
.cbi-section-table .tr.table-titles .th { font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); line-height: 1.25; }
|
||||
|
||||
/* Standalone data tables (Startup, Processes, Routes) -> rounded card. Inside a CBI section
|
||||
* the section already IS the card, so the frame is stripped there (above): no double border. */
|
||||
.table {
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--fs-panel);
|
||||
}
|
||||
/* Separators go on the CELLS, not on the rows, and that is not a preference: the frame above needs
|
||||
* `border-collapse: separate` (a collapsed table silently drops the radius), and in the separated
|
||||
* model a row's border is not painted at all — on the .tr these rules paint nothing, and what
|
||||
* draws the lines is base's per-cell `border-top`, which also runs along the table's top edge
|
||||
* across the frame's rounded corners (issue #7). base no longer sets it, and the lines are
|
||||
* declared here, where they paint. Remove one and the other is not a fallback.
|
||||
*
|
||||
* `:not(:last-child)` lives IN the body-row selector: a trailing bottom border draws a straight
|
||||
* line the full width and pokes past the frame's rounded bottom corners. */
|
||||
.table .tr.table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); }
|
||||
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td,
|
||||
.table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); }
|
||||
/* Because the separator above is a per-CELL border, an app that nudges one cell with
|
||||
* `position: relative; top: …` (luci-mod-dashboard's `.devices-info .td:nth-child(3)`) drags
|
||||
* that cell's border-bottom out of line with its row neighbours — a step, not a slanted line,
|
||||
* since each cell paints its own hairline. Bootstrap never shows this because its rows are
|
||||
* `border-collapse: collapse`, with no per-cell border to displace. The app's rule lands in the
|
||||
* same fenced `theme` layer (fs-sheets.js), so an ID root outranks its 7 classes on specificity
|
||||
* alone and needs no `!important`; `top` AND `bottom`, not just `top: 0`, so this also cancels
|
||||
* a `bottom` offset and leaves `position: relative` itself untouched for any
|
||||
* absolutely-positioned descendant. `top`/`bottom`, not the `inset-block` shorthand: the sheet
|
||||
* has never used a logical inset property before and `css-floor` has no support decision for
|
||||
* one, where the physical pair is already on the floor. Measured: offset .td top -3px -> 0, its
|
||||
* border-bottom back level with its neighbours'. */
|
||||
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
|
||||
/* descendant combinator (not >) so a real <table>'s browser-inserted <tbody> is covered
|
||||
* too — one rule themes every data table, div- or table-based. */
|
||||
.table .tr.table-titles, .table .tr.cbi-section-table-titles {
|
||||
background: transparent; /* drop the base panel2 header bar — match the dashboard's faint-label + underline look */
|
||||
}
|
||||
.table .tr.table-titles .th, .table .tr.cbi-section-table-titles .th,
|
||||
.table .tr.cbi-section-table-titles::before {
|
||||
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking);
|
||||
background: transparent;
|
||||
}
|
||||
/* The row's NAME, which must read the same on both releases: 25.12 renders a real
|
||||
* `.td.cbi-section-table-titles`, 24.10 base's `::before`. Both are a LABEL, not a value, so
|
||||
* both take the UI face — the surrounding value cells are mono, and base makes this one bold, so
|
||||
* with no 700 mono face shipped every rule name is drawn in synthetic bold mono, smearing the
|
||||
* grid on the one string in the row that is a name. Specificity (0,3,0), not source order, is
|
||||
* what beats the (0,1,0) mono rule. */
|
||||
.table.cbi-section-table .td.cbi-section-table-titles,
|
||||
.table.cbi-section-table .tr.cbi-section-table-row::before {
|
||||
font-family: var(--fs-font-sans); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
}
|
||||
.table .tr:not(.table-titles):not(.cbi-section-table-titles) {
|
||||
transition: background var(--fs-dur);
|
||||
}
|
||||
/* Row hover ONLY on real data/config tables. A plain layout `.table` — Diagnostics is one
|
||||
* <tr> of input+button spanning the whole card, with no header row, so fs-select never tags
|
||||
* it .fs-dt — lit the ENTIRE block on hover (issue #5.3), and a data table's placeholder row
|
||||
* ("no active leases") lit up too (issue #5.2, plus :not(.placeholder) above). Data tables
|
||||
* hover per-cell above; config tables (.cbi-section-table, never .fs-dt) hover data rows here. */
|
||||
/* :not(.placeholder) — a config table's "This section contains no values yet" row carries
|
||||
* .cbi-section-table-row too, so it lit up under the pointer exactly as the data tables' did
|
||||
* before the rule above excluded theirs (issue #15: reported on UPnP, but every empty
|
||||
* GridSection had it — firewall's NAT rules, IP sets, static routes). A hover cue promises a
|
||||
* row you can act on; there is no row. */
|
||||
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
|
||||
/* keep section/modal tables flush (no forced hover-card look) */
|
||||
.cbi-section .table > .tr, .cbi-modal .table > .tr { border-radius: 0; }
|
||||
|
||||
/* .fs-dt is set by fs-select.js on a #view .table that has a header row and is NOT a
|
||||
* .cbi-section-table — replacing a live `:has(.tr.table-titles):not(.cbi-section-table)`
|
||||
* the style engine re-evaluated on every mutation of these polled tables (processes,
|
||||
* routes, leases). */
|
||||
|
||||
/* ---- the card stack: one copy, every data table, no @container threshold ----
|
||||
*
|
||||
* Three @container thresholds meant two copies of these declarations, CSS being unable to share
|
||||
* a block across thresholds — 55 duplicated declarations, free to drift. All three really asked
|
||||
* "does it OVERFLOW?", which the browser can compute: fs-select.js measures it and sets
|
||||
* .fs-stacked, so every table finds its own width, a third-party app's included. Only the
|
||||
* judgement a measurement cannot make survives, in fs-select.js: "too cramped to be a table".
|
||||
*
|
||||
* A config table (.cbi-section-table) is deliberately not here and keeps its @container
|
||||
* (theme/65-dropdown.css): its rows hold widgets that bake in a width from the layout they were
|
||||
* rendered in, so un-collapsing one to read it CHANGES what is read — the firewall zone table
|
||||
* then claimed 1747px where it needs 1190px. Do not "finish the job" by measuring it.
|
||||
*
|
||||
* Shape = stock LuCI's phone layout: label ABOVE its value, cell = half the row. Label:value on
|
||||
* one line with the value right-flushed costs a leases row 7 full-width lines and gives the
|
||||
* value ~40% of the width; half-width pairs fold it into 4. Values stay left-aligned.
|
||||
*
|
||||
* ONE CONTRACT — do not split it again. The stack is MEASURED and fires at ANY width, while
|
||||
* `display: block` and the `.hide-xs`/`.hide-sm` drops once lived only inside
|
||||
* `@media (max-width: 767px)` (theme/90-responsive.css). The window between the two is real: the
|
||||
* sidebar layout's content column is (viewport - 224 - 56), so from ~768 to 860px it is already
|
||||
* below CRAMPED while that query is off. Measured at 790/820/850px, status_leases stacked with
|
||||
* `display: table` still in force and wifi_assoclist_table rendered all five of its .hide-xs
|
||||
* cells; at 760px both were correct. So they key off the CLASS. */
|
||||
/* ---- a table nobody has answered for yet takes no room ----
|
||||
*
|
||||
* A poll tick REPLACES these tables, and a fresh one carries no marks: for the moment between
|
||||
* landing and being stamped it is laid out as a full-width table, several screens taller than
|
||||
* the card stack it is about to be. The engine re-anchors on that intermediate and throws the
|
||||
* reader 612px out and back, twice per tick. fs-select.js answers in a MutationObserver
|
||||
* microtask, i.e. before paint — but only if nothing forces layout in between, and an app that
|
||||
* reads a width right after rendering does exactly that.
|
||||
*
|
||||
* So the table waits outside the layout until it has an answer. `display: none`, not
|
||||
* `visibility: hidden`, because a hidden box still occupies its height.
|
||||
*
|
||||
* Guarded on the fitters being alive: `:root[data-fs-fit]` is written by fs-fit.js and by
|
||||
* nothing else, so a document where that file never ran shows every table exactly as before this
|
||||
* rule existed. A stylesheet that can hide content on its own is one that can hide it
|
||||
* forever. */
|
||||
:root[data-fs-fit] #view .table.fs-dt:not(.fs-fitted),
|
||||
:root[data-fs-fit] #modal_overlay .table.fs-dt:not(.fs-fitted) { display: none; }
|
||||
|
||||
.table.fs-stacked { display: block; width: 100%; }
|
||||
/* `.fs-drop-xs` is the same drop one rung earlier: fs-select.js writes it on a table that has
|
||||
* overflowed, before it decides to card, because the view author has already said which columns
|
||||
* are expendable and a table with two fewer columns often fits. Same declaration, so the two
|
||||
* selectors share the block, and the class is what makes the drop a MEASURED decision rather
|
||||
* than a viewport guess (theme/90-responsive.css keeps that copy for tables that never card). */
|
||||
.table.fs-stacked .td.hide-sm, .table.fs-stacked .th.hide-sm,
|
||||
.table.fs-stacked .td.hide-xs, .table.fs-stacked .th.hide-xs,
|
||||
.table.fs-drop-xs .td.hide-sm, .table.fs-drop-xs .th.hide-sm,
|
||||
.table.fs-drop-xs .td.hide-xs, .table.fs-drop-xs .th.hide-xs { display: none; }
|
||||
|
||||
.table.fs-stacked .tr.table-titles,
|
||||
.table.fs-stacked .thead,
|
||||
.table.fs-stacked .tr.cbi-section-table-titles,
|
||||
.table.fs-stacked .tr.cbi-section-table-descr { display: none; }
|
||||
/* align-items: stretch (the default), NOT flex-start: the cells carry a per-cell
|
||||
* border-bottom, so when one cell of a pair wraps (a 2-line hostname beside a 1-line MAC)
|
||||
* a flex-start neighbour stays short and its separator paints 52px higher — a broken line
|
||||
* mid-card (measured). Stretched, both cells take the flex line's height and the
|
||||
* separators meet; content still sits at the top (the cells are blocks). */
|
||||
/* A `<tfoot>` with no `.tr` inside it IS the row — see the footer block below for which view
|
||||
* writes which shape.
|
||||
*
|
||||
* TWO rules, not one list: a selector list is not forgiving, so a browser without `:has()`
|
||||
* (Firefox before 121) throws away the WHOLE list, and `.table.fs-stacked .tr` — every card on
|
||||
* every stacked table — loses `display: flex` with it. Split, the `:has()` half degrades alone.
|
||||
* The duplicated body is under `css-dup`'s three-declaration floor, so it will not be flagged;
|
||||
* the two must change together. */
|
||||
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
|
||||
.table.fs-stacked tfoot:not(:has(> .tr)),
|
||||
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
|
||||
.table.fs-stacked .td {
|
||||
display: block; min-width: 0; width: auto;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* The column label, lifted above its value (LuCI writes it per cell as data-title).
|
||||
*
|
||||
* Pinned: the config table (theme/65-dropdown.css) needs these exact declarations under its
|
||||
* @container guard, and CSS cannot share a block between a class and a container query — the two
|
||||
* guards do not OR. tools/mirror.mjs keeps the copies byte-identical; edit one and the build
|
||||
* fails until you edit the other, a duplicate detector going quiet once two copies differ. */
|
||||
.table.fs-stacked .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* A footer cell is the fourth member here, and it has to carry `[data-title]` in its own selector
|
||||
* to win: `.table.fs-stacked tfoot .td::before` is (0,3,1) against the caption rule's (0,4,0) and
|
||||
* loses wherever it is written. Measured on the wifi footer with the titles stamped: `content`
|
||||
* computed to "SSID" over `Total:`. With the attribute it is (0,4,1) and (0,5,0). */
|
||||
.table.fs-stacked .td.cbi-section-actions::before,
|
||||
.table.fs-stacked .td:not([data-title])::before,
|
||||
.table.fs-stacked .td[data-title=""]::before,
|
||||
.table.fs-stacked tfoot .td[data-title]::before,
|
||||
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
|
||||
/* The row's buttons are not a label/value pair — full width below the pairs.
|
||||
* PINNED with the config table's copy — see above. */
|
||||
.table.fs-stacked .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* LuCI groups a row's buttons in ONE inner <div> (startup.js wraps all five), so the td's own
|
||||
* flex-wrap above never fires — the div is a single flex item, and .fs-main's overflow-x:clip
|
||||
* CUT the tail button (Startup at 390px, measured: the div needs 350px in a 324px cell and
|
||||
* "Stop" vanished). Same trap the config table hit on the interfaces row; PINNED to its copy
|
||||
* in theme/65-dropdown.css. */
|
||||
.table.fs-stacked .td.cbi-section-actions > * {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* Nothing in a half-width cell may force the row wider than the viewport: .fs-main is
|
||||
* overflow-x:clip, so an over-wide child is CUT, not scrolled to. */
|
||||
.table.fs-stacked .td > *,
|
||||
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* ---- the footer, in both shapes upstream writes it ----
|
||||
*
|
||||
* A `<tfoot>` is still a table-footer-group inside a `display: block` table, so in a card it drew
|
||||
* as a box beside the cards: measured at 390px on the station list, 186px wide against 354.
|
||||
* `20_lan.js` builds `tfoot > tr > td` and needs only the footer out of the way, the `.tr` above
|
||||
* already being a flex row; `30_wifi.js` appends the `<td>`s to the `<tfoot>` through E(), where
|
||||
* no parser runs and no row is implied, so there the footer has to become the row — that arm is
|
||||
* up with the `.tr` rule. `:has(> .tr)` tells them apart.
|
||||
*
|
||||
* An empty footer cell goes entirely: a card is a list of facts and an empty cell is not one. The
|
||||
* captions are suppressed up in the `content: none` group, for the specificity reason stated
|
||||
* there. */
|
||||
.table.fs-stacked tfoot:has(> .tr),
|
||||
.table.fs-stacked .tfoot:has(> .tr) { display: block; }
|
||||
.table.fs-stacked tfoot .td:empty,
|
||||
.table.fs-stacked .tfoot .td:empty { display: none; }
|
||||
|
||||
/* --- the DATA table's flavour (L.ui.Table, tagged .fs-dt) ---
|
||||
* Clip rows to the frame's corners: zebra paints its background on the .tr and the frame is
|
||||
* overflow:visible, so a striped row's square background pokes past the corners. Safe once
|
||||
* stacked — a flex-wrap row fits the width, so there is no horizontal column left to clip, which
|
||||
* is the only thing overflow:visible protected. `#view .table.fs-dt.fs-stacked` (1,3,0) outranks
|
||||
* the frame's `#view .table[id]` (1,2,0) on specificity, not on order.
|
||||
*
|
||||
* `#modal_overlay` as well, because the FRAME already reaches a dialog and this clip did not: a
|
||||
* data table inside a dialog's `.cbi-section` takes the frame from `.cbi-section .table[id]`
|
||||
* above and has the same corners with the same zebra painted square across them. */
|
||||
#view .table.fs-dt.fs-stacked,
|
||||
#modal_overlay .table.fs-dt.fs-stacked { overflow: hidden; }
|
||||
/* `:not(.placeholder)` — the plate is out of flow (base/40-tables.css) and its row exists only to
|
||||
* reserve the height it needs. Padding is subtracted from that reservation (border-box), so the
|
||||
* card's row inset left 41px of the 63px the plate is: measured on Wireless as 10px of text
|
||||
* hanging below the frame once the table stacked. A row with no rows in it has nothing to inset. */
|
||||
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
|
||||
/* ---- the pair width, and the adversary specificity cannot reach ----
|
||||
*
|
||||
* Half the row is what makes the card a card. It was `.fs-stacked .td` at (0,2,0), deliberately
|
||||
* low so the unguarded `.table .td.col-N` below, at (0,3,0), could beat it on specificity rather
|
||||
* than source order — which holds against every rule in a LAYER and none outside one.
|
||||
* luci-mod-dashboard ships `@media (max-width: 640px) { .Dashboard … .tr .td { flex: 2 2 10% } }`
|
||||
* unlayered, and that beats every layered rule at any specificity: measured at 390px, four cells
|
||||
* of a station row at 81px on one line, `flex-basis` computing to 10% — the pairs never formed.
|
||||
*
|
||||
* Hence the flag, the sanctioned kind. Spent narrowly, and by SELECTOR because a flag would
|
||||
* outrank these two families as well: `.cbi-section-actions` is a full-width line of buttons,
|
||||
* `[class*="col-"]` is the view author's own weight. The third `:not()` is not one of those — it
|
||||
* is how this rule loses to the meter below, both being flagged: without it (0,5,0) beats (0,4,0)
|
||||
* and the meter silently keeps half a line. */
|
||||
.table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) {
|
||||
flex: 1 1 50% !important;
|
||||
}
|
||||
/* A meter takes the whole line: `.cbi-progressbar::after` prints the bar's title above its right
|
||||
* edge (25-progressbar.css), which in a card is where the cell's own caption sits — measured at
|
||||
* 390px, `SIGNAL STRENGTH` and `RSSI: 65% (-57dBm)` drawn over each other. Beside the bar instead
|
||||
* would be paid out of the bar, too short at half a row to read as one. */
|
||||
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
|
||||
|
||||
/* ---- column weights: one unguarded block, unguarded on purpose ----
|
||||
* A weight (1-10) is a share of a CARDED row — the JS says which cell is a narrow badge and
|
||||
* which the wide status text; without it every cell falls back to the flat 50% above.
|
||||
*
|
||||
* Do not copy them under a guard. `flex` applies only to a FLEX ITEM, and a `.td` becomes one
|
||||
* exactly when its `.tr` turns into a flex container — by either mechanism (`.fs-stacked`,
|
||||
* measured at any width, or the config table's @container, which can card on a 1200px desktop).
|
||||
* Anywhere else they are inert, so one copy serves all three tiers. Basis N*30px, as in stock,
|
||||
* so the weights compose the same way. */
|
||||
.table .td.col-1, .table .th.col-1 { flex: 1 1 30px; }
|
||||
.table .td.col-2, .table .th.col-2 { flex: 2 2 60px; }
|
||||
.table .td.col-3, .table .th.col-3 { flex: 3 3 90px; }
|
||||
.table .td.col-4, .table .th.col-4 { flex: 4 4 120px; }
|
||||
.table .td.col-5, .table .th.col-5 { flex: 5 5 150px; }
|
||||
.table .td.col-6, .table .th.col-6 { flex: 6 6 180px; }
|
||||
.table .td.col-7, .table .th.col-7 { flex: 7 7 210px; }
|
||||
.table .td.col-8, .table .th.col-8 { flex: 8 8 240px; }
|
||||
.table .td.col-9, .table .th.col-9 { flex: 9 9 270px; }
|
||||
.table .td.col-10, .table .th.col-10 { flex: 10 10 300px; }
|
||||
|
||||
/* general table cell padding — compact but readable (Startup/Processes/etc) */
|
||||
.table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); }
|
||||
|
||||
/* ---- the last resort: a foreign <table> the card stack cannot claim ----
|
||||
*
|
||||
* fs-select.js tags any `#view table` that HAS a header row as `.table.fs-dt`, so it is measured
|
||||
* and cards like every other data table. What is left here is the table with no header at all: a
|
||||
* log, a statistics matrix, a layout table. Those must not be carded even if they overflow — a
|
||||
* card prints `data-title` above each value, and a matrix has no titles to print, so stacking one
|
||||
* produces a column of numbers with nothing saying what they are. Comparison is the point of
|
||||
* that shape; it scrolls instead.
|
||||
*
|
||||
* `display: block` plus `overflow-x: auto` on the table ITSELF, not on a wrapper: the theme
|
||||
* cannot add markup around someone else's element, and the block box is what gives it a scroll
|
||||
* container. The rows still lay out as a table, a `display: block` <table> wrapping its children
|
||||
* in an anonymous table box.
|
||||
*
|
||||
* Unguarded by a media query, because the viewport was never the fact that mattered: a narrow
|
||||
* panel or a split window overflows at any width, and a phone report is only where it was
|
||||
* noticed first.
|
||||
*
|
||||
* A dialog is the worst place to leave it out, hence the `#modal_overlay` half of the selector:
|
||||
* the overlay is the scroll container (base/60-modal.css) and the dialog is centred in it with
|
||||
* `margin: auto`, so an over-wide child scrolls the dialog's own edge off the screen instead of
|
||||
* scrolling itself. Measured at 390px with a four-column `nowrap` log table: in #view it scrolls
|
||||
* inside itself (358px shown of 636px, page scroll 0); in a dialog it rendered 636px wide in
|
||||
* 317px of room and gave the overlay 282px of horizontal scroll. */
|
||||
/* `.fs-xscroll` is the same outcome reached the other way: fs-select.js writes it on a `.table`
|
||||
* with no header row that has been measured to overflow — the shape a bare `<table>` selector
|
||||
* cannot reach, a third-party app being free to render its log as `<div class="table">`. A class
|
||||
* rather than a selector, and measured rather than declared, for a spec-level reason:
|
||||
* `overflow-x: auto` forces `overflow-y` to compute to `auto` too (css-overflow-3 §3.1), so a
|
||||
* table given this becomes a clip for every popup inside it, and ui.js then walks up from an open
|
||||
* dropdown, finds the TABLE as the scroll parent and sizes the list against it. The fitter
|
||||
* refuses the class to any table holding a widget, so this rule needs no `:has()` ladder. */
|
||||
#view table:not(.table):not(.cbi-section-table):not(.fs-dt),
|
||||
#modal_overlay table:not(.table):not(.cbi-section-table):not(.fs-dt),
|
||||
#view .table.fs-xscroll, #modal_overlay .table.fs-xscroll {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
/* the scroll gesture stops at this table instead of dragging the page sideways */
|
||||
overscroll-behavior-x: contain;
|
||||
/* the scrollbar IS the affordance here. A gradient "there is more to the right" shadow is the
|
||||
* usual trick and does not work on these tables: it has to paint on the scroll container,
|
||||
* behind rows that carry their own zebra fill, so it shows through every second row only. */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ---- a table of controls stacks: it is a form laid out as a table ----
|
||||
*
|
||||
* Written by fs-select.js on a header-less table that overflows and holds a control. Scrolling
|
||||
* one is not an option — the popup inside would be clipped by the scroll container it just
|
||||
* became — and doing nothing leaves it cut: at 320px of room the row needed 338 and the button
|
||||
* was past `.fs-main`'s clip with no way to reach it.
|
||||
*
|
||||
* `display: block` on all three levels, the one place this theme wants it: the rows are a form,
|
||||
* so there is nothing to compare across them and nothing to keep in columns. Each control takes
|
||||
* the width of its line and the table cannot exceed its parent by construction. Nothing here
|
||||
* creates a scroll container, so an open dropdown still escapes. */
|
||||
.table.fs-rowstack,
|
||||
.table.fs-rowstack .tr,
|
||||
.table.fs-rowstack .td,
|
||||
.table.fs-rowstack .th { display: block; width: auto; }
|
||||
/* the cells were table-cells a moment ago and carried its side padding; as blocks they need the
|
||||
* vertical rhythm instead, or the controls sit flush against each other */
|
||||
.table.fs-rowstack .td { padding-inline: 0; padding-block: var(--fs-space-1); }
|
||||
|
||||
/* ---- what makes a scrolled table readable: the row keeps its name ----
|
||||
*
|
||||
* Scrolling a table sideways takes the identity column away first, leaving a screenful of values
|
||||
* belonging to nothing. Pinning the first cell of every row is the one thing a card view gives
|
||||
* for free and a scrolled table does not.
|
||||
*
|
||||
* The cells need their own background (a row's does not travel with a sticky cell in the
|
||||
* separated model, as for the header above) and a hairline on the inline end, so the pinned
|
||||
* column reads as an edge rather than as text sliding over text. `z-index: 2` keeps it over the
|
||||
* sticky header's `1` where the two cross, at the corner cell. */
|
||||
#view .table.fs-xscroll .tr > :first-child,
|
||||
#modal_overlay .table.fs-xscroll .tr > :first-child,
|
||||
#view .table.fs-xscroll tr > :first-child,
|
||||
#modal_overlay .table.fs-xscroll tr > :first-child {
|
||||
position: sticky; left: 0; z-index: 2;
|
||||
background: var(--fs-panel);
|
||||
border-inline-end: var(--fs-hairline);
|
||||
}
|
||||
/* a keyboard user reaches the scroll box by tabbing to it (fs-select.js gives it tabindex and a
|
||||
* name); it has to show that it has focus — outline, not box-shadow, so forced-colors keeps it */
|
||||
#view .table.fs-xscroll:focus-visible,
|
||||
#modal_overlay .table.fs-xscroll:focus-visible {
|
||||
outline: 2px solid var(--fs-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
@layer theme {
|
||||
/* Alert panel: a left accent stripe on a panel card; the type variants recolour the frame.
|
||||
* `background: var(--fs-panel)` used to be repeated per variant to out-specify base's
|
||||
* gradients — a layer does that for free (theme beats base whatever the specificity), so
|
||||
* one rule does it. */
|
||||
.alert-message {
|
||||
padding: var(--fs-space-3-5) var(--fs-space-4);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: var(--fs-hairline);
|
||||
/* logical: the stripe marks the READING start of the alert, so it swaps sides with the
|
||||
* document (LuCI ships ar/fa/he catalogues) — physical, it sat on the far edge in RTL. */
|
||||
border-inline-start: 3px solid var(--fs-accent);
|
||||
/* The surface is OPAQUE and the status tint rides on top of it. A variant that replaces the
|
||||
* background with `var(--fs-*-fill)` paints an 18% wash with nothing behind it — invisible on
|
||||
* a flat canvas, and transparent to the wallpaper under Appearance -> Wallpaper, where the
|
||||
* tile reads straight through every status alert.
|
||||
*
|
||||
* So --fs-panel stays the background-COLOUR for every variant and the tint is a
|
||||
* background-IMAGE layer over it. The named `-fill` step is unchanged, so an untinted router
|
||||
* sees the same pixels, and the local `--fs-alert-tint` is what keeps this one rule instead
|
||||
* of six `background:` overrides that each have to re-state the panel. */
|
||||
background-color: var(--fs-panel);
|
||||
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
|
||||
color: var(--fs-text);
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
/* ---- status lives in the stripe and the surface; the ring is always neutral ----
|
||||
*
|
||||
* Split between `border-color` (all four sides, including the 3px stripe) and
|
||||
* `border-left-color` (the stripe alone), four of seven alerts wear a coloured ring and three a
|
||||
* grey one — a hierarchy nobody designed, with the difference between "not applied yet" and
|
||||
* "this is about to fail" hanging on one pixel of frame.
|
||||
*
|
||||
* The surface carries it instead, at the ladder's named `-fill` step, which
|
||||
* docs/design-system.md describes as the callout surface — exactly what an alert is. Not a fresh
|
||||
* `color-mix()`: an unnamed level is how four spellings of one tint appeared here before.
|
||||
*
|
||||
* `:not(.btn)` is load-bearing: `admin/system/flash` renders its confirm control as
|
||||
* `<label class="btn alert-message danger">`, a BUTTON wearing the alert classes, and without
|
||||
* the guard the tint out-ranks `.btn.danger` on source order: the "Force upgrade" control faded
|
||||
* from its danger fill (rgb(234,122,116)) to a pale wash. */
|
||||
.alert-message.warning:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-warn);
|
||||
--fs-alert-tint: var(--fs-warn-fill);
|
||||
}
|
||||
.alert-message.error:not(.btn), .alert-message.danger:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-danger);
|
||||
--fs-alert-tint: var(--fs-danger-fill);
|
||||
}
|
||||
.alert-message.success:not(.btn) {
|
||||
border-inline-start-color: var(--fs-good);
|
||||
--fs-alert-tint: var(--fs-good-fill);
|
||||
}
|
||||
/* `.info` is a STATUS and tints like the others; a bare .alert-message stays a flat panel, because
|
||||
* a note is not a status.
|
||||
*
|
||||
* `.notice` is grouped HERE rather than with .success, where it is pixel-identical — same fill,
|
||||
* same rail, no other channel — so one status word carries two meanings while the theme's own
|
||||
* `.label.notice` is accent and `.cbi-tooltip.notice` neutral. What settles the direction is
|
||||
* what LuCI means by it: ui.js emits `notice spinning` for the "applying configuration changes"
|
||||
* banner, i.e. IN PROGRESS rather than done, and stock luci-theme-bootstrap keeps green for
|
||||
* success alone. Informational, so accent. */
|
||||
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
|
||||
border-inline-start-color: var(--fs-accent);
|
||||
--fs-alert-tint: var(--fs-accent-fill);
|
||||
}
|
||||
|
||||
/* ---- a spinning alert is a glyph and a message, side by side ----
|
||||
*
|
||||
* The spinner is a `::before` on the element carrying `.spinning` (base/95-luci.css), and it is
|
||||
* `inline-block` so that it sits in the flow of the button labels and `em.spinning` texts that
|
||||
* usually host it. This host is neither: `ui.changes.displayStatus()` builds the apply dialog as
|
||||
* a BLOCK box whose content is a BLOCK element, so the glyph opens a line box of its own and the
|
||||
* message goes underneath it.
|
||||
*
|
||||
* Grid rather than flex, because the caller decides how many children there are and flex would
|
||||
* lay a second one out beside the first: `displayStatus()` passes one `<p>` today but builds two
|
||||
* elements for its non-spinning states, and `addNotification()` takes `spinning` from anyone.
|
||||
* Two columns — glyph, then everything else. Measured over the three shapes a caller can
|
||||
* produce: the content started 24.6 / 20.6 / 19.5px BELOW the glyph's line and now starts on it.
|
||||
*
|
||||
* The notification variant is deliberately untouched: ui.js writes `style="display:flex"` on the
|
||||
* element itself, and an inline style beats a layered rule, so those keep their own flex row. */
|
||||
.alert-message.spinning {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
/* centres the glyph against the FIRST row rather than pinning it to that row's top */
|
||||
align-items: center;
|
||||
/* `.alert-message`'s own `gap` above is the row gap between stacked children; the column gap
|
||||
* is the glyph's own spacing and is the smaller step, so it is stated rather than inherited
|
||||
* from that shorthand. */
|
||||
column-gap: var(--fs-space-2);
|
||||
}
|
||||
/* the glyph is a grid item now, so its inline margin would double the column gap; and it is
|
||||
* pinned to row 1 so that it stays beside the FIRST child however many follow */
|
||||
.alert-message.spinning::before { grid-row: 1; margin-inline-end: 0; }
|
||||
.alert-message.spinning > * { grid-column: 2; }
|
||||
|
||||
/* h2 is what the theme's own notices emit (the document had no heading above h4 at all); h4 is
|
||||
* here because a third-party alert may use it, and coverage only grows. h5/h6 joined for the
|
||||
* same reason — base styled them and the theme did not, so an alert heading looked like an
|
||||
* alert heading down to h4 and then fell back to base's. */
|
||||
.alert-message :is(h2, h3, h4, h5, h6) { margin: 0 0 var(--fs-space-1); font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
/* the alert's own content reset — moved from base with the heading ramp (45-misc.css): its
|
||||
* `line-height: inherit` beat the ramp's tight leading in-layer, and left behind it would
|
||||
* lose to the ramp by layer (measured: alert headings dropped 21 -> 16.8px) */
|
||||
.alert-message h4,
|
||||
.alert-message h5,
|
||||
.alert-message pre,
|
||||
.alert-message ul,
|
||||
.alert-message li,
|
||||
.alert-message p {
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
/* --fs-text, not --fs-dim: the paragraph IS the message. The line below already gives every
|
||||
* ul/ol/li/pre inside an alert full-strength ink, so a two-sentence alert had its first
|
||||
* sentence muted and its bullet list not — and on the tinted surface above, dim is the value
|
||||
* that measured 4.06:1 when the Reset button had it (55-buttons.css). */
|
||||
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
|
||||
/* …and the body elements an alert may carry. Absorbed from base/90-widgets.css, same values:
|
||||
* the theme had claimed only <p> out of that rule's six selectors. */
|
||||
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
|
||||
/* inner table (firmware Current/Available) — flatten so it does not read as a nested card
|
||||
* on the alert panel; subtle row dividers only */
|
||||
.alert-message .table { margin: var(--fs-space-2-5) 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; }
|
||||
/* --fs-text, not --fs-eyebrow-color: everywhere else an eyebrow sits on --fs-panel, but here the
|
||||
* surface is a status tint, and the muted ink does not survive it — measured on the firmware
|
||||
* table over the warning fill, hicontrast/light: 3.35-3.36:1 against AA's 4.5. The eyebrow still
|
||||
* reads as one from its tracking and size; only the ink changes. */
|
||||
.alert-message .table .th,
|
||||
/* The title-row selectors are not decoration: `.table .tr.table-titles .th` in 30-tables.css is
|
||||
* FOUR classes (0,4,0) and out-ranks the plain one above (0,3,0), so without these the header
|
||||
* kept the eyebrow ink no matter what this rule said — being later in the sheet buys nothing
|
||||
* against a heavier selector, which is the point of the "never win on source order" rule. */
|
||||
.alert-message .table .tr.table-titles .th,
|
||||
.alert-message .table .tr.cbi-section-table-titles .th { color: var(--fs-text); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); }
|
||||
/* the column gap is --fs-space-3 like every other cell in the theme; --fs-space-3-5 here was the
|
||||
* only 14px cell gap in the sheet, and nothing above defends it — the comment covers flattening
|
||||
* the frame, not the rhythm. (The row density stays --fs-space-2: a table inside an alert is a
|
||||
* dense readout, same as the config tables that state it deliberately.) */
|
||||
.alert-message .table .th, .alert-message .table .td { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
|
||||
.alert-message .table .td { color: var(--fs-text); }
|
||||
|
||||
/* The validation tooltip (ui.js pops it on a .cbi-tooltip-container). Its colour words were the
|
||||
* one status surface the theme never claimed, and nothing could see it: the gallery rendered a
|
||||
* plain `.cbi-tooltip` only, and an un-rendered widget shows no diff, which reads as "already
|
||||
* dead". The gallery renders all four now.
|
||||
*
|
||||
* The fill stays SOLID, unlike `.label` below: a tooltip floats over arbitrary content, so a
|
||||
* border carrying the status would have to read against whatever happens to be behind it. The
|
||||
* ink is the role's own `--fs-on-*`, which 03-palettes.css sizes per palette and per mode — a
|
||||
* global white failed AA on seven of the eight dark-palette fills. */
|
||||
/* One elevation for the widget. base lit it with a raw 2px halo in the hairline colour, which
|
||||
* measures 1.08-1.86:1 against the surfaces a tooltip lands on, while the theme overrode the
|
||||
* single instance somebody happened to look at. A tooltip floats over ARBITRARY content — the
|
||||
* same reason the colour variants below stay opaque — so it is the last surface that should be
|
||||
* separated from the page by a hairline. */
|
||||
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
|
||||
/* base reveals it on :hover alone. The container is `cursor: help` and carries no tabindex in
|
||||
* anything LuCI ships, so nothing can focus it today and this matches nothing — but the
|
||||
* focusability belongs to ui.js, and a third-party app that gives its own tooltip container a
|
||||
* tabindex gets a keyboard-reachable tooltip the day it does. */
|
||||
.cbi-tooltip-container:focus-within .cbi-tooltip:not(:empty) { inset-inline-start: auto; opacity: 1; }
|
||||
.cbi-tooltip.info { background: var(--fs-accent); border-color: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.cbi-tooltip.success { background: var(--fs-good); border-color: var(--fs-good); color: var(--fs-on-good); }
|
||||
.cbi-tooltip.error { background: var(--fs-danger); border-color: var(--fs-danger); color: var(--fs-on-danger); }
|
||||
.cbi-tooltip.notice { background: var(--fs-panel2); border-color: var(--fs-border); color: var(--fs-text); }
|
||||
|
||||
/* The `.label` pill. LuCI core barely uses it, but any luci-app-* may reach for it, so the
|
||||
* theme owns it in tokens (base's copy is absorbed). `.important` is the conventional name
|
||||
* for what we call accent; `.danger` has no counterpart there and is added because
|
||||
* packages use it. See docs/gallery.html. */
|
||||
.label {
|
||||
display: inline-block; padding: var(--fs-space-1) var(--fs-space-2); border-radius: var(--fs-radius-pill);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
color: var(--fs-dim); text-shadow: none; text-transform: none;
|
||||
/* carried over from the absorbed styles/base .label rule */
|
||||
white-space: nowrap; margin: .125em 0 .125em .4em;
|
||||
}
|
||||
/* The variants set no background, deliberately: they inherit the opaque --fs-panel2 from `.label`
|
||||
* above. A translucent tint of their own colour under text of that colour drags the background
|
||||
* toward the text and eats the contrast it needs, and being translucent it renders differently
|
||||
* on every surface, so no single percentage is safe — measured across palette x mode, every
|
||||
* variant landed at 3.4-4.4:1. On --fs-panel2 the palette guarantees >=4.5 by construction, and
|
||||
* the coloured border carries the status just as loudly. */
|
||||
.label.important, .label.notice { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.label.warning { border-color: var(--fs-warn); color: var(--fs-warn); }
|
||||
.label.success { border-color: var(--fs-good); color: var(--fs-good); }
|
||||
.label.danger, .label.error { border-color: var(--fs-danger); color: var(--fs-danger); }
|
||||
a.label:link, a.label:visited { color: var(--fs-dim); }
|
||||
a.label:hover { text-decoration: none; color: var(--fs-text); }
|
||||
|
||||
/* The show/hide toggles in a section title are .label pills too, but clickable. SINGLE
|
||||
* owner of the pill look: pages/20-overview.css used to restate it (page layer, unscoped —
|
||||
* so it silently won everywhere) with drifted values; these are the ones that were actually
|
||||
* rendering. The trailing overrides (text-transform … box-shadow) kill the uppercase /
|
||||
* margin / min-width / badge decorations base used to put on these pills. */
|
||||
.cbi-title .label {
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight);
|
||||
padding: var(--fs-space-1) var(--fs-space-3); border-radius: var(--fs-radius-pill);
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
color: var(--fs-dim); cursor: pointer; line-height: 1.4;
|
||||
text-transform: none; letter-spacing: 0;
|
||||
margin: 0; min-width: 0; box-shadow: none;
|
||||
}
|
||||
.cbi-title .label:hover { color: var(--fs-text); border-color: var(--fs-dim); opacity: 1; }
|
||||
/* These pills toggle section visibility — interactive, so keyboard focus must show. */
|
||||
.cbi-title .label:focus-visible {
|
||||
outline: none; border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* Surface stays OPAQUE (--fs-panel2 inherited from the base pill): accent text on
|
||||
* --fs-accent-soft is the accent-on-its-own-tint AA failure measured at 4.21:1 in the open
|
||||
* dropdown (theme/65-dropdown.css) — the border carries the accent instead. */
|
||||
.cbi-title .label.notice { opacity: 1; color: var(--fs-accent); border-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
@layer theme {
|
||||
/* ---- tab strips: one component, four entry points ----
|
||||
* LuCI emits a strip under four selectors with no markup difference worth honouring:
|
||||
* #tabmenu ul.tabs page tabs, rendered into the server shell
|
||||
* ul.cbi-tabmenu.mode mode switcher (Available / Installed / Updates)
|
||||
* .tabs view tabs (Routing -> IPv4 / IPv6)
|
||||
* .cbi-tabmenu CBI form tabs (System -> General / Logging / …)
|
||||
* They agree on everything except the active marker: `.active` for `ul.tabs`, `.cbi-tab` for
|
||||
* every `.cbi-tabmenu` flavour. All four render as pills in a card. */
|
||||
#tabmenu { margin-bottom: 0; }
|
||||
|
||||
/* the card */
|
||||
#tabmenu ul.tabs,
|
||||
ul.cbi-tabmenu.mode,
|
||||
.tabs,
|
||||
.cbi-tabmenu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-1);
|
||||
list-style: none;
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: var(--fs-space-2);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* kills a folder-tab baseline gradient (the 1px x 29px background image the strip
|
||||
* conventionally carries) by zeroing its size rather than restating it — base's copy is
|
||||
* absorbed, but any gradient a view or third-party app sets is sized away too */
|
||||
background-size: 0 0;
|
||||
}
|
||||
|
||||
/* a strip nested inside a .cbi-section card -> soft inset row, no frame (a card inside a
|
||||
* card reads as a bug) */
|
||||
.cbi-section .tabs,
|
||||
.cbi-section .cbi-tabmenu:not(.mode):not(.map) {
|
||||
background: var(--fs-panel2);
|
||||
padding: var(--fs-space-1-5);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li,
|
||||
.cbi-tabmenu > li {
|
||||
height: auto;
|
||||
margin: 0; padding: 0;
|
||||
border: 0; background: none;
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* the theme's own page tabs and the mode switcher never carry a sentence-length label, so
|
||||
* `none` is right for them — `ul.cbi-tabmenu.mode > li` below wins specificity over the plain
|
||||
* `.cbi-tabmenu > li` rule that follows, so the mode pills stay `none` regardless of order. */
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* the PLAIN CBI form tabs (ui.js, not the mode switcher): adblock's Overview page names one
|
||||
* "Настройки межсетевого экрана", measured 293px in a 264px column at 320px/Large — 28.6px past
|
||||
* it, section clipped by 30px; EN measured 0. The `a` already carries `white-space: nowrap;
|
||||
* overflow: hidden; text-overflow: ellipsis` (base/50-chrome.css) — it never engaged because the
|
||||
* li's `max-width: none` left its automatic minimum size at the label's full content width, so
|
||||
* flexbox never shrank it. `100%` is smaller than that content width once the column is, which
|
||||
* is exactly the case that broke; everywhere the tab already fits, this is a no-op. */
|
||||
.cbi-tabmenu > li {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li > a,
|
||||
ul.cbi-tabmenu.mode > li > a,
|
||||
.tabs > li > a,
|
||||
.cbi-tabmenu > li > a {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
border: 0;
|
||||
border-radius: var(--fs-radius);
|
||||
line-height: 1.3;
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight);
|
||||
color: var(--fs-dim);
|
||||
transition: background var(--fs-dur), color var(--fs-dur);
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li > a:hover,
|
||||
ul.cbi-tabmenu.mode > li > a:hover,
|
||||
.tabs > li > a:hover,
|
||||
.cbi-tabmenu > li > a:hover {
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
/* active pill */
|
||||
#tabmenu ul.tabs > li.active,
|
||||
ul.cbi-tabmenu.mode > li.cbi-tab,
|
||||
.tabs > li.active,
|
||||
.cbi-tabmenu > li.cbi-tab {
|
||||
color: var(--fs-on-accent);
|
||||
}
|
||||
#tabmenu ul.tabs > li.active > a,
|
||||
ul.cbi-tabmenu.mode > li.cbi-tab > a,
|
||||
.tabs > li.active > a,
|
||||
.cbi-tabmenu > li.cbi-tab > a {
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-on-accent);
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
/* ---- keyboard focus: a ring that survives the ACTIVE pill ----
|
||||
*
|
||||
* base/50-chrome.css drew it as `outline: 2px solid var(--fs-accent)` inset by -2px, which is
|
||||
* invisible on exactly the tab most likely to be focused, the active pill's own background being
|
||||
* that same accent (measured on the router: both rgb(86,157,245)).
|
||||
*
|
||||
* One rule for both states, on --fs-focus-ring-solo: a tab flips nothing else on focus, so the
|
||||
* ring IS the indicator, and the tinted --fs-focus-ring measures 1.15-1.29:1 against the strip —
|
||||
* the first version of this fix took that tint for the inactive pill and swapped one invisible
|
||||
* indicator for another.
|
||||
* -solo's 2px surface gap is also what lets one value serve the active pill. box-shadow, not
|
||||
* outline, because the strip clips and a shadow is not clipped by the scroll container. */
|
||||
#tabmenu ul.tabs > li > a:focus-visible,
|
||||
ul.cbi-tabmenu.mode > li > a:focus-visible,
|
||||
.tabs > li > a:focus-visible,
|
||||
.cbi-tabmenu > li > a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* Density steps for a crowded strip: fs-chrome.js (fitTabStrips) MEASURES whether the tabs
|
||||
* wrap to a second row and escalates .fs-dense1 -> .fs-dense2, mutually exclusive (dense1 is
|
||||
* removed the moment dense2 is added, so a pill is never trimmed by both at once). Both steps
|
||||
* give the pill the SAME padding (--fs-space-1 --fs-space-2) and, where the rule sets it at
|
||||
* all, the SAME font-size (--fs-type) — dense1 does not touch font-size, dense2 restates it;
|
||||
* neither actually shrinks it further, since --fs-type is already the strip's own base size.
|
||||
* Only the strip's own `gap` is touched by a dense rule, and only at d2; past that floor the
|
||||
* strip may wrap. The id in the first selector matches the #tabmenu rule's specificity. */
|
||||
#tabmenu ul.tabs.fs-dense1 > li > a,
|
||||
.tabs.fs-dense1 > li > a,
|
||||
.cbi-tabmenu.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
|
||||
#tabmenu ul.tabs.fs-dense2,
|
||||
.tabs.fs-dense2,
|
||||
.cbi-tabmenu.fs-dense2 { gap: var(--fs-space-1); }
|
||||
#tabmenu ul.tabs.fs-dense2 > li > a,
|
||||
.tabs.fs-dense2 > li > a,
|
||||
.cbi-tabmenu.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-2); font-size: var(--fs-type); }
|
||||
|
||||
/* map-level tabs keep the body font size instead of base's 16.5px/bold */
|
||||
.cbi-tabmenu.map > li { font-size: var(--fs-type); font-weight: var(--fs-weight); }
|
||||
/* the little "n errors" badge on a tab */
|
||||
.cbi-tab-disabled[data-errors]::after { background: var(--fs-danger); }
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
@layer theme {
|
||||
/* The realtime graphs size their drawing from #view, not from the box they draw into:
|
||||
* `width = document.querySelector('#view').offsetWidth - 2`, in every realtime view and in the
|
||||
* luci-app-*-status copies of them. That assumption holds only in a theme whose .cbi-section has
|
||||
* no gutter; ours is a card, 16px of padding plus a 1px border, so the canvas comes out 34px
|
||||
* narrower than the drawing — and the views draw NEWEST-first from the right, so the freshest
|
||||
* samples are the ones falling off the edge. It reads as a phone bug only because the loss is
|
||||
* absolute: 34px of a 1124px desktop plot is 3%, of a 322px phone plot 10%.
|
||||
*
|
||||
* So bleed the graph box back out to the card's border edge, making the canvas exactly
|
||||
* `#view - 2` again. The bleed is padding plus border, derived from --fs-card-pad rather than
|
||||
* restated, or narrowing the gutter would silently re-clip every graph.
|
||||
*
|
||||
* The `!important` fights the inline `width: 100%` the view writes on the box: nothing in a
|
||||
* cascade layer can outrank an inline declaration. */
|
||||
#view .cbi-section > div[style]:has(> svg) {
|
||||
width: auto !important;
|
||||
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
|
||||
}
|
||||
|
||||
/* …and the same box's frame, which the same inline style paints BLACK: every realtime view writes
|
||||
* `border:1px solid #000;background:#fff` on it. Black is a colour no palette of this theme
|
||||
* contains, so on a dark surface the graph reads as a framed cut-out, and the frame lands
|
||||
* exactly on the card's own border edge because of the bleed above (issue #32).
|
||||
*
|
||||
* Wider than the bleed rule on purpose: channel_analysis.js hangs off a bare [data-tab] div and
|
||||
* must not be bled, but its border is just as black, so this asks only "an inline-styled box
|
||||
* wrapping an <svg>".
|
||||
*
|
||||
* Only the COLOUR moves. The 1px stays, being the graph's own edge, and `background: #fff` is
|
||||
* left alone: the <svg> is sized to the box's content edge, so the white never paints. Only an
|
||||
* author `!important` outranks an inline declaration, and `border-color` alone is enough — the
|
||||
* width and style already agree with what the theme would ask for. */
|
||||
#view div[style]:has(> svg) {
|
||||
border-color: var(--fs-border) !important;
|
||||
}
|
||||
|
||||
/* WCAG 2.2 SC 1.4.1 (Use of Color, A): inside a block of text a link may not be identified
|
||||
* by COLOUR ALONE — it needs a non-colour cue or 3:1 against the surrounding text, and
|
||||
* --fs-accent on --fs-text measures 1.4:1 in dark mode. So: underline links in prose.
|
||||
* Scoped to prose deliberately — nav links, buttons, pills and table cells are identified
|
||||
* by position and shape, and underlining them would be noise. */
|
||||
p a, dd a,
|
||||
.cbi-value-description a,
|
||||
.alert-message a:not(.btn):not(.cbi-button) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* A <strong>/<b> is a LABEL, and a label is UI text, so it takes the UI face even on a monospaced
|
||||
* surface. LuCI builds every status readout as `<strong>MAC:</strong> ac:1f:6b:…`: the strong
|
||||
* names the datum, the text after it IS the datum. The theme puts mono on those surfaces, the
|
||||
* strong inherits it, and <strong> also means weight 700 — so the label asks for a bold mono
|
||||
* face that exists for no other reason: 227 elements across seven pages, and 20 KB of a
|
||||
* 600-weight mono face fetched to draw the word "MAC:".
|
||||
*
|
||||
* It must SET the face, not merely be excluded from the mono rule: an excluded element still
|
||||
* INHERITS its parent's. Excluding it was the first attempt and changed nothing.
|
||||
*
|
||||
* pre/code/#syslog/textarea are exempt: there a <strong> is part of the monospaced CONTENT. */
|
||||
:is(strong, b):not(pre *, code *, #syslog *, textarea *) {
|
||||
font-family: var(--fs-font-sans);
|
||||
}
|
||||
|
||||
/* Same argument, two holdouts. `.ifacebadge` is a BADGE — chrome, not a value — sitting in a mono
|
||||
* cell, and `.ifacebadge-active` makes it bold; sans keeps the active/inactive weight cue (WCAG
|
||||
* 1.4.1, that distinction not being carried by colour) with faces the page already has.
|
||||
* `<code>`/`<pre>` hold a LITERAL, which does not inherit its container's emphasis.
|
||||
*
|
||||
* The second selector is not redundant: the mono rule in theme/60-inputs.css is (0,1,0), a TIE
|
||||
* with a bare `.ifacebadge`, and a tie is settled by source order, which 60-inputs would win.
|
||||
* Win on specificity instead, at (0,2,0). */
|
||||
.ifacebadge,
|
||||
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
|
||||
code, pre { font-weight: var(--fs-weight-normal); }
|
||||
|
||||
/* ---- the poll's `fade-in` is not animated at all, and that is the whole rule ----
|
||||
*
|
||||
* `fade-in` is upstream's class and luci-mod-status re-marks with it on EVERY poll tick — each
|
||||
* section, and the wrapper holding all of them — so anything this theme hangs on that class
|
||||
* replays on every tick across the entire content column.
|
||||
*
|
||||
* A fade plus a 4px slide moved the page under the reader; the fade alone still pulsed the whole
|
||||
* column on every tick, reported from an iPhone as shaking that happens WITHOUT SCROLLING AT
|
||||
* ALL, which is what named it — a poll tick is the only thing that happens on a still page.
|
||||
*
|
||||
* So the theme animates nothing on it. The class stays in the markup, being upstream's, and the
|
||||
* `fade-in` keyframes in base/95-luci.css stay for the coverage reason stated there. */
|
||||
.cbi-title-ref { color: var(--fs-accent); }
|
||||
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
|
||||
.close:hover { opacity: 1; }
|
||||
.ifacebox { background: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.ifacebox .ifacebox-head { background: var(--fs-panel2); color: var(--fs-text); }
|
||||
/* The active-interface highlight lives in THIS layer, next to the head it overrides: the
|
||||
* same rule in base was dead — a layer beats specificity, and the plain `.ifacebox-head`
|
||||
* above repainted `.active` along with the rest. */
|
||||
.ifacebox .ifacebox-head.active { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.ifacebadge { background: var(--fs-panel2); border-color: var(--fs-border); }
|
||||
.ifacebadge-active { border-color: var(--fs-accent); }
|
||||
.zonebadge-empty { border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* The @container PLUMBING for the responsive tables — only WHICH boxes are query containers, under
|
||||
* what names. The card rules are not here: a CBI section table cards under `@container
|
||||
* fs-content (max-width: 992px)` (theme/65-dropdown.css — 992, not the row's own 960px floor,
|
||||
* because the query reads THIS element, one level above the `.cbi-section` that spends
|
||||
* `--fs-card-pad` before the table ever sees the width), and a data table cards by MEASUREMENT
|
||||
* (.fs-stacked, fs-select.js) at any width.
|
||||
*
|
||||
* Query the CONTENT width, not the viewport: the sidebar eats width, so a viewport breakpoint
|
||||
* stacks too late or too early depending on layout.
|
||||
*
|
||||
* Named, deliberately — an unnamed @container binds to the nearest ancestor that happens to have
|
||||
* a container-type, so giving container-type to any new component would silently re-target every
|
||||
* unnamed query underneath it:
|
||||
* fs-view the overview's grid (pages/20-overview.css, @container 800)
|
||||
* fs-content "whatever content column I am in", carried by .cbi-map, #view and .modal (below),
|
||||
* so a query resolves to the nearest of the three. */
|
||||
.cbi-map { container-type: inline-size; container-name: fs-content; }
|
||||
#view { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* A modal is not inside #view — ui.js appends #modal_overlay to <body> — so every @container query
|
||||
* above resolves to nothing in there and no table stacks: on a phone the Edit-interface dialog
|
||||
* rendered its form at desktop widths and cut the inputs off. Carry both names so a dialog
|
||||
* stacks exactly like the page, a third-party app's modal included. */
|
||||
.modal { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* pager (« prev · text · next ») — generic, not page-scoped: any .pager gets an inline
|
||||
* centred row with compact square buttons. */
|
||||
.pager.center { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2-5); padding: var(--fs-space-3-5) 0; }
|
||||
.pager .text { flex: 0 0 auto; font-size: var(--fs-type); color: var(--fs-dim); }
|
||||
|
||||
/* The heading ramp, absorbed from base wholesale (docs/css.md). Stock LuCI hands h2 to a page title
|
||||
* and h3/legend to a section title, and the heading-card rule below plus theme/30-tables.css
|
||||
* repaint exactly those two, so most of the ramp is only ever rendered by a third-party
|
||||
* luci-app-* — styled anyway, coverage being a contract. Every step is a scale token and the
|
||||
* ramp is monotonic. `legend` is deliberately not in the group: theme/30-tables.css owns it, and
|
||||
* a member here would fight that at equal specificity, i.e. on source order. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--fs-weight-bold);
|
||||
color: var(--fs-text);
|
||||
/* 1.5 is prose leading; a heading is a label and looks slack at it. */
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
h1 { margin-bottom: var(--fs-space-4); font-size: var(--fs-type-2xl); }
|
||||
h2 { font-size: var(--fs-type-xl); }
|
||||
h3 { font-size: var(--fs-type-lg); }
|
||||
h4 { font-size: var(--fs-type); }
|
||||
h5 { font-size: var(--fs-type); }
|
||||
h6 { font-size: var(--fs-type-xs); color: var(--fs-dim); text-transform: uppercase; }
|
||||
|
||||
/* Heading card (h2 + .cbi-map-descr): everything sitting directly on the page background
|
||||
* gets a .cbi-section card's frame, so a page reads as a stack of panels instead of loose
|
||||
* text floating over the (possibly patterned) background. */
|
||||
.cbi-map > h2,
|
||||
#view > h2 {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: var(--fs-space-4);
|
||||
font-size: var(--fs-type-xl);
|
||||
font-weight: var(--fs-weight-bold);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
/* a heading followed by a description: split the card across the two siblings (no wrapper
|
||||
* element exists to hold both) */
|
||||
.cbi-map > h2:has(+ .cbi-map-descr),
|
||||
#view > h2:has(+ .cbi-map-descr) {
|
||||
border-bottom: 0;
|
||||
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
|
||||
padding-bottom: var(--fs-space-1);
|
||||
margin-bottom: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cbi-map > h2 + .cbi-map-descr,
|
||||
#view > h2 + .cbi-map-descr {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: 0 var(--fs-space-4) var(--fs-space-4);
|
||||
color: var(--fs-dim);
|
||||
font-size: var(--fs-type);
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
|
||||
/* An action-button row (a `.right` wrapper holding Add/Save) sitting directly under a field
|
||||
* had no gap above it — the apk "Repo Public Keys -> Add key" paste box, and every other
|
||||
* paste-a-value-then-submit layout, pinned its button to the textarea. Scoped to a `.right`
|
||||
* immediately following a form control, so it never touches right-aligned text or cells. */
|
||||
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
|
||||
margin-top: var(--fs-space-2-5);
|
||||
}
|
||||
|
||||
/* A button row belonging to the PAGE TITLE rather than to any section. Nothing gives it a bottom
|
||||
* margin, so it sits flush against the first .cbi-section below and reads as part of that card
|
||||
* instead of as a page-level action; the same 16px every .cbi-section uses.
|
||||
*
|
||||
* And a top margin for the same reason at the other edge, the row sitting directly under the
|
||||
* page heading with nothing between them. Half the bottom gap, because a heading and its own
|
||||
* action belong together. */
|
||||
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
|
||||
|
||||
/* The per-section Delete button (`.cbi-section-remove`, what luci-base renders for every NAMED
|
||||
* section: SQM's queues, the firewall's zones, any app that lets you add and drop instances) sat
|
||||
* flush against whatever follows it — usually that section's own tab bar. Reported upstream on
|
||||
* SQM, but nothing here is about SQM: it is one class luci-base gives every such section, and
|
||||
* the gap belongs to the pattern rather than to the page. */
|
||||
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
|
||||
|
||||
/* A top-level block in a map that is not a section still keeps the page's rhythm.
|
||||
*
|
||||
* A view may return a bare widget where a section is expected — `s.render = () => new
|
||||
* ui.Textarea(...).render()` hands the map a plain `<div>` with no `.cbi-section` — so nothing
|
||||
* gives that block the gap every section carries and the next section starts against it. Stock
|
||||
* bootstrap measures the same 0px, but there a section is a stretch of page and here it is a
|
||||
* CARD, so the missing gap reads as two cards fused.
|
||||
*
|
||||
* Written against what a map CONTAINS rather than against one app: the exclusions are the
|
||||
* children luci-base itself puts there and which own their spacing already — the map heading and
|
||||
* its description, the action bar, a tabbed map's tab bar and pane, and sections.
|
||||
*
|
||||
* `:not(:empty)` because a map can hold a placeholder that renders nothing. */
|
||||
.cbi-map > *:not(.cbi-section):not(.cbi-map-descr):not(.cbi-page-actions):not(h2):not(legend):not(.cbi-tabmenu):not(.cbi-map-tabbed):not(:empty) {
|
||||
margin-bottom: var(--fs-card-gap);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
@layer theme {
|
||||
/* ---- the top-layout bar: :root[data-layout="top"], every width ----
|
||||
*
|
||||
* A DELTA, not a layout. The bar itself — sticky, blurred, row, popup submenus, square icon
|
||||
* buttons, right cluster — is the unguarded base in 20-shell.css, the bar being what the chrome
|
||||
* IS unless the vertical sidebar overrides it. This file adds what the top-layout bar wants on
|
||||
* top of that:
|
||||
* - a designed bar height, and padding aligned to the content column;
|
||||
* - the menu on the brand's row instead of wrapped beneath it.
|
||||
*
|
||||
* No `@media` floor: "does the menu fit on the brand's row?" is a property of the CONTENT (5
|
||||
* sections stock, eleven with a few luci-app-*), not of the viewport, so the whole bar is
|
||||
* MEASURED — fs-chrome.js shrinks the pills, collapses the poll pill to an icon, then stacks the
|
||||
* menu onto its own row, at any width. The under-item dropdown and its clamp therefore apply at
|
||||
* every width too. */
|
||||
:root[data-layout="top"] .fs-sidebar {
|
||||
/* min-height, not height: the bar has a designed height but must be able to GROW —
|
||||
* a long hostname wraps onto a second line inside the capped brand (20-shell.css),
|
||||
* and a fixed height would clip it. */
|
||||
min-height: var(--fs-bar-h); height: auto;
|
||||
gap: var(--fs-space-3);
|
||||
/* nowrap while the menu rides the brand's row. Without it flexbox wraps the BAR, carrying the
|
||||
* menu onto a second line by itself while its own pills stay in one row — a stacked bar in
|
||||
* everything but the styling. It also destroys the fits-on-one-row measurement, a menu handed
|
||||
* a whole row of course fitting. Forbidding the wrap forces the menu to share the row and to
|
||||
* wrap its OWN pills when it cannot, which is the signal fitChrome measures. */
|
||||
flex-wrap: nowrap;
|
||||
/* align the bar's contents to the .fs-content column, so the brand and the menu line
|
||||
* up with the headings and the cards below */
|
||||
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
|
||||
}
|
||||
|
||||
/* the menu rides the brand's row; its flex:1 opens the gap that pushes the right-hand
|
||||
* cluster to the content column's right edge */
|
||||
:root[data-layout="top"] .fs-sidebar ul.nav {
|
||||
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar #indicators,
|
||||
:root[data-layout="top"] .fs-themerow,
|
||||
:root[data-layout="top"] .fs-logout { order: 3; margin-inline-start: 0; }
|
||||
|
||||
/* The under-item dropdown anchor, its hover bridge and the clamp are NOT here: they belong to
|
||||
* the BAR and live unguarded in 20-shell.css. They used to be this file's, and that WAS issue
|
||||
* #19 — the sidebar layout collapsed onto a bar is the same chrome, and it inherited a panel
|
||||
* pinned to the bar's left edge with no bridge, unreachable by hover. This file is the top
|
||||
* layout's delta; a rule every bar state needs is not a delta. */
|
||||
|
||||
/* the mode strip (only rendered when a router has more than one top-level mode) takes
|
||||
* its own full-width row under the bar */
|
||||
/* :not(:empty):not(.single) instead of a hide pair — the bar's base rule (20-shell.css)
|
||||
* already hides the strip; see the sidebar twin there for why the pair was folded. */
|
||||
:root[data-layout="top"] .fs-modemenu:not(:empty):not(.single) {
|
||||
display: flex; order: 4; flex: 1 1 100%; margin: 0; gap: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* the bar replaces the sidebar's 22px top padding with the top-nav's 26px */
|
||||
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
|
||||
|
||||
/* ---- the menu does not fit on the brand's row: .fs-bar-stack ----
|
||||
*
|
||||
* Set by fs-chrome.js (fitChrome), not by a media query: whether the menu fits beside the brand
|
||||
* depends on how many sections the router HAS, so the width at which the bar must stack is a
|
||||
* property of the content and a hard-coded breakpoint gets it wrong in both directions. The JS
|
||||
* shrinks the pills first, then collapses the poll pill, and stacks only when even that still
|
||||
* overflows. */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack {
|
||||
flex-wrap: wrap; height: auto; padding: var(--fs-space-2-5) var(--fs-space-4);
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack ul.nav {
|
||||
order: 3; flex: 1 1 100%; margin-inline-start: 0;
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-logout { order: 2; }
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow { margin-inline-start: auto; }
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
|
||||
/* Two .fs-themerow buttons, Search then Appearance: only the FIRST rendered far-edge item may
|
||||
* keep the auto inline-start margin, exactly as in the base bar, or flexbox splits the free
|
||||
* space between the two autos and Search drifts to mid-bar. The base `.fs-themerow ~
|
||||
* .fs-themerow` guard is out-specified by the auto rule above (0,4,0 against 0,2,0), so it must
|
||||
* be restated here. Bites when #indicators is :empty — right after an SPA nav, before
|
||||
* the poll pill returns. */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
|
||||
|
||||
/* The right cluster's own escalation — .fs-ind-compact and .fs-bar-actrow — is NOT here: it
|
||||
* belongs to the BAR, and the sidebar layout is a bar too once fitShell has stamped
|
||||
* data-narrow. Both live with the bar in 20-shell.css; this file is only the top layout's
|
||||
* delta, and the menu's .fs-bar-stack above is the part that really is top-only. */
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
@layer theme {
|
||||
/* Buttons placed straight in a field, not in a .control-group (app action rows
|
||||
* Enable/Disable, Start/Restart/Stop): inline-block buttons only get whitespace spacing
|
||||
* horizontally, so on wrap the rows collapsed together. Wrapping flex with a real gap. */
|
||||
.cbi-value-field:has(> .cbi-button) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* The button. The native `input` types are in the selector list on purpose: LuCI's own views
|
||||
* always use <button class="cbi-button">, but a third-party luci-app-* may emit a bare
|
||||
* <input type="submit">, and base only gives those width/height: auto — coverage is a
|
||||
* contract (docs/gallery.html). `.btn` and `.cbi-button` are interchangeable in LuCI markup
|
||||
* (a row-action button usually carries both), so every rule here names both. */
|
||||
.cbi-button, .btn,
|
||||
input[type="button"], input[type="submit"], input[type="reset"] {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
/* auto, stated: theme/60-inputs.css's generic `input` box says 210px x 32px, and a base
|
||||
* reset would lose to it by layer — a button sizes to its label */
|
||||
width: auto; height: auto;
|
||||
min-height: var(--fs-ctl-h);
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
font-family: var(--fs-font-sans); font-weight: var(--fs-weight); line-height: 1.1;
|
||||
color: var(--fs-text);
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
box-shadow: none; text-shadow: none;
|
||||
/* border-COLOR, not `border`: the shorthand animates border-width too — a layout
|
||||
* animation on every hover instead of a paint one (absorbed from base with the ring) */
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* bare `button` rides along: a third-party app's unclassed <button> hovered the same border
|
||||
* cue in base's old glow rule, and border-color is a no-op on its border: 0 box — harmless
|
||||
* there, meaningful the moment the app gives it a border */
|
||||
.cbi-button:hover, .btn:hover, button:hover,
|
||||
input[type="button"]:hover, input[type="submit"]:hover, input[type="reset"]:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* Keyboard focus, not decoration: without this a button shows nothing on focus. The
|
||||
* `box-shadow: none` above kills any stray focus glow (theme beats base regardless of
|
||||
* specificity), and the UA outline only ever shows on :focus-visible, which this rule restyles.
|
||||
* A plain mouse :focus deliberately shows nothing — a glow rings every clicked button, and the
|
||||
* pressed cue below already answers the click. Same --fs-focus-ring as every field. */
|
||||
.cbi-button:focus-visible, .btn:focus-visible,
|
||||
input[type="button"]:focus-visible, input[type="submit"]:focus-visible, input[type="reset"]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* Solid variants sit ON --fs-accent/--fs-good/--fs-danger, so an accent-tinted ring is invisible
|
||||
* against them: ring in the surface colour with an accent halo. Written out as a literal ending
|
||||
* in --fs-accent-soft, that halo was a 10-15% tint at 1.15-1.29:1 and the only thing it painted
|
||||
* was the 2px gap; --fs-focus-ring-solo is the same geometry with a solid accent, under one
|
||||
* name. */
|
||||
.cbi-button-positive:focus-visible, .cbi-button-action:focus-visible,
|
||||
.cbi-button-negative:focus-visible, .cbi-button-remove:focus-visible,
|
||||
.cbi-button-apply:focus-visible, .cbi-button-save:focus-visible,
|
||||
.btn.primary:focus-visible, .btn.danger:focus-visible,
|
||||
.btn.error:focus-visible, .btn.success:focus-visible, .btn.info:focus-visible {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* Pressed state. The `box-shadow: none` above would erase any press cue, so the inset shadow
|
||||
* is declared here — the sole copy (base's identical one was absorbed). */
|
||||
.cbi-button:active, .btn:active, .btn.active,
|
||||
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active {
|
||||
box-shadow: inset 0 2px 4px color-mix(in srgb, var(--fs-border) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Solid variants — three fills, not six. `.cbi-button-*` is what LuCI's own views emit and
|
||||
* `.btn.<colour-word>` what a third-party luci-app-* tends to emit, and base paints those words
|
||||
* with gradients that are dead: the plain `.btn` rule above sets background, border and colour
|
||||
* in a later layer, so an unclaimed `.btn.danger` renders as a neutral grey button with no
|
||||
* colour cue. Coverage is a contract, so the theme claims them — in the SAME rule as their
|
||||
* .cbi-button-* twin, because written apart a recolour lands on one half and not the other. */
|
||||
.cbi-button-positive, .btn.success {
|
||||
background: var(--fs-good); border-color: transparent; color: var(--fs-on-good);
|
||||
}
|
||||
.cbi-button-action, .cbi-button-action.important, .btn.primary, .btn.info {
|
||||
background: var(--fs-accent); border-color: transparent; color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-button-negative, .cbi-button-negative.important, .cbi-button-remove,
|
||||
.btn.danger, .btn.error {
|
||||
background: var(--fs-danger); border-color: transparent; color: var(--fs-on-danger);
|
||||
}
|
||||
/* This hover must stay AHEAD of the page-action Save rule below: at equal specificity its
|
||||
* `border-color: transparent` would erase Save's outline on hover. (.cbi-button-remove is
|
||||
* deliberately absent — it never had the lift, and the row-action rules colour it their
|
||||
* own way.) */
|
||||
.cbi-button-positive:hover, .cbi-button-action:hover, .cbi-button-apply:hover,
|
||||
.cbi-button-save:hover, .cbi-button-negative:hover,
|
||||
.btn.success:hover, .btn.info:hover, .btn.danger:hover, .btn.error:hover {
|
||||
filter: var(--fs-hover-lift); border-color: transparent;
|
||||
}
|
||||
.cbi-button-reset, .cbi-button-link { background: transparent; border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* Size words. The `.btn` rule above re-sets padding and radius from the token scale in a
|
||||
* later layer, so a size word that carried only a font-size (as base's absorbed copy did)
|
||||
* came out with default-button padding. Theme owns .btn.large/.small in full now, geometry
|
||||
* on the radius scale rather than base's hardcoded 6px. */
|
||||
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); border-radius: var(--fs-radius); }
|
||||
.btn.small { font-size: var(--fs-type-xs); min-height: var(--fs-ctl-h-sm); padding: var(--fs-space-1) var(--fs-space-2-5); border-radius: var(--fs-radius-sm); }
|
||||
|
||||
.cbi-section .table[id] .td .btn,
|
||||
.cbi-section .table[id] .td .cbi-button { padding: var(--fs-space-1) var(--fs-space-2-5); font-size: var(--fs-type); }
|
||||
.alert-message .btn, .alert-message .cbi-button {
|
||||
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3); font-weight: var(--fs-weight);
|
||||
background: var(--fs-panel2); border: var(--fs-hairline); color: var(--fs-text);
|
||||
}
|
||||
.alert-message .btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Page action bar (.cbi-page-actions) — the Save/Apply footer. base renders a tall
|
||||
* half-empty plate with bottom-only radius; make it a compact rounded action bar. */
|
||||
.cbi-page-actions {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: var(--fs-space-2) 0;
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
text-align: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--fs-space-2);
|
||||
/* The design call: WRAP the bar rather than let its items shrink into it. Save/Reset are
|
||||
* plain text with no ellipsis, so their min-content protects them — a no-wrap row's shrink
|
||||
* factor lands entirely on the Save & Apply split control, whose caption DOES have an
|
||||
* ellipsis to give (theme/65-dropdown.css) and so absorbs the whole squeeze. Measured on the
|
||||
* gallery's own fixture (EN "Save & Apply"): a no-wrap row cut it by up to 87px at 320px/
|
||||
* Normal and 119px at 320px/Large, worse than the numbers this bug was filed against
|
||||
* (43-68px on a live page, where the sidebar leaves less room than the gallery's isolated
|
||||
* card) — same fault, same direction, this fixture just has less room to give.
|
||||
* `flex-wrap: wrap` sends the split control (or Save+Reset) to its own line once they no
|
||||
* longer fit one, so the caption is squeezed only by its OWN box, never by a sibling's:
|
||||
* the same fixture measures 0px cut at every width/density this bug was filed against, RU
|
||||
* or EN, after this one line. The caption still owns an ellipsis for the genuine edge it
|
||||
* cannot solve — a full RU sentence at Large density in a 320px column has nowhere left to
|
||||
* grow even alone on its line (measured: 33px still short there) — and that is the right
|
||||
* place for it to give, not a sibling button's unrelated width. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cbi-page-actions .cbi-button,
|
||||
.cbi-page-actions .btn {
|
||||
min-height: var(--fs-ctl-h);
|
||||
padding: 0 var(--fs-space-5);
|
||||
border-radius: var(--fs-radius);
|
||||
font-weight: var(--fs-weight);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Reset takes the same step of the ladder as Save (-soft at rest, -fill on hover), so the two read
|
||||
* as equally weighted secondary actions beside the solid Save & Apply — but off the DANGER role,
|
||||
* not the accent one: Reset discards your unsaved changes. (An opaque --fs-panel2 was the first
|
||||
* attempt and weighed MORE than Save's translucent 10-15% tint: a solid surface and a tint do
|
||||
* not read as the same fill, whatever their lightness.) The label is --fs-text for the same
|
||||
* reason as Save's — --fs-dim on
|
||||
* --fs-danger-soft measures 4.49:1 in the worst palette x mode block, under AA by a hundredth.
|
||||
* The role lives in the fill and the border. */
|
||||
.cbi-page-actions .cbi-button-reset {
|
||||
background: var(--fs-danger-soft);
|
||||
border: 1px solid var(--fs-danger-line);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
.cbi-page-actions .cbi-button-reset:hover {
|
||||
background: var(--fs-danger-fill);
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* There is no `.spinning` padding ladder here, and it must not come back: seven selectors
|
||||
* reserving room for an absolutely-positioned glyph, each climbing a class above whatever else
|
||||
* padded that element. The ladder lost twice in the field (issues #15 and #22), and every loss
|
||||
* looks the same — the spinner draws on top of the label, silently; #22 lost to a 0,5,0
|
||||
* data-table rule. base/95-luci.css puts the
|
||||
* glyph IN THE FLOW instead, so it reserves its own space. */
|
||||
|
||||
input[type="file"]::file-selector-button {
|
||||
margin-inline-end: var(--fs-space-3); padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
font-family: var(--fs-font-sans); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
color: var(--fs-text); background: var(--fs-panel2);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-sm); cursor: pointer;
|
||||
}
|
||||
input[type="file"]::file-selector-button:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Row-action buttons (Edit / Delete / Start-Stop) in a table or a .cbi-section-actions container.
|
||||
* Compact so tables stay dense: neutral outline by default, with the variants colouring the ones
|
||||
* that carry meaning. The neutral :hover comes FIRST on purpose — last, it greys the coloured
|
||||
* variants back out on hover, which only an !important on their colours hides. Equal
|
||||
* specificity, so a variant hover must be written after it.
|
||||
*
|
||||
* WCAG 2.2 SC 2.5.8: a pointer target must be >= 24x24 px, or spaced so 24px circles centred on
|
||||
* adjacent targets do not overlap. At 28px tall with a 1px margin either side the circles DO
|
||||
* overlap, so the exemption does not apply; 32px clears it. */
|
||||
.cbi-section-actions .cbi-button, .cbi-section-actions .btn,
|
||||
.cbi-section-table .cbi-button {
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); font-size: var(--fs-type);
|
||||
/* `.td .cbi-button, .td .btn` dropped (task 0151): the action cells this was filed against
|
||||
* (form.TableSection's `.td.cbi-section-table-cell.cbi-section-actions`, per 30-tables.css's
|
||||
* own discriminator) are already reached by `.cbi-section-actions .cbi-button`/`.btn` below —
|
||||
* `.cbi-section-actions` sits on the .td itself, so the descendant selector matches with no
|
||||
* `.td` needed. What the bare `.td` reached and those two do not is every OTHER button in
|
||||
* every OTHER cell: measured collateral, 14 buttons including the icon-only rail drag handle
|
||||
* and a plain-cell "Reserve IP" (Wireless's hand-built `.td.middle`, DHCP leases — neither
|
||||
* carries `.cbi-section-actions`), `overflow: hidden` re-baselining an inline-block and
|
||||
* growing the DHCP leases rows 50px -> 50.84px (10 rows at 1280) for nothing that clipped.
|
||||
* base/95-luci.css sizes each button `flex: 1 1 4em` inside `.td.cbi-section-actions`. A
|
||||
* DEFINITE flex-basis is what let the fix below regress the desktop: with `overflow: hidden`
|
||||
* alone, the automatic minimum for a definite basis resolves to 0 in EVERY row regardless of
|
||||
* room, so `flex-grow` divides by count instead of by need even where nothing needed to
|
||||
* shrink — measured live on Firewall Zones at 1440px, three buttons that read 37.66/97.73/
|
||||
* 89.69px in v0.14.11 converged to 75.02/75.03/75.03px, 11-21px of caption ellipsised for
|
||||
* zero gain (same row total, 225.08px, either way). `flex-basis: auto` is the one-property
|
||||
* fix: it hands sizing back to CONTENT, so the automatic minimum is the caption's own
|
||||
* min-content again and `flex-grow` only ever redistributes genuine slack — reproduced
|
||||
* locally (docs/gallery.html's Startup fixture, unconstrained): auto restores the exact
|
||||
* v0.14.11 widths (101.58/137.72/112.08/230.25px) at 1440, 0px moved, with `overflow: hidden`
|
||||
* left in place. `overflow: hidden` + the ellipsis below still answer the original fault —
|
||||
* a row genuinely narrower than its captions' combined content now shrinks BELOW their
|
||||
* min-content, not before, and the caption gives (ellipsis), never the neighbour's box.
|
||||
* Sweep-measured on real captions at every density: 14-19px on Startup, 12-13px on
|
||||
* "Клонировать", 16-18px on "Перезапустить"/"Перезагрузить", 5px on "Принудительно
|
||||
* завершить" — 0 with the English caption in the same row. `.cbi-page-actions`'s
|
||||
* `flex-wrap: wrap` (above) does not reach these — that selector is the Save/Apply footer,
|
||||
* not a table row, and this is a different container with no line to wrap onto. */
|
||||
flex-basis: auto;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
|
||||
background: transparent; background-image: none;
|
||||
border: var(--fs-hairline); color: var(--fs-dim);
|
||||
}
|
||||
.td .cbi-button:hover { border-color: var(--fs-dim); color: var(--fs-text); }
|
||||
|
||||
.td .cbi-button-action, .td .cbi-button-edit, .td .cbi-button-apply,
|
||||
.cbi-section-actions .cbi-button-edit, .cbi-section-actions .cbi-button-action {
|
||||
color: var(--fs-accent); border-color: var(--fs-accent-line); background: transparent;
|
||||
}
|
||||
/* THE HOVER INK IS --fs-text, NOT THE ROLE COLOUR, and that is the rule this file already
|
||||
* states twice with measurements: text of colour C on a translucent tint of C eats its own
|
||||
* contrast (Save: accent on accent-soft = 4.25:1, an AA failure; the alert `.label` chip: 3.4-4.4
|
||||
* across the palettes). The row actions were the last place it survived — the role still reads,
|
||||
* from the fill and the border, which is exactly how Save and Reset carry theirs. */
|
||||
.td .cbi-button-action:hover, .td .cbi-button-edit:hover, .td .cbi-button-apply:hover,
|
||||
.cbi-section-actions .cbi-button-action:hover {
|
||||
background: var(--fs-accent-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
}
|
||||
/* An enabled/positive toggle in a table cell (System -> Startup) rendered as blue text on a
|
||||
* green fill = unreadable. Green outline instead, matching the red-outline negative button. */
|
||||
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
|
||||
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
|
||||
}
|
||||
/* …and the border-colour the other two variants already had. Without it the green outline stayed
|
||||
* at -line while action and negative brightened to -line-hi, so hovering the positive toggle
|
||||
* read as less responsive than its neighbours in the same row. */
|
||||
.td .cbi-button-positive:hover, .cbi-section-actions .cbi-button-positive:hover {
|
||||
background: var(--fs-good-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-good-line-hi);
|
||||
}
|
||||
.td .cbi-button-negative, .td .cbi-button-remove,
|
||||
.cbi-section-actions .cbi-button-remove, .cbi-section-actions .cbi-button-negative {
|
||||
color: var(--fs-danger); border-color: var(--fs-danger-line); background: transparent;
|
||||
}
|
||||
.td .cbi-button-negative:hover, .td .cbi-button-remove:hover,
|
||||
.cbi-section-actions .cbi-button-negative:hover {
|
||||
background: var(--fs-danger-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* Page actions: Save & Apply is THE primary action, Save is demoted so only one button reads
|
||||
* as primary. `--on-color` drives the spinner mask in styles/base. */
|
||||
.cbi-page-actions .cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
|
||||
background: var(--fs-accent);
|
||||
background-image: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--fs-on-accent);
|
||||
--on-color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
/* Save carries a TINT at rest, not a bare outline: beside the solid Save & Apply a fully
|
||||
* transparent button reads as disabled rather than secondary. The steps come off the role ladder
|
||||
* (02-tokens.css) — -soft (12%) at rest, -fill (18%) on hover, one step up the same ladder.
|
||||
*
|
||||
* The label is --fs-text, not --fs-accent: never put text of colour C on a translucent tint of C
|
||||
* (docs/design-system.md). The tint drags the surface toward the text and eats its own contrast
|
||||
* — accent on accent-soft measures 4.25:1, an AA failure. The role is carried by the fill and
|
||||
* the border.
|
||||
*
|
||||
* `filter: none` cancels the shared hover's --fs-hover-lift: on a translucent tint a brightness
|
||||
* shift washes the fill out instead of deepening it. */
|
||||
.cbi-page-actions .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
|
||||
background: var(--fs-accent-soft);
|
||||
background-image: none;
|
||||
border: 1px solid var(--fs-accent-line);
|
||||
color: var(--fs-text);
|
||||
--on-color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
.cbi-page-actions .cbi-button-save:hover {
|
||||
background: var(--fs-accent-fill);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Buttons that are part of a compound field widget. */
|
||||
.cbi-dynlist > .add-item > button,
|
||||
.cbi-dynlist > .add-item > .cbi-button-add {
|
||||
/* min-width was a bare 38px — the same number --fs-ctl-h already states. A restated value
|
||||
* is the drift this ladder exists to prevent, and it also sat out the Density axis. */
|
||||
flex: 0 0 auto; width: auto; min-width: var(--fs-ctl-h);
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); margin: 0;
|
||||
border: 0; border-inline-start: var(--fs-hairline);
|
||||
border-radius: 0;
|
||||
background: transparent; color: var(--fs-dim);
|
||||
}
|
||||
.cbi-dynlist > .add-item > button:hover,
|
||||
.cbi-dynlist > .add-item > .cbi-button-add:hover { color: var(--fs-accent); }
|
||||
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral {
|
||||
border: 0; border-radius: 0; margin: 0;
|
||||
background: transparent; color: var(--fs-dim);
|
||||
padding: 0 var(--fs-space-3); min-height: var(--fs-ctl-h);
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral:hover { color: var(--fs-accent); }
|
||||
/* SQUARE, ON THE LADDER: these were the only buttons in the theme set in bare pixels
|
||||
* (40 wide x 34 high), and 34 was the one control height that appears in no scale — the ladder
|
||||
* is --fs-ctl-h-sm/-h/-h-lg = 30/38/44, plus the derived FIELD height (--fs-control-h, 32).
|
||||
* A literal also opts out of the Density axis: every other control shrinks with
|
||||
* --fs-density-box and the pager did not. Square because the glyph is an arrow, not a label. */
|
||||
.pager .btn.prev, .pager .btn.next {
|
||||
flex: 0 0 auto; width: var(--fs-ctl-h); min-width: var(--fs-ctl-h); height: var(--fs-ctl-h); padding: 0;
|
||||
border-radius: var(--fs-radius); border: var(--fs-hairline);
|
||||
background: var(--fs-panel2); color: var(--fs-text);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pager .btn.prev:hover, .pager .btn.next:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
}
|
||||
@@ -1,921 +0,0 @@
|
||||
@layer theme {
|
||||
/* Value rows.
|
||||
* display:flex + align-items:baseline come from base — do not restate, and do not switch to
|
||||
* center: a row's field can be taller than its control when a `.cbi-value-description` sits
|
||||
* under it, and center then pushes the label to the middle of control+description, visibly
|
||||
* below the control. baseline lines the label up with the field's first line for every
|
||||
* widget. */
|
||||
.cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: var(--fs-hairline); }
|
||||
/* stacked controls in a field (Local Time: date input + Sync buttons) need breathing room —
|
||||
* the raw <br> collapses them. Exclude the password reveal group: it must stay seamless. */
|
||||
.cbi-value-field > .control-group { margin-top: var(--fs-space-2); }
|
||||
.cbi-value-field > .control-group:not(:has(.cbi-input-password)) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center;
|
||||
}
|
||||
/* The hairline separates one value row from the NEXT one, so it is dropped whenever no value row
|
||||
* follows — not only at the end of the section. A section ending with a table or an action bar
|
||||
* otherwise draws the line across the top of whatever comes next, cutting through a framed
|
||||
* table's rounded corner. `:last-child` was the special case of this. */
|
||||
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
|
||||
/* A toggle/checkbox field has no text baseline: `.cbi-checkbox` is inline-flex with no line box,
|
||||
* so its synthesised baseline is the box's BOTTOM edge and align-items:baseline floats the
|
||||
* toggle ~5px above the label. Centre those rows instead — but only where no help sentence sits
|
||||
* under the control, since centring then drops the label to the middle of control+description,
|
||||
* the regression the comment above warns about. */
|
||||
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
|
||||
align-items: center;
|
||||
}
|
||||
/* The generic control transition — absorbed from base's transition group. ONLY the
|
||||
* transition: control typography stays in base on purpose (see the note there — as a
|
||||
* bare-element theme rule it out-layered `.zonebadge-empty`'s smaller font, measured
|
||||
* 10.8 -> 13px on the firewall). border-COLOR, not `border`: the shorthand animates
|
||||
* border-width too — a layout animation on every hover instead of a paint one. */
|
||||
input, button {
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
|
||||
/* keep base's padding-top (--fs-space-1) — it is tuned for base's align-items: baseline. */
|
||||
.cbi-value-title {
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
/* stated, not inherited: the control rule above hands every label `line-height: normal`,
|
||||
* and this one is TEXT — dropping it silently hands the label the font's own default
|
||||
* (measured: 18px -> normal, off the scale entirely). Absorbed from base with the rule
|
||||
* that made it necessary. */
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
/* Mono is for the VALUES a field holds. The exclusions:
|
||||
* buttons are chrome, not data — excluded here rather than relying on 55-buttons.css being
|
||||
* concatenated later. `:where()` keeps the exclusion at zero specificity, so the <select>
|
||||
* rule below still wins its own font.
|
||||
* .cbi-value-description is LuCI's inline HELP SENTENCE and lives inside .cbi-value-field.
|
||||
* `strong`/`b` — LuCI writes `<strong>MAC:</strong> ac:1f:6b:…`, so the strong is the LABEL
|
||||
* and the text after it the value. Feeding the label to the mono rule makes it mono AND
|
||||
* weight 700, a face of its own: 227 elements over seven pages in bold mono, and 20 KB of a
|
||||
* 600-weight mono face fetched to draw them.
|
||||
* The label rule itself is in theme/45-misc.css and must ASSIGN the sans face: a <strong> merely
|
||||
* excluded here still INHERITS mono from .cbi-value-field — excluding it alone was measured and
|
||||
* changed nothing. */
|
||||
.cbi-value-field,
|
||||
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
|
||||
/* text-wrap: the help sentence is a <div>, so base's prose rule (which reaches `p` and the
|
||||
* section/map descriptions) never covered it — and this is the one that wraps most, since it
|
||||
* sits in the field column. Same reasoning as there. */
|
||||
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
|
||||
|
||||
/* A text field in a CBI form is not a fixed 210px: that width is base's, and the same 210px
|
||||
* holds far less here — the field is monospaced and padded --fs-space-3 a side instead of 4,
|
||||
* so a server URL was clipped mid-domain while the .cbi-dynlist directly under it is 440px
|
||||
* wide (issue #5). Elastic, with the dynlist's own
|
||||
* ceiling so the two line up. Number/date/time keep the fixed width: they hold short values, and
|
||||
* a 440px box for a port number reads as a mistake. */
|
||||
.cbi-value-field input[type="text"],
|
||||
.cbi-value-field input[type="url"],
|
||||
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
|
||||
/* A .cbi-value is designed to hold ONE title+field pair, and base lays it out as a flex row. Some
|
||||
* apps cram several pairs into one .cbi-value, and the flex row then flows them side by side
|
||||
* until the fields overflow the card. Give each title and each field its own full-width row —
|
||||
* the stacked layout base already uses on a phone — scoped by `:has()` to the abnormal
|
||||
* multi-PAIR case only, so every normal single-field row keeps its label-beside-field layout.
|
||||
*
|
||||
* The test is the second TITLE, not the second field, and that is the whole of this rule's
|
||||
* safety: `:has()` is live, so keying on a second field means any app that appends one at
|
||||
* runtime — a status line, an output pane, a spinner holder — flips the row from "label beside
|
||||
* field" to "everything stacked" under the user's finger. Measured: pressing a button in such a
|
||||
* row moved that button 31px down. */
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
|
||||
flex: 1 1 100%;
|
||||
margin-inline-start: 0;
|
||||
text-align: start;
|
||||
}
|
||||
/* fields inside a table cell fill it — moved from base with the generic 210px box below:
|
||||
* in base these beat the box in-layer, left behind they would lose to it by layer */
|
||||
.td > input[type="text"],
|
||||
.td > input[type="password"],
|
||||
.td > select,
|
||||
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-dynlist > .add-item > .cbi-dropdown { width: 100%; }
|
||||
.cbi-section-create > * > input { width: 100%; }
|
||||
|
||||
/* --- live components that bypassed the theme --- */
|
||||
/* A CBI row is a <div class="cbi-value"> on a form and a <td class="cbi-value-field"> in a
|
||||
* section table — same <var>, and the theme had claimed only the table half. */
|
||||
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
|
||||
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
|
||||
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
|
||||
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
|
||||
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
|
||||
|
||||
/* A file-browser row must hold its own buttons, and the row CLIPS what does not fit rather than
|
||||
* showing it. base sizes the row by proportion (name `flex: 10`, actions `flex: 3`), which is
|
||||
* sound for base's own ~20px buttons in a text-height row; this theme's button is 36px tall with
|
||||
* 14px of side padding and a file row carries up to three of them, so at 23% of the row they do
|
||||
* not fit — and `.cbi-filebrowser > *` is `overflow: hidden`, so a button touches the clip box
|
||||
* on both axes and loses its rounded corners, which reads as a broken button.
|
||||
*
|
||||
* So: size the action column to its CONTENT and let the name give way (a name can ellipsize, a
|
||||
* button cannot), give the children a real gutter, and space the rows. It wraps rather than
|
||||
* clips when even that is not enough. */
|
||||
.cbi-filebrowser > * { border-bottom-color: var(--fs-border); padding: var(--fs-space-1-5) var(--fs-space-2); }
|
||||
.cbi-filebrowser > ul { display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||
.cbi-filebrowser > ul > li { align-items: center; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.cbi-filebrowser > ul > li > div:first-child { flex: 1 1 auto; min-width: 0; }
|
||||
/* `0 1 auto`, not `0 0 auto`: the actions block is three buttons (Deselect / Download / Delete) and
|
||||
* it may WRAP but may not shrink, so on a 320px screen it kept its one-line width and stuck 23px
|
||||
* out of the browser box — measured in the gallery at 320px, where `Delete` ended past the card.
|
||||
* Shrinking is what lets its own `flex-wrap` fire; `min-width: 0` is what lets shrinking reach
|
||||
* below the buttons' min-content, which is the pair every flex item in this theme needs. */
|
||||
.cbi-filebrowser > ul > li > div:last-child {
|
||||
flex: 0 1 auto; min-width: 0;
|
||||
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
|
||||
}
|
||||
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
|
||||
/* moved from base with the generic 210px box (same layer arithmetic as the .td rules) */
|
||||
.cbi-filebrowser .upload > div > input { width: 100%; }
|
||||
|
||||
/* Form widgets — toggles, file input. Applies to every CBI form. */
|
||||
|
||||
/* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the
|
||||
* checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words
|
||||
* (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox]
|
||||
* + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its own
|
||||
* margin: 0 outranks this rule by specificity ((0,2,1) vs this rule's (0,2,1) on a DIFFERENT
|
||||
* ancestor class, so the two never both match one element: `.cbi-checkbox` is never `.btn`). */
|
||||
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
|
||||
|
||||
/* checkbox -> toggle switch, drawn on the INPUT itself.
|
||||
*
|
||||
* Was drawn on label[for] with the real input hidden (position:absolute; opacity:0; width/
|
||||
* height:0) — forum.openwrt.org/t/251930 post #141: luci-app-modemdata (and any third-party CSS
|
||||
* built the way bootstrap expects, styling the native input) injects an unlayered
|
||||
* `label[for] { display: none }` flagged important, scoped to its own markup, which left
|
||||
* NOTHING visible, since the input it left alone was the invisible one. Drawing on the input
|
||||
* means an app that hides label[for] hides an already-empty placeholder, not the switch.
|
||||
*
|
||||
* The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they
|
||||
* make LuCI's most common control the only one in the theme that ignores the Density axis, while
|
||||
* every button and field around it moves: measured, the switch held 40x22 at Compact and at
|
||||
* Large while the fields beside it went 38 -> 32.3 -> 43.7. The switch is a box that holds a
|
||||
* knob, which is the
|
||||
* box tier by definition. Local names rather than new global tokens: one component needs one
|
||||
* number and three things derived from it.
|
||||
*
|
||||
* flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the
|
||||
* pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real
|
||||
* markup — makes the input (now the real, sized flex item; label[for] no longer is one, below) a
|
||||
* SECOND, shrinkable flex item. Unshrunk it stays exactly --sw-w; measured shrunk on the live
|
||||
* Install dialog on both stands when this box was still label[for]'s (task 0145): RU/Normal/390px
|
||||
* gave a used width of 30.17px against 40 declared and a knob 6.83px past the pill's right edge,
|
||||
* EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — flex-shrink: 0 alone took both
|
||||
* to -3.00px and -3.31px (the knob safely inside the pill, not flush with it). The flex row and
|
||||
* its sibling caption are unchanged by which element carries the pill, so the guard carries over.
|
||||
*
|
||||
* label[for] stays in the DOM (ui.js and every third-party app that builds this markup emit it)
|
||||
* but draws and occupies nothing: `[for]` still discriminates it from the SECOND label
|
||||
* Checkbox.render() appends when `tooltip` is set (form.Flag.tooltip — e.g. luci-app-firewall's
|
||||
* zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, which must keep
|
||||
* rendering. */
|
||||
/* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already
|
||||
* wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's
|
||||
* own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc
|
||||
* grows "неиспользуемые" (14 letters) to 20, wide enough on its own to push the whole row 24.6px
|
||||
* past `.cbi-value-field` at 320px/Large with no scroller — a real Russian compound this length
|
||||
* is not implausible (the theme already ships an untranslated one), so the row should absorb it
|
||||
* by breaking the word, not by escaping its column. */
|
||||
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
|
||||
/* label[for] takes over the input's old trick (position:absolute, zero box, opacity:0): out of
|
||||
* flow and out of the flex item count, so `.cbi-checkbox`'s `gap` still opens on only the SAME
|
||||
* two neighbours it always did — see the tooltip-container note below. */
|
||||
.cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||
.cbi-checkbox > input[type="checkbox"] {
|
||||
--sw-w: calc(40px * var(--fs-density-box));
|
||||
--sw-h: calc(22px * var(--fs-density-box));
|
||||
--sw-knob: calc(16px * var(--fs-density-box));
|
||||
--sw-pad: calc(2px * var(--fs-density-box));
|
||||
/* `appearance: none` and this compound's other unstated properties (color, the invalid
|
||||
* --bd-color/--fg-color custom props) still come from the bare `input[type="checkbox"]`
|
||||
* rule further down, written for a third-party app's raw markup: harmless to share, since
|
||||
* this input never reads --bd-color/--fg-color. The parts of that rule that WOULD paint
|
||||
* over this switch (the drawn box, the mark, the focus ring) are scoped with
|
||||
* `:not(:where(.cbi-checkbox > input))` at their own selector instead of being undone here —
|
||||
* cheaper than restating every property this rule already states for the opposite reason,
|
||||
* and it is one property, not eight, that both rules would otherwise fight over. */
|
||||
display: inline-block; position: relative; margin: 0; flex-shrink: 0;
|
||||
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
|
||||
background: var(--fs-track); border: var(--fs-hairline);
|
||||
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle;
|
||||
}
|
||||
/* The invisible overlay stretching the hit target to 44x30 (WCAG 2.5.8: the pill is 22px tall,
|
||||
* under the 24px floor) — moved here from label[for] unchanged. position:absolute, so it adds
|
||||
* nothing to layout; the knob keeps ::after.
|
||||
*
|
||||
* `content`/`position` come from the raw checkbox's own shared `::before, ::after { position:
|
||||
* absolute; content: "" }` rule further down — left UNSCOPED and reused here rather than
|
||||
* restated, since both pseudo-elements need exactly those two values anyway. The two rules
|
||||
* that would otherwise paint a drawn box and a mark ON this pseudo (their own selectors,
|
||||
* further down) are scoped with `:not(:where(.cbi-checkbox > input))` instead, so nothing is left to
|
||||
* override here.
|
||||
*
|
||||
* The inset stays literal while the pill above scales: this is an accessibility floor, and a
|
||||
* floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7,
|
||||
* over the minimum. */
|
||||
.cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; }
|
||||
.cbi-checkbox > input[type="checkbox"]::after {
|
||||
top: var(--sw-pad); inset-inline-start: var(--sw-pad);
|
||||
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
|
||||
background: var(--fs-dim);
|
||||
/* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on
|
||||
* every frame of the slide. translateX is composited. */
|
||||
transform: translateX(0);
|
||||
transition: transform var(--fs-dur-move), background var(--fs-dur);
|
||||
}
|
||||
/* The tooltip-container label (above) is unstyled by the switch rules and falls back to the
|
||||
* browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still
|
||||
* needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a
|
||||
* margin declared here: label[for] inside that flex row is `position: absolute` (above), so it
|
||||
* is never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox`
|
||||
* with just the switch input has one flex item (the input itself) and gets no gap at all, while
|
||||
* the tooltip fixture's input+tooltip-label pair gets exactly one. --fs-space-2 (8px), same
|
||||
* number the removed margin used — the gap the theme already uses beside an inline checkbox
|
||||
* glyph (`.btn > input[type="checkbox"]` above). The flex-item COUNT and the gap token are the
|
||||
* same as before this rule moved from label[for] to the input; only which element is the
|
||||
* absolute one swapped. */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
|
||||
/* The travel is derived rather than hand-written, so it cannot drift out of step with the three
|
||||
* sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a
|
||||
* border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so
|
||||
* the knob has 2px less room and deriving without that term lands it off-centre — the travel
|
||||
* comes out 20px where symmetry wants 18. The border does not scale with density, so the term
|
||||
* stays literal. */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after {
|
||||
transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
|
||||
background: var(--fs-on-accent);
|
||||
}
|
||||
/* The bare fallback ring further down (`input:focus`, for every untyped/unlisted input, and its
|
||||
* `input[type="checkbox"]:focus::before` twin) is scoped with `:not(:where(.cbi-checkbox > input))` at
|
||||
* its own selector, so a plain click on this real, visible input rings nothing — as it never
|
||||
* did before this input was the drawn element, since only :focus-visible below is meant to. */
|
||||
/* The <input> IS the switch now, so the ring goes there directly, or the most common control in
|
||||
* LuCI shows nothing under keyboard focus.
|
||||
*
|
||||
* -solo, because on the CHECKED switch — half the switches on any page — neither other channel
|
||||
* exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`,
|
||||
* so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the
|
||||
* same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */
|
||||
.cbi-checkbox > input[type="checkbox"]:focus-visible {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box
|
||||
* below, and left in base every one of those overrides would lose to that rule by layer. The box
|
||||
* is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number,
|
||||
* so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the
|
||||
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets.
|
||||
*
|
||||
* `.cbi-checkbox > input` matches the switch's own input too: this base rule stays unscoped for
|
||||
* it, since the switch's own rules above want its `appearance: none` and read neither
|
||||
* --bd-color nor --fg-color. But the drawn box, the mark and the focus ring below DO paint over
|
||||
* the switch once its input is a real, visible element rather than 0x0 — each of those three
|
||||
* selectors carries `:not(:where(.cbi-checkbox > input))` for that reason alone; undoing them instead,
|
||||
* property by property, on the switch's own rules costs more than the one exclusion clause
|
||||
* each needs. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
--bd-color: var(--fs-border);
|
||||
--fg-color: var(--fs-text);
|
||||
/* 16px on the grid, but on the BOX tier, not the space tier. `var(--fs-space-4)` was the
|
||||
* same 16px at Normal and the wrong multiplier everywhere else: space moves to .65 at
|
||||
* Compact and stays 1 at Large, so the tick box went to 10.4px (smaller than any other
|
||||
* control on the page — measured) and then refused to grow at Large. A checkbox is a box
|
||||
* that has to HOLD an icon, which is what --fs-density-box is defined for. */
|
||||
--box: calc(16px * var(--fs-density-box));
|
||||
--mark-inset: 3px;
|
||||
/* ---- the drawing is 16px; the target is 24px ----
|
||||
*
|
||||
* WCAG 2.2 SC 2.5.8 lets an undersized target pass on SPACING — 24px centre to centre — which
|
||||
* is what a 16px tick box has always relied on. That holds while the theme decides the layout
|
||||
* and stops the moment a third-party app lays out its own rows, putting this checkbox and a
|
||||
* link inside the 24px. A target that passes only because of somebody else's markup is not a
|
||||
* target that passes.
|
||||
*
|
||||
* So the ELEMENT is 24px and the drawing stays 16px, centred inside it: the two pseudo
|
||||
* elements are already absolutely positioned, so centring costs `inset: 0; margin: auto`.
|
||||
* Nothing moves visually — the box, the tick and the focus ring are all drawn by the pseudo
|
||||
* elements, and the element itself has neither background nor border.
|
||||
*
|
||||
* `max()`, not a flat 24px: the Large density scales `--box` to 18.4px, and a target must
|
||||
* never SHRINK when the user asks for a bigger UI. */
|
||||
--hit: max(24px, var(--box));
|
||||
appearance: none;
|
||||
width: var(--hit);
|
||||
height: var(--hit);
|
||||
/* …and the target does not cost a row its height: the element is 24px but occupies its old
|
||||
* `--box` in layout, the negative margin pulling the extra area back out of the flow so the
|
||||
* box overflows itself symmetrically. Measured on a third-party listing: rows went 42px ->
|
||||
* 51px with the target simply grown, and 43px with the margin, the target still answering to
|
||||
* a hit test at its centre. */
|
||||
margin: calc((var(--box) - var(--hit)) / 2);
|
||||
color: var(--fg-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
/* padding/border-radius are NOT reset: the generic input box's values land on an
|
||||
* appearance-none 16px box whose pseudo-elements are absolute — invisible, and what the
|
||||
* widget has always computed */
|
||||
}
|
||||
input[type="checkbox"]::before,
|
||||
input[type="checkbox"]::after,
|
||||
input[type="radio"]::before,
|
||||
input[type="radio"]::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
|
||||
input[type="radio"]::before {
|
||||
/* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with
|
||||
* a fixed size is the one centring that needs no translate and therefore behaves the same in
|
||||
* RTL, where an `inset-inline-start: 50%` + `translate: -50%` pair would push the drawing the
|
||||
* wrong way. */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: var(--box);
|
||||
height: var(--box);
|
||||
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
|
||||
border: 1px solid var(--bd-color);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* each box states its own radius — one shared 2px re-declared to 50% for the radio would
|
||||
* be a same-selector shadow inside this layer */
|
||||
input[type="checkbox"]::before { border-radius: 2px; }
|
||||
input[type="radio"],
|
||||
input[type="radio"]::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* keyboard/click focus rings on the DRAWN box — the theme's one ring token, replacing
|
||||
* base's old generic glow; :focus (not :focus-visible) keeps the pre-absorption behaviour
|
||||
* of ringing a clicked box. Both states (plain and invalid) are declared once, further
|
||||
* down this file, in the selector lists every other same-body ring joins — see the two
|
||||
* `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
|
||||
input[type="radio"]::after {
|
||||
/* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark
|
||||
* centres the same way rather than measuring from the target's own edge */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: calc(var(--box) - var(--mark-inset) * 2);
|
||||
height: calc(var(--box) - var(--mark-inset) * 2);
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
|
||||
input[type="radio"]:checked::after {
|
||||
/* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
|
||||
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
|
||||
* It replaces a solid filled path that was byte-identical to luci-theme-bootstrap's — and to
|
||||
* luci-theme-openwrt-2020's, which is the same project's artwork, not evidence the drawing is
|
||||
* generic. A mask keys on ALPHA, so a stroked path masks exactly as a filled one did. */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");
|
||||
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
background: var(--fg-color);
|
||||
}
|
||||
input[type="radio"]:checked:after {
|
||||
/* the radio's dot — filled, because a ring would read as "not selected". OURS: our grid,
|
||||
* our radius (the fill is the one shape a stroke cannot express). */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>");
|
||||
}
|
||||
input[type="checkbox"].cbi-input-invalid,
|
||||
input[type="radio"].cbi-input-invalid {
|
||||
--bd-color: var(--fs-danger);
|
||||
--fg-color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* The generic field box, absorbed from base's `input, textarea, select, …` group; each other
|
||||
* member lives with its component rule now. Bare `input` keeps the historical 210px box as the
|
||||
* fallback for every type the exhaustive text-input list below does not name — and for a
|
||||
* typeless <input>, which IS a text input. The widget types that must NOT wear this box carry
|
||||
* their own theme rules: a base override would lose to this rule by layer whatever its
|
||||
* specificity. */
|
||||
input {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
height: var(--fs-control-h);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
background: var(--fs-panel);
|
||||
color: var(--fs-text);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
}
|
||||
/* base's old `#localtime { width: 300px }` media rule was NOT moved here: the elastic
|
||||
* .cbi-value-field width above already out-layered it (measured on the live router: the
|
||||
* field rendered 440px, not 300) — the rule was dead, and a dead rule is deleted, not
|
||||
* relocated */
|
||||
|
||||
/* native file input — the `initial`/auto resets are absorbed from base: the generic box
|
||||
* above would otherwise dress the native chooser in a 210px bordered field */
|
||||
input[type="file"] {
|
||||
color: var(--fs-dim); font-size: var(--fs-type);
|
||||
padding: initial;
|
||||
border: initial;
|
||||
line-height: initial;
|
||||
box-shadow: none;
|
||||
/* no !important: this out-specifies the generic `input { width: 210px }` on its own.
|
||||
* height/background are NOT reset: the chooser keeps the generic box's control height
|
||||
* and panel tint, as it always has. */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* native <select> — custom chevron + theme (matching the text inputs). The chevron data-URI
|
||||
* lives in 03-palettes.css per palette/mode block, because a URI cannot read var(); it
|
||||
* replaced a hardcoded grey that ignored palette and dark mode. */
|
||||
select, select.cbi-input-select, .cbi-value-field select {
|
||||
/* unprefixed only: the floor (Chrome 108/Firefox 101/Safari 15.4) clears unprefixed
|
||||
* `appearance` everywhere (Chrome 84, Firefox 80, Safari 15.4 — docs/css.md). -webkit- was
|
||||
* 96 B across these four rules for nothing this sheet still has to run on. */
|
||||
appearance: none;
|
||||
background-color: var(--fs-panel2);
|
||||
background-image: var(--fs-select-chevron);
|
||||
/* right inset from the scale, not a literal 12 — the .cbi-select shell below states the same
|
||||
* position and two spellings of one number is how they would drift apart */
|
||||
/* two 6px bands forming the V, the far one a band-width further in; --fs-space-3 stays the
|
||||
* single spelling of the inset, as the note above says */
|
||||
background-repeat: no-repeat; background-size: 6px 6px;
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
padding: var(--fs-space-2) var(--fs-space-8) var(--fs-space-2) var(--fs-space-3);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type);
|
||||
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
|
||||
/* width/display absorbed from base's generic field box: a select's natural width is its
|
||||
* longest option, so without the fixed box two selects in one form column ragged-edge;
|
||||
* display stated because Chromium's base-select UA default is inline-flex, not the
|
||||
* inline-block the widget has always computed */
|
||||
width: 210px;
|
||||
display: inline-block;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* group-then-refine, directly under its group: the listbox variant must NOT take the closed
|
||||
* control's one-line height. A SURFACE token, not an ink one: this once read --fs-on-accent
|
||||
* (the ink meant to go ON an accent fill), which resolves to plain white and painted a white
|
||||
* listbox in dark mode. `height: inherit` absorbed from base with the width above — without
|
||||
* it the fixed box would cap a multi-row listbox at one line. */
|
||||
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); height: inherit; }
|
||||
/* :focus joins the shared accent ring in the text-input group below. */
|
||||
/* absorbed from base's reset: 100% (= inherit) keeps the option on its select's size, and
|
||||
* only <option> needs the 2em leading — the other controls carry line-height: normal above */
|
||||
option { font-size: 100%; line-height: 2em; }
|
||||
select option { background: var(--fs-panel); color: var(--fs-text); }
|
||||
|
||||
/* Native <select> drop list: the OS renders it, so the `select option` colours above are ignored
|
||||
* on most platforms. Chromium's customisable-select (`appearance: base-select`) renders the
|
||||
* picker in the DOM, so it can be themed like the .cbi-dropdown menu; @supports-gated, and
|
||||
* browsers without it keep the styled closed control plus the native list.
|
||||
*
|
||||
* Scoped to `.cbi-value-field select` only: a third-party app can mount a raw <select> outside
|
||||
* the CBI form and drive it its own way, and early Chrome builds mis-render such a select as a
|
||||
* single-item list. Those sit outside .cbi-value-field, so this scope leaves them native. */
|
||||
@supports (appearance: base-select) {
|
||||
.cbi-value-field select { appearance: base-select; }
|
||||
.cbi-value-field select::picker-icon { display: none; }
|
||||
::picker(select) {
|
||||
appearance: base-select;
|
||||
background: var(--fs-panel); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
|
||||
padding: var(--fs-space-1-5); margin-top: var(--fs-space-1-5);
|
||||
}
|
||||
.cbi-value-field select option {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm); font-size: var(--fs-type);
|
||||
/* no `color` here: `select option` above already sets --fs-text, and this restated it
|
||||
* (switched off against 174 options, nothing moved). `background: transparent` is NOT the
|
||||
* same — it deliberately undoes that rule's --fs-panel so the row's own hover shows. */
|
||||
background: transparent; min-height: 0;
|
||||
}
|
||||
.cbi-value-field select option::checkmark { display: none; }
|
||||
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
|
||||
/* Opaque surface + inset rail, same shape as the open .cbi-dropdown's selected row
|
||||
* (theme/65-dropdown.css): accent text on --fs-accent-soft measured 4.21:1 in dark,
|
||||
* under AA — the tint of the accent eats its own contrast. */
|
||||
.cbi-value-field select option:checked {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
}
|
||||
|
||||
/* .cbi-select — the legacy luci-compat shell (a div around a native <select>), absorbed from base
|
||||
* whole: the generic field box, the shell's own paint and a focus ring on the theme's token. The
|
||||
* shell is a DIV, so display and width must be stated. `:not(select)` because the class also
|
||||
* appears ON a bare <select>: a replaced element cannot draw the ::before chevron, so shell
|
||||
* paint there means a gradient with no glyph. */
|
||||
/* It paints like the themed <select>, being the same control in an older shell — never a
|
||||
* skeuomorphic surface of its own: a gradient from panel to border is lighter at the bottom in
|
||||
* the dark palettes, and nothing else in the theme has a gradient at all. The geometry below is
|
||||
* the `select` rule above, property for property, so a legacy shell and a modern select line up
|
||||
* in one form column. This is the non-JS fallback: the people who see it already have something
|
||||
* broken. */
|
||||
.cbi-select:not(select) {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
/* `height`, NOT `min-height` — the inner select fills the shell with `height: 100%`, and a
|
||||
* percentage against an auto-height parent has no definite containing block to resolve
|
||||
* against, so it falls back to content height: measured, the select collapsed 30px -> 15px
|
||||
* inside a 38px shell, text at the top and half the hit area gone. The themed `select` can
|
||||
* use a floor because it IS the control; a wrapper has to state a height for its child. */
|
||||
height: var(--fs-ctl-h);
|
||||
color: var(--fs-text);
|
||||
font-size: var(--fs-type);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background-color: var(--fs-panel2);
|
||||
background-image: var(--fs-select-chevron);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 6px 6px;
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* The disabled cue used to live ENTIRELY on the ▾ plate: base/30-forms.css dims
|
||||
* `.cbi-select[disabled]::before` and never the shell itself. With the plate gone the state had
|
||||
* no visual at all, so the shell carries it now. */
|
||||
.cbi-select[disabled]:not(select) {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* .focus and .cbi-input-invalid.focus join the shared accent/danger ring rules further down
|
||||
* this file — same two bodies as every other :focus/:focus-within ring on this token. */
|
||||
/* the native <select> inside the shell: invisible, filling it, clearing the ▾ plate on the
|
||||
* right. The inset is a scale step; the right margin is the plate's width, so it stays
|
||||
* em-relative to the glyph it clears. Every themed-select declaration above (chevron,
|
||||
* padding, min-height) must be answered here, or the inner select paints like a free-standing
|
||||
* one inside the 32px shell. */
|
||||
.cbi-select select,
|
||||
.cbi-select select:focus {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
/* clears the CHEVRON, which the shell now paints on its own background — the same
|
||||
* --fs-space-8 inset the themed select uses. `margin-right: .6em` used to clear the ▾ plate
|
||||
* instead, and an em-relative margin against a px-positioned glyph is how the two drifted. */
|
||||
padding: 0 var(--fs-space-8) 0 var(--fs-space-3);
|
||||
font-size: var(--fs-type);
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* spinner / loading */
|
||||
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
|
||||
|
||||
/* Text inputs and other field widgets, rounded and themed to match the selects and dropdowns
|
||||
* (base leaves them at radius 3 and white). Excludes checkbox/radio and file, handled above.
|
||||
*
|
||||
* The type list must be EXHAUSTIVE: an omission does not fall back to "unstyled", it falls back
|
||||
* to BASE, which still paints a stock white 3px-radius field beside a themed one. */
|
||||
.cbi-input-text, .cbi-input-password, .cbi-input-textarea,
|
||||
.cbi-section-create-name, .cbi-value-field input.create-item-input,
|
||||
input[type="text"], input[type="password"], input[type="number"],
|
||||
input[type="email"], input[type="url"], input[type="search"],
|
||||
input[type="tel"], input[type="date"], input[type="time"],
|
||||
input[type="datetime-local"], input[type="month"], input[type="week"],
|
||||
input[type="color"],
|
||||
textarea {
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-text);
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
min-height: var(--fs-ctl-h);
|
||||
box-shadow: none;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* width/font-size absorbed from base's generic field box, same numbers — a textarea has
|
||||
* always sat at the fixed 210px (the elastic rule above names only text/url/search) */
|
||||
/* `max-width: 100%` beside the width, because a textarea is the one field whose intrinsic width
|
||||
* comes from an ATTRIBUTE (`cols`, default 20) rather than from the box: measured at a 300px
|
||||
* viewport, two textareas in the gallery stood 19px outside their card while every other control
|
||||
* in the same column fitted. The 210px is the designed width and still applies wherever there is
|
||||
* room for it. */
|
||||
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type); }
|
||||
/* A textarea's native scrollbar is a square strip and a rounded corner does not clip it, so on a
|
||||
* scrolling config field the grey bar pokes past the radius as a square notch. Fix: a slim
|
||||
* self-rounded thumb floated off the edges (transparent border plus background-clip:
|
||||
* padding-box), a transparent track, and a track margin keeping the thumb clear of the corners.
|
||||
*
|
||||
* Deliberately no `scrollbar-width`/`scrollbar-color`: either makes Chromium switch to the
|
||||
* standard scrollbar and ignore this whole ::-webkit block — and that standard bar is the
|
||||
* square, unclipped one. Firefox has no ::-webkit and clips natively.
|
||||
*
|
||||
* 16px wide so ::-webkit-resizer, sized off the scrollbar, gets a bigger grab area; the 5px
|
||||
* transparent thumb border still leaves it 6px visible. */
|
||||
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
|
||||
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: var(--fs-dim); border-radius: var(--fs-radius-pill);
|
||||
border: 5px solid transparent; background-clip: padding-box;
|
||||
}
|
||||
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
|
||||
/* The bottom-right square where the vertical bar meets the resize grip defaulted to white
|
||||
* and poked past the rounded corner — transparent, so the frame shows. */
|
||||
textarea::-webkit-scrollbar-corner { background: transparent; }
|
||||
/* The resize grip highlights the frame's bottom-right corner — an accent arc tracing the frame's
|
||||
* own radius, with no straight legs. Two borders plus the corner radius draw the arc, and the
|
||||
* legs vanish because the box is shrunk to exactly the radius: the resizer is the scrollbar's
|
||||
* 16px, so a `16px − radius` margin leaves a radius-sized box whose right and bottom border IS
|
||||
* the quarter curve. (A mask would give a shorter arc, but ::-webkit-resizer ignores mask, and a
|
||||
* baked-in SVG colour would not follow the palette.) */
|
||||
/* PHYSICAL on purpose, and mirrored by hand below: ::-webkit-resizer is a UA-drawn corner whose
|
||||
* side follows the writing direction, and it does not accept logical properties — with only the
|
||||
* LTR rule the arc drew on the wrong corner in an RTL document (a stray quarter-circle on the
|
||||
* left of every textarea). */
|
||||
textarea::-webkit-resizer {
|
||||
background: transparent;
|
||||
border-right: 2px solid var(--fs-accent);
|
||||
border-bottom: 2px solid var(--fs-accent);
|
||||
border-bottom-right-radius: var(--fs-radius);
|
||||
margin: calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius));
|
||||
}
|
||||
:root[dir="rtl"] textarea::-webkit-resizer {
|
||||
border-right: 0; border-left: 2px solid var(--fs-accent);
|
||||
border-bottom-right-radius: 0; border-bottom-left-radius: var(--fs-radius);
|
||||
margin: calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0;
|
||||
}
|
||||
|
||||
.cbi-input-text:hover, .cbi-input-password:hover, .cbi-input-textarea:hover,
|
||||
input[type="text"]:hover, input[type="password"]:hover, input[type="number"]:hover,
|
||||
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
|
||||
textarea:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
|
||||
* for the types the list does not name and for a typeless <input>; on a bare checkbox/radio it
|
||||
* lands on the drawn box UNDER the ::before ring below — coincident boxes, one visible ring, as
|
||||
* the old glow behaved. `:not(:where(.cbi-checkbox > input))` excludes the toggle switch, whose input is
|
||||
* a real, visible element with its own :focus-visible-only ring (above) — a plain click on it
|
||||
* must ring nothing. `select:focus` and the keyboard-removable dynlist chip (ui.js gives it a
|
||||
* tabindex) join here rather than repeating the same three declarations under their own
|
||||
* selector: one shared body instead of three, 149 B off cascade.css. */
|
||||
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
|
||||
.cbi-section-create-name:focus,
|
||||
input:focus:not(:where(.cbi-checkbox > input)),
|
||||
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
|
||||
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
|
||||
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.cbi-dynlist > .item:focus {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* The plain accent ring above, restated for four components whose selector cannot join that
|
||||
* list without changing what it matches (a pseudo-element, a legacy shell class, two
|
||||
* `:focus-within` containers) — same two declarations, one shared body instead of four, 186 B
|
||||
* off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a
|
||||
* `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the
|
||||
* floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one
|
||||
* selector. `:not(:where(.cbi-checkbox > input))` on the checkbox part for the same reason as the plain
|
||||
* ring above: the toggle switch's own :focus-visible rule is the only one allowed to ring it. */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
|
||||
.cbi-select.focus,
|
||||
.cbi-dynlist > .add-item:focus-within,
|
||||
.control-group:is(:has(> .cbi-input-password)):focus-within {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* Invalid state keeps the danger cue. `.cbi-value-error input` is the Lua-CBI path and belongs
|
||||
* HERE, not in base: base does declare a red border-color for it, but the rule above sets the
|
||||
* `border` SHORTHAND, and a shorthand in a later layer wipes the longhand out regardless of
|
||||
* specificity — so every third-party app on luci-compat rendered the field in --fs-border grey
|
||||
* and had lost its field-error cue while the modern .cbi-input-invalid path worked.
|
||||
*
|
||||
* `.cbi-input-invalid:focus` is not redundant with the rule it sits beside: the generic
|
||||
* `input:focus` above is (0,1,1) and would repaint a focused invalid field accent, and
|
||||
* `.cbi-input-invalid:focus` at (0,2,0) is what stops it — the TYPED `:focus` rules above are
|
||||
* (0,2,1) and still outrank this, so a typed invalid field focuses accent, exactly as before
|
||||
* the absorption. The checkbox/radio `::before` ring and the legacy `.cbi-select` shell need
|
||||
* the identical danger body for the same reason on their own elements — one shared rule
|
||||
* instead of four, 186 B off cascade.css. */
|
||||
input[type="checkbox"].cbi-input-invalid:focus::before,
|
||||
input[type="radio"].cbi-input-invalid:focus::before,
|
||||
.cbi-select.cbi-input-invalid.focus,
|
||||
.cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid,
|
||||
.cbi-value-error input, .cbi-value-error textarea,
|
||||
.cbi-input-invalid:focus {
|
||||
border-color: var(--fs-danger);
|
||||
box-shadow: var(--fs-focus-ring-invalid);
|
||||
}
|
||||
/* The same rejected value shown by a ui.Dropdown rather than an <input>: the widget itself and,
|
||||
* when it is closed, the <li> that displays the value. base carried these two and the theme only
|
||||
* the input, so half of one state lived in a layer the theme cannot reach. */
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
|
||||
|
||||
/* readonly keeps its faded border — moved from base with the generic box (its border-color
|
||||
* longhand would lose to the box's `border` shorthand by layer). The doubled [readonly] is
|
||||
* a deliberate specificity step: at (0,1,1) this ties the typed input rules above and the
|
||||
* cascade would rest on file order — in base the same tie made the fade silently LOSE to
|
||||
* the typed border, so a readonly text field never faded at all; (0,2,1) states the intent. */
|
||||
:is(input, select, textarea)[readonly][readonly] {
|
||||
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
|
||||
pointer-events: auto;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* dynamic list: keep base's layout (inline-flex column of chips + one add-item row) — do NOT
|
||||
* set display/flex-wrap here or the add row breaks. Only colours, rounding, a compact +
|
||||
* button, and no wrapping box: capped narrow so the chip's × sits near the text instead of
|
||||
* floating across a full-width field. */
|
||||
.cbi-dynlist {
|
||||
gap: var(--fs-space-1-5);
|
||||
background: transparent; border: 0; padding: 0;
|
||||
/* `min(240px, 100%)`, not a bare 240: the floor is there so a chip's × sits near its text
|
||||
* rather than floating across a full-width field, and it becomes a defect the moment the
|
||||
* column is narrower than the floor — measured in the gallery at a 320px viewport, the list
|
||||
* held 240px inside a 210px field and every chip, the add row and its input stood 29px
|
||||
* outside the card. A floor that exceeds the room is not a floor, it is an overflow. */
|
||||
min-height: 0; min-width: min(240px, 100%); max-width: var(--fs-field-max); width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cbi-dynlist > .item {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text);
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* a chip is keyboard-removable (ui.js gives it a tabindex): :focus joins the shared accent
|
||||
* ring in the text-input group above. */
|
||||
/* The chip's × plate is a BUTTON in base's paint group (`.item::after, .btn, .cbi-button`) —
|
||||
* absorbed with it, bare as base had it (coverage: an .item outside a dynlist keeps its
|
||||
* plate). The machinery — content/position/edges/align — stays in base with the widget. */
|
||||
.item::after {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(var(--fs-panel), var(--fs-panel) 25%, var(--fs-panel2));
|
||||
padding: 0 var(--fs-space-3);
|
||||
color: var(--fs-text);
|
||||
line-height: 2em;
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* the hover cue is the lift, not a colour: the plate's fill is a gradient (see
|
||||
* --fs-hover-lift in 02-tokens.css) — replaces base's old focus-glow-on-hover */
|
||||
.item:hover::after { filter: var(--fs-hover-lift); }
|
||||
.cbi-dynlist > .item::after {
|
||||
/* display/padding absorbed from base's machinery rule: the bare plate above says
|
||||
* inline-block / space-3, and by layer it would beat base's refinement */
|
||||
display: inline-flex;
|
||||
padding: 0 var(--fs-space-2);
|
||||
color: var(--fs-danger);
|
||||
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
|
||||
}
|
||||
/* text add-item = one bordered input group (input + + button attached) */
|
||||
.cbi-dynlist > .add-item {
|
||||
align-items: stretch;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm); overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* :focus-within joins the shared accent ring in the text-input group above. */
|
||||
.cbi-dynlist > .add-item > input,
|
||||
/* `width: auto` keeps the flex basis in charge: this input sits INSIDE .cbi-value-field, so the
|
||||
* elastic width above would otherwise hand it 100% of the field on top of `flex: 1 1 auto`. */
|
||||
.cbi-dynlist > .add-item > .cbi-input-text {
|
||||
flex: 1 1 auto; min-width: 0; width: auto;
|
||||
border: 0; background: transparent;
|
||||
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
|
||||
}
|
||||
|
||||
/* password reveal: input + ∗ button as ONE seamless rounded group.
|
||||
* `gap: 0` is load-bearing: base gives every .control-group a gap, and this is the one group
|
||||
* whose members must touch — the group draws the single border and clips them. */
|
||||
.control-group:has(> .cbi-input-password) {
|
||||
display: inline-flex; align-items: stretch; gap: 0;
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.control-group:has(> .cbi-input-password) > .cbi-input-password {
|
||||
border: 0; border-radius: 0;
|
||||
background: transparent; box-shadow: none;
|
||||
min-height: var(--fs-ctl-h);
|
||||
}
|
||||
/* :focus-within (wrapped in :is() for the shared list) joins the accent ring in the
|
||||
* text-input group above. */
|
||||
/* base pulls .cbi-section-create up 3px to hug a table border, which glues the "Add" button
|
||||
* to the last row — give it a real gap instead. */
|
||||
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* `.hidden` must win over every component rule in this layer. An enumerated list
|
||||
* (`.cbi-value.hidden`, `.cbi-section.hidden`, …) only ever covers the elements someone has
|
||||
* already been bitten by, and the one it misses is the Enter-key submit every legacy Lua page
|
||||
* carries: luci-compat opens each form with `<input type="submit" class="hidden">`, and an
|
||||
* attribute selector counts as a class, so `input[type="submit"]` at (0,1,1) beats a bare
|
||||
* `.hidden` at (0,1,0) and a stray "Save" button floats above the page title (issue #12).
|
||||
* Doubling the class states the invariant once at (0,2,0) instead of chasing elements. An
|
||||
* `!important` would do it too, and would invert the layer order for every future override.
|
||||
* `fs-sheets.js` (rehostIntoThemeLayer) re-hosts a foreign app's own sheet into `@layer theme`,
|
||||
* where it now competes on specificity instead of losing to an unlayered rule by layer order
|
||||
* alone: measured on owrt2512 with luci-app-splify2 26.9, `<aside class="hidden lg:flex …">`
|
||||
* (Rail.tsx) stayed `display: none` at 1440/1920px because `.hidden.hidden` at (0,3,0) — see
|
||||
* below — outranked `.lg\:flex` at (0,1,0). `:not([class*=":"])` excludes any class list that
|
||||
* carries a Tailwind/UnoCSS responsive variant (the colon in `lg:flex`, `md:block`, `sm:inline`)
|
||||
* from the invariant above; LuCI itself never emits a class containing `:`, so nothing stock
|
||||
* is affected. Specificity moves to (0,3,0) — `:not()` counts its argument's attribute
|
||||
* selector, (0,1,0) — still clear of `input[type="submit"]` at (0,1,1) (issue #12, unchanged). */
|
||||
.hidden.hidden:not([class*=":"]) { display: none; }
|
||||
/* RangeSlider (UIRangeSlider) — the one LuCI widget base leaves to native browser rendering.
|
||||
* Token-driven track/thumb.
|
||||
* The INPUT box is the WCAG 2.2 SC 2.5.8 target (24px min): it used to be the 6px track
|
||||
* itself, measured 258x6 on owrt2512/390x844 (System > System). Box and paint split here —
|
||||
* a transparent 24px box, the 6px bar drawn by the track pseudo-element inside it.
|
||||
*
|
||||
* A flat `height: 24px` DOES move the row, and not through `.cbi-range-slider`'s own cross
|
||||
* size — that stayed 29.5px throughout, already pinned by the value chip beside it. It moves
|
||||
* through the enclosing LINE BOX: `.cbi-value-field` lays this element out as ordinary inline
|
||||
* content, and the line's height is anchored on where `.cbi-range-slider` (inline-flex, no
|
||||
* baseline-participating child) reports ITS baseline to the ancestor line — which tracks the
|
||||
* first flex item's own edge and moved with the input's box. Measured before either fix below:
|
||||
* `.cbi-value-field` +2.75px (Chromium) / +2.5px (Firefox), `.cbi-value` +1.5px / +1.0px,
|
||||
* track/thumb ~1.25px lower.
|
||||
*
|
||||
* First half of the fix is the checkbox's own trick (`--hit`/`--box` below): grow the box for
|
||||
* the hit target, then pull the extra area back out of flow with a symmetric negative margin
|
||||
* so the box overflows itself around its old centre instead of pushing something taller. 6px
|
||||
* was the box before this rule; -9px is (6px - 24px) / 2. This is what keeps a BARE range
|
||||
* input (no `.cbi-range-slider` wrapper, no taller sibling — a third-party app's own markup)
|
||||
* from growing its own row, the same way the checkbox's margin protects a row with no toggle
|
||||
* label beside it. Measured on its own, though, it left THIS row's growth above unchanged: the
|
||||
* line-box placement of `.cbi-range-slider` tracks the input's own edge, not the flow space the
|
||||
* margin trick frees up, so the second half below is still needed on top of it. */
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
width: 100%; height: 24px; min-height: 0; padding: 0;
|
||||
margin-block: -9px;
|
||||
background: transparent; border: 0; border-radius: 0;
|
||||
outline: none; box-shadow: none; cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--fs-accent); border: 2px solid var(--fs-panel);
|
||||
cursor: pointer;
|
||||
/* WebKit centres the thumb on the INPUT's box, not the track: (6 - 16) / 2 = -5px pulls
|
||||
* it back onto the 6px track. Firefox centres ::-moz-range-thumb on the track itself. */
|
||||
margin-top: -5px;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
|
||||
}
|
||||
/* -solo: the track sets `border: 0` and `outline: none`, so there is no second channel here
|
||||
* either — the tinted ring was the whole indicator on a 6px bar. Set on the track pseudo,
|
||||
* not the input, so the ring still hugs the 6px bar rather than the 24px hit box. */
|
||||
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
|
||||
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
|
||||
/* THE SAME CEILING THE ELASTIC TEXT INPUTS TAKE, and the reason is the whole point of the
|
||||
* token: a `.cbi-value-field` is `flex: 1`, so `width: 100%` here meant the slider ate the
|
||||
* entire card while the select two rows above stopped at 210px and the readout chip was flung
|
||||
* to the card's right edge. Every stretching control in a form row now ends at one x.
|
||||
*
|
||||
* `vertical-align: middle` answers the second half of the 24px-target growth explained above
|
||||
* `input[type="range"]`. Default (`baseline`) places this inline-flex box in its line by the
|
||||
* edge of its first flex item — the range input — so that edge moving 6px -> 24px still pushed
|
||||
* `.cbi-value-field`'s line taller even with the margin trick already in place. `middle` places
|
||||
* the box by its own total height instead, which the margin trick already held at its old
|
||||
* 29.5px (pinned by the value chip, unaffected by the input's growth), decoupling the two.
|
||||
* Measured on the gallery slider fixture, both engines, this rule plus the margin trick against
|
||||
* HEAD: `.cbi-value-field` +0.000px in both, `.cbi-value` -0.187px (Chromium) / -0.183px
|
||||
* (Firefox) — under a fifth of a CSS px, not a pixel a screen can render differently. Box
|
||||
* unchanged at 258x24. */
|
||||
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
|
||||
.cbi-range-slider-value {
|
||||
background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
|
||||
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);
|
||||
/* NOT 600: no face is bundled, so 600 either drags a 20 KB file back in or is faked by
|
||||
* synthetic emboldening, which smears a monospace grid.
|
||||
* The chip already reads as emphatic from its surface, border and accent ink. */
|
||||
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
|
||||
}
|
||||
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
@layer theme {
|
||||
/* Generic .dropdown-menu popup. Nothing in luci-base emits it — the split "Save & Apply" button is
|
||||
* a `.cbi-dropdown`, a different widget — so this is coverage vocabulary: stock
|
||||
* luci-theme-bootstrap styles it, so a third-party luci-app-* may emit it and expect a themed
|
||||
* result — theme/10-chrome.css claims the same class for the same reason. Not the theme's nav
|
||||
* menu, which is a plain nested <ul>.
|
||||
* Hover text is accent to match the open cbi-dropdown list: this is a form control's menu. */
|
||||
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus-visible { background: var(--fs-panel2); background-image: none; color: var(--fs-accent); }
|
||||
|
||||
/* Split "Save & Apply": a plain dropdown in the action bar carries no frame of its own.
|
||||
*
|
||||
* The guard is the same `:not(.btn):not(.cbi-button)` the widget rules below use, and it is
|
||||
* load-bearing: the split control's outer div IS the button, and the class on it is the one
|
||||
* ui.js picked for the SELECTED mode — `cbi-button-apply`, or `cbi-button-negative` the moment
|
||||
* the user picks "Apply unchecked" (luci.js's ComboButton `classes` map). Unguarded, this rule
|
||||
* (0,2,0) erases that variant's fill while its ink survives from the variant rule that sets no
|
||||
* background: a button painted page-on-page, at 1.06-1.13:1. */
|
||||
.cbi-page-actions .cbi-dropdown:not(.btn):not(.cbi-button) {
|
||||
border: 0; border-radius: var(--fs-radius); background: transparent;
|
||||
}
|
||||
/* Apply is the one variant with no fill of its own — `.cbi-button-action` and the rest carry
|
||||
* theirs from theme/55-buttons.css, and `.cbi-button-apply` is filled only where it is THE
|
||||
* primary action, which is this bar. Painted by SPECIFICITY — it used to be a rule written
|
||||
* afterwards, i.e. a fix that depended on source order. */
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button {
|
||||
background: var(--fs-accent);
|
||||
background-image: none;
|
||||
border: 0;
|
||||
color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button:hover { filter: var(--fs-hover-lift); }
|
||||
|
||||
/* ---- cbi-dropdown widget: the JS select used across all forms ----
|
||||
* Restyled to the design dropdown (panel2, rounded, chevron, focus ring) with a themed open
|
||||
* list. A native <select> is styled separately.
|
||||
*
|
||||
* The widget's MACHINERY is here too, absorbed from base whole. ui.js drives it purely through
|
||||
* attributes ([open], [multiple], [empty], [more], [display], [selected]) and the rules below
|
||||
* turn those into a working combobox. The display state machine is UNFLAGGED: upstream resolved
|
||||
* the states with !important, but flag-vs-flag falls back to specificity-then-order anyway, so
|
||||
* the machine below states the same resolution openly. The flags came in with the absorbed
|
||||
* widget from a theme with no @layer, which needed them to beat its own bare `ul` rule. */
|
||||
.cbi-dropdown {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
.cbi-dropdown img {
|
||||
/* constrain a vector icon of any intrinsic size to the text line */
|
||||
max-height: 1.6em;
|
||||
width: auto;
|
||||
}
|
||||
.cbi-dropdown > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
/* clears the button's own side inset so the label starts on its text, not on its border.
|
||||
* Unflagged: (0,3,1) over the rule above's (0,1,1) — it only ever needed a flag to beat that
|
||||
* rule's flag. */
|
||||
.cbi-dropdown.btn > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
|
||||
margin: 0 0 0 var(--fs-space-3);
|
||||
}
|
||||
.cbi-dropdown.btn.spinning > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) {
|
||||
margin: 0;
|
||||
}
|
||||
.cbi-dropdown > ul.preview {
|
||||
display: none;
|
||||
}
|
||||
/* no `display` here: `.open` states its own below, and `.more` is display: none until the
|
||||
* [multiple][more]/[multiple][empty] showers flip it — one declaration per state, or the
|
||||
* audit's same-selector shadow check fires on this very pair */
|
||||
.cbi-dropdown > .open,
|
||||
.cbi-dropdown > .more {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
/* margin only: base also gave the button's chevron a .5em padding and a border-left divider,
|
||||
* but both were DEAD on arrival — the theme's `.open` rule below zeroes padding and border in
|
||||
* a later layer. A dead declaration is deleted, not relocated. */
|
||||
.cbi-dropdown.btn > .open,
|
||||
.cbi-dropdown.cbi-button > .open {
|
||||
margin-inline-start: .5em;
|
||||
}
|
||||
/* display only: the placeholder's ink is the theme's own `li[placeholder]` --fs-placeholder
|
||||
* below — the same token base/30-forms.css gives the attribute */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
|
||||
display: none;
|
||||
}
|
||||
.cbi-dropdown > ul > li {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* the ellipsis above did nothing: a flex item's automatic minimum floors it at its OWN
|
||||
* content width until `min-width` says otherwise, so `overflow: hidden` never engaged and a
|
||||
* value longer than the box ran past it instead of eliding — measured at 320px, RU cut
|
||||
* mid-glyph 132px (Compact) / 225px (Normal) / 280px (Large) on https-dns-proxy, 66px on
|
||||
* nlbw/config, 70px inside the network interface-edit modal. No `title`/`aria-label` here on
|
||||
* purpose: the full string stays in the DOM and the accessibility tree, so a screen reader
|
||||
* still gets it whole — a `title` is not announced on touch, and an `aria-label` carrying the
|
||||
* SHORTENED text would violate WCAG 2.5.3 (the accessible name must contain the visible
|
||||
* text). */
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
color: inherit;
|
||||
}
|
||||
.cbi-dropdown > ul > li .hide-open { display: initial; }
|
||||
.cbi-dropdown > ul > li .hide-close { display: none; }
|
||||
/* the chip divider on a BUTTON dropdown's preview; the form dropdown zeroes it below */
|
||||
.cbi-dropdown > ul > li[display]:not([display="0"]) {
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
.cbi-dropdown[empty] > ul {
|
||||
max-width: 1px;
|
||||
}
|
||||
.cbi-dropdown > ul > li > form {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cbi-dropdown > ul > li img {
|
||||
vertical-align: middle;
|
||||
margin-inline-end: .25em;
|
||||
}
|
||||
.cbi-dropdown > ul > li > form > input[type="checkbox"] {
|
||||
margin: 0 var(--fs-space-1) 0 0;
|
||||
}
|
||||
|
||||
/* ---- the display state machine, on specificity ----
|
||||
* Every shower out-specifies the hider it overrides: li[display] (0,2,2) over the li hider
|
||||
* (0,1,2); [open] > ul.dropdown > li (0,3,2) likewise; the placeholder rules (0,5,2 and 0,6,2)
|
||||
* over the placeholder hider (0,4,2); the form rule (0,4,3) over its hider (0,1,3). [empty]'s
|
||||
* block (0,2,2) loses to [open]'s flex (0,3,2) deliberately — an open empty list still lays out
|
||||
* as a popup — which is the resolution the old flags produced among themselves. */
|
||||
.cbi-dropdown > ul > li[display],
|
||||
.cbi-dropdown[open] > ul.preview,
|
||||
.cbi-dropdown[open] > ul.dropdown > li,
|
||||
.cbi-dropdown[multiple] > ul > li > label,
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li,
|
||||
.cbi-dropdown[multiple][more] > .more,
|
||||
.cbi-dropdown[multiple][empty] > .more {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
/* The one state the old flags actually carried: a closed optional single dropdown with an empty
|
||||
* value. ui.js hands the [display] attribute to the placeholder row itself, and the placeholder
|
||||
* hider (0,4,2) out-specifies the generic li[display] shower (0,2,2), so unflagged the closed
|
||||
* control renders EMPTY. `:not([open])` keeps this out of the open list, where the
|
||||
* [optional][open] rule below owns the row. */
|
||||
.cbi-dropdown:not([open]):not(.btn):not(.cbi-button) > ul > li[placeholder][display] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[empty] > ul > li,
|
||||
.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] {
|
||||
display: block;
|
||||
}
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li > form {
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
|
||||
/* `min-width` because the thing that overflows an open row is a CHILD with an inline width, which
|
||||
* wrapping the row cannot reach: form.js's RichListValue writes the description block with an
|
||||
* inline `min-width:25vw`, assuming the list grows to fit — true on a theme that leaves
|
||||
* ul.dropdown unbounded, false here (capped at min(92vw, 420px), 90-responsive.css). The inline
|
||||
* floor is a VIEWPORT fraction, so the wider the screen the worse it gets: at 2559px, 25vw is
|
||||
* 640px inside a 406px row and the row's own `overflow: hidden` cut the sentence mid-word
|
||||
* (issue #15). An author !important is the only thing that outranks an inline declaration. */
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: initial; min-width: 0 !important; }
|
||||
.cbi-dropdown[open] > ul.dropdown > li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li[unselectable] {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) {
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-text);
|
||||
min-height: var(--fs-ctl-h);
|
||||
/* absorbed from base's generic field box with the width below. The fixed height is
|
||||
* load-bearing next to the min-height: without it a closed dropdown grows to its
|
||||
* tallest child (the Save & Apply split control carries a 40px button — measured,
|
||||
* 38 -> 42px on the gallery). */
|
||||
height: var(--fs-control-h);
|
||||
font-size: var(--fs-type);
|
||||
transition: var(--fs-field-transition);
|
||||
/* Width absorbed from base/30-forms.css, where it was grouped with .cbi-dynlist's — so the
|
||||
* theme sized the dynlist and base still sized the dropdown, two halves of the same
|
||||
* decision in two layers. Same numbers, and they are NOT the dynlist's (240/440): a
|
||||
* dropdown holds one value, a dynlist a column of them. */
|
||||
min-width: 210px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
/* The widget's own box. In base these two carried `!important` — not to beat an inline
|
||||
* style, but to beat base's OWN generic form-field rule, which sets `display: inline-block`
|
||||
* and `padding: 4px` on this very selector at a higher specificity (0,3,0 vs 0,1,0). A later
|
||||
* LAYER beats it for free, which is exactly what the layer split is for. */
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
}
|
||||
/* …and the button dropdown (the Save & Apply split control), where the flag was beating the
|
||||
* theme's own `.cbi-button` padding. Specificity does it: 0,2,0 over 0,1,0. */
|
||||
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { display: inline-flex; padding: 0; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button):hover { border-color: var(--fs-dim); }
|
||||
.cbi-dropdown:focus, .cbi-dropdown[open] {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* `.cbi-dropdown > ul.dropdown > li` is the same menu row on a BUTTON dropdown (the Save & Apply
|
||||
* split control), which the `:not(.btn):not(.cbi-button)` guard excludes — so that menu kept
|
||||
* base's .25em rows while every other dropdown got these. One menu, one row. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li,
|
||||
.cbi-dropdown > ul.dropdown > li {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); color: var(--fs-text);
|
||||
font-family: var(--fs-font-sans);
|
||||
/* one menu row: a grid step, which is what a line of text at this scale rounds to. NOT
|
||||
* `calc(--fs-type * --fs-leading)` — that is 19.5px, and a half-pixel on a BOX edge is
|
||||
* the thing the scale exists to stop. Absorbed with the machinery above. */
|
||||
min-height: var(--fs-space-5);
|
||||
}
|
||||
/* …and the row's CONTENTS, which is where that font is otherwise lost. A MULTI-select wraps every
|
||||
* item in `<form><label><input type=checkbox> text</label></form>`, and `.cbi-value-field *` in
|
||||
* theme/60-inputs.css hands mono to every descendant of a field — matching that form and label
|
||||
* directly, while the rule above only reaches the `<li>`. So the same widget draws two fonts
|
||||
* depending on one option: a single-select list sans, every multi-select list mono, in the open
|
||||
* menu and in the closed control alike.
|
||||
*
|
||||
* It is a LABEL, not a value — the distinction `<strong>` gets in theme/45-misc.css — and mono
|
||||
* spacing is what makes a wordy option list look stretched. `inherit`, not the token again: the
|
||||
* row above is the one place the menu's font is chosen. */
|
||||
.cbi-dropdown > ul > li > form,
|
||||
.cbi-dropdown > ul > li label { font-family: inherit; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
|
||||
/* The same ink the `placeholder` attribute takes in base/30-forms.css — "-- Please choose --" is
|
||||
* the absence of a value, not one.
|
||||
*
|
||||
* It repeats the row rule's two selectors instead of writing `> ul > li[placeholder]` once,
|
||||
* because that shorter form (0,2,2) LOSES to the row's own `color: var(--fs-text)` (0,3,2) and
|
||||
* the placeholder row rendered in the value's ink on every form dropdown — dead from the day it
|
||||
* was written, and only visible once `placeholder-ink` measured the row. Each selector here
|
||||
* out-specifies its counterpart above: (0,4,2) over (0,3,2), (0,3,2) over (0,2,2). */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder],
|
||||
.cbi-dropdown > ul.dropdown > li[placeholder] { color: var(--fs-placeholder); }
|
||||
/* the multiselect's inline filter/create inputs — moved from base with the generic `input`
|
||||
* box in theme/60-inputs.css: in base these beat it in-layer, left behind they would lose
|
||||
* to it by layer and render as free-standing 210px fields inside the open list */
|
||||
.cbi-dropdown > ul > li input[type="text"] { height: var(--fs-space-5); }
|
||||
.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { width: 100%; }
|
||||
|
||||
/* Custom chevron in the toggle (stock text/svg arrow hidden). `.open` is the only hit target that
|
||||
* opens the list — on a button dropdown (.cbi-dropdown.btn, e.g. Diagnostics) a click anywhere
|
||||
* else RUNS the button. `font-size: 0` collapsed the stock `line-height: 2em`, so the span
|
||||
* measured 28x0 and the chevron (an absolute ::after) sat outside its own box: aiming at it hit
|
||||
* the button. Hence a real box, chevron centred in it. */
|
||||
.cbi-dropdown > .open {
|
||||
display: flex;
|
||||
font-size: 0; line-height: 0; border: 0;
|
||||
position: relative; padding: 0;
|
||||
align-self: stretch;
|
||||
min-width: var(--fs-btn-size); min-height: var(--fs-ctl-h-sm);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* `.more` is the "…" a multi-value dropdown shows when its chips overflow. base wrote it in ONE
|
||||
* rule with `.open`; the theme took `.open` for the chevron above and left its twin behind, so
|
||||
* the two halves of the same toggle strip sat in different layers. Same geometry, absorbed —
|
||||
* and hidden by default here (the [multiple][more]/[multiple][empty] showers reveal it),
|
||||
* with base's muted ink. */
|
||||
.cbi-dropdown > .more { line-height: 2em; padding: 0 .3em; color: var(--fs-dim); display: none; }
|
||||
.cbi-dropdown > .open > * { display: none; }
|
||||
.cbi-dropdown > .open::after {
|
||||
content: ""; position: absolute; inset: 0; margin: auto;
|
||||
width: 7px; height: 7px;
|
||||
border-right: 2px solid var(--fs-dim); border-bottom: 2px solid var(--fs-dim);
|
||||
transform: translateY(-2px) rotate(45deg); /* optical centre of a "v" */
|
||||
}
|
||||
/* A BUTTON dropdown draws that chevron on the button's own FILL, where --fs-dim is a grey nobody
|
||||
* measured against it — on the accent-filled Save & Apply it reads as a smudge rather than an
|
||||
* arrow. currentColor takes whatever ink the variant already set, so there is no second list of
|
||||
* variants to keep in sync. The form-control dropdown keeps the muted chevron on purpose: there
|
||||
* the arrow is an affordance beside the value, not the content. */
|
||||
.cbi-dropdown.btn > .open::after,
|
||||
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
|
||||
|
||||
/* open list -> design popover (this was once stated four times over; these are the values
|
||||
* that were winning). */
|
||||
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
/* what makes it a popup at all — out of flow, stacked from the --fs-z-* scale;
|
||||
* absorbed with the machinery */
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-dropdown);
|
||||
transition: max-height var(--fs-dur-move);
|
||||
overflow-y: auto;
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
padding: var(--fs-space-1-5);
|
||||
/* the gap that detaches the popover from its control. It was DEAD until the `ul` margin
|
||||
* flags above came out (they out-ranked it from a lower specificity), so the list sat 1px
|
||||
* off the control — the one value that moved when they went. */
|
||||
margin-top: var(--fs-space-1-5);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li {
|
||||
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2) var(--fs-space-3);
|
||||
border-inline-start: 0; color: var(--fs-text);
|
||||
position: relative;
|
||||
/* An OPEN row wraps; only the closed preview ellipsizes. `.cbi-dropdown > ul > li` sets nowrap
|
||||
* plus ellipsis, which is right for the collapsed control — one line showing the current
|
||||
* value — and wrong for the menu: the list is capped at min(92vw, 420px), so an option
|
||||
* carrying a description runs out of box and is cut mid-word, with no ellipsis because the
|
||||
* cut falls inside a nested element (issue #15). Raising the cap instead would let one
|
||||
* verbose option decide the width of every menu on the page. */
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
/* no border-bottom divider: on a rounded box it curls up at the corners. The straight
|
||||
* inset strip below (::after) is the divider, so the hover pill stays rounded. */
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:not(:last-child)::after {
|
||||
content: ""; position: absolute; inset-inline: 11px; bottom: 0;
|
||||
height: 1px; background: var(--fs-border);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:hover,
|
||||
.cbi-dropdown[open] > ul.dropdown > li.focus { background: var(--fs-panel2); color: var(--fs-accent); }
|
||||
/* After hover on purpose: hovering the selected row must still read as selected. The surface is
|
||||
* OPAQUE (--fs-panel2) and the accent is carried by the inset rail, not by a tint of itself:
|
||||
* accent text on --fs-accent-soft measures 4.21:1 in dark, under AA — the tint drags the
|
||||
* background toward the text and eats its own contrast. axe never saw it, no gallery case
|
||||
* rendering an OPEN dropdown with a chosen value. */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected] {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* The focused row wears the theme's ring, not the browser's: ui.js focuses a row as it opens the
|
||||
* menu, and the UA answers a programmatic focus with `outline: auto` — a system-blue capsule
|
||||
* around the top row of every dropdown that already has a value.
|
||||
*
|
||||
* Inset rather than the token's outer 3px, because the menu clips its own overflow and an outer
|
||||
* ring is cut on the first and last rows. The rail rides INSIDE this ring so a focused row that
|
||||
* is also selected keeps it — a shorthand cannot merge with the one below, so the two are listed
|
||||
* together and `--fs-row-rail` keeps that a reference rather than a third copy.
|
||||
*
|
||||
* Both selectors carry `[selected]`-level weight on purpose: the selected-row paint above is
|
||||
* (0,4,2) and a bare `li:focus-visible` would tie with it, leaving the focus ring to source
|
||||
* order. */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected]:focus-visible,
|
||||
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
/* A MULTI-select row says "chosen" with its CHECKBOX, so it does not also need the rail. The rail
|
||||
* is `inset 2px 0 0` on a row with an 8px radius, so it draws as an ARC rather than a bar —
|
||||
* fine as the single-select cue it was designed for, one row at a time, and wrong down a column
|
||||
* of them, where three chosen rows read as a broken frame. The checkbox is a non-colour cue in
|
||||
* its own right, which is what WCAG 2.2 SC 1.4.1 asks of "selected"; a single-select row has
|
||||
* none and keeps the rail.
|
||||
*
|
||||
* Written as an override rather than by narrowing the paint below, because those three
|
||||
* declarations are pinned byte-identical to `select option:checked` in theme/60-inputs.css
|
||||
* (tools/mirror.mjs) — a native `<select multiple>` option has no checkbox, so the rail is still
|
||||
* the only cue there. `:not(:focus-visible)` keeps this off the focused row. */
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: var(--fs-hairline); margin-top: var(--fs-space-1); }
|
||||
/* The dropdown's "add item" field is a text input and takes the same radius as every other
|
||||
* one (theme/60-inputs.css). It said 8px here, and won or lost on source order. */
|
||||
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, so the
|
||||
* wrapper stays transparent: no double frame. */
|
||||
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
|
||||
background: none; border: 0; overflow: visible; box-shadow: none;
|
||||
}
|
||||
|
||||
.cbi-section-table .td .cbi-dropdown,
|
||||
.cbi-section-table .td .cbi-input-select,
|
||||
.cbi-section-table .td select,
|
||||
.cbi-section-table .td input[type="text"] {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Below ~960px of available width a multi-dropdown row no longer fits, so each config row cards.
|
||||
* Same shape as the fs-dt stack (theme/30-tables.css): label above its cell, a read-only cell
|
||||
* half the row, a widget cell the whole width.
|
||||
*
|
||||
* A @container, not the measurement the data tables use, and it must stay one: these rows are
|
||||
* full of widgets that bake in a width from the layout they were rendered in, so un-collapsing
|
||||
* one to take a reading CHANGES what is read — the firewall zone table then claimed 1747px
|
||||
* where it needs 1190px. Do not "finish the job" by measuring it.
|
||||
*
|
||||
* 992, not 960: the query reads `.cbi-map` (theme/45-misc.css), one level above the `.cbi-section`
|
||||
* that actually holds the table, and never subtracts what that level spends — `--fs-card-pad` on
|
||||
* both sides, 32px at the density every page but Compact ships. Measured live (task 0163):
|
||||
* firewall/zones' `.cbi-map` sat at 968px, 936px once the section's own padding was out, and its
|
||||
* six RU headers needed 1017px — 968 cleared the un-adjusted 960px check and the table never
|
||||
* carded, clipping the header row 49-65px with no scroller anywhere near it (English's shorter
|
||||
* headers happened to fit the same 968px, which is why only RU showed it). 992 = 960 + 32 makes
|
||||
* the check ask what it always meant to: whether the SECTION's own content, not the map's, has
|
||||
* cleared 960px. */
|
||||
@container fs-content (max-width: 992px) {
|
||||
.table.cbi-section-table,
|
||||
.table.cbi-section-table .tbody,
|
||||
.table.cbi-section-table .td { display: block; width: auto; }
|
||||
/* Hide the header in BOTH markups — a card repeats every label, so the header is a duplicate that
|
||||
* only runs off the right edge. form.js wraps titles and descriptions in a `.thead`, while the
|
||||
* Lua CBI that many third-party apps still render through emits a bare
|
||||
* `tr.cbi-section-table-titles` with no .thead, which is why hiding only `.thead` left
|
||||
* luci-app-openvpn's header on screen. `.tr.table-titles` is deliberately not listed:
|
||||
* it comes from L.ui.Table, which never renders a .cbi-section-table, and is hidden by the
|
||||
* fs-dt stack in theme/30-tables.css. */
|
||||
.table.cbi-section-table .thead,
|
||||
.table.cbi-section-table .tr.cbi-section-table-titles,
|
||||
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
|
||||
/* no align-items: flex-start — stretch keeps a pair's cells (and their separators) the
|
||||
* same height when one wraps; see the data card's .tr in theme/30-tables.css. */
|
||||
.table.cbi-section-table .tr {
|
||||
display: flex; flex-wrap: wrap; width: auto;
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-panel2);
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5) var(--fs-space-3);
|
||||
margin-bottom: var(--fs-space-3);
|
||||
}
|
||||
.table.cbi-section-table .tr:hover { background: var(--fs-panel2); }
|
||||
/* `.table.cbi-section-table`, not `.cbi-section-table`: the desktop cell padding is written as
|
||||
* `.table.cbi-section-table .td` and outranks a class-lighter selector, so a plainer one here
|
||||
* silently keeps the desktop padding.
|
||||
*
|
||||
* margin: 0 is load-bearing. The Lua CBI gives a table CELL the same `.cbi-value-field` class
|
||||
* a FORM field carries, and base indents that class by 20px, so a stacked cell measures
|
||||
* 50% + 20px, two no longer fit on a line, and each pair-cell wraps to its own. */
|
||||
.table.cbi-section-table .td {
|
||||
flex: 1 1 100%; min-width: 0; margin: 0;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
/* same as the data card's cells (theme/30-tables.css): a carded cell holds opaque
|
||||
* tokens (a real-length IPv6 GUA is 39 chars with no break point — colons are not
|
||||
* break opportunities), and without `anywhere` one ran 156px past a 360px viewport
|
||||
* on the Interfaces page. overflow-wrap inherits, so the inner spans get it too;
|
||||
* white-space beats the `.nowrap` class LuCI puts on some cells. */
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* A cell that only READS a value (dummy text, a flag, one button) is short, so two fit on a
|
||||
* line; anything with a real widget keeps the full width (the 100% basis above) — a dropdown
|
||||
* at half a phone's width is unusable. Both spellings of each widget: `CBI.DummyValue` from
|
||||
* form.js, `dvalue` from the Lua CBI's typename. */
|
||||
.table.cbi-section-table .td[data-widget="CBI.DummyValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.FlagValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.Button"],
|
||||
.table.cbi-section-table .td[data-widget="dvalue"],
|
||||
.table.cbi-section-table .td[data-widget="fvalue"],
|
||||
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
|
||||
/* PINNED to the data table's copy in theme/30-tables.css: the same card, needed under a CLASS
|
||||
* there (.fs-stacked, measured) and under this @container here, and CSS cannot OR the two
|
||||
* guards. tools/mirror.mjs holds the copies byte-identical — a duplicate detector goes QUIET
|
||||
* once they differ, exactly when it should shout. */
|
||||
.table.cbi-section-table .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* The row's buttons are not a label/value pair: full width, each button stretching to
|
||||
* share the line (Edit | Delete). PINNED — see above. (These six declarations were the
|
||||
* data table's six in a DIFFERENT ORDER, so a grep could not tell they were one rule.) */
|
||||
.table.cbi-section-table .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
|
||||
/* flex-wrap: LuCI groups the row's buttons in ONE inner <div>, so the td's own wrap
|
||||
* (above) never fires — the div is a single flex item. Four buttons are ~330px; on a
|
||||
* 320px phone the carded interfaces row overflowed its section by 17px. PINNED to the
|
||||
* data table's copy (theme/30-tables.css), which hit the same trap on Startup. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > * {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
.table.cbi-section-table .td .cbi-dropdown,
|
||||
.table.cbi-section-table .td select,
|
||||
.table.cbi-section-table .td input[type="text"] { width: 100%; min-width: 0; }
|
||||
.table.cbi-section-table .td .zone-forwards { width: 100%; }
|
||||
}
|
||||
|
||||
/* ---- widget popups must escape the table card ----
|
||||
* The standalone-table card (theme/30-tables.css) clips row backgrounds to its rounded corners
|
||||
* with `overflow: hidden`, but a form table also holds .cbi-dropdown widgets whose open list is
|
||||
* absolutely positioned — the clip crops it at the table's edge. So drop the clip where a widget
|
||||
* lives; the `.cbi-dropdown[open]` z-index above lifts the list over the following cards.
|
||||
*
|
||||
* Dropping it squares the frame off, though: a real <table> inherits `border-collapse: collapse`,
|
||||
* which ignores border-radius — the card's corners came from the clip, not the border. The
|
||||
* separated model restores them, and at zero spacing it lays out identically. */
|
||||
.table:has(.cbi-dropdown),
|
||||
.table:has(.cbi-dynlist) {
|
||||
overflow: visible;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
@layer theme {
|
||||
/* base/60-modal.css draws the dialog's elevation in --fs-panel2, which in footstrap light is the
|
||||
* same hex as --fs-bg: the shadow under the top of the z-stack drawn in the exact colour of the
|
||||
* page behind it (1.00:1 light, 1.27 dark — a glow, never a shadow, panel2 being lighter than
|
||||
* the canvas). Every other floating surface in the theme takes --fs-shadow-pop. Unscoped
|
||||
* `.modal`, not `#modal_overlay .modal`: .modal is reachable outside the overlay, and the layer
|
||||
* beats base at equal specificity. */
|
||||
.modal { box-shadow: var(--fs-shadow-pop); }
|
||||
|
||||
/* The uci-changes diff takes its tints from the ladder in base (--fs-*-fill for the block,
|
||||
* --fs-*-line for its border) and nowhere else — a second set here paints the same widget at two
|
||||
* strengths depending on whether it is inside the modal, which it always is. Only geometry is
|
||||
* left here: small rounding on the diff boxes and legend swatches.
|
||||
*
|
||||
* The popup's own radius is not restated: base rounds a modal to `var(--fs-radius-lg)` with a
|
||||
* comment saying a modal is a card. */
|
||||
|
||||
/* moved from base with the heading ramp (45-misc.css): the `line-height: normal` here beat
|
||||
* the ramp's tight leading in-layer for a dialog's h4 title, and left behind it would lose
|
||||
* to the ramp by layer (measured: modal headings dropped normal -> 15.6px) */
|
||||
.modal > * {
|
||||
line-height: normal;
|
||||
margin-bottom: .5em;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* an alert dropped into a dialog keeps the alert's own rhythm. In base this resolved by
|
||||
* FILE ORDER (90-widgets after 60-modal, both 0,1,0) — here it wins on specificity, so the
|
||||
* cascade no longer rests on position. */
|
||||
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
|
||||
/* moved from base with the generic field box in theme/60-inputs.css: a dialog's log/output
|
||||
* textarea fills the dialog, and base's width:100% would lose to the 210px box by layer */
|
||||
.modal > pre,
|
||||
.modal > textarea {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* A config-form modal (Bridge device options, firewall rule …) is only 600px wide by
|
||||
* default — cramped for a form with a table (Bridge VLAN filtering, whose .cbi-map container
|
||||
* then falls under the 992px card threshold and cards even on a big screen). Fluid via vw:
|
||||
* widens toward 1100 on desktop (the container clears 992 and tables stay tables), stays
|
||||
* narrow (cards) on tablet/phone. */
|
||||
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
|
||||
|
||||
/* A modal must never gain a HORIZONTAL scroll: #modal_overlay is the scroll container
|
||||
* (base/60-modal.css, `overflow: auto`) and the dialog is centred in it with `margin: auto`, so
|
||||
* one over-wide child scrolls the modal's own
|
||||
* left edge off the screen — the dialog still there, just unreachable-looking. Measured at
|
||||
* 360px: the overlay wanted 634px of scroll against 530px of room.
|
||||
*
|
||||
* Two children do it, both stock LuCI markup:
|
||||
* - `<label class="btn">` — flash.js writes each checkbox row as one, and `.btn` is
|
||||
* `white-space: pre`, which is right for a button's own label and wrong for a sentence, so
|
||||
* it cannot wrap at any width. Scoped to `label`; a real button keeps `pre`.
|
||||
* - a `<li>` carrying a checksum, one unbreakable 64-character token. */
|
||||
/* …and `overflow-wrap` with it: `white-space: normal` lets the sentence wrap at its spaces, which
|
||||
* is not enough for a label ending in a ~200px path token — in any dialog narrower than that the
|
||||
* label still runs past the box. Measured in the gallery at a 320px viewport: the label was 277px
|
||||
* inside a 264px column, 13px out.
|
||||
*
|
||||
* `min-width: 0` is the other half, and without it the wrap never fires: `.btn` is `inline-flex`,
|
||||
* so the label is a flex CONTAINER whose automatic minimum size is its content's min-content.
|
||||
*
|
||||
* `anywhere` rather than `break-word`, which follows the tier doctrine rather than contradicting
|
||||
* it (base/40-tables.css states it for cells): `break-word` leaves min-content at the widest
|
||||
* token, and the widest token here is wider
|
||||
* than the dialog at 320px, while the anonymous text flex item cannot be given a `min-width` of
|
||||
* its own. A dialog label has no card to fold into. */
|
||||
#modal_overlay .modal label.btn { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
|
||||
#modal_overlay .modal li { overflow-wrap: anywhere; }
|
||||
#modal_overlay .uci-dialog div > ins,
|
||||
#modal_overlay .uci-dialog div > del,
|
||||
#modal_overlay .uci-dialog div > var { border-radius: var(--fs-radius-sm); }
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
/* same token as the diff blocks above — a literal 4px sat outside the Rounding axis */
|
||||
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); overflow: hidden; }
|
||||
|
||||
/* A changed option is `<var><ins>uci set …</ins></var>`: the outer plate is rounded above and the
|
||||
* coloured fill is the child, and `overflow: hidden` does not round it, because the child never
|
||||
* reaches the corner it would be clipped at — the parent holds it 3px in (1px border plus 2px
|
||||
* padding). So the plate has rounded corners with a square block inside them, and the legend's
|
||||
* swatches are squares inside circles (issue #14). The child takes the parent's radius minus
|
||||
* that inset, which makes the two curves concentric; `max()` keeps it at 0 rather than negative
|
||||
* when Rounding is turned down. */
|
||||
#modal_overlay .uci-dialog var > ins,
|
||||
#modal_overlay .uci-dialog var > del {
|
||||
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
@layer theme {
|
||||
/* ==================================================================
|
||||
* The page-search palette. Markup is built by fs-search.js and parented to <body>:
|
||||
* the vertical .fs-sidebar is `overflow: auto`, which would clip a child of the trigger,
|
||||
* and the overlay has to cover the whole viewport anyway.
|
||||
* ================================================================== */
|
||||
|
||||
/* The scrim. --fs-scrim is the theme's one colour literal (plain black at .7) on purpose:
|
||||
* it is the absence of light behind a dialog, not a tint of any palette token. */
|
||||
.fs-search-ov {
|
||||
position: fixed; inset: 0; z-index: var(--fs-z-popover);
|
||||
/* `display` here is what the `hidden` attribute has to beat — base/10-reset.css does that for
|
||||
* every element now, so this file no longer restates it for the overlay alone. */
|
||||
display: flex; justify-content: center;
|
||||
/* not centred vertically: the box grows DOWNWARD as results come in, and a centred one
|
||||
* would slide the input out from under the user's eyes on every keystroke */
|
||||
align-items: flex-start;
|
||||
padding: max(8vh, var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);
|
||||
background: var(--fs-scrim);
|
||||
animation: fs-fade var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
/* The dialog itself. It is a SCROLL CONTAINER, and that is not decoration: without a cap the
|
||||
* box grows past a short viewport (a phone in landscape is ~380px tall) and the rows at the
|
||||
* bottom become unreachable — a `position: fixed` popup cannot be scrolled by the page. */
|
||||
.fs-search-box {
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
width: 100%; max-width: 560px; max-height: min(70dvh, 560px);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-search-row {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2-5); flex: 0 0 auto;
|
||||
padding: var(--fs-space-3) var(--fs-space-3-5);
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
.fs-search-ico { display: inline-flex; color: var(--fs-faint); }
|
||||
.fs-search-ico .fs-ico { width: 18px; height: 18px; }
|
||||
/* The input carries no frame of its own — the ROW is the field and the box around it is the
|
||||
* dialog. Two classes (0,2,0) on purpose: the generic field box in 60-inputs.css is
|
||||
* `input[type="text"], …` (0,1,1) and outranks a single class in the same layer whatever the
|
||||
* source order — measured, it painted this input with the page-field border, the panel2 fill,
|
||||
* 8px/11px of padding and a focus ring inside the dialog's own frame.
|
||||
*
|
||||
* 16px rather than the theme's body size, deliberately: a font-size below 16px makes iOS Safari
|
||||
* zoom the page when the field takes focus, and this one takes focus as the palette opens. */
|
||||
.fs-search-row .fs-search-input {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
margin: 0; padding: 0; border: 0; min-height: 0; background: transparent;
|
||||
color: var(--fs-text);
|
||||
font-family: inherit; font-size: var(--fs-type-lg); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
}
|
||||
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
|
||||
/* one placeholder ink for the whole theme (03-palettes.css) — this row is not measured by
|
||||
* `placeholder-ink`, the gallery having no search dialog, and must not drift from the fields
|
||||
* that are */
|
||||
.fs-search-row .fs-search-input::placeholder { color: var(--fs-placeholder); font-weight: var(--fs-weight); }
|
||||
|
||||
.fs-search-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--fs-space-1-5); }
|
||||
|
||||
.fs-search-opt {
|
||||
display: flex; align-items: baseline; gap: var(--fs-space-2-5);
|
||||
padding: var(--fs-space-2) var(--fs-space-2-5); border-radius: var(--fs-radius);
|
||||
color: var(--fs-text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
/* One highlight for both pointer and keyboard: `.active` is set by fs-search.js for the
|
||||
* aria-activedescendant row AND on pointermove, so the mouse and the arrow keys can never
|
||||
* disagree about which row Enter would open. */
|
||||
.fs-search-opt.active { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||
.fs-search-opt-title { flex: 0 1 auto; font-weight: var(--fs-weight-bold); font-size: var(--fs-type); }
|
||||
/* the breadcrumb: which section owns the page — the whole point of searching for it */
|
||||
.fs-search-opt-path {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
color: var(--fs-faint); font-size: var(--fs-type); text-align: end;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fs-search-opt.active .fs-search-opt-path { color: inherit; }
|
||||
|
||||
/* "Recently visited" — the caption over the rows */
|
||||
.fs-search-note {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3-5) var(--fs-space-0-5);
|
||||
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;
|
||||
}
|
||||
|
||||
/* …and the box's EMPTY STATE when there are no rows at all ("No pages found"), which reads as
|
||||
* a sentence, not as an eyebrow label. :has() rather than a sibling combinator: the note sits
|
||||
* ABOVE the list it describes, so `+` cannot reach back to it. */
|
||||
.fs-search-box:has(.fs-search-list:empty) .fs-search-note {
|
||||
padding: var(--fs-space-6) var(--fs-space-3-5) var(--fs-space-7);
|
||||
text-align: center; text-transform: none; letter-spacing: 0;
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
@layer theme {
|
||||
/* Phone overflow safety net (<=767px). .fs-main uses overflow-x: clip, so anything wider than the
|
||||
* viewport is silently CUT on the right rather than scrolled to. A device sweep found the
|
||||
* offenders below; each fix lets the control or table shrink or wrap. Tablets were already
|
||||
* clean. */
|
||||
/* A pair of buttons an app packs into one `inline-flex` must be allowed to wrap its labels.
|
||||
*
|
||||
* `white-space: pre` on a button is stock — luci-theme-bootstrap sets it too — and it holds
|
||||
* while the label is short. Ours are wider than stock at the same job: measured on
|
||||
* ssclash's split button at 320, `Сохранить и перезагрузить конфигурацию` is 295px against
|
||||
* stock's 245, because this theme's face is wider than the system stack even at a smaller
|
||||
* size (13px vs 14px), and the 14px side padding adds 12 of the 50. The app's wrapper is
|
||||
* `inline-flex`, so it takes the buttons' max-content — 324px inside a 288px column — and
|
||||
* overflows by 36. Stock fits only because its buttons are narrower.
|
||||
*
|
||||
* Narrowing the buttons would be re-typesetting the theme to suit one app. Letting the label
|
||||
* wrap costs nothing where it already fits — a short label has no wrap point — and turns the
|
||||
* one case that does not into two lines. Scoped to the width where it happens: at 390 the
|
||||
* same page is clean.
|
||||
*
|
||||
* `min-width: 0` with it, or the automatic minimum keeps the button at its longest word and
|
||||
* the flex line cannot shrink it at all.
|
||||
*
|
||||
* `word-break: normal` is the third half of it. base/10-reset.css gives every button
|
||||
* `break-all` — carried over from the fork, and inert while the label cannot wrap at all. The
|
||||
* moment it can, `break-all` takes precedence over word boundaries and the label breaks mid-word
|
||||
* (`Сохранить и перезагрузить ко` / `нфиг`). `overflow-wrap: break-word` keeps the escape hatch
|
||||
* for a single word wider than the button, which is what `break-all` was there for. */
|
||||
@container fs-view (max-width: 360px) {
|
||||
#view .cbi-button, #view .btn,
|
||||
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* The foreign-<table> scroll fallback used to live here, guarded by this query. It does not
|
||||
* any more: whether a table fits is a property of its CONTENT and its COLUMN, never of the
|
||||
* viewport — the same argument the fit engine is built on — and a 400px-wide panel on a
|
||||
* 1600px desktop needs it exactly as much as a phone does. It is unguarded now, in
|
||||
* theme/30-tables.css, next to the card stack it is the fallback FOR. */
|
||||
|
||||
/* Must stay INSIDE this @media, for specificity within one layer: the 400px cap lives in
|
||||
* theme/65-dropdown.css on `.cbi-dropdown:not(.btn):not(.cbi-button)` — (0,3,0) against this
|
||||
* rule's (0,1,0) — so unscoped this would lose to it everywhere and cap nothing. Inside the
|
||||
* phone tier it is what the narrow width needs: a dropdown must never exceed its field. */
|
||||
.cbi-dropdown { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* Software page: stack each labelled control. base gives its labels a min-width: 250px that
|
||||
* overflows a ~360px screen, and `.controls` is a flex ROW, so its columns cram side by side
|
||||
* and overlap. Scoped by data-page, not by an id: on 24.10 the "Disk space:" label ships with
|
||||
* no id, so a `:has()` scope silently misses the whole page there. !important because
|
||||
* package-manager.js injects an unlayered inline style block, which outranks every layer.
|
||||
* Scoped by `#view[data-page]`, not `body[data-page]`: the attribute lives on the view
|
||||
* itself so the outgoing page keeps its own rules for the whole staging window (fs-router.js,
|
||||
* commitStage) — the same bug and the same fix as styles/pages/30-software.css, discovered
|
||||
* here separately because this narrow-viewport tier is invisible to a gate run at desktop
|
||||
* width (tools/spa-parity.mjs's narrow-viewport pass).
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity while matching the exact
|
||||
* same element — 20-overview.css, top of `@layer page`, has the full reasoning and the
|
||||
* measured numbers. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
|
||||
/* `:not(.pager)` — the page reuses `.controls` for the « / "Displaying 1-100 of 7677" / »
|
||||
* pager, and its .pager is a <div> too. Blocking it stacked those three onto three lines
|
||||
* (reported). Only the LABELLED control groups want stacking; the pager is a row. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
|
||||
/* …and the pager itself: a centred row that stays one. `.text` is the only part that may
|
||||
* give way, so the two buttons keep their size instead of being squeezed. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager {
|
||||
display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .btn { flex: 0 0 auto; }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .text { flex: 0 1 auto; min-width: 0; }
|
||||
/* the bar's ::after value ("63% used …") normally floats above the bar, but the "Disk
|
||||
* space:" label already sits there — on a phone the long value text overlapped label +
|
||||
* bar. Drop it BELOW. The bar is a fixed 10px box, so a static ::after cannot push the
|
||||
* next control: reserve space with margin-bottom and place the value absolutely in it.
|
||||
* The disk section is the only `label` immediately followed by a `.cbi-progressbar`. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
|
||||
width: 100%; margin-bottom: 3em;
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar::after {
|
||||
top: calc(100% + 5px); inset-inline: 0 auto; bottom: auto;
|
||||
text-align: start; white-space: normal; max-width: 100%;
|
||||
}
|
||||
/* `> div > label` — the group's TITLE label ("Filter:", "Actions:", "Display LuCI
|
||||
* translation packages:"), not every label on the page. Written as plain `.controls label`
|
||||
* it also blocked the three radio labels nested a level deeper, and `filtered / all / none`
|
||||
* came out one per line. They are a set of three short choices: they belong on one row. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > label { display: block; min-width: 0; white-space: normal; margin-bottom: var(--fs-space-1); }
|
||||
/* …and the row they sit in. Stock separates them with text nodes, which is enough to
|
||||
* keep them apart but not to keep them from splitting mid-choice — a flex row with a real
|
||||
* gap wraps between labels instead of inside one. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > div:has(> label > input[type="radio"]) {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1) var(--fs-space-5);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group { display: flex; flex-wrap: wrap; min-width: 0; gap: var(--fs-space-2); }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group input { flex: 1 1 140px; min-width: 0; max-width: 100%; }
|
||||
|
||||
/* The Diagnostics stack is not here: a page name and a viewport are neither of the facts that
|
||||
* matter. Any app can put a field, a select and a button in one header-less
|
||||
* `<div class="table">`, and what decides whether that row fits is the ROOM — at 320px the
|
||||
* row needs 338px, and inside a narrow panel at 900px of viewport it needs the same.
|
||||
* fs-select.js writes `.fs-rowstack` on any such table that overflows, at any width, and
|
||||
* theme/30-tables.css carries the declarations. */
|
||||
|
||||
/* No `table.fs-dt { display: block; width: 100% }` here — it sat here to stop a data table
|
||||
* that still fitted as a table from exceeding its column, and its absence is what makes the
|
||||
* measured design work at phone widths at all: `display: block` on a <table> DISCARDS the
|
||||
* table formatting
|
||||
* context, so the rows are wrapped in an anonymous table sized to the block and the table can
|
||||
* never be wider than its parent — which means it can never overflow, which means
|
||||
* `fit.overflows()` can never see anything below 767px. Measured on Processes at 720px with
|
||||
* such a rule in place: the Command column sat at 126px against a widest token of 353px, and
|
||||
* forcing that column's `overflow-wrap` three different ways produced the same 126px and the
|
||||
* same "the table fits" every time. */
|
||||
|
||||
/* generic caps: an input/select must never exceed its field, and long key/value strings
|
||||
* in the Overview cards must break. */
|
||||
.cbi-value-field input, .cbi-value-field select, .cbi-value-field .cbi-dropdown { max-width: 100%; }
|
||||
/* …and once the label is on its own line the control takes the line it was given — a
|
||||
* field sized to its content left half the row empty and made the form read as a ragged
|
||||
* column (stock does the same on a phone). checkbox/radio/file keep their intrinsic size:
|
||||
* a full-width checkbox is a hit target the size of the screen. */
|
||||
.cbi-value-field select,
|
||||
.cbi-value-field input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
|
||||
.cbi-value-field .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-value-field .cbi-dynlist,
|
||||
.cbi-value-field .cbi-select { width: 100%; }
|
||||
/* min-width only: breaking a long value is base/40-tables.css's default for every cell now,
|
||||
* and this copy moved nothing when switched off against 126 matching cells. `min-width: 0` is
|
||||
* NOT redundant — switching that off moved 12 — so it stays. */
|
||||
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
|
||||
|
||||
/* Stack each field: label on its own line, control and description full-width below. Side by side
|
||||
* on the desktop squeezes the field — and any .cbi-value-description inside it — into a
|
||||
* ~120px column on a phone, so one sentence of help wraps to 8 lines. The row already
|
||||
* flex-wraps; these two full-width children are what stack it.
|
||||
*
|
||||
* Not scoped to #view, and that is the point: ui.js appends a modal to <body>, so an
|
||||
* `#view .cbi-value` rule leaves every DIALOG in the desktop layout and on a phone the
|
||||
* Edit-interface form pushes its inputs past the dialog's right edge. */
|
||||
/* `start` undoes base's `text-align: end` (the label hugs its field when the two share a
|
||||
* row); stacked, it goes back to the reading edge. Logical for the same reason base's is. */
|
||||
.cbi-value > .cbi-value-title { flex: 1 1 100%; text-align: start; padding-top: 0; }
|
||||
.cbi-value > .cbi-value-field { flex: 1 1 100%; min-width: 0; }
|
||||
|
||||
/* The stock phone contract every luci-app-* is written against. These classes are emitted by
|
||||
* LuCI's own JS and by third-party apps that copied them, and they are styled nowhere but
|
||||
* bootstrap's mobile.css — so a theme that ignores them renders a phone layout its own app JS
|
||||
* believes it has already handled. */
|
||||
/* A column the emitting JS asks to DROP on a phone: the wireless Associated Stations table marks
|
||||
* its MAC column .hide-xs, and stock LuCI shows Network/Host/Signal/Rate and no MAC.
|
||||
*
|
||||
* The `.table .td.hide-xs` half is not redundant: the stacking rules select the cell as
|
||||
* `table.fs-dt .td` (0,2,1) and `.cbi-section-table .td` (0,2,0), both outranking a bare
|
||||
* `.hide-xs` (0,1,0), so the cell keeps their
|
||||
* `display: block` and stays visible. Stock LuCI wins this with !important; one class more
|
||||
* wins it without one. */
|
||||
.hide-sm, .hide-xs,
|
||||
.table .td.hide-sm, .table .td.hide-xs,
|
||||
.table .th.hide-sm, .table .th.hide-xs { display: none; }
|
||||
/* The `.col-N` weights used to live here, inside the phone tier. They are UNGUARDED now,
|
||||
* in theme/30-tables.css: `flex` is inert on anything that is not a flex item, and a `.td`
|
||||
* becomes one exactly when its table cards — so one copy covers the phone tier, the
|
||||
* measured stack at any width and the config table's @container, with no second copy to
|
||||
* keep in step. See the block there. */
|
||||
|
||||
/* iOS Safari zooms the page in when a focused control's text is under 16px and never zooms
|
||||
* back out — the theme's 13px inputs did that on every form. Stock LuCI needs !important
|
||||
* for it; here the theme layer is enough. */
|
||||
input, textarea, select, .cbi-dropdown > ul > li { font-size: var(--fs-type-lg); }
|
||||
}
|
||||
|
||||
/* Dropdown list: anchored to its widget, never full-bleed.
|
||||
*
|
||||
* ui.js has a touch branch (`'ontouchstart' in window`) that stretches the open list across the
|
||||
* viewport with INLINE offsets — `ul.style.left = ${-rect.left}px`, `ul.style.right =
|
||||
* ${rect.right - vpWidth}px` — which assume the list's containing block is the
|
||||
* viewport. It is not: .cbi-dropdown is position: relative, so a negative `left` drags the list
|
||||
* off to the side and clips the labels. An author !important is the only thing that outranks an
|
||||
* inline declaration, so pin it back under the widget. Harmless on desktop, where ui.js sets
|
||||
* only top/bottom/max-height.
|
||||
*
|
||||
* Only left/right carry the flag — those are the two properties ui.js writes inline. The widths
|
||||
* never appear in an inline style (ui.js only CLEARS `ul.style.width`, on close), so they win
|
||||
* from this layer on their own. */
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: auto !important;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
max-width: min(92vw, 420px);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
@layer theme {
|
||||
/* prefers-reduced-motion — a medical accommodation: vestibular disorders make sliding, fading and
|
||||
* spinning UI genuinely nauseating. Honoured globally rather than per component; every
|
||||
* transition and keyframe in this theme is decoration and none carries information.
|
||||
*
|
||||
* The !important here is the documented exception, and not the same kind as the ones that fight
|
||||
* an inline `style=`: a cascade layer cannot help, since styles/base sets transitions too and
|
||||
* !important INVERTS the layer order, so only an important declaration reaches all of them at
|
||||
* once. This is the canonical form as published by the WCAG working group. DO NOT COPY THE FLAG
|
||||
* OUT OF THIS BLOCK.
|
||||
*
|
||||
* .01ms rather than 0s: a zero duration means `transitionend`/`animationend` never fire, and JS
|
||||
* that waits for one (LuCI's modal teardown does) would hang. `scroll-behavior` is on the list
|
||||
* because smooth scrolling is motion too, and a jump is what this user asked for. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* prefers-reduced-transparency — the user asked the OS for opaque surfaces. Every surface in the
|
||||
* theme is opaque already: no blur, an opaque bar, opaque pop surfaces. The one thing left is the
|
||||
* wash over an uploaded photo. */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
:root {
|
||||
/* the uploaded background is a translucent wash over a photo — exactly the effect this
|
||||
* user asked the OS to drop. Make the scrim opaque so the canvas is flat, not a photo
|
||||
* ghosting through (16-login-bg.css lays it over the image as one background layer). */
|
||||
--fs-photo-scrim: var(--fs-bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* prefers-contrast — the user asked the OS for MORE contrast but, unlike forced-colors, left the
|
||||
* palette to us. Same mechanism as reduced-transparency: re-state the tokens and every rule that
|
||||
* reads them follows. What moves and why:
|
||||
* - the hairlines: --fs-border is the most-read line in the theme and sits at ~20% of the
|
||||
* text's contrast, so it is pulled toward --fs-text. The role hairlines (-line/-line-hi, a
|
||||
* 40/55% tint of their role) go to the FULL role colour — a tint exists to be quiet, which
|
||||
* is the opposite of what this user asked for.
|
||||
* - secondary text: --fs-dim toward --fs-text, and --fs-faint most of the way there too
|
||||
* (10-11px eyebrow labels are where AA erodes first). Not all the way ONTO --fs-dim: the two
|
||||
* are the export tier's --text-color-medium and -low, so collapsing them republishes one
|
||||
* colour twice to every app reading the gradation. 82% is a window tools/export-tier.mjs
|
||||
* measures rather than takes on trust: below 80 the fainter ink drops under AA on footstrap
|
||||
* dark's --fs-panel2 (4.51:1 at 80), above 82 the high-to-low ramp falls under the 0.10
|
||||
* spread the tier promises (0.098 at 84); 82 clears both, at 4.68:1 and 0.110.
|
||||
* - --fs-placeholder, in BOTH modes. It is the one token the theme ships deliberately under AA
|
||||
* (3.99-4.48:1 light, 3.43-6.05:1 dark): a hint has to move far enough not to read as a
|
||||
* typed value, and a dark palette has only 6.45:1 of ink to spend. A reader who asked the OS
|
||||
* for more contrast has said which of the two they want, so the mixes go back to the
|
||||
* AA-clearing 62% and 80% — 4.98:1 and 4.61:1, at 37% and 20% of the ink-to-field range
|
||||
* instead of 44% and 34%. Only --fs-placeholder-mix moves: the mix itself is spelt once, in
|
||||
* 03-palettes.css. `placeholder-ink` measures this pass as well.
|
||||
* - the focus ring: --fs-focus-ring is a tint of the accent (--fs-accent-soft, 10% light and
|
||||
* 15% dark), so at "more contrast" it carries the accent at 60% instead. Its sibling
|
||||
* --fs-focus-ring-solo is already the solid accent and needs no restatement here. */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--fs-border: color-mix(in srgb, var(--fs-text) 45%, var(--fs-bg));
|
||||
--fs-dim: color-mix(in srgb, var(--fs-text) 85%, var(--fs-bg));
|
||||
--fs-faint: color-mix(in srgb, var(--fs-text) 82%, var(--fs-bg));
|
||||
--fs-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fs-accent) 60%, transparent);
|
||||
--fs-accent-line: var(--fs-accent); --fs-accent-line-hi: var(--fs-accent);
|
||||
--fs-good-line: var(--fs-good); --fs-good-line-hi: var(--fs-good);
|
||||
--fs-warn-line: var(--fs-warn); --fs-warn-line-hi: var(--fs-warn);
|
||||
--fs-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
|
||||
/* the placeholder is the one token shipped under AA (see the note above); this reader
|
||||
* asked for contrast, so both modes buy the AA ink back, at the percentages that clear
|
||||
* it. Light here, dark in the block below — per mode exactly as in 03-palettes.css. */
|
||||
--fs-placeholder-mix: 62%;
|
||||
}
|
||||
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
|
||||
}
|
||||
|
||||
/* forced-colors (Windows High Contrast) — the OS replaces every colour the theme picked, and
|
||||
* background-color on most elements is forced to the system canvas, so any state carried by
|
||||
* BACKGROUND alone vanishes: the active tab pill, the checked toggle and the selected dropdown
|
||||
* row all look exactly like their inactive siblings, which is a correctness bug rather than a
|
||||
* cosmetic one. Re-state those states with a border or outline, which forced-colors keeps, in
|
||||
* system colour keywords so they follow the user's scheme. */
|
||||
@media (forced-colors: active) {
|
||||
/* Active tab, selected dropdown row, checked toggle: outline the state.
|
||||
*
|
||||
* `.ifacebox-head.active` and Apply join the list rather than getting a rule of their own,
|
||||
* being the same KIND of state — carried by a background, with no word to fall back on — and
|
||||
* the same repair. "This port is up" was indistinguishable from a dead port, and Save & Apply
|
||||
* did not read as a BUTTON at all: it is a .cbi-dropdown wrapper, so with its fill forced to
|
||||
* Canvas only the label inside the <li> kept a highlight.
|
||||
*
|
||||
* Not extended to the alert and .label variants: encoding severity in border-style is not a
|
||||
* scale anyone can learn, and the honest repair is a word or an icon in the markup, which is
|
||||
* ui.js's. */
|
||||
#tabmenu ul.tabs > li.active > a,
|
||||
ul.cbi-tabmenu.mode > li.cbi-tab > a,
|
||||
.tabs > li.active > a,
|
||||
.cbi-tabmenu > li.cbi-tab > a,
|
||||
.cbi-dropdown > ul > li[selected],
|
||||
.ifacebox .ifacebox-head.active,
|
||||
.cbi-page-actions .cbi-button-apply {
|
||||
outline: 2px solid Highlight;
|
||||
outline-offset: -2px;
|
||||
color: HighlightText;
|
||||
background: Highlight;
|
||||
forced-color-adjust: none; /* keep the fill: it IS the state */
|
||||
}
|
||||
/* Which page am I on: the menu's active item carries its state in exactly the two things forced
|
||||
* colors drops, a 10%-alpha accent background and the box-shadow rail. Measured with the query
|
||||
* emulated, all twelve #topmenu links reported the same weight, shadow, border and colour, so
|
||||
* the current page was not marked at all. `aria-current="page"` is on the link, so this was a
|
||||
* sighted-user loss only. The rail comes back as a real border, which forced colors keeps. */
|
||||
#topmenu li.active > a {
|
||||
border-inline-start: 3px solid Highlight;
|
||||
padding-inline-start: calc(var(--fs-space-3) - 3px);
|
||||
}
|
||||
/* the toggle switch is a bare rounded box in forced colors — give the knob and
|
||||
* the track an edge so on/off is distinguishable at all. Scoped via `input[type="checkbox"]`,
|
||||
* matching 60-inputs.css, which now draws the switch there: an `<input>` selector cannot
|
||||
* reach ui.Checkbox's `tooltip` label (the hint-icon one, unstyled otherwise, built with no
|
||||
* `for` attribute) by construction, so the old `[for]` discriminator is no longer needed to
|
||||
* keep that label from growing a border it does not own. */
|
||||
.cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked {
|
||||
background: Highlight;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]::after { background: ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
|
||||
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
|
||||
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
|
||||
/* the focus ring is a box-shadow everywhere in this theme, and box-shadow is
|
||||
* dropped entirely in forced-colors — so keyboard focus would be invisible */
|
||||
:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
@layer theme {
|
||||
/* ---- print ----
|
||||
* A LuCI page ends up on paper or in a PDF as documentation, and without this block it prints
|
||||
* the dark chrome, the sticky bar and — in the sidebar layout — ONE page, the shell being
|
||||
* height:100svh with overflow:hidden there.
|
||||
*
|
||||
* No `!important`: tools/audit.py allows the flag only in its listed files, and nothing here
|
||||
* fights an inline style. Where a rule must beat a same-layer
|
||||
* chrome rule it wins on SPECIFICITY — a repeated class at (0,4,0), one step over the layout
|
||||
* guards' (0,3,1) — never on this file sorting after 20-shell.css. */
|
||||
@media print {
|
||||
/* Ink-friendly surfaces whatever palette/mode is active: this block sits in the
|
||||
* theme layer, so it outranks every tokens-layer palette block without naming
|
||||
* data-darkmode or the palettes. Every rule reads the tokens, so one re-statement
|
||||
* re-paints the whole page. */
|
||||
:root {
|
||||
--fs-bg: #fff; --fs-panel: #fff; --fs-panel2: #f4f4f4;
|
||||
--fs-text: #000; --fs-dim: #333; --fs-faint: #555;
|
||||
--fs-border: #bbb; --fs-track: #ddd;
|
||||
--fs-shadow-pop: none;
|
||||
--fs-bar-bg: #fff;
|
||||
}
|
||||
/* Navigation, the search overlay and the action buttons cannot be used on paper. The
|
||||
* Appearance popover was on this list; it is a PAGE now, and a page prints — an admin
|
||||
* printing System -> Appearance wants what is on it. */
|
||||
.fs-sidebar.fs-sidebar.fs-sidebar.fs-sidebar,
|
||||
.fs-skip.fs-skip,
|
||||
.fs-search-ov.fs-search-ov,
|
||||
.cbi-page-actions.cbi-page-actions,
|
||||
.cbi-tabmenu.cbi-tabmenu,
|
||||
footer { display: none; }
|
||||
/* Undo the sidebar layout's scroll frame — the ONE-page bug above. */
|
||||
.fs-shell.fs-shell.fs-shell.fs-shell { height: auto; overflow: visible; }
|
||||
.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
|
||||
.fs-content.fs-content { max-width: none; padding: 0; }
|
||||
/* same element as 15-wallpaper.css, later in the layer — the pattern stays off the paper */
|
||||
.fs-pattern.fs-pattern { display: none; }
|
||||
/* and the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) { background-image: none; }
|
||||
/* keep a card in one piece where the page allows it */
|
||||
.cbi-section { break-inside: avoid; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user