, so base's prose rule (which reaches `p` and the
* section/map descriptions) never covered it — and this is the one that wraps most, since it
* sits in the field column. Same reasoning as there. */
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
/* A text field in a CBI form is not a fixed 210px: that width is base's, and the same 210px
* holds far less here — the field is monospaced and padded --fs-space-3 a side instead of 4,
* so a server URL was clipped mid-domain while the .cbi-dynlist directly under it is 440px
* wide (issue #5). Elastic, with the dynlist's own
* ceiling so the two line up. Number/date/time keep the fixed width: they hold short values, and
* a 440px box for a port number reads as a mistake. */
.cbi-value-field input[type="text"],
.cbi-value-field input[type="url"],
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
/* A .cbi-value is designed to hold ONE title+field pair, and base lays it out as a flex row. Some
* apps cram several pairs into one .cbi-value, and the flex row then flows them side by side
* until the fields overflow the card. Give each title and each field its own full-width row —
* the stacked layout base already uses on a phone — scoped by `:has()` to the abnormal
* multi-PAIR case only, so every normal single-field row keeps its label-beside-field layout.
*
* The test is the second TITLE, not the second field, and that is the whole of this rule's
* safety: `:has()` is live, so keying on a second field means any app that appends one at
* runtime — a status line, an output pane, a spinner holder — flips the row from "label beside
* field" to "everything stacked" under the user's finger. Measured: pressing a button in such a
* row moved that button 31px down. */
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
flex: 1 1 100%;
margin-inline-start: 0;
text-align: start;
}
/* fields inside a table cell fill it — moved from base with the generic 210px box below:
* in base these beat the box in-layer, left behind they would lose to it by layer */
.td > input[type="text"],
.td > input[type="password"],
.td > select,
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-dynlist > .add-item > .cbi-dropdown { width: 100%; }
.cbi-section-create > * > input { width: 100%; }
/* --- live components that bypassed the theme --- */
/* A CBI row is a
on a form and a
in a
* section table — same , and the theme had claimed only the table half. */
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
/* A file-browser row must hold its own buttons, and the row CLIPS what does not fit rather than
* showing it. base sizes the row by proportion (name `flex: 10`, actions `flex: 3`), which is
* sound for base's own ~20px buttons in a text-height row; this theme's button is 36px tall with
* 14px of side padding and a file row carries up to three of them, so at 23% of the row they do
* not fit — and `.cbi-filebrowser > *` is `overflow: hidden`, so a button touches the clip box
* on both axes and loses its rounded corners, which reads as a broken button.
*
* So: size the action column to its CONTENT and let the name give way (a name can ellipsize, a
* button cannot), give the children a real gutter, and space the rows. It wraps rather than
* clips when even that is not enough. */
.cbi-filebrowser > * { border-bottom-color: var(--fs-border); padding: var(--fs-space-1-5) var(--fs-space-2); }
.cbi-filebrowser > ul { display: flex; flex-direction: column; gap: var(--fs-space-1); }
.cbi-filebrowser > ul > li { align-items: center; flex-wrap: wrap; gap: var(--fs-space-2); }
.cbi-filebrowser > ul > li > div:first-child { flex: 1 1 auto; min-width: 0; }
/* `0 1 auto`, not `0 0 auto`: the actions block is three buttons (Deselect / Download / Delete) and
* it may WRAP but may not shrink, so on a 320px screen it kept its one-line width and stuck 23px
* out of the browser box — measured in the gallery at 320px, where `Delete` ended past the card.
* Shrinking is what lets its own `flex-wrap` fire; `min-width: 0` is what lets shrinking reach
* below the buttons' min-content, which is the pair every flex item in this theme needs. */
.cbi-filebrowser > ul > li > div:last-child {
flex: 0 1 auto; min-width: 0;
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
}
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
/* moved from base with the generic 210px box (same layer arithmetic as the .td rules) */
.cbi-filebrowser .upload > div > input { width: 100%; }
/* Form widgets — toggles, file input. Applies to every CBI form. */
/* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the
* checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words
* (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox]
* + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its input is
* opacity:0, so this margin moves nothing there. */
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
/* checkbox -> toggle switch
*
* The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they
* make LuCI's most common control the only one in the theme that ignores the Density axis, while
* every button and field around it moves: measured, the switch held 40x22 at Compact and at
* Large while the fields beside it went 38 -> 32.3 -> 43.7. The switch is a box that holds a
* knob, which is the
* box tier by definition. Local names rather than new global tokens: one component needs one
* number and three things derived from it.
*
* flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the
* pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real
* markup, not the caption-less div the gallery had — makes the pill a SECOND, shrinkable flex
* item. Unshrunk it stays exactly --sw-w; measured shrunk on the live Install dialog on both
* stands, RU/Normal/390px gave a used width of 30.17px against 40 declared and a knob 6.83px past
* the pill's right edge, EN/Large/360px 36.22 against 46 declared and a 6.47px overhang —
* flex-shrink: 0 alone took both to -3.00px and -3.31px (the knob safely inside the pill, not
* flush with it), so a second guard (e.g. min-width: var(--sw-w), redundant with a non-shrinking
* flex-basis) was not needed and does not ship.
*
* Scoped to `.cbi-checkbox > label[for]`, not a bare `.cbi-checkbox > label`: ui.js's
* Checkbox.render() appends a SECOND label when `tooltip` is set (form.Flag.tooltip — e.g.
* luci-app-firewall's zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon,
* and the bare selector painted it as a second switch too — measured on the gallery's tooltip
* fixture, both labels 40x22 at rgb(55,62,71), flush against each other with no gap. `[for]` is
* the discriminator rather than `input[type="checkbox"] + label`: Checkbox.render() always
* builds the switch label as `E('label', { 'for': id })` and the tooltip label with no `for` at
* all, so the attribute alone tells the two apart, at a fifth of the bytes — the sibling
* combinator repeated across five rules (this one, its ::before/::after, and the two
* forced-colors rules in 95-a11y-media.css) put cascade.css 186 B over its budget; rescoping
* dropped it back under. */
/* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already
* wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's
* own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc
* grows "неиспользуемые" (14 letters) to 20, wide enough on its own to push the whole row 24.6px
* past `.cbi-value-field` at 320px/Large with no scroller — a real Russian compound this length
* is not implausible (the theme already ships an untranslated one), so the row should absorb it
* by breaking the word, not by escaping its column. */
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
.cbi-checkbox > input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.cbi-checkbox > label[for] {
--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));
display: inline-block; position: relative; margin: 0; flex-shrink: 0;
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
background: var(--fs-track); border: var(--fs-hairline);
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle;
}
/* The input is opacity:0 at 0x0, so this label IS the whole hit box of LuCI's most common
* control — and at 22px tall it sits under the 24px floor of WCAG 2.5.8. The floor is about the
* CLICKABLE area, not the drawing: an invisible overlay stretches the target to 44x30 without
* fattening the pill (a visual 44x24 was tried and looked bloated). position:absolute, so it
* adds nothing to layout; the knob keeps ::after.
*
* The inset stays literal while the pill above scales: this is an accessibility floor, and a
* floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7,
* over the minimum. */
.cbi-checkbox > label[for]::before {
content: ""; position: absolute; inset: -4px -2px;
}
.cbi-checkbox > label[for]::after {
content: ""; position: absolute; top: var(--sw-pad); inset-inline-start: var(--sw-pad);
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
background: var(--fs-dim);
/* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on
* every frame of the slide. translateX is composited. */
transform: translateX(0);
transition: transform var(--fs-dur-move), background var(--fs-dur);
}
/* The tooltip-container label (above) is now unstyled by the switch rules, so it falls back to
* the browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still
* needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a
* margin declared here: the checkbox input inside that flex row is `position: absolute`, so it is
* never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox`
* with just the switch label has one flex item and gets no gap at all (measured: cb1's box is
* still 40px wide, unchanged), while the tooltip fixture's two labels get exactly one. --fs-space-2
* (8px), same number the removed margin used — the gap the theme already uses beside an inline
* checkbox glyph (`.btn > input[type="checkbox"]` above) — measured on the gallery fixture: top
* edges 0.5px apart (the icon glyph's own line box, not this gap) and left-to-right edge 8px,
* both unchanged from before this rescope. */
.cbi-checkbox > input[type="checkbox"]:checked + label { background: var(--fs-accent); border-color: transparent; }
/* The travel is derived rather than hand-written, so it cannot drift out of step with the three
* sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a
* border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so
* the knob has 2px less room and deriving without that term lands it off-centre — the travel
* comes out 20px where symmetry wants 18. The border does not scale with density, so the term
* stays literal. */
.cbi-checkbox > input[type="checkbox"]:checked + label::after {
transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
background: var(--fs-on-accent);
}
/* The real checkbox is opacity:0 at 0x0 above, so base's `input:focus::before` glow paints on a
* 0x0 box. The IS the switch, so the ring goes there, or the most common control in LuCI
* shows nothing under keyboard focus.
*
* -solo, because on the CHECKED switch — half the switches on any page — neither other channel
* exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`,
* so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the
* same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */
.cbi-checkbox > input[type="checkbox"]:focus-visible + label {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring-solo);
}
.cbi-checkbox > input[type="checkbox"]:disabled + label { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box
* below, and left in base every one of those overrides would lose to that rule by layer. The box
* is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number,
* so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets. */
input[type="checkbox"],
input[type="radio"] {
--bd-color: var(--fs-border);
--fg-color: var(--fs-text);
/* 16px on the grid, but on the BOX tier, not the space tier. `var(--fs-space-4)` was the
* same 16px at Normal and the wrong multiplier everywhere else: space moves to .65 at
* Compact and stays 1 at Large, so the tick box went to 10.4px (smaller than any other
* control on the page — measured) and then refused to grow at Large. A checkbox is a box
* that has to HOLD an icon, which is what --fs-density-box is defined for. */
--box: calc(16px * var(--fs-density-box));
--mark-inset: 3px;
/* ---- the drawing is 16px; the target is 24px ----
*
* WCAG 2.2 SC 2.5.8 lets an undersized target pass on SPACING — 24px centre to centre — which
* is what a 16px tick box has always relied on. That holds while the theme decides the layout
* and stops the moment a third-party app lays out its own rows, putting this checkbox and a
* link inside the 24px. A target that passes only because of somebody else's markup is not a
* target that passes.
*
* So the ELEMENT is 24px and the drawing stays 16px, centred inside it: the two pseudo
* elements are already absolutely positioned, so centring costs `inset: 0; margin: auto`.
* Nothing moves visually — the box, the tick and the focus ring are all drawn by the pseudo
* elements, and the element itself has neither background nor border.
*
* `max()`, not a flat 24px: the Large density scales `--box` to 18.4px, and a target must
* never SHRINK when the user asks for a bigger UI. */
--hit: max(24px, var(--box));
appearance: none;
width: var(--hit);
height: var(--hit);
/* …and the target does not cost a row its height: the element is 24px but occupies its old
* `--box` in layout, the negative margin pulling the extra area back out of the flow so the
* box overflows itself symmetrically. Measured on a third-party listing: rows went 42px ->
* 51px with the target simply grown, and 43px with the margin, the target still answering to
* a hit test at its centre. */
margin: calc((var(--box) - var(--hit)) / 2);
color: var(--fg-color);
position: relative;
display: inline-block;
cursor: pointer;
background: none;
border: none;
/* padding/border-radius are NOT reset: the generic input box's values land on an
* appearance-none 16px box whose pseudo-elements are absolute — invisible, and what the
* widget has always computed */
}
input[type="checkbox"]::before,
input[type="checkbox"]::after,
input[type="radio"]::before,
input[type="radio"]::after {
position: absolute;
content: "";
}
input[type="checkbox"]::before,
input[type="radio"]::before {
/* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with
* a fixed size is the one centring that needs no translate and therefore behaves the same in
* RTL, where an `inset-inline-start: 50%` + `translate: -50%` pair would push the drawing the
* wrong way. */
inset: 0;
margin: auto;
width: var(--box);
height: var(--box);
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
border: 1px solid var(--bd-color);
transition: var(--fs-field-transition);
}
/* each box states its own radius — one shared 2px re-declared to 50% for the radio would
* be a same-selector shadow inside this layer */
input[type="checkbox"]::before { border-radius: 2px; }
input[type="radio"],
input[type="radio"]::before {
border-radius: 50%;
}
/* keyboard/click focus rings on the DRAWN box — the theme's one ring token, replacing
* base's old generic glow; :focus (not :focus-visible) keeps the pre-absorption behaviour
* of ringing a clicked box. Both states (plain and invalid) are declared once, further
* down this file, in the selector lists every other same-body ring joins — see the two
* `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */
input[type="checkbox"]::after,
input[type="radio"]::after {
/* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark
* centres the same way rather than measuring from the target's own edge */
inset: 0;
margin: auto;
width: calc(var(--box) - var(--mark-inset) * 2);
height: calc(var(--box) - var(--mark-inset) * 2);
}
input[type="checkbox"]:checked::after,
input[type="radio"]:checked::after {
/* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
* It replaces a solid filled path that was byte-identical to luci-theme-bootstrap's — and to
* luci-theme-openwrt-2020's, which is the same project's artwork, not evidence the drawing is
* generic. A mask keys on ALPHA, so a stroked path masks exactly as a filled one did. */
--checkmark-icon: url("data:image/svg+xml, ");
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
mask: var(--checkmark-icon) center/cover no-repeat;
background: var(--fg-color);
}
input[type="radio"]:checked:after {
/* the radio's dot — filled, because a ring would read as "not selected". OURS: our grid,
* our radius (the fill is the one shape a stroke cannot express). */
--checkmark-icon: url("data:image/svg+xml, ");
}
input[type="checkbox"].cbi-input-invalid,
input[type="radio"].cbi-input-invalid {
--bd-color: var(--fs-danger);
--fg-color: var(--fs-danger);
}
/* The generic field box, absorbed from base's `input, textarea, select, …` group; each other
* member lives with its component rule now. Bare `input` keeps the historical 210px box as the
* fallback for every type the exhaustive text-input list below does not name — and for a
* typeless , which IS a text input. The widget types that must NOT wear this box carry
* their own theme rules: a base override would lose to this rule by layer whatever its
* specificity. */
input {
display: inline-block;
width: 210px;
height: var(--fs-control-h);
padding: var(--fs-space-1) var(--fs-space-2);
background: var(--fs-panel);
color: var(--fs-text);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
}
/* base's old `#localtime { width: 300px }` media rule was NOT moved here: the elastic
* .cbi-value-field width above already out-layered it (measured on the live router: the
* field rendered 440px, not 300) — the rule was dead, and a dead rule is deleted, not
* relocated */
/* native file input — the `initial`/auto resets are absorbed from base: the generic box
* above would otherwise dress the native chooser in a 210px bordered field */
input[type="file"] {
color: var(--fs-dim); font-size: var(--fs-type);
padding: initial;
border: initial;
line-height: initial;
box-shadow: none;
/* no !important: this out-specifies the generic `input { width: 210px }` on its own.
* height/background are NOT reset: the chooser keeps the generic box's control height
* and panel tint, as it always has. */
width: auto;
}
/* native — custom chevron + theme (matching the text inputs). The chevron data-URI
* lives in 03-palettes.css per palette/mode block, because a URI cannot read var(); it
* replaced a hardcoded grey that ignored palette and dark mode. */
select, select.cbi-input-select, .cbi-value-field select {
/* unprefixed only: the floor (Chrome 108/Firefox 101/Safari 15.4) clears unprefixed
* `appearance` everywhere (Chrome 84, Firefox 80, Safari 15.4 — docs/css.md). -webkit- was
* 96 B across these four rules for nothing this sheet still has to run on. */
appearance: none;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
/* right inset from the scale, not a literal 12 — the .cbi-select shell below states the same
* position and two spellings of one number is how they would drift apart */
/* two 6px bands forming the V, the far one a band-width further in; --fs-space-3 stays the
* single spelling of the inset, as the note above says */
background-repeat: no-repeat; background-size: 6px 6px;
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
padding: var(--fs-space-2) var(--fs-space-8) var(--fs-space-2) var(--fs-space-3);
border: var(--fs-hairline); border-radius: var(--fs-radius);
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type);
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
/* width/display absorbed from base's generic field box: a select's natural width is its
* longest option, so without the fixed box two selects in one form column ragged-edge;
* display stated because Chromium's base-select UA default is inline-flex, not the
* inline-block the widget has always computed */
width: 210px;
display: inline-block;
transition: var(--fs-field-transition);
}
/* group-then-refine, directly under its group: the listbox variant must NOT take the closed
* control's one-line height. A SURFACE token, not an ink one: this once read --fs-on-accent
* (the ink meant to go ON an accent fill), which resolves to plain white and painted a white
* listbox in dark mode. `height: inherit` absorbed from base with the width above — without
* it the fixed box would cap a multi-row listbox at one line. */
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); height: inherit; }
/* :focus joins the shared accent ring in the text-input group below. */
/* absorbed from base's reset: 100% (= inherit) keeps the option on its select's size, and
* only needs the 2em leading — the other controls carry line-height: normal above */
option { font-size: 100%; line-height: 2em; }
select option { background: var(--fs-panel); color: var(--fs-text); }
/* Native drop list: the OS renders it, so the `select option` colours above are ignored
* on most platforms. Chromium's customisable-select (`appearance: base-select`) renders the
* picker in the DOM, so it can be themed like the .cbi-dropdown menu; @supports-gated, and
* browsers without it keep the styled closed control plus the native list.
*
* Scoped to `.cbi-value-field select` only: a third-party app can mount a raw outside
* the CBI form and drive it its own way, and early Chrome builds mis-render such a select as a
* single-item list. Those sit outside .cbi-value-field, so this scope leaves them native. */
@supports (appearance: base-select) {
.cbi-value-field select { appearance: base-select; }
.cbi-value-field select::picker-icon { display: none; }
::picker(select) {
appearance: base-select;
background: var(--fs-panel); border: var(--fs-hairline);
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
padding: var(--fs-space-1-5); margin-top: var(--fs-space-1-5);
}
.cbi-value-field select option {
padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm); font-size: var(--fs-type);
/* no `color` here: `select option` above already sets --fs-text, and this restated it
* (switched off against 174 options, nothing moved). `background: transparent` is NOT the
* same — it deliberately undoes that rule's --fs-panel so the row's own hover shows. */
background: transparent; min-height: 0;
}
.cbi-value-field select option::checkmark { display: none; }
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
/* Opaque surface + inset rail, same shape as the open .cbi-dropdown's selected row
* (theme/65-dropdown.css): accent text on --fs-accent-soft measured 4.21:1 in dark,
* under AA — the tint of the accent eats its own contrast. */
.cbi-value-field select option:checked {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
}
/* .cbi-select — the legacy luci-compat shell (a div around a native ), absorbed from base
* whole: the generic field box, the shell's own paint and a focus ring on the theme's token. The
* shell is a DIV, so display and width must be stated. `:not(select)` because the class also
* appears ON a bare : a replaced element cannot draw the ::before chevron, so shell
* paint there means a gradient with no glyph. */
/* It paints like the themed , being the same control in an older shell — never a
* skeuomorphic surface of its own: a gradient from panel to border is lighter at the bottom in
* the dark palettes, and nothing else in the theme has a gradient at all. The geometry below is
* the `select` rule above, property for property, so a legacy shell and a modern select line up
* in one form column. This is the non-JS fallback: the people who see it already have something
* broken. */
.cbi-select:not(select) {
display: inline-block;
width: 210px;
/* `height`, NOT `min-height` — the inner select fills the shell with `height: 100%`, and a
* percentage against an auto-height parent has no definite containing block to resolve
* against, so it falls back to content height: measured, the select collapsed 30px -> 15px
* inside a 38px shell, text at the top and half the hit area gone. The themed `select` can
* use a floor because it IS the control; a wrapper has to state a height for its child. */
height: var(--fs-ctl-h);
color: var(--fs-text);
font-size: var(--fs-type);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
padding: 0;
position: relative;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
background-repeat: no-repeat;
background-size: 6px 6px;
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
transition: var(--fs-field-transition);
}
/* The disabled cue used to live ENTIRELY on the ▾ plate: base/30-forms.css dims
* `.cbi-select[disabled]::before` and never the shell itself. With the plate gone the state had
* no visual at all, so the shell carries it now. */
.cbi-select[disabled]:not(select) {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
}
/* .focus and .cbi-input-invalid.focus join the shared accent/danger ring rules further down
* this file — same two bodies as every other :focus/:focus-within ring on this token. */
/* the native inside the shell: invisible, filling it, clearing the ▾ plate on the
* right. The inset is a scale step; the right margin is the plate's width, so it stays
* em-relative to the glyph it clears. Every themed-select declaration above (chevron,
* padding, min-height) must be answered here, or the inner select paints like a free-standing
* one inside the 32px shell. */
.cbi-select select,
.cbi-select select:focus {
appearance: none;
outline: none;
border: none;
background: transparent;
background-image: none;
box-shadow: none;
height: 100%;
min-height: 0;
width: 100%;
/* clears the CHEVRON, which the shell now paints on its own background — the same
* --fs-space-8 inset the themed select uses. `margin-right: .6em` used to clear the ▾ plate
* instead, and an em-relative margin against a px-positioned glyph is how the two drifted. */
padding: 0 var(--fs-space-8) 0 var(--fs-space-3);
font-size: var(--fs-type);
line-height: normal;
cursor: pointer;
}
/* spinner / loading */
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
/* Text inputs and other field widgets, rounded and themed to match the selects and dropdowns
* (base leaves them at radius 3 and white). Excludes checkbox/radio and file, handled above.
*
* The type list must be EXHAUSTIVE: an omission does not fall back to "unstyled", it falls back
* to BASE, which still paints a stock white 3px-radius field beside a themed one. */
.cbi-input-text, .cbi-input-password, .cbi-input-textarea,
.cbi-section-create-name, .cbi-value-field input.create-item-input,
input[type="text"], input[type="password"], input[type="number"],
input[type="email"], input[type="url"], input[type="search"],
input[type="tel"], input[type="date"], input[type="time"],
input[type="datetime-local"], input[type="month"], input[type="week"],
input[type="color"],
textarea {
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
color: var(--fs-text);
padding: var(--fs-space-2) var(--fs-space-3);
min-height: var(--fs-ctl-h);
box-shadow: none;
transition: var(--fs-field-transition);
}
/* width/font-size absorbed from base's generic field box, same numbers — a textarea has
* always sat at the fixed 210px (the elastic rule above names only text/url/search) */
/* `max-width: 100%` beside the width, because a textarea is the one field whose intrinsic width
* comes from an ATTRIBUTE (`cols`, default 20) rather than from the box: measured at a 300px
* viewport, two textareas in the gallery stood 19px outside their card while every other control
* in the same column fitted. The 210px is the designed width and still applies wherever there is
* room for it. */
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type); }
/* A textarea's native scrollbar is a square strip and a rounded corner does not clip it, so on a
* scrolling config field the grey bar pokes past the radius as a square notch. Fix: a slim
* self-rounded thumb floated off the edges (transparent border plus background-clip:
* padding-box), a transparent track, and a track margin keeping the thumb clear of the corners.
*
* Deliberately no `scrollbar-width`/`scrollbar-color`: either makes Chromium switch to the
* standard scrollbar and ignore this whole ::-webkit block — and that standard bar is the
* square, unclipped one. Firefox has no ::-webkit and clips natively.
*
* 16px wide so ::-webkit-resizer, sized off the scrollbar, gets a bigger grab area; the 5px
* transparent thumb border still leaves it 6px visible. */
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
textarea::-webkit-scrollbar-thumb {
background: var(--fs-dim); border-radius: var(--fs-radius-pill);
border: 5px solid transparent; background-clip: padding-box;
}
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
/* The bottom-right square where the vertical bar meets the resize grip defaulted to white
* and poked past the rounded corner — transparent, so the frame shows. */
textarea::-webkit-scrollbar-corner { background: transparent; }
/* The resize grip highlights the frame's bottom-right corner — an accent arc tracing the frame's
* own radius, with no straight legs. Two borders plus the corner radius draw the arc, and the
* legs vanish because the box is shrunk to exactly the radius: the resizer is the scrollbar's
* 16px, so a `16px − radius` margin leaves a radius-sized box whose right and bottom border IS
* the quarter curve. (A mask would give a shorter arc, but ::-webkit-resizer ignores mask, and a
* baked-in SVG colour would not follow the palette.) */
/* PHYSICAL on purpose, and mirrored by hand below: ::-webkit-resizer is a UA-drawn corner whose
* side follows the writing direction, and it does not accept logical properties — with only the
* LTR rule the arc drew on the wrong corner in an RTL document (a stray quarter-circle on the
* left of every textarea). */
textarea::-webkit-resizer {
background: transparent;
border-right: 2px solid var(--fs-accent);
border-bottom: 2px solid var(--fs-accent);
border-bottom-right-radius: var(--fs-radius);
margin: calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius));
}
:root[dir="rtl"] textarea::-webkit-resizer {
border-right: 0; border-left: 2px solid var(--fs-accent);
border-bottom-right-radius: 0; border-bottom-left-radius: var(--fs-radius);
margin: calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0;
}
.cbi-input-text:hover, .cbi-input-password:hover, .cbi-input-textarea:hover,
input[type="text"]:hover, input[type="password"]:hover, input[type="number"]:hover,
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
textarea:hover { border-color: var(--fs-dim); }
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
* for the types the list does not name and for a typeless ; on checkbox/radio it lands
* on the invisible element box UNDER the drawn ::before ring above — coincident boxes, one
* visible ring, as the old glow behaved. `select:focus` and the keyboard-removable dynlist
* chip (ui.js gives it a tabindex) join here rather than repeating the same three
* declarations under their own selector: one shared body instead of three, 149 B off
* cascade.css. */
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
.cbi-section-create-name:focus,
input:focus,
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
textarea:focus,
select:focus,
.cbi-dynlist > .item:focus {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
outline: none;
}
/* The plain accent ring above, restated for four components whose selector cannot join that
* list without changing what it matches (a pseudo-element, a legacy shell class, two
* `:focus-within` containers) — same two declarations, one shared body instead of four, 186 B
* off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a
* `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the
* floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one
* selector. */
input[type="checkbox"]:focus::before, input[type="radio"]:focus::before,
.cbi-select.focus,
.cbi-dynlist > .add-item:focus-within,
.control-group:is(:has(> .cbi-input-password)):focus-within {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
/* Invalid state keeps the danger cue. `.cbi-value-error input` is the Lua-CBI path and belongs
* HERE, not in base: base does declare a red border-color for it, but the rule above sets the
* `border` SHORTHAND, and a shorthand in a later layer wipes the longhand out regardless of
* specificity — so every third-party app on luci-compat rendered the field in --fs-border grey
* and had lost its field-error cue while the modern .cbi-input-invalid path worked.
*
* `.cbi-input-invalid:focus` is not redundant with the rule it sits beside: the generic
* `input:focus` above is (0,1,1) and would repaint a focused invalid field accent, and
* `.cbi-input-invalid:focus` at (0,2,0) is what stops it — the TYPED `:focus` rules above are
* (0,2,1) and still outrank this, so a typed invalid field focuses accent, exactly as before
* the absorption. The checkbox/radio `::before` ring and the legacy `.cbi-select` shell need
* the identical danger body for the same reason on their own elements — one shared rule
* instead of four, 186 B off cascade.css. */
input[type="checkbox"].cbi-input-invalid:focus::before,
input[type="radio"].cbi-input-invalid:focus::before,
.cbi-select.cbi-input-invalid.focus,
.cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid,
.cbi-value-error input, .cbi-value-error textarea,
.cbi-input-invalid:focus {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
/* The same rejected value shown by a ui.Dropdown rather than an : the widget itself and,
* when it is closed, the that displays the value. base carried these two and the theme only
* the input, so half of one state lived in a layer the theme cannot reach. */
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
/* readonly keeps its faded border — moved from base with the generic box (its border-color
* longhand would lose to the box's `border` shorthand by layer). The doubled [readonly] is
* a deliberate specificity step: at (0,1,1) this ties the typed input rules above and the
* cascade would rest on file order — in base the same tie made the fade silently LOSE to
* the typed border, so a readonly text field never faded at all; (0,2,1) states the intent. */
:is(input, select, textarea)[readonly][readonly] {
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
pointer-events: auto;
cursor: auto;
}
/* dynamic list: keep base's layout (inline-flex column of chips + one add-item row) — do NOT
* set display/flex-wrap here or the add row breaks. Only colours, rounding, a compact +
* button, and no wrapping box: capped narrow so the chip's × sits near the text instead of
* floating across a full-width field. */
.cbi-dynlist {
gap: var(--fs-space-1-5);
background: transparent; border: 0; padding: 0;
/* `min(240px, 100%)`, not a bare 240: the floor is there so a chip's × sits near its text
* rather than floating across a full-width field, and it becomes a defect the moment the
* column is narrower than the floor — measured in the gallery at a 320px viewport, the list
* held 240px inside a 210px field and every chip, the add row and its input stood 29px
* outside the card. A floor that exceeds the room is not a floor, it is an overflow. */
min-height: 0; min-width: min(240px, 100%); max-width: var(--fs-field-max); width: 100%;
vertical-align: top;
}
.cbi-dynlist > .item {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-sm);
color: var(--fs-text);
box-shadow: none;
margin: 0;
transition: var(--fs-field-transition);
}
/* a chip is keyboard-removable (ui.js gives it a tabindex): :focus joins the shared accent
* ring in the text-input group above. */
/* The chip's × plate is a BUTTON in base's paint group (`.item::after, .btn, .cbi-button`) —
* absorbed with it, bare as base had it (coverage: an .item outside a dynlist keeps its
* plate). The machinery — content/position/edges/align — stays in base with the widget. */
.item::after {
display: inline-block;
cursor: pointer;
background: linear-gradient(var(--fs-panel), var(--fs-panel) 25%, var(--fs-panel2));
padding: 0 var(--fs-space-3);
color: var(--fs-text);
line-height: 2em;
border: var(--fs-hairline);
border-radius: var(--fs-radius);
transition: var(--fs-field-transition);
}
/* the hover cue is the lift, not a colour: the plate's fill is a gradient (see
* --fs-hover-lift in 02-tokens.css) — replaces base's old focus-glow-on-hover */
.item:hover::after { filter: var(--fs-hover-lift); }
.cbi-dynlist > .item::after {
/* display/padding absorbed from base's machinery rule: the bare plate above says
* inline-block / space-3, and by layer it would beat base's refinement */
display: inline-flex;
padding: 0 var(--fs-space-2);
color: var(--fs-danger);
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
}
/* text add-item = one bordered input group (input + + button attached) */
.cbi-dynlist > .add-item {
align-items: stretch;
background: var(--fs-panel2); border: var(--fs-hairline);
border-radius: var(--fs-radius-sm); overflow: hidden;
transition: var(--fs-field-transition);
}
/* :focus-within joins the shared accent ring in the text-input group above. */
.cbi-dynlist > .add-item > input,
/* `width: auto` keeps the flex basis in charge: this input sits INSIDE .cbi-value-field, so the
* elastic width above would otherwise hand it 100% of the field on top of `flex: 1 1 auto`. */
.cbi-dynlist > .add-item > .cbi-input-text {
flex: 1 1 auto; min-width: 0; width: auto;
border: 0; background: transparent;
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
}
/* password reveal: input + ∗ button as ONE seamless rounded group.
* `gap: 0` is load-bearing: base gives every .control-group a gap, and this is the one group
* whose members must touch — the group draws the single border and clips them. */
.control-group:has(> .cbi-input-password) {
display: inline-flex; align-items: stretch; gap: 0;
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
overflow: hidden;
transition: var(--fs-field-transition);
}
.control-group:has(> .cbi-input-password) > .cbi-input-password {
border: 0; border-radius: 0;
background: transparent; box-shadow: none;
min-height: var(--fs-ctl-h);
}
/* :focus-within (wrapped in :is() for the shared list) joins the accent ring in the
* text-input group above. */
/* base pulls .cbi-section-create up 3px to hug a table border, which glues the "Add" button
* to the last row — give it a real gap instead. */
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
/* `.hidden` must win over every component rule in this layer. An enumerated list
* (`.cbi-value.hidden`, `.cbi-section.hidden`, …) only ever covers the elements someone has
* already been bitten by, and the one it misses is the Enter-key submit every legacy Lua page
* carries: luci-compat opens each form with ` `, and an
* attribute selector counts as a class, so `input[type="submit"]` at (0,1,1) beats a bare
* `.hidden` at (0,1,0) and a stray "Save" button floats above the page title (issue #12).
* Doubling the class states the invariant once at (0,2,0) instead of chasing elements. An
* `!important` would do it too, and would invert the layer order for every future override.
* `fs-sheets.js` (rehostIntoThemeLayer) re-hosts a foreign app's own sheet into `@layer theme`,
* where it now competes on specificity instead of losing to an unlayered rule by layer order
* alone: measured on owrt2512 with luci-app-splify2 26.9, ``
* (Rail.tsx) stayed `display: none` at 1440/1920px because `.hidden.hidden` at (0,3,0) — see
* below — outranked `.lg\:flex` at (0,1,0). `:not([class*=":"])` excludes any class list that
* carries a Tailwind/UnoCSS responsive variant (the colon in `lg:flex`, `md:block`, `sm:inline`)
* from the invariant above; LuCI itself never emits a class containing `:`, so nothing stock
* is affected. Specificity moves to (0,3,0) — `:not()` counts its argument's attribute
* selector, (0,1,0) — still clear of `input[type="submit"]` at (0,1,1) (issue #12, unchanged). */
.hidden.hidden:not([class*=":"]) { display: none; }
/* RangeSlider (UIRangeSlider) — the one LuCI widget base leaves to native browser rendering.
* Token-driven track/thumb.
* The INPUT box is the WCAG 2.2 SC 2.5.8 target (24px min): it used to be the 6px track
* itself, measured 258x6 on owrt2512/390x844 (System > System). Box and paint split here —
* a transparent 24px box, the 6px bar drawn by the track pseudo-element inside it.
*
* A flat `height: 24px` DOES move the row, and not through `.cbi-range-slider`'s own cross
* size — that stayed 29.5px throughout, already pinned by the value chip beside it. It moves
* through the enclosing LINE BOX: `.cbi-value-field` lays this element out as ordinary inline
* content, and the line's height is anchored on where `.cbi-range-slider` (inline-flex, no
* baseline-participating child) reports ITS baseline to the ancestor line — which tracks the
* first flex item's own edge and moved with the input's box. Measured before either fix below:
* `.cbi-value-field` +2.75px (Chromium) / +2.5px (Firefox), `.cbi-value` +1.5px / +1.0px,
* track/thumb ~1.25px lower.
*
* First half of the fix is the checkbox's own trick (`--hit`/`--box` below): grow the box for
* the hit target, then pull the extra area back out of flow with a symmetric negative margin
* so the box overflows itself around its old centre instead of pushing something taller. 6px
* was the box before this rule; -9px is (6px - 24px) / 2. This is what keeps a BARE range
* input (no `.cbi-range-slider` wrapper, no taller sibling — a third-party app's own markup)
* from growing its own row, the same way the checkbox's margin protects a row with no toggle
* label beside it. Measured on its own, though, it left THIS row's growth above unchanged: the
* line-box placement of `.cbi-range-slider` tracks the input's own edge, not the flow space the
* margin trick frees up, so the second half below is still needed on top of it. */
input[type="range"] {
appearance: none;
width: 100%; height: 24px; min-height: 0; padding: 0;
margin-block: -9px;
background: transparent; border: 0; border-radius: 0;
outline: none; box-shadow: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-moz-range-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel);
cursor: pointer;
/* WebKit centres the thumb on the INPUT's box, not the track: (6 - 16) / 2 = -5px pulls
* it back onto the 6px track. Firefox centres ::-moz-range-thumb on the track itself. */
margin-top: -5px;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
}
/* -solo: the track sets `border: 0` and `outline: none`, so there is no second channel here
* either — the tinted ring was the whole indicator on a 6px bar. Set on the track pseudo,
* not the input, so the ring still hugs the 6px bar rather than the 24px hit box. */
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
/* THE SAME CEILING THE ELASTIC TEXT INPUTS TAKE, and the reason is the whole point of the
* token: a `.cbi-value-field` is `flex: 1`, so `width: 100%` here meant the slider ate the
* entire card while the select two rows above stopped at 210px and the readout chip was flung
* to the card's right edge. Every stretching control in a form row now ends at one x.
*
* `vertical-align: middle` answers the second half of the 24px-target growth explained above
* `input[type="range"]`. Default (`baseline`) places this inline-flex box in its line by the
* edge of its first flex item — the range input — so that edge moving 6px -> 24px still pushed
* `.cbi-value-field`'s line taller even with the margin trick already in place. `middle` places
* the box by its own total height instead, which the margin trick already held at its old
* 29.5px (pinned by the value chip, unaffected by the input's growth), decoupling the two.
* Measured on the gallery slider fixture, both engines, this rule plus the margin trick against
* HEAD: `.cbi-value-field` +0.000px in both, `.cbi-value` -0.187px (Chromium) / -0.183px
* (Firefox) — under a fifth of a CSS px, not a pixel a screen can render differently. Box
* unchanged at 258x24. */
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
.cbi-range-slider-value {
background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);
/* NOT 600: no face is bundled, so 600 either drags a 20 KB file back in or is faked by
* synthetic emboldening, which smears a monospace grid.
* The chip already reads as emphatic from its surface, border and accent ink. */
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
}
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
}