@layer theme { /* Value rows. * display:flex + align-items:baseline come from base — do not restate, and do not switch to * center: a row's field can be taller than its control when a `.cbi-value-description` sits * under it, and center then pushes the label to the middle of control+description, visibly * below the control. baseline lines the label up with the field's first line for every * widget. */ .cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: var(--fs-hairline); } /* stacked controls in a field (Local Time: date input + Sync buttons) need breathing room — * the raw
collapses them. Exclude the password reveal group: it must stay seamless. */ .cbi-value-field > .control-group { margin-top: var(--fs-space-2); } .cbi-value-field > .control-group:not(:has(.cbi-input-password)) { display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; } /* The hairline separates one value row from the NEXT one, so it is dropped whenever no value row * follows — not only at the end of the section. A section ending with a table or an action bar * otherwise draws the line across the top of whatever comes next, cutting through a framed * table's rounded corner. `:last-child` was the special case of this. */ .cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; } /* A toggle/checkbox field has no text baseline: `.cbi-checkbox` is inline-flex with no line box, * so its synthesised baseline is the box's BOTTOM edge and align-items:baseline floats the * toggle ~5px above the label. Centre those rows instead — but only where no help sentence sits * under the control, since centring then drops the label to the middle of control+description, * the regression the comment above warns about. */ .cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) { align-items: center; } /* The generic control transition — absorbed from base's transition group. ONLY the * transition: control typography stays in base on purpose (see the note there — as a * bare-element theme rule it out-layered `.zonebadge-empty`'s smaller font, measured * 10.8 -> 13px on the firewall). border-COLOR, not `border`: the shorthand animates * border-width too — a layout animation on every hover instead of a paint one. */ input, button { transition: var(--fs-field-transition); } /* keep base's padding-top (--fs-space-1) — it is tuned for base's align-items: baseline. */ .cbi-value-title { color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight); /* stated, not inherited: the control rule above hands every label `line-height: normal`, * and this one is TEXT — dropping it silently hands the label the font's own default * (measured: 18px -> normal, off the scale entirely). Absorbed from base with the rule * that made it necessary. */ line-height: var(--fs-leading); } /* Mono is for the VALUES a field holds. The exclusions: * buttons are chrome, not data — excluded here rather than relying on 55-buttons.css being * concatenated later. `:where()` keeps the exclusion at zero specificity, so the , 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 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 ), 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 , 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 ; 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, `