update 2026-10-22 10:57:37

This commit is contained in:
action committed 2026-10-22 10:57:37 +08:00
1 parent 72fa0cc54b
commit 06b74cb314
155 files changed
+37981

No files matched your search

@@ -0,0 +1,107 @@
@layer theme {
/* Pins inherited-only props on [data-fs-chrome], closing what the fence cannot: inheritance
* from html/body. ROOT-only selector and text-align: start (never left) are load-bearing —
* third-party-apps.md, "Rule 7. The chrome is fenced off, in three places that must agree". */
: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;
}
/* Brand/logo/wordmark/logout/indicators: one look for every layout — a layout file sets only
* PLACEMENT (flex, order, rail collapse), never 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);
border: var(--fs-hairline); /* glyph is baked-colour (header.ut), no currentColor use */
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto;
}
.fs-logo svg { width: 20px; height: 20px; }
.fs-logo-ring { fill: #002b49; } /* flips with mode like the favicon SVG; cyan arcs stay baked */
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
/* First tab stop — never display:none/visibility:hidden, that drops it from the tab order. */
.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);
}
/* clip-path, not a -68px translate: the translate still bled ~16px of drop shadow into the
* viewport (--fs-shadow-pop is offset 12px/blur 34px); clip-path clips the shadow too. */
.fs-skip:not(:focus), .fs-sr {
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;
}
/* Clipped, not display:none/visibility:hidden — both take the <h1> out of the a11y tree too. */
.fs-sr { position: absolute; margin: -1px; }
/* tabindex="-1" on <main> makes Safari/Chrome move focus here rather than just scroll to it. */
#maincontent:focus { outline: none; }
/* box and base colour: 20-shell.css (shared with .fs-themerow) */
.fs-logout:hover { color: var(--fs-danger); }
/* fitTabStrips (fs-chrome.js) adds .fs-dense1/2 when the horizontal menu row would wrap, and
* strips them in the vertical sidebar — no media guard needed. */
.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); }
/* One markup (`ul.nav > li > a`) for both layouts, so these three run once. */
ul.nav { position: relative; margin: 0; list-style: none; }
ul.nav > li { display: block; }
ul.nav a { line-height: 19px; text-decoration: none; }
/* No emitter here, stays styled under the coverage contract — conventions.md, "Coverage is a
* contract". */
.dropdown-menu li { display: block; position: relative; }
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; } /* denser than ul.nav a above */
.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);
}
/* Plate on the PAGE only; the open ANCESTOR keeps the ink alone — issue #37 (two identical
* plates stacked on System -> System otherwise). chrome.md, "fs-chrome.js". */
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
/* --fs-focus-ring-solo: none of these flips a border on focus, so the ring alone must clear
* 3:1 — the tinted ring reads only 1.15-1.29:1 (two-ring contract, 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);
}
/* accent-color reaches a foreign app's native checkbox/radio/progress — the theme's own are
* appearance:none and never see it. */
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
}
@@ -0,0 +1,43 @@
@layer theme {
/* .fs-pattern: the masked wallpaper element. Why a dedicated element, why z-index: -1 needs a
* transparent body — design-system.md, "Wallpaper rendering: why the canvas moves to :root". */
.fs-pattern { display: none; }
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
/* transparent for both wallpapers: an opaque --fs-bg here would cover the shell's one background layer
* (the photo on the file wallpaper, the .fs-pattern element on the pattern one). */
:root[data-wallpaper="pattern"] body,
:root:is([data-wallpaper="pattern"], [data-wallpaper="file"]) .fs-main { background-color: transparent; }
:root:is([data-wallpaper="pattern"], [data-wallpaper="file"]) footer.fs-footer { background: transparent; }
/* --fs-pattern-url is `none` until an upload exists (head.ut/fs-prefs.js) — the axis picked
* with no file is a bare canvas, not 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 (default): file's own colours discarded, only alpha used through the mask —
* design-system.md, "Pattern colours". */
: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;
}
/* original: the file's own palette, plain tiled background, no 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: cards paint their own opaque --fs-panel, so nothing needs
* dimming globally. */
}
@@ -0,0 +1,11 @@
@layer theme {
/* The `file` wallpaper: scrim + photo on one element — design-system.md,
* "Wallpaper rendering: why the canvas moves to :root". */
: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;
background-attachment: fixed; /* one calm backdrop the page scrolls under, not a sliding strip */
}
}
@@ -0,0 +1,569 @@
@layer theme {
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
body {
background-color: var(--fs-bg);
font-family: var(--fs-font-sans);
}
/* One stroke for every chrome icon (header.ut, svgIcon()): 5 attributes per <svg> were 300 B of each admin page */
.fs-ico, .fs-chevron { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
/* placement only — the look lives in 10-chrome.css */
.fs-brand { flex: 1 1 auto; min-width: 0; }
/* Hostname handling, two rules for two failures — chrome.md, "Hostname handling". */
.fs-wordmark {
overflow-wrap: anywhere; min-width: 0;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
/* collapse the sidebar to an icon rail. The toggle only EXISTS in the vertical sidebar,
* so its `display` is declared there, not here, or it would just be shadowed. */
.fs-railtoggle {
align-items: center; justify-content: center;
width: calc(28px * var(--fs-density-box)); height: calc(28px * var(--fs-density-box));
padding: 0; flex: 0 0 auto;
border: 1px solid transparent; border-radius: var(--fs-radius-sm);
background: transparent; color: var(--fs-faint); cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
.fs-railtoggle:hover { color: var(--fs-text); background: var(--fs-panel2); border-color: var(--fs-border); }
.fs-railtoggle .fs-ico { width: 16px; height: 16px; transition: rotate var(--fs-dur-move); }
/* base `ul` gives a 24px inline-start margin and a disc; padding is already 0 (reset) */
.fs-modemenu { list-style: none; margin: 0; }
.fs-sidebar ul.nav > li > a .fs-label { flex: 1 1 auto; }
/* collapse chevron for sections with children */
.fs-chevron {
width: 14px; height: 14px; flex: 0 0 auto;
color: var(--fs-dim);
transition: rotate var(--fs-dur-move);
}
/* `rotate`, not `transform: rotate()`: individual transform properties compose across rules,
* so the RTL mirror below sets `scale` without either rule restating the other's value. */
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
/* The two chevrons are DIRECTION, not decoration, so with the document flipped (head.ut
* stamps dir) they must be mirrored explicitly — logical properties flip the box sides
* (margin, padding, inset, radius), but a glyph needs the transform. */
:root[dir="rtl"] .fs-chevron,
:root[dir="rtl"] .fs-railtoggle .fs-ico { scale: -1 1; }
.fs-sidebar ul.nav > li.has-sub > a { cursor: pointer; }
/* .fs-logout shares the .fs-themerow button rule below (display, colour, box); only its
* LAYOUT (square button vs full-width row) differs per layout block. */
/* The bar is the default chrome and the only copy of it, unguarded — chrome.md, "The bar:
* fit steps, scroll model, a rail that must match its guard". The vertical sidebar below is
* the single guarded override, winning on SPECIFICITY, never on source order. */
.fs-shell {
display: flex; flex-direction: column;
min-height: 100svh; width: 100%; /* svh not dvh — chrome.md, same heading */
}
.fs-sidebar {
display: flex;
width: 100%; height: auto;
position: sticky; top: 0; z-index: var(--fs-z-sticky);
flex-direction: row; flex-wrap: wrap; align-items: center;
gap: var(--fs-space-2-5); padding: var(--fs-space-2-5) var(--fs-space-4);
background: var(--fs-bar-bg);
border-inline-end: 0; border-bottom: var(--fs-hairline);
/* the submenu popups anchor to this bar and must escape it */
overflow: visible;
}
/* brand may shrink (min-width: 0) but is capped at 30ch, tracking the text not the viewport;
* the extra line grows the bar's own min-height (50-toplayout.css). */
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
/* the bar has no section caption, no column to collapse, and its height follows its
* content — so the flex filler has nothing to fill */
.fs-navlabel, .fs-spacer, .fs-railtoggle, .fs-modemenu { display: none; }
/* the mode strip opts in at 2+ modes; empty (before renderModeMenu in fs-chrome.js runs: no gap, no brand
* shift) or `.single` stays hidden by the line above. */
.fs-modemenu:not(:empty):not(.single) { display: flex; }
/* the menu on its own full-width row beneath the brand. The DESKTOP bar lifts it back
* onto the brand's row whenever it fits — MEASURED, not a breakpoint
* (fs-chrome.js fitChrome adds .fs-bar-stack when it does NOT fit). */
.fs-sidebar ul.nav {
display: flex;
order: 3; flex: 1 1 100%;
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
}
/* anchors the dropdown to its OWN item, not the bar — issue #19, chrome.md, "A bar dropdown
* anchors to its own <li>". A phone never shows it: `hover: none` disables the rule. */
.fs-sidebar ul.nav > li.has-sub { position: relative; }
.fs-sidebar ul.nav > li > a {
display: flex; align-items: center;
padding: var(--fs-space-1-5) var(--fs-space-3); border-radius: var(--fs-radius);
color: var(--fs-dim);
font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
}
/* a bar item is its label: no icon, no chevron */
.fs-sidebar ul.nav > li > a .fs-ico,
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
/* popup card under its own item, sized to content and capped to the viewport; clampDropdown
* (fs-chrome.js) nudges one back inside near the inline-end edge, every bar state. */
.fs-sidebar ul.nav ul {
display: none; list-style: none;
position: absolute; inset-inline: 0 auto; top: calc(100% + 8px); z-index: var(--fs-z-flyout);
width: max-content; min-width: 200px; max-width: calc(100vw - 32px);
margin: 0; padding: var(--fs-space-2);
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
}
/* an invisible bridge across the 8px gap, so travelling from the item down into the panel
* does not drop the hover and close it. Unguarded: every state that can open a panel on
* hover needs it, not just one layout. */
.fs-sidebar ul.nav > li.has-sub > ul::before {
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
}
/* hover only on a real pointer: a tap leaves :hover stuck on mobile browsers, which
* would block tap-to-close */
@media (hover: hover) {
.fs-sidebar ul.nav > li.has-sub:hover > ul { display: block; }
}
.fs-sidebar ul.nav > li.has-sub.open > ul { display: block; }
/* a submenu link is the same link in every layout, the accordion included. */
.fs-sidebar ul.nav ul a {
display: block; padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm);
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
}
/* right cluster: the poll pill, then Search and Log out as square icon buttons.
* LuCI's #indicators (poll "Refreshing", unsaved changes) collapses when empty, so it
* only shows when there is something to report. */
.fs-sidebar #indicators {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1-5);
order: 2; margin-inline-start: auto; padding: 0;
}
/* the square icon button both far-edge controls are; .fs-themerow adds only what a <button> needs */
.fs-themerow, .fs-logout {
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto; order: 2;
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: var(--fs-hairline); border-radius: var(--fs-radius);
background: transparent; color: var(--fs-dim);
}
.fs-themerow {
margin-inline-start: auto;
/* see the :not(:empty) rule below — two auto margins would SPLIT the free space between
* them instead of pushing one cluster to the bar's far edge */
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
text-align: start; cursor: pointer;
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
}
/* .fs-ind-compact then .fs-bar-actrow, both by fitCluster (fs-chrome.js) — chrome.md, "Right
* cluster: two fit steps". max-width releases with the flex basis, or the 30ch cap leaves
* the brand short of the row it was just given. */
.fs-sidebar.fs-bar-actrow .fs-brandrow { flex: 1 1 100%; max-width: 100%; }
/* the poll pill: label off, masked glyph on. Static green arrows, grey when the poll is paused —
* the same --fs-icon-refresh as the rail, and the same reason it does not spin (a permanently
* spinning glyph makes an idle router look busy, and a moving target cannot be clicked). */
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"] {
width: var(--fs-btn-size); padding: 0; font-size: 0; justify-content: center; position: relative;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
background: var(--fs-dim);
}
/* every OTHER indicator collapses to the same box and shows the badge fs-chrome.js lifted out of
* its label — the count, or a dot when there is no number — exactly as the collapsed rail does.
* min-width rather than width: three digits must not be clipped, and here there is room to grow. */
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"]) {
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
* FIRST one actually rendered. #indicators is earlier in the DOM, so when the poll pill
* shows IT opens the gap and .fs-themerow's auto must stand down — otherwise flexbox
* SPLITS the free space between the two and the pill drifts away from the buttons.
* .fs-themerow keeps its own auto for when the pill is empty and collapsed. */
.fs-sidebar #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
/* on a square button the BORDER carries the state — the fill and the ink come from the
* shared hover/active groups in 10-chrome.css */
.fs-themerow:hover,
.fs-themerow[aria-expanded="true"] { border-color: var(--fs-accent); }
.fs-logout:hover { border-color: var(--fs-danger); }
.fs-themerow .fs-label, .fs-logout .fs-label { display: none; }
.fs-themerow .fs-ico,
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
/* The vertical sidebar: the single override, guarded POSITIVELY (never `:not([data-layout=
* …])`); everything the bar declared above must be answered here. The `data-narrow`
* measurement and the 521px JS-disabled floor: chrome.md, "`data-narrow`". */
@media (min-width: 521px) {
/* Static, never sticky (issue #7) — chrome.md, "A static sidebar, never position:
* sticky". Confined here; the top layout and the phone bar keep window scrolling. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
flex-direction: row; height: 100svh; overflow: hidden;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
height: 100svh; overflow-y: auto;
/* Firefox-only overflow leak past the static sidebar (issue #12) — chrome.md,
* "contain: paint … is the fix for issue #12". */
contain: paint;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
width: var(--fs-sidebar-w); height: 100svh;
flex: 0 0 auto;
position: static;
flex-direction: column; flex-wrap: nowrap; align-items: normal;
gap: normal; padding: var(--fs-space-5) var(--fs-space-3-5);
background: var(--fs-panel);
border-inline-end: var(--fs-hairline); border-bottom: 0;
box-shadow: none;
/* `overflow: auto`, not overflow-y alone: the bar sets `overflow: visible` for its
* popups, and a lone overflow-y here would leave overflow-x visible — which the
* spec forces back to auto anyway. Say both. */
overflow: auto;
z-index: auto;
}
/* the column is already 224px wide — that IS the cap, so the bar's 30ch one must go
* or the brand is capped twice, at the wrong value */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-brandrow {
display: flex; align-items: center; gap: var(--fs-space-1-5);
flex: 0 0 auto; order: 0; min-width: auto; max-width: none; padding-block: 0 var(--fs-space-5); padding-inline: var(--fs-space-2) var(--fs-space-1);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-navlabel {
display: block;
color: var(--fs-dim);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); letter-spacing: .1em;
text-transform: uppercase;
padding: var(--fs-space-1-5) var(--fs-space-2-5) var(--fs-space-2);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-spacer { display: block; flex: 1 1 auto; min-height: 12px; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-railtoggle { display: inline-flex; }
/* spacing only: whether the strip shows is the bar's :not(:empty):not(.single) rule above,
* and the rail's hide below outranks it (0,5,0 vs 0,3,0). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu { margin-top: var(--fs-space-1); }
/* vertical main nav */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #modemenu {
order: 0; flex: 0 1 auto;
flex-direction: column; flex-wrap: nowrap; align-items: normal; gap: var(--fs-space-0-5);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li { position: relative; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
gap: var(--fs-space-3);
padding: var(--fs-space-2-5) var(--fs-space-3);
/* Bold is the root level's own signal (lightening children reads as hierarchy instead); only the accordion needs it. */
font-weight: var(--fs-weight-bold); white-space: normal;
line-height: 1.1;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-chevron { display: block; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-ico,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-ico { width: 17px; height: 17px; }
/* nested submenu -> an inline accordion, not a popup */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul {
display: none;
/* the bar anchors this panel under itself (absolute + top/left); an accordion is
* just the next block in the column, so the offsets must go back to auto or they
* linger in the computed style */
position: static; top: auto; inset-inline: auto; z-index: auto;
width: auto; min-width: 0; max-width: none;
flex-direction: column; gap: var(--fs-space-0-5);
margin-block: 0; margin-inline: var(--fs-space-7) 0; padding: 0;
background: none;
border: 0; border-radius: 0; box-shadow: none;
overflow: hidden;
}
/* the bar's hover bridge does not follow: static here, its containing block would fall
* back to the <li> and swallow clicks above it. The rail brings its own (::after). */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
/* An open section is a GROUP: an indent alone does not say where it ends, and after ten
* children the next root category read as an eleventh (issue #37). No :not([data-rail])
* guard needed — the rail's own rule below restates these at higher specificity. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
display: flex;
margin-block: var(--fs-space-1) var(--fs-space-3);
margin-inline: var(--fs-space-7) 0;
padding-inline-start: var(--fs-space-1);
border-inline-start: var(--fs-hairline);
}
/* Search and Log out are full-width text rows down here */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout {
order: 0; margin-inline-start: 0;
width: 100%; height: auto;
justify-content: normal;
gap: var(--fs-space-3); padding: var(--fs-space-2-5) var(--fs-space-3);
border: 0;
/* no background here: at (0,4,0) it would out-rank the (0,2,0) hover and open plates in 10-chrome.css */
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout { font-weight: var(--fs-weight); font-size: var(--fs-type); }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout:hover { background: var(--fs-panel2); }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-label,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-label { display: block; flex: 1 1 auto; }
/* the poll pill sits in the sidebar footer, above Search */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
order: 0; margin-inline-start: 0;
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
}
/* In the column the indicator is one of a stack of ROWS, not a row of buttons —
* Search and Log out below it run the sidebar's full width, so a pill at its own
* content width read as a stray chip. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
flex: 1 1 100%;
justify-content: center;
}
/* Collapsed sidebar (icon rail): <html data-rail="true">, persisted in localStorage and
* re-applied before paint by head.ut. Labels collapse and a section's submenu becomes a
* flyout anchored at its icon's inline-end. Same block as the vertical sidebar, so the two guards
* cannot drift — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
* which would clip the flyouts; the rail is short enough not to scroll */
overflow: visible;
/* no z-index/sticky: static, like the vertical sidebar (issue #7) — its flyouts
* still cover the content on their own --fs-z-flyout in the root stacking context. */
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-railtoggle .fs-ico { rotate: 180deg; }
/* only the rail's own labels collapse — a `.fs-label` also exists on every
* submenu link, and those live inside the flyout where they must stay */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-wordmark,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-navlabel,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-modemenu,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
/* a rail item has no accordion — its children fly out sideways — so the chevron says
* nothing and crowds the icon. Selector kept this long because the vertical override
* turns the chevron back on at (0,6,3), so a plainer form here would only be (0,6,0). */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout {
justify-content: center; gap: 0; padding: var(--fs-space-2-5) 0;
}
/* leaf sections have no flyout — show their name as a tooltip bubble */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a::after {
content: attr(data-label);
position: absolute; inset-inline-start: 100%; top: 50%; transform: translateY(-50%);
margin-inline-start: var(--fs-space-2-5); z-index: var(--fs-z-flyout);
padding: var(--fs-space-1-5) var(--fs-space-2-5); border-radius: var(--fs-radius-sm);
background: var(--fs-panel); border: var(--fs-hairline);
box-shadow: var(--fs-shadow-pop);
color: var(--fs-text); font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a:hover::after { opacity: 1; }
/* the flyout: opened by hover on a mouse, or by .open which the menu JS toggles on tap
* (exclusively — fs-chrome.js flyoutMode). Hover gated on a real pointer: mobile
* browsers leave :hover stuck on the tapped element, which would block tap-to-close. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
display: none;
position: absolute; inset-inline-start: 100%; top: -8px; margin-block: 0; margin-inline: var(--fs-space-2-5) 0;
min-width: 196px; padding: var(--fs-space-2); z-index: var(--fs-z-flyout);
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
box-shadow: var(--fs-shadow-pop);
animation: fs-fade var(--fs-dur);
}
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
@media (hover: hover) {
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub:hover > ul { display: flex; }
}
/* The flyout's heading reuses the bar's hover-bridge pseudo-element (issue #22) —
* chrome.md, same heading as above. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
content: attr(data-title);
position: static; inset: auto; height: auto;
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
}
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
width: 12px; height: 100%;
}
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
* narrow phone would paint a bare glyph in the phone bar until the modules land.
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
/* Static, deliberately: this is a poll running quietly forever, not something being
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
}
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
* poll was stopped, the one thing the indicator exists to report. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
background: var(--fs-dim);
}
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
* attribute + :not() past stylelint's selector-max-specificity (1,7,0). `.fs-sidebar` is
* deliberately absent: #indicators is an id. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits)
* and a fixed box would clip it. */
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this
* rule: it applies at every width, this one must stay inside the 521px floor. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
}
/* ---------- main column ---------- */
.fs-main {
flex: 1 1 auto;
min-width: 0;
display: flex; flex-direction: column;
background: var(--fs-bg);
overflow-x: clip;
}
.fs-sidebar #indicators:empty { display: none; }
.fs-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
/* fs-router.js builds the incoming page in a second #view here, then moves its children into
* the live one, so the visible page is never emptied while the next one loads. `visibility:
* hidden` (not display: none) keeps the box so a self-sizing view sees no zero width;
* `height: 0` stops the page moving; `overflow: clip` (not hidden) skips a second scrollport. */
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
/* Slow-navigation hairline, shown once a nav outlives 150ms (fs-router.js). Fixed, and a
* child of <body>, not .fs-main (a column flex container shrinks its 2px to zero) or
* .fs-shell (contain: paint would re-base and clip a fixed descendant). transform/opacity
* only, both composited; the one style flush is on activation out of 'done', no layout. */
#fs-nav-progress {
position: fixed; inset-inline: 0; top: 0;
height: 2px;
background: var(--fs-accent);
transform: scaleX(0); transform-origin: left center;
opacity: 0; pointer-events: none;
z-index: var(--fs-z-tooltip);
}
:root[dir="rtl"] #fs-nav-progress { transform-origin: right center; }
#fs-nav-progress[data-state="active"] {
opacity: 1;
transform: scaleX(.8);
transition: transform 2s cubic-bezier(.1, .7, .1, 1), opacity 120ms linear;
}
#fs-nav-progress[data-state="done"] {
transform: scaleX(1);
transition: transform 120ms ease-out, opacity 300ms linear 100ms;
}
@media (prefers-reduced-motion: reduce) {
#fs-nav-progress[data-state="active"],
#fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); }
}
/* ---------- footer ----------
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright, so a
* third-party view emitting its own <footer> stays themed too. */
footer {
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: var(--fs-hairline);
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
/* centre the flex LINE (base declares no justify-content) so text-align has something
* to centre rather than a <span> already shrunk to its content */
justify-content: center;
text-align: center;
/* target-size floor for the two links this sentence carries (WCAG 2.2 SC 2.5.8) —
* chrome.md, "A footer link pair needs 26px". */
line-height: max(26px, calc(var(--fs-type) * 2));
}
footer a { color: var(--fs-dim); }
footer a:hover { color: var(--fs-accent); }
/* Notifications — ui.addNotification prepends .alert-message as the first child of
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
.fs-main > .alert-message {
order: 1; margin: var(--fs-space-4) auto 0;
width: calc(100% - var(--fs-content-pad) * 2);
max-width: calc(var(--fs-content-max) - var(--fs-content-pad) * 2);
}
.fs-main > .fs-content { order: 2; }
.fs-main > .fs-footer { order: 3; }
/* phone tier: content gutter, alert-message width and wrap — merged, none override each
* other (distinct selectors), must stay after the base rules above they narrow */
@media (max-width: 767px) {
.fs-content { padding: var(--fs-space-4); }
/* width: auto, not the base width above: that calc subtracts the DESKTOP gutter while
* .fs-content's own gutter is narrower here — auto plus the side margins tracks the
* content column exactly at any width in this tier. */
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
/* a top-level alert's action buttons were nowrap, so Dismiss was shoved past the panel
* and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main for #view's too. */
.fs-main .alert-message { flex-wrap: wrap; }
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
/* the action row is a nested flex that stayed nowrap and clipped its first label */
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
.fs-main .alert-message .btn,
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
}
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
* overflowed a 320px button). Outside the @media: table-contract refuses overflow-wrap in a width query */
.fs-main .alert-message .btn,
.fs-main .alert-message button { overflow-wrap: anywhere; }
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
#indicators [data-indicator] {
/* wears the row's own --fs-btn-size and --fs-radius, not a pill, or it reads as a
* different kind of object beside Search and Log out */
display: inline-flex; align-items: center;
min-height: var(--fs-btn-size);
text-transform: none;
font-weight: var(--fs-weight); font-size: var(--fs-type);
/* the bar's control surface; ui.js emits a bare `<span data-indicator=…>`, no class to key on */
background: transparent;
border: var(--fs-hairline);
color: var(--fs-dim);
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
}
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
/* nothing else gives a clickable indicator a pointer cursor — base's rule carries geometry
* only, and this theme emits no <header> at all — so "Refreshing" showed a text caret. */
#indicators [data-indicator][data-clickable] { cursor: pointer; }
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so it
* takes the control ring the buttons beside it take (55-buttons.css). :focus-visible, not
* :focus: a pointer press on the pill should not draw a ring. */
#indicators [data-indicator][data-clickable]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
}
@@ -0,0 +1,85 @@
@layer theme {
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
* the right edge instead of stuffed inside a fat pill. Default reserves that value's own line
* in flow (bootstrap's margin:1.4em model) — an abspos value with zero reserve spilled 5-6px
* UP across the divider of the row above, off the overview page (key/value .table, no header
* row). Contexts that put the value BESIDE the bar or on a neighbour's line opt out with
* margin-block-start: 0. */
.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;
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
.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: data-fs-level set from JS (80/92% thresholds) every poll
* tick. Solid tokens, no color-mix, nothing 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);
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;
}
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
* meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead
* of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the
* whole rule, form row included. @mirror pins the shared declarations. */
.cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @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);
margin-block-start: 0;
/* @endmirror */
--fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
}
.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 */
}
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
* the row). Back over the bar with a one-line top margin (was 6-9px overlap).
* `:has(.tr.table-titles)` stays for SPECIFICITY, matching the beside-rule's (0,6,0). */
.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;
}
}
@@ -0,0 +1,446 @@
@layer theme {
/* ---------- panels / cards (cbi) ---------- */
.cbi-section, .cbi-map > .cbi-section-node, .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); }
/* bare <legend> only; ink from the heading ramp. No line-height — base already sets 1. */
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 {
margin: 0; padding: 0; border: 0;
}
/* bare title (no wrapper) sat flush below — same 12px; excludes .cbi-title h3, already gapped */
.cbi-section > h3:first-child,
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
/* a heading FOLLOWING a table had no air; separates more from what precedes than introduces */
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
/* [id]/.fs-dt = data table; no id = key/value, no frame/clip; nested tables too */
.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%; }
/* moved from base with the generic field box (theme/60-inputs.css) */
.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; }
/* filter placeholder sliced mid-glyph on a long title (issue #12); ellipsis changes the
* failure instead. No !important — the inline 30px flag width still stands. */
.table.cbi-section-table .cbi-section-filter {
padding: var(--fs-space-1) var(--fs-space-1-5);
font-size: var(--fs-type-field-xs);
text-overflow: ellipsis;
}
/* width:100% cannot shrink below min-content; the inverse rule further down restores the
* floor on a data table — read that block before touching either. */
.cbi-section .table .td,
.cbi-section .table .th {
padding-block: var(--fs-space-2-5);
padding-inline: 0 var(--fs-space-4);
border: 0; border-bottom: var(--fs-hairline);
line-height: 1.45; vertical-align: middle;
text-align: start;
}
/* hugs the far edge, beating text-align:start above (form.TableSection's own action cells;
* hand-built ones 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: uppercase broke LuCI's own casing (issue #39); weight/size/ink alone
* still read as a heading. */
.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) --- `:is()` at ONE specificity: split, a table
* known only by the tag (Status -> Routing) lost to key/value's default. The padding, frame, corner
* and placeholder rules key on `:is(table, div).fs-dt` (0,1,1) as v0.14.14's `table.fs-dt`, plus
* the bare div: `.table.fs-dt .td` (0,3,0) beat an app's `.table td:nth-child(1)` (0,2,1),
* filemanager padding-left 5 -> 12px (docs/css.md). The rules that were `.table.fs-dt` stay so. */
.cbi-section .table:is([id], .fs-dt) .td,
.cbi-section .table:is([id], .fs-dt) .th,
:is(table, div).fs-dt .td, :is(table, div).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 min-content FLOOR belongs here: break-word (unlike base's -anywhere) does not lower
* min-content, so an overflow stays visible to fit.overflows(). :not(.fs-stacked) is the
* fitter's own measuring state. */
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
/* the ONE column the fitter allows to shred rather than card the table (fs-select.js);
* (0,5,0) beats the floor above on specificity, not source order */
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
/* processes.js's inline style= cannot be outranked by a layer — !important is the sanctioned
* fight against it. A CARD IS NOT EXEMPT: an unlayered app rule breaks a token that would fit. */
.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. */
/* sticks each .th, not the row — a row's fill does not travel with a sticky cell here. 0 in
* the sidebar (.fs-main scrolls itself), --fs-bar-live where the document does. */
: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);
}
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
* (18 jumps/1500 frames measured) — anchoring.md, "What the engine is told". */
.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 (border-collapse:separate, or the radius is silently dropped); #view too —
* Startup renders .table.fs-dt with no section */
.cbi-section .table[id], #view .table[id],
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, :is(table, div).fs-dt {
border-collapse: separate; border-spacing: 0;
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
/* EXPLICIT: the generic .table rule below still says overflow:hidden, clipped otherwise */
overflow: visible;
}
/* frame is overflow:visible, so nothing clips the corner cells — round the four OUTER ones.
* `:is(.tr.table-titles, .thead)` covers both header shapes; without .thead a real <thead>
* kept square top corners. */
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
:is(table, div).fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-start-start-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,
:is(table, div).fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-start-end-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:first-child,
#view .table[id] .tr:last-child .td:first-child,
:is(table, div).fs-dt .tr:last-child .td:first-child { border-end-start-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:last-child,
#view .table[id] .tr:last-child .td:last-child,
:is(table, div).fs-dt .tr:last-child .td:last-child { border-end-end-radius: var(--fs-radius-lg); }
/* empty table: L.ui.Table adds .tr.placeholder; spanning it is base's job, framed flavour left here */
.cbi-section .table[id] .tr.placeholder .td,
#view .table[id] .tr.placeholder .td,
:is(table, div).fs-dt .tr.placeholder .td {
padding-block: var(--fs-space-2); /* this layer's own cell padding made the plate 4px tall */
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: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
* excluded along with .fs-dt and the legend's inline table-layout. Shape, not a class, says
* data table: any .th or a 3rd .td rules a table out — a real key/value row is exactly 2 .td
* and no .th. docs/css.md, "The key/value discriminator". */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):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(.th)):not(:has(.td:nth-child(3))):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 the 40%/nowrap column starves the value — stacked below (560, not the config
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
@container fs-content (max-width: 560px) {
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):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(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child {
border-bottom: 0;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):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(.th)):not(:has(.td:nth-child(3))):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: the one no-id .table with inline table-layout:fixed, six
* equal columns overflowing on a phone — folded into label/value pairs. */
@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; the MAC badge keeps base's flat 4px, square against the card corners (issue #5.4) */
.cbi-section .ifacebox, .cbi-section .zonebadge,
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
.zonebadge input { width: 6em; } /* moved from base with the generic field box */
/* head rounds WITH the box (issue #7); max() since Rounding goes to 0, an invalid subtraction */
.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 height:0/overflow:hidden still drew empty padding/border bars
* (issue #10). visibility not display:none — stock JS reads a graph's width once in a rAF. */
[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%: bold --fs-danger on a tint OF --fs-danger eats its own contrast (12% measured
* 4.4:1); at 6% every palette clears 4.5 (4.87/4.95/5.28/6.09) */
.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 GridSection name CELL on 24.10, a ::before from data-title where form.js has no real
* cell — named here too or the column lines up with nothing */
.table.cbi-section-table .th, .table.cbi-section-table .td,
.table.cbi-section-table .tr::before { padding-block: var(--fs-space-2); padding-inline: 0 var(--fs-space-3); }
/* zero inline-start padding is right only INSIDE a card; a self-framed table glued its header text
* to its own corner without this (issue #7) */
.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 -> rounded card; inside a CBI section the section already IS the
* card, so the frame is stripped there (above) */
.table {
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
overflow: hidden;
background: var(--fs-panel);
}
/* separators on the CELLS: the frame needs border-collapse:separate, a row's own border
* paints nothing. :not(:last-child) keeps a trailing one off the rounded corner. */
.table .tr.table-titles > .th,
.table .tr.cbi-section-table-titles > .th,
.table .tr.cbi-section-table-titles::before,
.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); }
/* per-CELL border, so a foreign `position: relative; top:` (dashboard) drags one cell's
* border out of line — `top` AND `bottom` cancels either without touching `relative` itself */
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
/* descendant combinator so a real <table>'s inserted <tbody> is covered too */
.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;
}
/* row NAME reads the same on both releases (real cell / ::before) — a LABEL, sans face */
.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 (an untagged plain .table lit its whole block,
* issue #5.3; a placeholder row lit up too, issue #5.2/#15) */
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
/* .fs-dt: fs-select.js on a header-row #view .table that is NOT .cbi-section-table —
* replacing a live :has() selector re-evaluated on every mutation. */
/* One card-stack copy, no @container: fs-select.js already measures OVERFLOW and sets
* .fs-stacked. The config table keeps its own @container — its widgets bake in a width. */
/* a poll REPLACES a data table with a fresh, unmarked copy that briefly lays out full-width,
* throwing the reader 612px out and back — waits OUTSIDE the layout until fs-select.js
* answers, guarded on :root[data-fs-fit]. */
: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, on an overflowed table before carding */
.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,
.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; }
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
* 1-line neighbour's (52px) */
/* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list,
* losing display:flex on every card. Under css-dup's floor — keep both in step by hand. */
.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. PINNED: the config table needs this under its
* own @container guard, which cannot share a block with a class. */
.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 needs [data-title] in its OWN selector to win the caption rule's (0,4,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; }
/* row buttons are not a label/value pair — full width below the pairs. PINNED, 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 buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
* the tail button. PINNED to the config table's copy. */
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
}
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
* it does not scroll to an over-wide child */
.table.fs-stacked .td > *,
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
/* a <tfoot> is still a table-footer-group in a display:block table, drawing beside the cards
* instead of under them; `:has(> .tr)` tells the two shapes upstream writes it in apart. */
.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; }
/* --- DATA table flavour (L.ui.Table, tagged .fs-dt) --- the frame stays overflow:visible so a
* ComboButton's open list can leave the card; zebra and hover paint on the .tr, so the two outer
* rows carry the frame's inner radius (its radius less the 1px border) instead of a clip. */
.table.fs-dt.fs-stacked :is(.tr:first-child, :is(.tr.table-titles, .thead) + .tr) {
border-start-start-radius: calc(var(--fs-radius-lg) - 1px);
border-start-end-radius: calc(var(--fs-radius-lg) - 1px);
}
.table.fs-dt.fs-stacked .tr:last-child {
border-end-start-radius: calc(var(--fs-radius-lg) - 1px);
border-end-end-radius: calc(var(--fs-radius-lg) - 1px);
}
/* :not(.placeholder) — the empty-state plate is out of flow; its row only reserves height,
* and padding subtracted from that left text hanging below the frame (Wireless) */
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
/* half the row makes the card; col-N cells are excluded by the second :not(), not outranked. Third :not() loses on purpose to the meter below. */
.table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) {
flex: 1 1 50% !important;
}
/* a first cell that is one token (fs-select.js): "/dev/mtdblock6" is 110px, the half-split left
* 94-106px at 360-390 and `anywhere` cut it mid-word. fit-content of one token is the token,
* capped at the row, so a 64-char hash still breaks inside the card; a phrase keeps its half. */
.table.fs-stacked .tr > .td.fs-td-token:first-child:not(.cbi-section-actions):not([class*="col-"]) { min-width: fit-content; }
/* a meter takes the whole line — its own title prints over the cell's caption otherwise */
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
/* column weights (1-10): a share of a CARDED row, unguarded — `flex` only applies once a
* `.td` becomes a flex item, so one copy serves every tier and is inert elsewhere. */
.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); }
/* last resort: no header row, never carded (no data-title to print). .fs-xscroll: same,
* MEASURED to overflow; refused to a table holding a widget (clips a popup inside). */
#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;
overscroll-behavior-x: contain; /* the gesture stops here instead of dragging the page */
scrollbar-width: thin; /* the scrollbar IS the affordance; a shadow trick shows through zebra rows */
}
/* a header-less table holding a control stacks instead of scrolling — a scroll container
* would clip the popup inside; nothing here creates one, so a 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 (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); }
/* pins the row's NAME while scrolling — its own background (a row's fill does not travel
* with a sticky cell) and a hairline so it reads as an edge, not text over text. */
#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; inset-inline-start: 0; z-index: 2;
background: var(--fs-panel);
border-inline-end: var(--fs-hairline);
}
/* a keyboard user tabs to the scroll box (fs-select.js gives it tabindex+name) — 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;
}
}
@@ -0,0 +1,128 @@
@layer theme {
/* alert panel: a left accent stripe on a panel card; variants recolour the frame */
.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, swapping sides in RTL (LuCI ships ar/fa/he) */
border-inline-start: 3px solid var(--fs-accent);
/* --fs-panel stays the background-COLOUR; the tint is a background-IMAGE layer over it,
* not a replacement — a `-fill` background alone is transparent to the wallpaper. */
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 stripe+surface, ring stays neutral. :not(.btn) is load-bearing: a confirm
* control is a `<label class="btn alert-message danger">`, and without the guard the tint
* out-ranked `.btn.danger`, fading its fill. */
.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);
}
/* .notice groups HERE, not with .label.notice's accent: ui.js emits `notice spinning` for an
* IN-PROGRESS banner, so it reads as informational rather than success. */
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
border-inline-start-color: var(--fs-accent);
--fs-alert-tint: var(--fs-accent-fill);
}
/* spinner ::before is inline-block for its usual inline hosts, but displayStatus() builds a
* BLOCK host, opening its own line. GRID, not flex: the caller decides child count. */
.alert-message.spinning {
display: grid;
grid-template-columns: auto 1fr;
align-items: center; /* centres the glyph against the FIRST row, not pinned to its top */
/* the column gap is the glyph's own spacing, stated rather than inherited from the row `gap` */
column-gap: var(--fs-space-2);
}
/* grid item now, so its inline margin would double the column gap; row 1 keeps it 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; h4 covers a third-party alert, h5/h6 joined so an
* alert heading does not fall back to base's look past h4. */
.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); }
/* moved from base with the heading ramp: line-height:inherit beat the ramp's leading
* in-layer, left behind it loses by layer (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 — dim measured 4.06:1 on the tint */
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
/* the body elements an alert may carry, absorbed from base/90-widgets.css (same values) */
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
/* inner table (firmware Current/Available) flattened — no nested-card look, 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: the muted ink does not survive the status tint
* (3.35-3.36:1 against AA's 4.5). */
.alert-message .table .th,
/* title-row arms (0,5,0) outrank 30-tables.css's `.table .tr.table-titles .th` (0,4,0) */
.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); }
/* column gap --fs-space-3 like every other cell; row density --fs-space-2, a dense readout */
.alert-message .table .th, .alert-message .table .td { padding-block: var(--fs-space-2); padding-inline: 0 var(--fs-space-3); background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
.alert-message .table .td { color: var(--fs-text); }
/* validation tooltip: fill stays SOLID, unlike .label below — floats over arbitrary content,
* so a border alone cannot carry the status. Shadow replaces base's raw 2px halo (1.08-1.86:1). */
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
/* base reveals it on :hover alone; no tabindex in anything LuCI ships today, but a third
* party's own container may add one */
.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); }
/* .label: core barely uses it, any luci-app-* may. .important is bootstrap's accent name. */
.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;
white-space: nowrap; margin-block: .125em; margin-inline: .4em 0; /* carried over from the absorbed base rule */
}
/* no background: a translucent tint under same-colour text eats contrast (3.4-4.4:1);
* inherited opaque --fs-panel2 guarantees >=4.5. */
.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); }
/* section-title toggles are .label pills, clickable — SINGLE owner */
.cbi-title .label {
padding: var(--fs-space-1) var(--fs-space-3);
border: var(--fs-hairline);
color: var(--fs-dim); cursor: pointer;
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; }
/* toggles 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);
}
/* opaque surface (--fs-panel2 inherited): accent text on --fs-accent-soft measured 4.21:1
* (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); }
}
@@ -0,0 +1,113 @@
@layer theme {
/* one component, three LuCI selectors, no markup difference worth honouring: #tabmenu ul.tabs
* (page tabs), .tabs (view tabs), .cbi-tabmenu (CBI form tabs, the mode switcher included).
* Active marker: .active on ul.tabs, .cbi-tab on every .cbi-tabmenu flavour. */
#tabmenu { margin-bottom: 0; }
/* the card */
#tabmenu ul.tabs,
.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);
/* zeroes the folder-tab baseline gradient (1px x 29px) instead of restating it, so a
* third-party gradient the strip carries is sized away too */
background-size: 0 0;
}
/* a strip nested in .cbi-section -> 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,
.tabs > li,
.cbi-tabmenu > li {
height: auto;
margin: 0; padding: 0;
border: 0; background: none;
border-radius: var(--fs-radius);
color: var(--fs-dim);
}
/* page tabs and the mode switcher never carry a sentence-length label; ul.cbi-tabmenu.mode > li
* wins specificity over .cbi-tabmenu > li below, so mode pills stay none regardless of order */
#tabmenu ul.tabs > li,
ul.cbi-tabmenu.mode > li,
.tabs > li {
max-width: none;
}
/* CBI form tabs only, not the mode switcher: a long label overflowed its column by 28.6px at
* 320px/Large, EN measured 0 — docs/chrome.md "Tab strips" has the measurement */
.cbi-tabmenu > li {
max-width: 100%;
}
#tabmenu ul.tabs > 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,
.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,
.tabs > li.active,
.cbi-tabmenu > li.cbi-tab {
color: var(--fs-on-accent);
}
#tabmenu ul.tabs > li.active > 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);
}
/* one ring for both states, on --fs-focus-ring-solo: base's outline is invisible on the active
* pill, whose background is the same accent — docs/chrome.md "Tab strips" has the contrast
* numbers. box-shadow, not outline, because the strip clips and a shadow is not */
#tabmenu ul.tabs > li > a:focus-visible,
.tabs > li > a:focus-visible,
.cbi-tabmenu > li > a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* fitTabStrips (fs-chrome.js) escalates dense1 -> dense2 on wrap; for tabs both are the same
* pill padding (gap and font-size already equal the base), so one list serves both */
#tabmenu ul.tabs:is(.fs-dense1, .fs-dense2) > li > a,
.tabs:is(.fs-dense1, .fs-dense2) > li > a,
.cbi-tabmenu:is(.fs-dense1, .fs-dense2) > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
/* 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); }
}
@@ -0,0 +1,136 @@
@layer theme {
/* realtime graphs size their canvas from #view, clipping 34px off the newest samples — docs/css.md "Misc: the realtime graph bleed" */
#view .cbi-section > div[style]:has(> svg) {
width: auto !important;
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
}
/* same box's frame, painted BLACK inline — only colour moves; wider than the bleed above, channel_analysis.js's box must not be bled but is just as black */
#view div[style]:has(> svg) {
border-color: var(--fs-border) !important;
}
/* WCAG 1.4.1: --fs-accent on --fs-text measures 1.4:1 in dark, needs a non-colour cue in
* prose. Nav links/buttons/pills are identified by position and shape instead. */
p a, dd a,
.cbi-value-description a,
.alert-message a:not(.btn):not(.cbi-button) {
text-decoration: underline;
}
/* a label must set sans, not merely be excluded — excluded still inherits mono (227 elements/
* 20 KB of bold mono for nothing); exempt in pre/code/#syslog/textarea, monospaced content.
* `.ifacebadge` is a badge (chrome, not a value) in a mono cell; wins on specificity (0,2,0)
* over theme/60-inputs.css's (0,1,0) mono rule, not source order */
:is(strong, b):not(pre *, code *, #syslog *, textarea *),
.ifacebadge,
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
code, pre { font-weight: var(--fs-weight-normal); }
.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); }
/* lives in THIS layer, next to the head it overrides: the same rule in base was dead, a layer beats specificity */
.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); }
/* @container PLUMBING only, card rules live with the tables. Named: an unnamed query binds to the nearest container-type ancestor, silently re-targeted by a new one */
.cbi-map { container-type: inline-size; container-name: fs-content; }
#view { container-type: inline-size; container-name: fs-view fs-content; }
/* ui.js appends #modal_overlay outside #view, so the names above must repeat here or no dialog query resolves.
* box-shadow merged in from theme/70-modal.css: base's elevation = --fs-bg's hex in footstrap
* light, a glow not a shadow; others take --fs-shadow-pop */
.modal { container-type: inline-size; container-name: fs-view fs-content; box-shadow: var(--fs-shadow-pop); }
/* pager (« prev · text · next ») — generic, not page-scoped */
.pager.center { display: flex; 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); }
/* absorbed from base; stock LuCI only renders h2/h3, the rest is third-party coverage.
* `legend` excluded: theme/30-tables.css owns it. */
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, 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 get a .cbi-section card's frame, so the page reads as a
* stack of panels rather than loose text over the 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);
}
/* 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);
}
/* .right holding Add/Save directly under a field had no gap: pinned the button to the textarea */
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
margin-top: var(--fs-space-2-5);
}
/* button row belonging to the PAGE TITLE, not a section — same 16px as .cbi-section, half above */
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
/* Delete button luci-base renders for every NAMED section sat flush against what follows — one class, not one app */
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
/* a view may hand the map a bare widget with no `.cbi-section`; exclusions are the children luci-base already spaces itself */
.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);
}
/* the disclosure chevron, one rule for both hosts: the overview card toggle (a stock element, keyed by
* attribute) and the Appearance fold (ours, keyed by class), so the two cannot drift.
* pages/20-overview.css sizes the pill around it. */
.cbi-title .label[data-indicator="poll-status"]::after,
.fs-ap-chev::after {
content: "";
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(45deg); /* closed: chevron down */
margin-top: -2px; /* optical centring of the down chevron */
transition: transform var(--fs-dur-move);
}
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after,
button.fs-ap-fold[aria-expanded="true"] .fs-ap-chev::after {
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
}
}
@@ -0,0 +1,32 @@
@layer theme {
/* a DELTA on top of the bar's unguarded base (20-shell.css), every width — no @media floor,
* fitChrome measures instead — docs/css.md "Toplayout: measured, not media-queried". */
:root[data-layout="top"] .fs-sidebar {
/* min-height not height: a long hostname wraps the capped brand onto a second line (20-shell.css) */
min-height: var(--fs-bar-h);
gap: var(--fs-space-3);
}
/* the stacked bar (.fs-bar-stack, set by fitChrome in fs-chrome.js, not a media query) IS the
* base bar, so the delta below skips it instead of restating the base values */
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) {
/* nowrap or flexbox wraps the BAR itself instead of the menu's own pills — the signal fitChrome measures */
flex-wrap: nowrap;
/* aligns the bar to .fs-content so the brand/menu line up with the headings below */
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
}
/* flex:1 opens the gap pushing the right cluster to the column's edge */
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) ul.nav {
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
}
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) #indicators,
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) .fs-themerow,
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) .fs-logout { order: 3; margin-inline-start: 0; }
/* dropdown anchor/bridge/clamp live unguarded in 20-shell.css, not here (issue #19) */
/* placement only: the strip's own show/hide (empty, single) lives in 20-shell.css */
:root[data-layout="top"] .fs-modemenu { order: 4; flex: 1 1 100%; gap: var(--fs-space-1); }
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
}
@@ -0,0 +1,250 @@
@layer theme {
/* buttons straight in a field (Enable/Disable, Start/Restart/Stop) only get whitespace
* spacing inline-block, collapsing rows on wrap — wrapping flex with a real gap instead */
.cbi-value-field:has(> .cbi-button) {
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
}
/* native input types listed on purpose: LuCI also emits <input type=submit|reset|button>
* (sysauth, luci-compat cbi/*.htm), mostly with .cbi-button; luci-app-splash emits bare ones.
* Base only gives those width/height: auto — coverage is a contract (docs/gallery.html) */
.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 210x32px, a base reset would lose to it by layer */
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 the shorthand, which also animates width — a layout animation on every hover */
transition: var(--fs-field-transition);
}
/* bare `button` rides along: harmless on a border:0 box, meaningful once an app gives it one */
.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 only: box-shadow:none above kills any stray glow, plain mouse :focus shows
* nothing on purpose — the pressed cue below already answers a click */
.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/-good/-danger, an accent-tinted ring is invisible against
* them — ring in the surface colour with a solid accent halo (--fs-focus-ring-solo) instead */
.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);
}
/* the sole copy of the pressed inset shadow (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);
}
/* three fills, each shared by `.cbi-button-*` (LuCI's own views) and `.btn.<colour-word>` (third-party
* apps), or a recolour lands on one half and not the other.
* The `.important` twins are needed: at 0,2,0 they beat the neutral `input[type=button]` paint (0,1,1)
* for dockerman's `<input type=button class="cbi-button-action important">`. */
.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);
}
/* the lift is for the fills above plus the page-action Save & Apply: brightness deepens a solid
* fill and washes out an outline or a tint, so neutral Apply/Save keep their hairline.
* `.td .cbi-button-apply` is the one outline that does lift. */
.cbi-button-positive:hover, .btn.success:hover,
.cbi-button-action:hover, .btn.primary:hover, .btn.info:hover,
.cbi-button-negative:hover, .cbi-button-remove:hover, .btn.danger:hover, .btn.error:hover,
.cbi-page-actions .cbi-button-apply:hover, .td .cbi-button-apply: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); }
/* .btn owns size words in full now: the plain rule above re-sets padding in a later
* layer, so a size word carrying only font-size (base's old copy) got default padding */
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); }
.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); }
/* size only: ui.changes puts .alert-message on the apply/rollback modal itself, so a paint here
* (0,2,0, after the fills) turned 'Apply unchecked' into the same panel2 grey as 'Cancel' */
.alert-message .btn, .alert-message .cbi-button {
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3);
}
.alert-message .btn:not(.cbi-button-positive, .cbi-button-action, .cbi-button-negative, .cbi-button-remove,
.primary, .info, .success, .danger, .error):hover { border-color: var(--fs-accent); color: var(--fs-accent); }
/* Save/Apply footer: base renders a tall half-empty plate with bottom-only radius, made a compact rounded 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);
/* WRAP the bar rather than let items shrink into it: a no-wrap row put the whole squeeze on
* the Save & Apply split control's caption (measured up to 119px cut at 320px/Large) instead
* of sharing it — docs/css.md "Buttons: the page-action bar wraps, not shrinks" has the
* before/after numbers. */
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);
vertical-align: middle;
}
/* Reset takes Save's same ladder step (-soft/-fill) off the DANGER role, since it discards
* unsaved changes; label is --fs-text for the same over-tint reason as Save's below. */
.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);
}
/* no `.spinning` padding ladder here, and it must not come back — base/95-luci.css puts the
* glyph IN THE FLOW instead, reserving its own space (the ladder lost twice: issues #15, #22) */
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): neutral outline, variants colour the ones that
* carry meaning. Neutral :hover comes FIRST — after it, it would grey the coloured variants
* back out on hover at equal specificity. 32px clears WCAG 2.5.8's 24px target/spacing rule;
* 28px did not (adjacent 24px circles overlapped). */
.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);
/* flex-basis: auto, not a definite basis: with one, the automatic minimum resolves to 0 in
* every row and flex-grow divides by count instead of by need — docs/css.md "Buttons:
* `flex-basis: auto`, not the definite basis base gave every row action" has the
* regression this fixed and the measured widths before/after. */
flex-basis: auto;
}
/* a ComboButton is a widget: its open list is a child of the box, and its label ellipsizes in its own <li> */
:is(.cbi-section-actions .cbi-button, .cbi-section-actions .btn, .cbi-section-table .cbi-button):not(.cbi-dropdown) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
background: transparent;
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;
}
/* hover ink is --fs-text, not the role colour: text of colour C on a tint of C eats its own
* contrast (Save: accent on accent-soft = 4.25:1, AA fail) — role reads from fill + border */
.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 blue text on a green fill = unreadable; green outline instead */
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
}
/* without this border-colour the positive toggle read less responsive than its hover neighbours (which brighten to -line-hi) */
.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);
}
/* Save & Apply is THE primary action, Save demoted so only one reads primary */
.cbi-page-actions .cbi-button-apply,
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
background: var(--fs-accent);
border: 1px solid transparent;
color: var(--fs-on-accent);
text-shadow: none;
}
/* a TINT at rest, not a bare outline, or Save reads disabled beside the solid Save & Apply;
* -soft/-fill off the role ladder. Label --fs-text, not --fs-accent: accent on accent-soft
* measures 4.25:1, an AA failure (docs/design-system.md). */
.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);
border: 1px solid var(--fs-accent-line);
color: var(--fs-text);
text-shadow: none;
}
.cbi-page-actions .cbi-button-save:hover {
background: var(--fs-accent-fill);
border-color: var(--fs-accent-line-hi);
}
/* 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 number --fs-ctl-h already states — a restated value is the drift this ladder exists to prevent */
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: the only buttons once set in bare pixels (40x34, 34 being a height in
* no scale) and opted out of the Density axis; 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: var(--fs-hairline);
}
.pager .btn.prev:hover, .pager .btn.next:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
}
@@ -0,0 +1,534 @@
@layer theme {
/* baseline, not center: a taller field (help sentence under the control) would push a
* centered label off the control's first line */
.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 need a gap, a raw <br> collapses it; password-reveal excluded, 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;
}
/* hairline separates a row from the NEXT one, so it drops whenever none follows, not only at the section's end */
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
/* .cbi-checkbox has no text baseline (synthesised at its bottom edge, floats ~5px above the label) — centred, except with a help sentence under it (see the baseline note above) */
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
align-items: center;
}
/* ONLY the transition here, shared by every field box below (`textarea` included); typography stays in base/30-forms.css. border-COLOR not the shorthand, which also animates width */
input, button, textarea {
transition: var(--fs-field-transition);
}
.cbi-value-title {
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
/* stated not inherited: this is TEXT, the control rule hands labels line-height: normal (measured 18px -> normal without this) */
line-height: var(--fs-leading);
}
/* mono is for VALUES: buttons, the help sentence and `<strong>` LABELS excluded — a label
* fed to mono also goes bold, 227 elements/20 KB of face for nothing. theme/45-misc.css's
* label rule must ASSIGN sans; excluding `<strong>` here alone still inherits mono */
.cbi-value-field,
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
/* not the generic input's fixed 210px (above): mono + padded --fs-space-3, 210px clipped a URL mid-domain (issue #5); elastic, capped to the dynlist's ceiling */
.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); }
/* base assumes ONE title+field pair; stack full-width on a SECOND title (not a second field — a runtime-appended field would re-stack under the user's finger, measured: a button moved 31px) */
.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;
}
.td > input[type="text"],
.td > input[type="password"],
.td > select,
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-dynlist > .add-item > .cbi-dropdown,
.cbi-section-create > * > input { width: 100%; }
/* --- live components that bypassed the theme --- */
/* a CBI row is <div class="cbi-value"> on a form, <td class="cbi-value-field"> in a table — same <var>, 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); }
/* base sizes a row by proportion for its own ~20px buttons; this theme's 36px button (3 per
* row) does not fit at 23% and `overflow: hidden` clips the corners — size actions to content,
* let the name ellipsize, wrap when 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`: may wrap but not shrink below min-content, or it sticks out (measured 23px past the box at 320px before min-width: 0) */
.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; }
.cbi-filebrowser .upload > div > input { width: 100%; }
/* Form widgets — toggles, file input. Applies to every CBI form. */
/* the shared checkbox rule below gives the input a -4px margin (16px box in a 24px hit area, Normal
* density), so inside a .btn label the mark sat flush against the text; .5em restores the gap.
* The toggle switch is unaffected: its own margin:0 wins on a different ancestor */
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
/* checkbox -> toggle switch, drawn on the INPUT — docs/css.md "Inputs: the toggle switch,
* drawn on the input" has the forum regression this replaced. density-scaled local tokens. */
/* overflow-wrap: anywhere for the one caption word too long even at full width (pseudo-loc: a
* 14-letter Russian compound grown to 20 pushed the row 24.6px past the field at 320px/Large) */
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
/* label[for] stays in the DOM but draws nothing: out of flow, out of the flex item count, so gap opens only on the switch input + an optional caption */
.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));
--sw-travel: calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px);
/* appearance/--bd-color/--fg-color come from the shared input[type=checkbox] rule below;
* flex-shrink: 0 keeps the pill at its width beside a caption (docs/css.md "Inputs: the toggle switch, drawn on the input, not the label") */
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);
}
/* hit-target overlay to 44x30 (WCAG 2.5.8 floor 24px, pill is 22px); inset stays literal, not
* density-scaled — a floor that shrinks with Density is not one */
.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 re-lays out the row every frame, translateX is composited */
transform: translateX(0);
transition: transform var(--fs-dur-move), background var(--fs-dur);
}
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
/* derived so it cannot drift from the sizes above; -2px is the pill's two 1px borders (the knob sits against the padding box, so it has 2px less room — travel is 18px not 20) */
.cbi-checkbox > input[type="checkbox"]:checked::after {
transform: translateX(var(--sw-travel));
background: var(--fs-on-accent);
}
/* the knob starts at the inline-start edge, so RTL travels the other way (translateX is physical) */
:root[dir="rtl"] .cbi-checkbox > input[type="checkbox"]:checked::after { transform: translateX(calc(var(--sw-travel) * -1)); }
/* the input IS the switch, ring goes here; -solo: checked switch already spends its border channel on accent, only the tinted shadow shows */
.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 { cursor: not-allowed; } /* opacity comes from base/30-forms.css input[disabled] */
/* raw checkbox/radio, absorbed from base: undoes the generic input box below. 16px box on the
* grid, mark inset FROM it. `.cbi-checkbox > input` also matches the switch's own input
* (unscoped, it wants this rule's appearance:none) — selectors that WOULD paint the switch
* exclude it individually below instead. */
input[type="checkbox"],
input[type="radio"] {
--bd-color: var(--fs-border);
--fg-color: var(--fs-text);
/* box tier not space tier: --fs-space-4 gave 16px at Normal but 10.4px at Compact (smaller than any control) and never grew at Large */
--box: calc(16px * var(--fs-density-box));
--mark-inset: 3px;
/* WCAG 2.5.8's 24px spacing floor holds only while the theme owns the layout; ELEMENT is
* 24px (drawing stays 16px, centred), max() so it never shrinks under --box at Large */
--hit: max(24px, var(--box));
appearance: none;
width: var(--hit);
height: var(--hit);
/* negative margin pulls the extra hit area back out of flow (without it, rows measured 42px -> 51px) */
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 not reset: land on an appearance-none box whose pseudo-elements are absolute, invisible either way */
}
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 {
/* inset:0; margin:auto centres a fixed box the same in RTL as LTR; a translate pair would not */
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);
}
input[type="checkbox"]::before { border-radius: 2px; }
input[type="radio"],
input[type="radio"]::before {
border-radius: 50%;
}
/* keyboard/click focus ring on the DRAWN box, the theme's one ring token */
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
input[type="radio"]::after {
/* mark inset from the DRAWING, which is centred in the target, not 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: stroked path (this theme's icon language), replacing a fill byte-identical to luci-theme-bootstrap's; mask keys on alpha so a stroke masks like a fill 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 {
/* radio dot filled, not ringed — a ring reads as "not selected" */
--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);
}
/* generic field box, absorbed from base; bare input keeps the historical 210px fallback for types the list below does not name */
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);
font-size: var(--fs-type-field);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
}
/* base's old #localtime{width:300px} media rule not moved here: the elastic field width above already out-layers it (measured 440px, not 300) */
input[type="file"] {
color: var(--fs-dim); font-size: var(--fs-type);
padding: initial;
border: initial;
line-height: initial;
box-shadow: none;
/* no !important: width:auto out-specifies the generic input{width:210px} alone */
width: auto;
}
/* native <select> chevron + theme; the chevron is --fs-select-chevron (two gradient bands), the RTL rule below mirrors its position */
select, select.cbi-input-select, .cbi-value-field select, .cbi-select:not(select) {
/* unprefixed only, floor clears it everywhere — docs/css.md "The browser floor" */
appearance: none;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
/* right inset is the scale step, not a literal 12; the .cbi-select shell shares this rule */
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-block: var(--fs-space-2); padding-inline: var(--fs-space-3) var(--fs-space-8);
border: var(--fs-hairline); border-radius: var(--fs-radius);
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type-field);
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
width: 210px;
display: inline-block;
transition: var(--fs-field-transition);
}
/* background-position has no logical keyword, so it is mirrored by hand; the two bands swap offsets
* to keep the chevron's left-leaning half on the left. The padding above is logical and follows. */
:root[dir="rtl"] :is(select, .cbi-select:not(select)) {
background-position: left calc(var(--fs-space-3) + 5px) center, left calc(var(--fs-space-3) - 1px) center;
}
/* height:inherit so the multiple/listbox variant does not take the closed control's one-line height */
select[multiple] { height: inherit; }
option { font-size: 100%; line-height: 2em; }
select option { background: var(--fs-panel); color: var(--fs-text); }
/* the OS renders the native list, so `select option` above is mostly ignored; Chromium's
* base-select renders it in the DOM instead, themeable like .cbi-dropdown; @supports-gated,
* scoped to .cbi-value-field so a third-party app's raw <select> stays 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);
/* background:transparent undoes `select option`'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); }
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
.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 — legacy luci-compat shell around a native <select>; it takes the themed select rule above and
* keeps only its box. :not(select): the class also appears on a bare <select>, which cannot draw the ::before chevron */
.cbi-select:not(select) {
/* height not min-height: the child's height:100% falls back to content height against an auto-height parent (measured collapse 30px -> 15px in a 38px shell) */
height: var(--fs-ctl-h);
padding: 0;
position: relative;
/* the shell draws no text; the shared rule's field size belongs to its select child */
font-size: var(--fs-type);
}
/* base/30-forms.css only dims the old ▾ plate; with the plate gone the disabled state had no visual, so the shell carries it now */
.cbi-select[disabled]:not(select) {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
}
/* invisible, filling the shell, clearing the plate by the same --fs-space-8 inset the themed select uses */
.cbi-select select,
.cbi-select select:focus {
appearance: none;
outline: none;
border: none;
background: transparent;
box-shadow: none;
height: 100%;
min-height: 0;
width: 100%;
padding-block: 0; padding-inline: var(--fs-space-3) var(--fs-space-8);
font-size: var(--fs-type-field);
line-height: normal;
cursor: pointer;
}
/* :where() keeps this at 0,1,0: a bare :not() would be 0,2,0 and beat `.alert-message p` (0,1,1).
* A button keeps its own label ink while it spins; dim on a solid fill measured 1.00-2.24:1. */
.spinning:where(:not(.btn, .cbi-button)), .cbi-value em, em.spinning { color: var(--fs-dim); }
/* type list must be EXHAUSTIVE: an omission falls back to the generic `input` box above, beside a themed one, not to "unstyled" */
.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;
}
/* max-width:100% beside the fixed 210px: a textarea's intrinsic width comes from `cols` (default 20), which put two textareas 19px outside their card at 300px before this */
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type-field); }
/* a square native scrollbar is not clipped by a rounded corner; self-rounded thumb + transparent track fixes it. no scrollbar-width/-color: either switches Chromium to the square standard bar */
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); }
textarea::-webkit-scrollbar-corner { background: transparent; }
/* accent arc traces the frame's own radius (two borders shrunk to 16px - radius); mirrored by
* hand below, ::-webkit-resizer ignores logical properties */
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); }
/* fallback ring for untyped inputs; excludes the toggle switch, whose :focus-visible-only ring must not double up */
.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;
}
/* same ring, shared by 4 selectors that cannot join the list above; :is() around :has() keeps the list forgiving — unwrapped, a :has() compound sharing a list fails the WHOLE rule below the floor */
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);
}
/* .cbi-value-error input (Lua-CBI) must live here: base's red border-color is a longhand and
* this rule's border shorthand wipes it by layer regardless of specificity.
* The same rejected value shown by a ui.Dropdown: base carried the widget and its closed <li>, theme only the input half.
* The enhanced select's validator fails on the hidden <select>, so its widget (the next sibling) is matched through it;
* (0,5,1) beats 65-dropdown's border shorthand (0,3,0), its hover (0,4,0) and its focus ring (0,2,0); the closed <li> takes the border only, the ring sits on the widget */
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,
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
select.cbi-input-invalid[data-fs-select="1"] + .cbi-dropdown:not(.btn):not(.cbi-button) {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
/* doubled [readonly] states intent at (0,2,1): at (0,1,1) it only tied the typed input rules above and silently lost */
: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;
}
/* keep base's layout (inline-flex chips + add-item row); only colours, rounding, a compact + button, narrow cap so a chip's × sits near its text */
.cbi-dynlist {
gap: var(--fs-space-1-5);
background: transparent; border: 0; padding: 0;
/* min(240px,100%) not a bare 240: a floor that exceeds the room is an overflow (measured 29px outside a 210px field at 320px) */
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);
}
.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);
}
.item:hover::after { filter: var(--fs-hover-lift); }
.cbi-dynlist > .item::after {
display: inline-flex;
padding: 0 var(--fs-space-2);
color: var(--fs-danger);
/* logical: base pins the × with inset-inline-end, so in RTL the rounded corners must follow it */
border-radius: 0;
border-start-end-radius: var(--fs-radius-sm);
border-end-end-radius: var(--fs-radius-sm);
}
.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);
}
.cbi-dynlist > .add-item > input,
/* width:auto keeps the flex basis in charge — this input sits inside .cbi-value-field, the elastic width above would otherwise hand it 100% 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;
}
/* UIDynamicList drop indicator (ui.js puts .drag-over on the dragenter target): (0,4,0) out-ranks the border
* shorthands above and in 55-buttons; [draggable] (ui.js sets it on every .item) lifts the item past .item:focus (0,3,0) */
.cbi-dynlist > .item[draggable].drag-over,
.cbi-dynlist > .add-item > :is(.cbi-input-text, .cbi-button-add).drag-over {
border-top: 1px solid var(--fs-text);
}
/* input + button as ONE seamless group; gap:0 is load-bearing — base gives every .control-group a gap, this is the one whose members must touch */
.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);
}
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
/* `.hidden.hidden` beats component rules up to (0,3,0) that come earlier in source, without
* !important (which would invert layer order); it also covers the Enter-key submit legacy Lua
* pages carry (issue #12). The @container table rules and fs-stacked actions outrank it.
* :not([class*=":"]) excludes a Tailwind/UnoCSS class fs-sheets.js re-hosts into this layer —
* docs/css.md "Inputs: `.hidden.hidden`, and the Tailwind/UnoCSS exception". */
.hidden.hidden:not([class*=":"]) { display: none; }
/* the WCAG 2.5.8 target (24px min) split from the 6px track — docs/css.md "Inputs: the range
* slider's hit target and its line-box" has the two-part fix and its numbers */
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 on the INPUT's box not the track: (6-16)/2 = -5px */
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;
}
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); }
/* same ceiling as the elastic text inputs; vertical-align:middle decouples the line-box from the input's own edge, which moved 6px -> 24px above */
.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 bold mono face bundled, drags in 20 KB or fakes it with synthetic emboldening */
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); }
}
@@ -0,0 +1,397 @@
@layer theme {
/* generic .dropdown-menu popup: nothing in luci-base emits it — coverage vocabulary, since
* stock luci-theme-bootstrap styles it and a third-party app may emit it */
.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); color: var(--fs-accent); }
/* the outer div IS the button, class = ui.js's SELECTED mode; unguarded this erased that
* variant's fill while its ink survived — measured 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 (theme/55-buttons.css gives the rest theirs); painted by specificity, not source order */
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
.cbi-page-actions .cbi-dropdown > .cbi-button {
background: var(--fs-accent);
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 ---- */
/* machinery absorbed from base, driven by ui.js attributes; the state machine below is
* UNFLAGGED, stating openly what upstream's !important flags resolved — docs/css.md
* "Dropdown: the display state machine, unflagged" has the specificity table. */
.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, unflagged (0,3,1) over the rule above's (0,1,1) */
.cbi-dropdown.btn > ul:not(.dropdown),
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
margin-block: 0;
margin-inline: 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, `.more` is none until the showers flip it — one declaration per state or the audit's same-selector shadow check fires */
.cbi-dropdown > .open,
.cbi-dropdown > .more {
flex-grow: 0;
flex-shrink: 0;
flex-direction: column;
justify-content: center;
text-align: center;
}
/* currentColor: the variant's own ink, 4.6-5.4:1 on every button fill measured; --fs-border is 1.45:1 */
.cbi-dropdown.btn > .open,
.cbi-dropdown.cbi-button > .open {
border-inline-start: 1px solid currentColor;
}
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
display: none;
}
.cbi-dropdown > ul > li {
display: none;
font-size: var(--fs-type-field);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* ellipsis did nothing until min-width: 0 — a flex item's automatic minimum floors it at its
* own content width — docs/css.md "Dropdown: ellipsis needs `min-width: 0` first". */
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; }
/* a button dropdown's label is a button label, not a form field's 16px value; block, not the
* state machine's flex, since text-overflow only draws its "…" on a block container */
.cbi-dropdown:is(.btn, .cbi-button) > ul:not(.dropdown) > li[display] { display: block; font-size: inherit; }
/* 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-block: 0;
margin-inline: 0 var(--fs-space-1);
}
/* ---- the display state machine, on specificity (docs/css.md has the per-selector table) ---- */
/* a closed optional dropdown with an empty value renders EMPTY unflagged too: the placeholder
* hider out-specifies the generic shower. :not([open]) keeps this out of the open list. */
.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,
.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: a CHILD with an inline width overflows, wrapping the row cannot reach it —
* form.js's RichListValue assumes an unbounded list (issue #15). */
.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);
/* fixed height: without it a closed dropdown grows to its tallest child (Save & Apply's
* 40px button, measured 38 -> 42px on the gallery); --fs-ctl-h, since the min-height floor always won */
height: var(--fs-ctl-h);
font-size: var(--fs-type);
transition: var(--fs-field-transition);
/* absorbed from base/30-forms.css; NOT the dynlist's numbers (240/440) — one value vs a column of them */
min-width: 210px;
max-width: 400px;
width: auto;
}
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { 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 row on a BUTTON dropdown, excluded by
* :not(.btn):not(.cbi-button) below — one menu, one row, but two selectors. */
.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);
/* a menu row is not a button label: the open list sits inside the button and would inherit its 600 and 1.1 line (a plain dropdown's rows: 400, 1.5) */
font-weight: var(--fs-weight-normal); line-height: var(--fs-leading);
/* a grid step, not calc(--fs-type * --fs-leading) (19.5px) — a half-pixel box edge is what the scale exists to stop */
min-height: var(--fs-space-5);
}
/* a MULTI-select wraps items in <form><label>…</label>, which theme/60-inputs.css's `.cbi-value-field *` would hand mono — inherit keeps it the row's sans, a LABEL not a value */
.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; }
/* same ink base/30-forms.css gives `placeholder`; repeats the row rule's two selectors rather
* than a shorter form, which LOSES to the row's own color and rendered in the value's ink. */
.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 (theme/60-inputs.css) */
.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%; }
/* `.open` is the only hit target that opens the list (a click elsewhere on a button dropdown
* RUNS the button). `font-size: 0` collapsed the stock line-height to 28x0, putting the
* chevron outside its own box — a real box fixes that. */
.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` was one base rule with `.open`, split when the theme took `.open` for the chevron */
.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" */
}
/* --fs-dim reads as a smudge on the accent-filled Save & Apply; currentColor uses the variant's own ink */
.cbi-dropdown.btn > .open::after,
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
/* open list -> design popover */
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
.cbi-dropdown[open] > ul.dropdown {
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);
margin-top: var(--fs-space-1-5);
/* anchored to its widget: ui.js's touch branch assumes the viewport as containing block; !important beats the inline style */
inset-inline-start: 0 !important;
inset-inline-end: auto !important;
min-width: 100%;
/* desktop only: min(92vw,420px) ignores trigger's left offset, grew past viewport under 767px (113 findings) */
width: max-content;
max-width: min(92vw, 420px);
}
.cbi-dropdown[open] > ul.dropdown > li {
border-radius: var(--fs-radius-sm);
border-inline-start: 0;
position: relative;
/* an OPEN row wraps; only the closed preview ellipsizes — the menu is capped at
* min(92vw, 420px), so a verbose option would otherwise decide the width of every menu */
white-space: normal; overflow-wrap: anywhere;
/* no border-bottom: it curls at the corners of a rounded box; the ::after inset strip below is the divider instead */
border-bottom: 0;
}
/* The row's own wrap above never reaches a `.zonebadge` (base/95-luci.css): `white-space: nowrap`
* there, right for a one-line pill in a table cell, wrong here where a multi-homed interface
* (RichListValue) nests two `.ifacebadge` chips in one badge with no space between them — an
* unbreakable 254-258px at 320px (`R` past the 320px viewport), overflowing
* regardless of the popup's own width. Scoped to the open list only: every other `.zonebadge`
* keeps `nowrap`. */
.cbi-dropdown[open] > ul.dropdown > li .zonebadge {
white-space: normal; overflow-wrap: anywhere;
}
.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 — docs/css.md
* "Dropdown: the open list's rail and the selected-row's contrast". */
.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 */
}
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
* INSIDE it so a focused+selected row keeps both */
.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);
}
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
.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); }
.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, 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:not(.btn):not(.cbi-button),
.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 a multi-dropdown row no longer fits, so each row cards, same shape as the
* data-table stack (theme/30-tables.css); a @container, not the tables' own measurement, since
* un-collapsing a row to measure it CHANGES what its widgets read. 992, not 960 — docs/css.md
* "Dropdown: 992px, not 960" has the padding subtraction and the regression it fixed. */
@container fs-content (max-width: 992px) {
.table.cbi-section-table,
.table.cbi-section-table .thead,
.table.cbi-section-table .tbody,
.table.cbi-section-table .td { display: block; width: auto; }
/* a card repeats every label, so the header goes in BOTH markups (form.js's .thead children,
* the Lua CBI's bare titles/descr rows) — except the opt-in Table Filters row (25.12 tags it
* -titles too): no card repeats it, and hidden its 4 gallery controls rendered 0x0 */
.table.cbi-section-table .thead > :not(.cbi-section-table-filter),
.table.cbi-section-table .tr.cbi-section-table-titles:not(.cbi-section-table-filter),
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
/* stretch (not flex-start) keeps a pair's cells the same height when one wraps */
.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); }
/* margin: 0 load-bearing: the Lua CBI gives a table CELL the .cbi-value-field class a form
* field carries, base indents it 20px — without the reset two cells no longer fit a line */
.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;
/* overflow-wrap: anywhere for opaque tokens (a real IPv6 GUA has no break point) — one
* ran 156px past a 360px viewport before this */
overflow-wrap: anywhere; white-space: normal;
}
/* the filter cells are .th, so the .td rule above never reaches them: one field per card
* line, and no row divider (.cbi-section .table .th's border-bottom) between fields */
.table.cbi-section-table .tr.cbi-section-table-filter > .th {
flex: 1 1 100%; min-width: 0; border: 0;
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5);
}
/* a cell that only READS a value is short, two fit a line; a real widget keeps full width (a dropdown at half a phone's width is unusable) */
.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 (theme/30-tables.css, tools/mirror.mjs): needed under a
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
.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, sharing the line. PINNED — see above. */
.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; }
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
* div is a single flex item. PINNED to the data table's copy, same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
}
.table.cbi-section-table .td .zone-forwards { width: 100%; }
}
/* the table card (theme/30-tables.css) clips with overflow: hidden, but a .cbi-dropdown's open
* list is absolutely positioned — drop the clip, restore corners via separated borders instead */
.table:has(.cbi-dropdown),
.table:has(.cbi-dynlist) {
overflow: visible;
border-collapse: separate;
border-spacing: 0;
}
}
@@ -0,0 +1,46 @@
@layer theme {
/* beats the heading ramp's tight leading in-layer for a dialog's h4 (45-misc.css) */
.modal > * {
line-height: normal;
margin-bottom: .5em;
max-width: 100%;
}
/* wins on specificity, not base's file-order tie */
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
/* moved from base: width:100% loses to the 210px generic box by layer */
.modal > pre,
.modal > textarea {
white-space: pre-wrap;
overflow: auto;
width: 100%;
}
/* 600px is cramped for a table form (.cbi-map cards on a big screen); vw widens toward 1100 on desktop only */
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
#modal_overlay .modal label.btn {
/* @mirror btn-wrap/anywhere */
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
/* @endmirror */
}
#modal_overlay .modal li { overflow-wrap: anywhere; }
/* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */
#modal_overlay .uci-dialog div > ins,
#modal_overlay .uci-dialog div > del,
#modal_overlay .uci-dialog div > var,
#modal_overlay .uci-change-legend-label > ins,
#modal_overlay .uci-change-legend-label > del,
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); }
#modal_overlay .uci-change-legend-label > ins,
#modal_overlay .uci-change-legend-label > del,
#modal_overlay .uci-change-legend-label > var { overflow: hidden; }
/* fill child sits 3px inset, overflow:hidden never reaches the corner — max() floors at 0 (issue #14) */
#modal_overlay .uci-dialog var > ins,
#modal_overlay .uci-dialog var > del {
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
}
}
@@ -0,0 +1,80 @@
@layer theme {
/* page-search palette. fs-search.js parents it to <body>: .fs-sidebar is overflow:auto, which
* would clip a child of the trigger, and the overlay covers the whole viewport anyway. */
/* --fs-scrim is the theme's one colour literal (plain black at .7) on purpose: 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);
/* what the `hidden` attribute has to beat — base/10-reset.css covers every element now */
display: flex; justify-content: center;
/* not centred vertically: the box grows DOWNWARD as results come in, centred 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);
}
/* a SCROLL CONTAINER, not decoration: uncapped the box grows past a short viewport (phone
* landscape ~380px), and 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 ROW is the field, the input carries no frame of its own — two classes (0,2,0) needed to
* outrank 60-inputs.css's generic field box (0,1,1) in the same layer regardless of source
* order. --fs-type-floor at every width, not the body size: below 16px iOS Safari zooms the page
* on focus, and this field focuses 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-floor); font-weight: var(--fs-weight); line-height: 1.4;
}
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
/* ink comes from base ::placeholder (one for the whole theme); only the weight is restated */
.fs-search-row .fs-search-input::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 pointer AND keyboard: .active is set on both aria-activedescendant and pointermove, so mouse and arrow keys can never disagree which row Enter opens */
.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); }
.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; }
.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;
}
/* empty state ("No pages found") reads as a sentence, not an eyebrow label. :has(), not a
* sibling combinator: the note sits ABOVE the list it describes, `+` 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);
}
}
@@ -0,0 +1,78 @@
@layer theme {
/* phone overflow safety net (<=767px): .fs-main is overflow-x: clip, cutting anything wider silently; tablets were clean */
/* split-button labels wrap under 360px — docs/css.md "Responsive: a split button's label must wrap under 360px" */
@container fs-view (max-width: 360px) {
#view .cbi-button, #view .btn,
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
/* @mirror btn-wrap/anywhere */
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
/* @endmirror */
}
}
@media (max-width: 767px) {
/* foreign-<table> fallback unguarded now in theme/30-tables.css: fit is content/column, never viewport */
/* button dropdowns: no floor, capped to the column */
.cbi-dropdown { min-width: 0; max-width: 100%; }
/* form dropdowns: 65-dropdown.css's (0,3,0) 210px/400px floor outranks the rule above, a @media adds no
* specificity; the doubled class lifts this one to (0,4,0), min() so a 320px modal field is not overflowed */
.cbi-dropdown.cbi-dropdown:not(.btn):not(.cbi-button) { min-width: min(210px, 100%); max-width: 100%; }
/* phone tier over 65-dropdown.css's `width: max-content` (0,3,1): the doubled class makes this (0,4,1); trigger already capped to field width, `auto` keeps the popup on-screen */
.cbi-dropdown[open] > ul.dropdown.dropdown { width: auto; }
/* package-manager.js injects an unlayered `.controls > * > label { min-width: 250px }` (overflows ~360px) and `.controls { display: flex }`; !important beats that sheet, and the two pager rows' inline display:none until the first display() */
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
/* :not(.pager): blocking it stacked « / text / » onto three lines */
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
: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 > .text { flex: 0 1 auto; min-width: 0; }
/* disk gauge value moved BELOW the bar — docs/css.md "Responsive: the disk gauge value moves below the bar" */
: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`: only the TITLE label, a bare `.controls label` also caught nested radio labels */
: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); }
/* radio row: stock's &nbsp; text nodes don't stop a mid-choice split, a real gap does */
: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%; }
/* Diagnostics stack not here: fs-select.js writes .fs-rowstack at any width, theme/30-tables.css carries it */
/* no `table.fs-dt { display: block }`: discards the table context — docs/css.md "Responsive: no `display: block` on `table.fs-dt`" */
/* generic caps: a field never exceeds its column */
.cbi-value-field input, .cbi-value-field select { max-width: 100%; }
/* content-sized leaves half the row empty; checkbox/radio/file keep intrinsic size */
.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: 0 only, base/40-tables.css already breaks a long value */
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
/* side-by-side squeezes a field to ~120px; not #view-scoped, ui.js appends modals to <body> */
.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; }
/* stock phone contract; `.td.hide-xs` not redundant, stacking rules (0,2,1)/(0,2,0) outrank a bare `.hide-xs` (0,1,0) */
.hide-sm, .hide-xs,
.table .td.hide-sm, .table .td.hide-xs,
.table .th.hide-sm, .table .th.hide-xs { display: none; }
/* .col-N weights UNGUARDED now, theme/30-tables.css: flex is inert on a non-flex item */
/* no field-size rule here: the 16px iOS floor is the --fs-type-field token, set under this query in 02-tokens.css */
}
}
@@ -0,0 +1,95 @@
@layer theme {
/* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules,
* and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below
* (.stylelintrc.json holds the allowlist). .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* re-states the tokens so every rule reading them follows — docs/css.md "A11y: prefers-contrast token shifts, and the numbers behind them" has why each number */
@media (prefers-contrast: more) {
/* theme layer outranks the tokens-layer twins (04-nocolormix.css): without color-mix an unguarded
* re-set leaves --fs-hairline, the focus ring and the placeholder ink invalid at use */
@supports (color: color-mix(in srgb, red 50%, transparent)) {
: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);
}
}
:root {
--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-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
/* the one token shipped under AA at rest; both modes buy the ink back here */
--fs-placeholder-mix: 62%;
}
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
}
/* forced-colors: background-color is forced to the system canvas, so state carried by
* BACKGROUND alone vanishes (active tab, checked toggle, open-list selected row) — re-stated with a
* border/outline in system keywords, which forced-colors keeps — docs/css.md "A11y:
* forced-colors state repairs". */
@media (forced-colors: active) {
#tabmenu ul.tabs > li.active > a,
.tabs > li.active > a,
.cbi-tabmenu > li.cbi-tab > a,
.cbi-dropdown[open] > ul.dropdown > 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 */
}
/* active #topmenu state was only a 10%-accent background + box-shadow rail, both dropped */
#topmenu li.active > a {
border-inline-start: 3px solid Highlight;
padding-inline-start: calc(var(--fs-space-3) - 3px);
}
/* input[type=checkbox] (60-inputs.css draws the switch there) cannot reach ui.Checkbox's tooltip label */
.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; }
/* a glyph drawn as a background fill cut out by mask is Canvas on Canvas here: the poll glyph keeps 0 of the
* 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked
* one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused
* keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at the stylelint ceiling (1,7,0) and still win by file order. */
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"],
input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
input[type="radio"][type="radio"]:checked::after,
:is(.btn, .cbi-button).spinning::before {
background: ButtonText;
forced-color-adjust: none;
}
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
background: GrayText;
}
.cbi-value-description:not(:empty)::before,
.spinning::before {
background: CanvasText;
forced-color-adjust: none;
}
/* icon-only buttons (rail toggle, search) lose their hover fill */
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
/* box-shadow is dropped entirely in forced-colors, and themed rules set outline: none above (0,1,0)
* or in the page layer, so only !important reaches them. #maincontent keeps its deliberate none:
* the router and the skip link focus <main>. */
:focus-visible:not(#maincontent) { outline: 2px solid Highlight !important; outline-offset: 1px; }
}
}
@@ -0,0 +1,31 @@
@layer theme {
/* without this a page prints the dark chrome and only ONE page (shell is 100svh,
* overflow:hidden). No !important: each arm is at least one class over the
* strongest chrome rule it meets. */
@media print {
/* theme layer outranks every tokens-layer palette block without naming data-darkmode */
: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;
}
/* nav/search/actions unusable on paper; Appearance is a PAGE now, stays and prints */
.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; }
/* undoes the sidebar layout's scroll frame, the ONE-page bug above */
.fs-shell.fs-shell.fs-shell.fs-shell.fs-shell,
.fs-main.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
.fs-content.fs-content.fs-content.fs-content { max-width: none; padding: 0; }
/* (0,4,0) over 15-wallpaper.css's :root[data-wallpaper="pattern"] .fs-pattern (0,3,0) */
.fs-pattern.fs-pattern.fs-pattern.fs-pattern { display: none; }
/* 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; }
.cbi-section { break-inside: avoid; }
}
}