@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 IS the switch now, so the ring goes there directly, or the most common control in * LuCI shows nothing under keyboard focus. * * -solo, because on the CHECKED switch — half the switches on any page — neither other channel * exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`, * so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the * same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */ .cbi-checkbox > input[type="checkbox"]:focus-visible { border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring-solo); } .cbi-checkbox > input[type="checkbox"]:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; } /* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box * below, and left in base every one of those overrides would lose to that rule by layer. The box * is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number, * so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the * .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets. * * `.cbi-checkbox > input` matches the switch's own input too: this base rule stays unscoped for * it, since the switch's own rules above want its `appearance: none` and read neither * --bd-color nor --fg-color. But the drawn box, the mark and the focus ring below DO paint over * the switch once its input is a real, visible element rather than 0x0 — each of those three * selectors carries `:not(:where(.cbi-checkbox > input))` for that reason alone; undoing them instead, * property by property, on the switch's own rules costs more than the one exclusion clause * each needs. */ input[type="checkbox"], input[type="radio"] { --bd-color: var(--fs-border); --fg-color: var(--fs-text); /* 16px on the grid, but on the BOX tier, not the space tier. `var(--fs-space-4)` was the * same 16px at Normal and the wrong multiplier everywhere else: space moves to .65 at * Compact and stays 1 at Large, so the tick box went to 10.4px (smaller than any other * control on the page — measured) and then refused to grow at Large. A checkbox is a box * that has to HOLD an icon, which is what --fs-density-box is defined for. */ --box: calc(16px * var(--fs-density-box)); --mark-inset: 3px; /* ---- the drawing is 16px; the target is 24px ---- * * WCAG 2.2 SC 2.5.8 lets an undersized target pass on SPACING — 24px centre to centre — which * is what a 16px tick box has always relied on. That holds while the theme decides the layout * and stops the moment a third-party app lays out its own rows, putting this checkbox and a * link inside the 24px. A target that passes only because of somebody else's markup is not a * target that passes. * * So the ELEMENT is 24px and the drawing stays 16px, centred inside it: the two pseudo * elements are already absolutely positioned, so centring costs `inset: 0; margin: auto`. * Nothing moves visually — the box, the tick and the focus ring are all drawn by the pseudo * elements, and the element itself has neither background nor border. * * `max()`, not a flat 24px: the Large density scales `--box` to 18.4px, and a target must * never SHRINK when the user asks for a bigger UI. */ --hit: max(24px, var(--box)); appearance: none; width: var(--hit); height: var(--hit); /* …and the target does not cost a row its height: the element is 24px but occupies its old * `--box` in layout, the negative margin pulling the extra area back out of the flow so the * box overflows itself symmetrically. Measured on a third-party listing: rows went 42px -> * 51px with the target simply grown, and 43px with the margin, the target still answering to * a hit test at its centre. */ margin: calc((var(--box) - var(--hit)) / 2); color: var(--fg-color); position: relative; display: inline-block; cursor: pointer; background: none; border: none; /* padding/border-radius are NOT reset: the generic input box's values land on an * appearance-none 16px box whose pseudo-elements are absolute — invisible, and what the * widget has always computed */ } input[type="checkbox"]::before, input[type="checkbox"]::after, input[type="radio"]::before, input[type="radio"]::after { position: absolute; content: ""; } input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before, input[type="radio"]::before { /* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with * a fixed size is the one centring that needs no translate and therefore behaves the same in * RTL, where an `inset-inline-start: 50%` + `translate: -50%` pair would push the drawing the * wrong way. */ inset: 0; margin: auto; width: var(--box); height: var(--box); background: linear-gradient(var(--fs-panel), var(--fs-panel2)); border: 1px solid var(--bd-color); transition: var(--fs-field-transition); } /* each box states its own radius — one shared 2px re-declared to 50% for the radio would * be a same-selector shadow inside this layer */ input[type="checkbox"]::before { border-radius: 2px; } input[type="radio"], input[type="radio"]::before { border-radius: 50%; } /* keyboard/click focus rings on the DRAWN box — the theme's one ring token, replacing * base's old generic glow; :focus (not :focus-visible) keeps the pre-absorption behaviour * of ringing a clicked box. Both states (plain and invalid) are declared once, further * down this file, in the selector lists every other same-body ring joins — see the two * `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */ input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after, input[type="radio"]::after { /* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark * centres the same way rather than measuring from the target's own edge */ inset: 0; margin: auto; width: calc(var(--box) - var(--mark-inset) * 2); height: calc(var(--box) - var(--mark-inset) * 2); } input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after, input[type="radio"]:checked::after { /* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in * base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid). * It replaces a solid filled path that was byte-identical to luci-theme-bootstrap's — and to * luci-theme-openwrt-2020's, which is the same project's artwork, not evidence the drawing is * generic. A mask keys on ALPHA, so a stroked path masks exactly as a filled one did. */ --checkmark-icon: url("data:image/svg+xml,"); -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 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 a bare checkbox/radio it * lands on the drawn box UNDER the ::before ring below — coincident boxes, one visible ring, as * the old glow behaved. `:not(:where(.cbi-checkbox > input))` excludes the toggle switch, whose input is * a real, visible element with its own :focus-visible-only ring (above) — a plain click on it * must ring nothing. `select:focus` and the keyboard-removable dynlist chip (ui.js gives it a * tabindex) join here rather than repeating the same three declarations under their own * selector: one shared body instead of three, 149 B off cascade.css. */ .cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus, .cbi-section-create-name:focus, input:focus:not(:where(.cbi-checkbox > input)), input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus, input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus, input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus, textarea:focus, select:focus, .cbi-dynlist > .item:focus { border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring); outline: none; } /* The plain accent ring above, restated for four components whose selector cannot join that * list without changing what it matches (a pseudo-element, a legacy shell class, two * `:focus-within` containers) — same two declarations, one shared body instead of four, 186 B * off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a * `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the * floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one * selector. `:not(:where(.cbi-checkbox > input))` on the checkbox part for the same reason as the plain * ring above: the toggle switch's own :focus-visible rule is the only one allowed to ring it. */ input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before, .cbi-select.focus, .cbi-dynlist > .add-item:focus-within, .control-group:is(:has(> .cbi-input-password)):focus-within { border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring); } /* Invalid state keeps the danger cue. `.cbi-value-error input` is the Lua-CBI path and belongs * HERE, not in base: base does declare a red border-color for it, but the rule above sets the * `border` SHORTHAND, and a shorthand in a later layer wipes the longhand out regardless of * specificity — so every third-party app on luci-compat rendered the field in --fs-border grey * and had lost its field-error cue while the modern .cbi-input-invalid path worked. * * `.cbi-input-invalid:focus` is not redundant with the rule it sits beside: the generic * `input:focus` above is (0,1,1) and would repaint a focused invalid field accent, and * `.cbi-input-invalid:focus` at (0,2,0) is what stops it — the TYPED `:focus` rules above are * (0,2,1) and still outrank this, so a typed invalid field focuses accent, exactly as before * the absorption. The checkbox/radio `::before` ring and the legacy `.cbi-select` shell need * the identical danger body for the same reason on their own elements — one shared rule * instead of four, 186 B off cascade.css. */ input[type="checkbox"].cbi-input-invalid:focus::before, input[type="radio"].cbi-input-invalid:focus::before, .cbi-select.cbi-input-invalid.focus, .cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid, .cbi-value-error input, .cbi-value-error textarea, .cbi-input-invalid:focus { border-color: var(--fs-danger); box-shadow: var(--fs-focus-ring-invalid); } /* The same rejected value shown by a ui.Dropdown rather than an : 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, `