update 2026-09-24 06:37:55

This commit is contained in:
action committed 2026-09-24 06:37:55 +08:00
1 parent 3d5ff73d1e
commit 907a158127
144 files changed
+43946

No files matched your search

@@ -0,0 +1,115 @@
@layer base {
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
/* scroll-margin-top: an anchor jump must clear the sticky bar or it lands underneath it
* (WCAG 2.2 SC 2.4.11). The token, never a literal — the bar is 46px. */
*, *::before, *::after {
scroll-margin-top: var(--fs-bar-h);
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
abbr[title], acronym[title] {
border-bottom: 1px dotted;
font-weight: inherit;
cursor: help;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* `hidden` is how a VIEW says an element is not there, and the only way available to code that
* ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any
* `display` a theme sets on a class beats it and the element paints anyway: measured here on
* `.tr` (table-row), `.td` (table-cell), `ul.nav > li` (block), `.cbi-page-actions` (flex) and
* `.ifacebox` (inline-flex).
*
* The flag is the point, not a workaround for a layer mistake: this must outrank every display
* in tokens/base/theme/page at once, including the id-specific ones in the page layer, and it
* has to keep doing so for rules not written yet. `until-found` is excluded
* because there `display: none` would break the find-in-page reveal the value exists for. */
[hidden]:not([hidden="until-found"]) { display: none !important; }
html {
font-size: 100%;
height: 100%;
}
/* :focus-visible, not :focus — the ring is for the keyboard, and the UA heuristic keeps it off a
* mouse click. A 1px dotted outline is near-invisible on a dark panel, and it was the only focus
* indicator generic links had. */
a:focus-visible {
outline: 2px solid var(--fs-accent);
outline-offset: 2px;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -.5em;
}
sub {
bottom: -.25em;
}
/* font-size: theme/60-inputs.css (the control-typography rule + `option`) — absorbed */
button,
input,
select,
option,
textarea {
margin: 0;
box-sizing: border-box;
vertical-align: baseline;
}
button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
cursor: pointer;
appearance: button;
word-break: break-all;
}
/* disabled-opacity fade: 30-forms.css, same elements + pointer-events/cursor */
input[type="search"] {
appearance: textfield;
/* no box-sizing: content-box — it contradicts the universal border-box above, and the
* theme sizes fields by padding, so a search box comes out ~22px too wide */
}
input[type="search"]::-webkit-search-decoration {
appearance: none;
}
textarea {
overflow: auto;
vertical-align: top;
font-family: var(--fs-font-mono);
}
/* LuCI's .control-group is bootstrap's JOINED input-group: the trailing button pulled back over
* the input so their borders coincided, with its left corners squared to match. This theme does
* not join controls — every button takes the full token radius from a later layer, so the
* squared corners never apply while the pull-back still does and the button's rounded corner
* sits on the input's (issue #7). Separate controls with a real gap instead.
*
* The password reveal is the one group that IS joined, and theme/60-inputs.css builds that seam
* itself (one border around the group, `gap: 0`). */
.control-group {
display: inline-flex;
gap: var(--fs-space-2);
}
}
@@ -0,0 +1,166 @@
@layer base {
/* The body box, links, and the type scale a view inherits from plain markup.
* body background/padding/font-family: theme/20-shell.css — absorbed.
* Every size and gutter here reads the scale in 02-tokens.css rather than stating a literal. */
body {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
/* unitless: it inherits as a FACTOR, so a 20px heading gets 30px of leading instead of the
* flat length that would clip it */
line-height: var(--fs-leading);
color: var(--fs-text);
min-height: 100%;
display: flex;
flex-direction: column;
/* gutter, not `overflow-y: scroll`: the same no-reflow guarantee without a dead scrollbar
* down every short page */
scrollbar-gutter: stable;
}
a {
color: var(--fs-accent);
text-decoration: none;
line-height: inherit;
font-weight: inherit;
}
a:hover {
color: var(--fs-accent);
text-decoration: underline;
}
.nowrap:not(span) {
/* :not(span) — a badge must be free to wrap on a narrow screen, and nowrap
defeats every other width constraint on it */
white-space: nowrap;
}
p,
.cbi-map-descr,
.cbi-section-descr,
.table .tr.cbi-section-table-descr .th {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
margin-bottom: var(--fs-space-2);
/* prose, so no orphans: a single trailing word on its own line reads as a rendering fault.
* `pretty`, not `balance` — balance is capped at a few lines and re-flows the whole block,
* which is for headings. An unsupporting browser ignores it. */
text-wrap: pretty;
}
p small {
font-size: var(--fs-type-xs);
color: var(--fs-dim);
}
/* The heading ramp lives in theme/45-misc.css — absorbed whole (weights, sizes, ink);
* theme/30-tables.css owns `legend`. Only the `<h_ small>` treatment remains here. */
/* one rule for every `<h_ small>`, relative so it tracks whatever step its heading sits on */
h1 small,
h2 small,
h3 small,
h4 small,
h5 small,
h6 small {
font-size: .7em;
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
ul, ol {
margin: 0 0 var(--fs-space-4) var(--fs-space-6);
}
ul ul,
ul ol,
ol ol,
ol ul {
margin-bottom: 0;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
li {
color: var(--fs-dim);
}
dl {
margin-bottom: var(--fs-space-4);
}
dl dt {
font-weight: var(--fs-weight-bold);
}
dl dd {
margin-inline-start: var(--fs-space-2);
}
hr {
margin: var(--fs-space-5) 0;
border: 0;
border-bottom: var(--fs-hairline);
}
strong {
font-style: inherit;
font-weight: var(--fs-weight-bold);
}
em {
font-style: italic;
font-weight: inherit;
line-height: inherit;
}
small { font-size: var(--fs-type) }
address {
display: block;
margin-bottom: var(--fs-space-4);
}
/* no shared padding: both members set their own below (code a chip's inset, pre a block's) */
code, pre {
font-family: var(--fs-font-mono);
font-size: var(--fs-type);
border-radius: var(--fs-radius-sm);
}
code {
/* panel2 (6.45:1), not a border-level surface: --fs-text on the latter is 4.40:1 in dark,
* under AA */
background-color: var(--fs-panel2);
color: var(--fs-text);
padding: 1px var(--fs-space-1);
/* task 0162: a WireGuard public key (`admin/status/wireguard`, one 44-char base64 run, no
* space or hyphen anywhere in it) sits inside an inline `<code>` with no width of its own —
* `white-space` stays `normal` (inherited), so the run never finds a space to wrap at and
* measured 64px past the content column at 320px. `overflow-wrap: anywhere`, the same
* escape hatch every other opaque, user-supplied token in this sheet already carries
* (a MAC in pages/60-assoclist.css, a hostname in pages/10-login.css): it only ever fires
* once a line would otherwise overflow, so a short inline snippet that already fits wraps
* nowhere differently. */
overflow-wrap: anywhere;
}
pre {
--border-color: color-mix(in srgb, var(--fs-border) 15%, transparent);
background-color: var(--fs-panel2);
display: block;
padding: var(--fs-space-2);
margin: 0 0 var(--fs-space-4);
border: 1px solid var(--border-color);
border-radius: var(--fs-radius-sm);
white-space: pre-wrap;
word-wrap: break-word;
}
}
@@ -0,0 +1,275 @@
@layer base {
/* Forms: input types, the .cbi-value row layout, and the focus/disabled/
* readonly/invalid states shared by every CBI widget. */
fieldset {
margin-bottom: var(--fs-space-2);
padding-top: var(--fs-space-2);
}
/* size: theme/30-tables.css (`legend`) — absorbed */
fieldset legend {
display: block;
line-height: 1;
color: var(--fs-text);
padding-top: var(--fs-space-5);
}
/* Control typography stays in BASE: moved to the theme layer, a bare-element rule out-ranks
* every classed base refinement regardless of specificity, and the firewall's zone badges grew
* from 10.8 to 13px. In base, classed rules beat it on specificity, which is the contract they
* were written against. `select` is not a member — its face, size and leading live in
* 60-inputs.css. */
label,
input,
button {
font-family: var(--fs-font-sans);
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
line-height: normal;
}
.cbi-value {
display: flex;
flex-wrap: wrap;
margin-bottom: var(--fs-space-4);
align-items: baseline;
}
.cbi-value-field {
/* logical, not margin-left: this is the gap AFTER the label column, and LuCI ships four RTL
* languages (ar/fa/he/ur). Identical to margin-left while nothing in openwrt/luci stamps
* dir=rtl, so it costs nothing and is already correct if one ever does. */
margin-inline-start: var(--fs-space-5);
flex: 1;
/* a flex item will not shrink below min-content by default, so a nested table or one long
* unbreakable value pushes the whole row out of the card */
min-width: 0;
}
/* size + ink: theme/60-inputs.css (`.cbi-value-title`) — absorbed. Left: the label
* column's geometry, which theme does not touch. */
.cbi-value label.cbi-value-title {
/* the nudge that lands the label's first baseline on the field's, tuned for the unitless
* leading in 20-typography.css */
padding-top: var(--fs-space-1);
/* 180px is a column WIDTH, not a rhythm — what fits LuCI's longest option labels beside a
* field — so it is deliberately not on the 4px step scale */
flex: 0 0 180px;
/* `end`, not `right`: the label is aligned TOWARDS its field, which is a logical intent.
* Not the same as base/95-luci.css's `.left/.right/.center`, which are LuCI's forcing
* utilities — a class named `right` means right in any direction. Never sweep those onto
* start/end. */
text-align: end;
/* theme/90-responsive.css restates `flex: 1 1 100%` under 767px (label above field), and a
* PERCENTAGE flex-basis is still a "specified size suggestion" only once it is smaller than
* the automatic (content) minimum — with no `min-width` stated there or here, a label whose
* text has too few break points renders at its own min-content instead of the row's width, a
* flex item's automatic minimum size doing exactly what it always does. Sweep-measured on
* ssclash's settings form at 320px: a Russian label rendered 320px wide inside a 254-276px
* row (Compact/Normal/Large), the section clipped 33-50px with no scroller to reach the rest,
* 0 with the English string in the same box. Reproduced locally the same shape with a
* synthetic unbroken run in the gallery's own fixture (docs/gallery.html) — real RU option
* text with normal word breaks does not need to hit this floor at all once the item can
* shrink to the row's own width instead of its content's.
*
* `min-width: 0` here, not in theme/90-responsive.css: that file restates `flex`, never
* `min-width`, so a value stated on the desktop rule survives the later layer's flex-basis
* change untouched — the cascade only overrides the PROPERTIES a later rule actually sets. */
min-width: 0;
}
.cbi-value > .cbi-section,
.cbi-value > .cbi-tblsection {
width: 100%;
}
label > input[type="checkbox"],
label > input[type="radio"] {
vertical-align: text-top;
margin: 0;
}
label[for] {
cursor: pointer;
}
/* The shared field box (the 210px x control-height default, #localtime's widening, and the
* per-widget escapes: file, button types, checkbox/radio, .cbi-select, readonly, the
* cell-filling widths) lives in theme/60-inputs.css — absorbed whole. The escapes had to move
* with the box: each beat it here in-layer, and left behind they would lose to a theme rule by
* layer whatever their specificity. */
.cbi-dynlist {
height: auto;
/* min-height: theme/60-inputs.css — absorbed */
display: inline-flex;
flex-direction: column;
}
/* item paint: theme/60-inputs.css — absorbed. Left: the drag-and-drop machinery
* UIDynamicList needs. */
.cbi-dynlist > .item {
margin-bottom: var(--fs-space-1);
padding: var(--fs-space-1) 2em var(--fs-space-1) var(--fs-space-2);
position: relative;
pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
overflow: hidden;
word-break: break-all;
cursor: move; /* drag-and-drop */
user-select: text; /* text selection in drag-and-drop */
}
/* display/padding/border + radius + × ink: theme/60-inputs.css (the bare `.item::after`
* plate + its dynlist refinement) — absorbed. Left: what pins the plate to the chip. */
.cbi-dynlist > .item::after {
content: "×";
position: absolute;
align-items: center;
top: -1px;
inset-inline-end: -1px;
bottom: -1px;
font-weight: var(--fs-weight-bold);
pointer-events: auto;
}
/* indication line for drag-and-drop in UIDynamicList*/
.cbi-dynlist > .item.drag-over {
border-top: 1px solid var(--fs-text);
}
/* Make item being dragged in UIDynamicList partially transparent*/
.cbi-dynlist > .item.dragging {
opacity: .5;
}
/* prevent pointer changing when over the span element in UIDynamicList */
.cbi-dynlist > .item > span {
pointer-events: none;
}
.cbi-dynlist > .add-item {
display: flex;
}
/* indication line for drag-and-drop in UIDynamicList*/
.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
border-top: 1px solid var(--fs-text);
}
/* indication line for drag-and-drop in UIDynamicList*/
.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
border-top: 1px solid var(--fs-text);
}
.cbi-dynlist > .add-item > input,
.cbi-dynlist > .add-item > button {
/* flex: theme/60-inputs.css — absorbed */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* the same one-line control height, so a toggle lines up with the field above it */
.cbi-value-field > .cbi-checkbox,
.cbi-value-field > div > .cbi-radio {
height: var(--fs-control-h);
display: inline-flex;
align-items: center;
}
.cbi-radio {
cursor: pointer;
gap: var(--fs-space-1);
}
/* .cbi-select lives in theme/60-inputs.css — absorbed whole. Do not re-add an option rule here
* either: layer beats specificity, so theme's bare `option` outranks even
* `.cbi-select select option:checked`. */
/* Checkbox/radio drawing: theme/60-inputs.css — absorbed whole (it undoes the generic input
* box, which lives there now). */
/* --fs-placeholder, not --fs-dim: a placeholder carries the option's DEFAULT and must not read
* as a value the reader typed (03-palettes.css has the numbers). */
::placeholder {
color: var(--fs-placeholder);
}
/* Transitions and the focus/hover ring are the theme's one ring token (--fs-focus-ring /
* --fs-focus-ring-invalid). A generic glow here rings every clicked button and every hovered
* widget in a second, unnamed focus style. */
input[disabled],
button[disabled],
select[disabled],
textarea[disabled],
.cbi-dropdown[disabled]:not(.btn):not(.cbi-button),
input[type="checkbox"][disabled]::before,
input[type="checkbox"][disabled]::after,
input[type="radio"][disabled]::before,
input[type="radio"][disabled]::after {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
cursor: default;
}
/* readonly border fade: theme/60-inputs.css — absorbed with the generic box */
.cbi-optionals,
.cbi-section-create {
padding: 0 0 var(--fs-space-3) var(--fs-space-3);
}
/* `gap`, not a negative-margin pair: that trick predates flex gap and leaks, the outer -3px
* pulling the row into whatever sits beside it */
.cbi-section-create {
display: inline-flex;
align-items: center;
gap: var(--fs-space-2);
}
.cbi-section-create > * {
flex: 1 1 auto;
}
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. The legacy `.actions` plate has no
* theme override, so it stays: some luci-app-* still emits it. */
.actions {
background: var(--fs-panel2);
margin-bottom: var(--fs-space-4);
/* a plate, so it takes the card's own gutter on every side */
padding: var(--fs-card-pad);
border-top: var(--fs-hairline);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
text-align: end;
}
/* Physical, mirrored by hand — see `.close` in base/90-widgets.css for why `float` takes no
* logical keyword. Unmirrored it contradicts its own container: `.actions` above sets
* `text-align: end`, which follows the writing direction, so in RTL the plate aligns its
* content one way and the secondary action the other. Measured in a live document at 1400px:
* 582px from the reading edge in LTR, 10px in RTL. */
.actions .secondary-action,
.cbi-page-actions .secondary-action{
float: right;
}
:root[dir="rtl"] .actions .secondary-action,
:root[dir="rtl"] .cbi-page-actions .secondary-action { float: left; }
.actions .secondary-action a,
.cbi-page-actions .secondary-action a {
line-height: var(--fs-control-h);
}
.actions .secondary-action a:hover,
.cbi-page-actions .secondary-action a:hover {
text-decoration: underline;
}
.cbi-page-actions > form {
display: inline;
margin: 0;
}
}
@@ -0,0 +1,130 @@
@layer base {
/* Tables. LuCI builds them out of divs carrying .table/.tr/.th/.td rather than
* real table markup, so the display roles have to be assigned by hand. */
.tr { display: table-row; }
.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
.table {
display: table;
width: 100%;
margin-bottom: var(--fs-space-4);
padding: 0;
font-size: var(--fs-type);
border-collapse: collapse;
position: relative;
}
/* No per-cell border-top, and that is a fix rather than an omission: the theme draws a table's
* separators on the ROW (theme/30-tables.css), so a cell border here is a second copy of every
* separator — and because the cell line is straight and runs to the table's very edge while the
* frame is rounded, a square corner cuts across the radius (issue #7). */
.table .th, .table .td {
display: table-cell;
/* middle is deliberate: a cell's content is a mix of text, badges and buttons, and
* top-aligning mixed-height rows reads as a layout fault */
vertical-align: middle;
padding: var(--fs-space-2) var(--fs-space-3);
text-align: start;
}
/* An unbreakable value must not leave the card, in ANY .table. Asking what KIND of table it is
* first leaves a third-party key/value `<div class="table">` matching nothing, so one unbroken
* token — an ICCID, a base64 key, a DUID — holds the column open and pushes the table outside
* its card (measured on 3ginfo-lite).
*
* Not scoped to the phone tier: that leaves a hole where the column is narrow but the WINDOW is
* not (with a 130-character token the table escaped its card by 147px at 900px and 279px at
* 768px), which is the argument the fit engine is built on — whether something fits is a
* property of the content and its column, not of the viewport. The cost is nil: across 8 pages
* x 4 widths x 849 text cells nothing else changes.
*
* `anywhere`, not `break-word`: `break-word` permits the break without reducing the cell's
* min-content contribution, so a `display: table` box still cannot shrink below its widest
* token and the column stays open — tried, and the table stayed 355px wide in a 346px column.
*
* That is a CONTAINMENT default, right for every tier with nowhere to put an overflow — a
* key/value include, the config table, the Overview meter rows. It is wrong for the one tier
* that IS measured: a data table can card, break one column or scroll, so it wants the truth
* instead, and theme/30-tables.css gives it `break-word` for as long as it is a table. Do not
* unify the two by moving either.
*
* Set per ROLE: a value may break anywhere, a label breaks at words. `anywhere` costs a
* min-content of ONE CHARACTER, which is the point on a value — the column may collapse rather
* than push the table out — and the bug it looks like on a header, since an auto-layout table
* hands the surplus to whichever column asks loudest and the header row pays: measured on
* Processes, `PID` came out as "PI/D" and the header row stood 104px tall, against 54px with
* the floor at its longest word (issue #32).
*
* `break-word` for the label, not `normal`: a header that IS one long token must still break
* rather than overflow, it just may not shrink the column below that word. */
.table .td { overflow-wrap: anywhere; }
.table .th { overflow-wrap: break-word; }
/* …and the first column is a label too, whichever element it is made of.
*
* `anywhere` costs a min-content of one character, and auto table layout hands each column its
* share by MAX-content, so a two-column key/value table whose value is one enormous token
* starves the key column down to that character — measured at 8000 characters of value, the key
* column fell from 95px to 55px, and on the reporter's host the header read one glyph per line
* (issue #36, on Firefox and Blink alike).
*
* `break-word` puts the floor back at the longest WORD, the same trade `.th` makes above: the
* first column is the row's identity, and it is what you read the table by.
*
* A carded row is exempt and must be: `.table.fs-stacked .td` (theme/30-tables.css) restates
* `anywhere` from a later layer, and once every cell is on its own line there is no
* neighbouring column left to starve. */
.table .tr > .td:first-child { overflow-wrap: break-word; }
.table .tr:first-child .th {
padding-top: var(--fs-space-2);
font-weight: var(--fs-weight-bold);
vertical-align: top;
}
/* The empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps the
* table's own height when there is nothing to show.
*
* Out of flow on purpose: a single-cell row in a `display: table` spans only the FIRST column,
* so in flow the plate is a narrow box under column one and its sentence becomes that column's
* max-content, widening it (275px -> 502px on an empty section). Absolute takes it
* out of the column sizing entirely and `inset-inline: 0` spans it across the table.
*
* WHAT IT MUST NOT DO IS PIN ITSELF TO THE BOTTOM. It used to say `bottom: 0`, which inside the
* `position: relative` .table means the bottom of the whole TABLE — the same place as its own row
* only while the placeholder is the last row there is. `form.TableSection` renders its `footer`
* into a `<tfoot>` AFTER the `<tbody>`, so on every empty section that declares one the plate
* printed straight across the totals: measured on luci-app-dockerman's Containers, Images and
* Volumes tables (issue #36) as a 1190x35 overlap, the full width of the table, identically on
* Blink and on Gecko 153. With no `bottom` at all an absolutely positioned box keeps its STATIC
* position — where the row actually is — which is what was meant all along, and the reserved row
* height below is what holds that space open. */
.tr.placeholder {
height: calc(3em + var(--fs-space-4));
}
.tr.placeholder > .td {
position: absolute;
inset-inline: 0;
text-align: center;
line-height: 3em;
/* …and the plate must be exactly as tall as the row reserved for it, or it hangs out of the
* table at whichever end it is anchored to. `--fs-space-4` is two `--fs-space-2` at every
* density, so this and the row height above are the same number twice — which is what lets
* the cell keep its own size while the table flavours pad their ordinary cells
* differently. Measured on Wireless, whose table pads 10px a side: the plate came out 67px
* inside a 63px row and printed 4px below the frame's bottom border. */
padding-block: var(--fs-space-2);
}
.tr.drag-over-above,
.tr.drag-over-below {
border: 2px solid var(--fs-accent);
border-width: 2px 0 0 0;
}
.tr.drag-over-below {
border-width: 0 0 2px 0;
}
}
@@ -0,0 +1,126 @@
@layer base {
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip, breadcrumbs and the
* footer. styles/theme/ repaints these; what stays here is the geometry a view can rely on. */
header {
position: sticky;
top: 0;
z-index: var(--fs-z-header);
overflow: visible;
color: var(--fs-dim);
display: flex;
}
li.menu, .dropdown {
position: relative;
}
.dropdown:hover ul.dropdown-menu {
inset-inline-start: 0;
}
/* Tab-strip visuals (margins, gradients, per-state colours): theme/40-tabs.css —
* absorbed. Left: strip geometry. */
.tabs > li, .cbi-tabmenu > li {
flex: 0 1 auto;
display: flex;
align-items: center;
}
.tabs > li > a, .cbi-tabmenu > li > a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
outline: none;
}
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict
* reports. */
.cbi-tab-disabled[data-errors]::after {
content: attr(data-errors);
/* badge fill: theme/40-tabs.css — absorbed */
color: var(--fs-on-danger);
height: var(--fs-space-4);
min-width: var(--fs-space-4);
/* a pill, so it says so, rather than half the height above restated by hand */
border-radius: var(--fs-radius-pill);
text-align: center;
margin-inline-end: var(--fs-space-1);
padding: 0 var(--fs-space-1);
display: inline-flex;
flex-direction: column;
justify-content: center;
font-size: var(--fs-type);
}
.cbi-tab-descr {
/* the negative top pulls it under the tab strip it describes */
margin: calc(var(--fs-space-2) * -1) 0 var(--fs-space-4) 0;
}
/* hangs directly under the tab strip: 100% IS the strip's height by definition, where a literal
* would be a hidden pair with whatever sizes the tab */
.tabs .menu-dropdown, .tabs .dropdown-menu {
top: 100%;
border-width: 1px;
/* square top-left only: the panel hangs off the tab above it and the two share that
* corner */
border-radius: 0 var(--fs-radius) var(--fs-radius) var(--fs-radius);
}
/* the caret beside a tab's label, centred on the text box rather than nudged down it */
.tabs a.menu:after, .tabs .dropdown-toggle:after {
border-top-color: var(--fs-dim);
align-self: center;
margin-inline-start: var(--fs-space-1);
}
.tabs li.open.menu .menu, .tabs .open.dropdown .dropdown-toggle {
border-color: var(--fs-border);
}
.tabs li.open a.menu:after, .tabs .dropdown.open .dropdown-toggle:after {
border-top-color: var(--fs-text);
}
.breadcrumb {
padding: var(--fs-space-2) var(--fs-space-3);
margin: 0 0 var(--fs-space-4);
background: linear-gradient(to bottom, var(--fs-panel), var(--fs-panel2));
border: var(--fs-hairline);
border-radius: var(--fs-radius);
display: flex;
flex: 0;
}
.breadcrumb li {
list-style: none;
}
.breadcrumb li:not(:last-child)::after {
content: "|";
padding: 0 var(--fs-space-1);
}
.breadcrumb .active a {
color: var(--fs-dim);
}
/* Footer paint: theme/20-shell.css — absorbed. Left: the flex skeleton, where margin-top:auto
* pins the footer to the bottom of the flex-column body. Do not re-add
* `justify-content: space-between` or a span margin — they came from a two-column footer this
* theme does not have. */
footer {
margin-top: auto;
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
footer ul.breadcrumb {
margin-inline-start: auto;
}
}
@@ -0,0 +1,81 @@
@layer base {
/* the modal overlay ui.js shows for config diffs, uploads and login, plus the colour variants
* shared by .btn / .alert-message / .cbi-tooltip */
#modal_overlay {
position: fixed;
top: 0;
bottom: 0;
background: var(--fs-scrim);
z-index: var(--fs-z-overlay);
overflow: auto;
transition: opacity var(--fs-dur);
opacity: 0;
visibility: hidden;
}
.modal {
width: 90%;
/* clear of the sticky bar, and centred */
margin: var(--fs-space-6) auto;
min-height: var(--fs-control-h);
max-width: 600px;
/* a modal is a CARD — the large radius, like every other panel */
border-radius: var(--fs-radius-lg);
background: var(--fs-panel);
border: var(--fs-hairline);
/* box-shadow: theme/70-modal.css — absorbed; the old one was a shadow in the colour of the
* page, and the theme gives every .modal --fs-shadow-pop instead */
/* the card's own gutter on every side; the button row's spacing is its own business */
padding: var(--fs-card-pad);
min-width: 270px;
}
/* .modal > * (normal leading, the .5em rhythm, max-width): theme/70-modal.css — absorbed
* with the heading ramp, which would out-layer the leading here */
/* .modal > pre/textarea (pre-wrap, fill the dialog): theme/70-modal.css — absorbed with
* the generic field box (its 210px would out-layer the width here) */
.modal > .button-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.modal > .button-row > button:not(:last-of-type) {
margin-inline-end: var(--fs-space-2);
}
.modal > .button-row > button:first-of-type {
margin-inline-end: auto;
}
body.modal-overlay-active {
overflow: hidden;
/* svh: `100vh` counts the mobile URL bar as if it were not there and crops the modal, while
* `dvh` re-lays the dialog out on every frame of the bar's slide. The small viewport height
* is the one that is both correct and still. */
height: 100svh;
}
body.modal-overlay-active #modal_overlay {
inset-inline: 0;
opacity: 1;
visibility: visible;
}
/* the cross inside a button or alert: `line-height: 1` centres the glyph in its own box, where a
* fixed leading stops matching the moment the type scale moves */
.btn .close, .alert-message .close {
font-family: var(--fs-font-sans);
line-height: 1;
}
/* The whole colour-word block is absorbed — do not re-add any of it. `.btn.<word>` lives in
* theme/55-buttons.css, `.alert-message.<word>` and `.cbi-tooltip.<word>` in
* theme/35-alerts.css. The alert variants were already dead: theme paints plain
* `.alert-message`, and a later layer beats this one whatever the specificity. The tooltip
* variants were not — they were alive and un-themed, which is why the note that used to sit
* here said the theme did not claim them; the gallery renders all four now, and that is what
* turned the claim into a measurement. */
}
@@ -0,0 +1,94 @@
@layer base {
/* `.btn` and `.cbi-button` are interchangeable in LuCI markup; a row action carries both plus a
* `-<variant>` class. The paint — box, fill, radius — is theme/55-buttons.css's, along with the
* `.item::after` member, whose plate now lives with the dynlist in theme/60-inputs.css. What is
* left here is --on-color, which is not paint: base/95-luci.css masks the spinner glyph with
* it, so it must track the variant. */
.btn,
.cbi-button {
--on-color: var(--fs-text);
cursor: pointer;
font-size: var(--fs-type);
white-space: pre;
}
/* border-color: theme/60-inputs.css — absorbed. The ink stays: theme paints no colour here. */
.cbi-input-invalid,
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li,
.cbi-value-error input {
color: var(--fs-danger);
}
/* `--on-color` only. A `border-color` here would be dead: theme/55-buttons.css sets `border` on
* `.cbi-button, .btn`, and a later layer beats this one whatever the specificity, so the
* variant wears the theme's colour either way — proven, not reasoned: stripping the ones that
* used to sit here changes no computed style over docs/gallery.html, which renders every one of
* these variants. `--on-color` stays because it is not paint — base/95-luci.css masks the
* spinner glyph with it. */
.cbi-button-positive,
.cbi-button-fieldadd,
.cbi-button-add,
.cbi-button-save {
--on-color: var(--fs-good);
}
.btn.primary,
.cbi-button-action,
.cbi-button-apply,
.cbi-button-reload,
.cbi-button-edit {
--on-color: var(--fs-accent);
}
.cbi-button-negative,
.cbi-section-remove .cbi-button,
.cbi-button-reset,
.cbi-button-remove {
--on-color: var(--fs-danger);
}
.cbi-page-actions::after {
display: table;
content: "";
clear: both;
}
.cbi-page-actions > * {
vertical-align: middle;
}
/* No `float` here: theme/55-buttons.css makes `.cbi-page-actions` a flex container, and float
* does not apply to a flex item, so one would compute to `none` and move nothing while reading
* like a physical side the logical-property sweep had missed. No context revives it — print
* hides the whole container (theme/97-print.css). Verified in a live document in both writing
* directions: the button sits 569px from the reading edge with the float and 569px without. */
.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
margin-inline-end: var(--fs-space-1);
}
/* fills and borders: theme/55-buttons.css — absorbed. --on-color stays (the spinner mask). */
.btn.primary,
.cbi-button-action.important,
.cbi-page-actions .cbi-button-apply,
.cbi-section-actions .cbi-button-edit {
--on-color: var(--fs-on-accent);
}
.cbi-button-positive.important,
.cbi-page-actions .cbi-button-save {
--on-color: var(--fs-on-good);
}
.cbi-button-negative.important {
--on-color: var(--fs-on-danger);
/* fill + border: theme/55-buttons.css — absorbed */
}
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
--on-color: var(--fs-good);
/* fill + border: theme/55-buttons.css — absorbed */
}
}
@@ -0,0 +1,143 @@
@layer base {
/* small widgets a view or a third-party luci-app-* can drop anywhere: tooltip, progress bar,
* zone badge, button states, close cross, alert box, .label pill */
/* .cbi-title-ref colour: theme/45-misc.css — absorbed. The arrow stays: content,
* not paint. */
.cbi-title-ref::after {
content: "➙";
}
.cbi-tooltip-container {
cursor: help;
}
.cbi-tooltip {
position: absolute;
z-index: var(--fs-z-tooltip);
/* logical: `left: -10000px` in an RTL document parks the tooltip past the INLINE-END edge,
* which is scrollable overflow — every page grew a 10000px horizontal scrollbar in ar/fa/he
* (measured). Past the inline-start edge is unreachable in both directions. */
inset-inline-start: -10000px;
/* box-shadow: theme/35-alerts.css — absorbed */
border-radius: var(--fs-radius-sm);
background: var(--fs-panel);
/* `pre`, not `pre-wrap`, NEVER wraps — task 0162: live-audit measured a firewall rule-jump
* tooltip (`nftables/iptables`, "Chain POSTROUTING, Rule #1") 65-72px past the column at
* 320px, spaces and all, because `pre` disables wrapping outright regardless of where a space
* sits. `pre-wrap` keeps every literal run of whitespace this rule was carried for — the same
* reason `35-alerts.css`'s validation tooltip and a multi-line firewall hint still read
* correctly — while letting the browser wrap at one of them once the line is too long. */
white-space: pre-wrap;
padding: var(--fs-space-0-5) var(--fs-space-1);
opacity: 0;
transition: opacity var(--fs-dur-fade);
}
.cbi-tooltip-container:hover .cbi-tooltip:not(:empty) {
inset-inline-start: auto;
opacity: 1;
transition: opacity var(--fs-dur-fade);
}
/* .cbi-progressbar paint: theme/25-progressbar.css — absorbed. The fill's starting
* width stays: the poll JS animates the inline width up from it. */
.cbi-progressbar > div {
width: 0%;
}
.zonebadge .cbi-tooltip {
padding: 1px;
background: inherit;
margin: -1.6em 0 0 -5px;
border-radius: var(--fs-radius-sm);
pointer-events: none;
/* box-shadow: theme/35-alerts.css states one elevation for every .cbi-tooltip */
}
.zonebadge .cbi-tooltip > * {
margin: 1px;
z-index: var(--fs-z-raise);
position: relative;
}
.zone-forwards {
display: flex;
flex-wrap: nowrap;
}
.zone-forwards > * {
flex: 1 1 40%;
padding: 1px;
text-wrap: auto;
}
.zone-forwards > span {
flex-basis: 10%;
text-align: center;
}
.zone-forwards .zone-src,
.zone-forwards .zone-dest {
display: flex;
flex-direction: column;
}
/* pressed-state shadow (.btn.active/:active): theme/55-buttons.css — absorbed */
.btn.disabled,
.btn[disabled] {
cursor: default;
opacity: var(--fs-disabled-opacity);
}
/* `.btn.large` / `.btn.small`: theme/55-buttons.css, in full — absorbed. */
/* close cross — colour, shadow, opacities: theme/70-modal.css — absorbed. Left:
* geometry. */
/* `line-height: 1` centres the glyph in its own box; it was 13.5px under a 20px face, and a
* hand-nudged length only lands for one font size */
/* Physical, and mirrored by hand below — the same shape as `::-webkit-resizer`
* (theme/60-inputs.css) and the two chevrons (theme/20-shell.css). `float` takes no logical
* keyword this theme can rely on: `inline-end` is newer than
* the `oklch(from …)` and `:has()` this sheet already requires only in Chromium, and a browser
* that does not know the keyword drops the declaration and leaves the cross floating
* nowhere. Measured in a live document at 1400px: the cross sat 549px from the reading edge in
* LTR and 41px in RTL, i.e. it jumped from the end of the heading to its start — in front of
* the title, where a dismissal control is not. It appears in every modal and in every
* `.alert-message`, so this is the RTL bug with the widest reach. */
.close {
float: right;
font-size: var(--fs-type-xl);
font-weight: var(--fs-weight-bold);
line-height: 1;
}
:root[dir="rtl"] .close { float: left; }
.close:hover {
text-decoration: none;
}
/* alert paint: theme/35-alerts.css — absorbed. Left: geometry and the white-space reset. */
.alert-message {
position: relative;
margin-bottom: var(--fs-space-2);
white-space: unset;
}
.alert-message .close {
margin-top: 1px;
}
/* colour, margin and the content reset: theme/35-alerts.css — absorbed with the heading ramp */
.alert-message button {
margin: var(--fs-space-1) 0;
}
/* `.label` pill and variants: theme/35-alerts.css — absorbed, as is the indicator pill
* (showIndicator() only ever emits into #indicators, which theme/20-shell.css paints). */
header [data-indicator] {
white-space: nowrap;
text-shadow: none;
margin: var(--fs-space-1) 0 var(--fs-space-1) var(--fs-space-1);
}
}
@@ -0,0 +1,749 @@
@layer base {
/* Widgets with no generic equivalent: CBI section tables, interface/zone badges,
* the uci change dialog, spinners, the file browser, and a few rules luci-mod-*
* depends on. */
/* .hidden: theme/60-inputs.css (with the .cbi-* variants LuCI emits) */
form.inline { display: inline; margin-bottom: 0; }
#modemenu li:last-child span.divider { display: none }
/* #syslog: absorbed into pages/70-syslog.css — in base it LOST to the theme layer's generic
* form-field box (a layer beats specificity), so the log rendered as a 210px column. */
/* `:not(.placeholder)`: a hover cue promises a row you can act on, and the empty-table row is
* not one. The theme's row hover already spares it (theme/30-tables.css, issue #5.2), but this
* rule paints the CELL, and a placeholder row carrying no `.cbi-section-table-row` class
* escapes that guard — measured on UPnP (issue #15).
* Fixed here rather than repainted later, so there is one hover contract. */
.cbi-section-table .tr:not(.placeholder):hover .td,
.cbi-section-table .tr:not(.placeholder):hover .th,
.cbi-section-table .tr:not(.placeholder):hover::before {
background-color: var(--fs-panel2);
}
.cbi-section-table .tr.cbi-section-table-descr .th {
font-weight: var(--fs-weight-normal);
}
.cbi-section-table-titles {
font-weight: var(--fs-weight-bold);
}
/* The GridSection name column, where the two releases' markup genuinely differs and one
* stylesheet serves both: 25.12's form.js renders the name as a real `th` plus
* `td.cbi-section-table-titles` and keeps `data-title` on the row only for the drag handle,
* while 24.10 renders no cell at all and expects `::before` to generate the column from
* `data-title`. Upstream ships a different cascade.css per branch (25.12's says
* `content: none`, 24.10's `content: attr(data-title) " "`), so taking 25.12's copy verbatim
* deletes the Name column on every 24.10 router (issue #9).
*
* Each row type therefore asks the MARKUP, never the release:
* header — carries `data-title` on 24.10 only; 25.12 appends a real `th` instead.
* row — BOTH set `data-title`, so the discriminator is the name cell 25.12 adds.
* descr — has neither, so it follows the header that already proved the branch. It needs the
* empty spacer (`data-title` is absent, so the content is just " ") or its column
* labels sit one cell left of the values they describe. A descr row with no header
* row above it (every column carrying a description but no title) therefore keeps no
* spacer — no shipped page renders that, so it is untested rather than handled.
* Never key on the VALUE: the header's data-title is _('Name'), i.e. translated.
*
* This rule states only that the column EXISTS — its padding, type and separator are the cell
* rules' job in theme/30-tables.css, next to the real cells it has to line up with. (Upstream
* declares the border-top here; ours would draw the stray line above the header that issue #7
* removed from every other cell.) */
/* SPLIT OFF THE :has() HALF, and the split is the point: an engine that cannot parse `:has()`
* throws away the WHOLE rule, siblings included — so a Firefox older than 121 lost the two
* plain selectors above with it, and stacked tables came up with no card labels at all. Same
* declarations, two rules, and the half that needs nothing new survives on its own. */
.cbi-section-table-titles.named[data-title]::before,
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.cbi-section-table-row[data-title]:not(:has(> .cbi-section-table-titles))::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.left { text-align: left !important; }
.right { text-align: right !important; }
.center { text-align: center !important; }
.top { vertical-align: top !important; }
.middle { vertical-align: middle !important; }
.bottom { vertical-align: bottom !important; }
table table td,
.cbi-value-field table td {
border: none;
}
/* cell-filling field widths (+ the numeric-cell refinement): theme/30-tables.css —
* absorbed with the generic field box, which would out-layer them here */
.table.cbi-section-table .td.cbi-section-table-cell {
white-space: nowrap;
text-align: end;
}
.td.cbi-section-actions {
text-align: end;
vertical-align: middle;
width: 15%;
}
.td.cbi-section-actions > * {
display: flex;
}
.td.cbi-section-actions > :not(.cbi-dropdown) > *,
.td.cbi-section-actions > * > form > * {
flex: 1 1 4em;
/* at 1px apart, adjacent row actions sat inside each other's 24px target circle
* (WCAG 2.2 SC 2.5.8) — see the min-height in theme/55-buttons.css */
margin: 0 var(--fs-space-1);
}
.td.cbi-section-actions > * > form {
display: inline-flex;
margin: 0;
}
.table.valign-middle .td {
vertical-align: middle;
}
.cbi-rowstyle-2,
.tr.table-titles,
.tr.cbi-section-table-titles {
background: var(--fs-panel2);
}
th[data-sortable-row] { cursor: pointer; }
th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
/* The help sentence, indented to clear the ? glyph its ::before hangs in the margin. The
* indent and the glyph's own `left` are ONE relationship — both said 1.25em, stated twice — so
* they now derive from a single local. */
.cbi-value-description {
--help-indent: 1.25em;
margin: var(--fs-space-1) 0 0 var(--help-indent);
position: relative;
}
.cbi-value-description:not(:empty)::before {
/* OURS, and drawn as a STROKE like every other glyph this theme owns (--spinner-icon below,
* --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid). It replaces a solid
* filled path copied verbatim from luci-theme-bootstrap: a drawn path is authored expression
* in a way `padding: 8px` is not, which made it the sharpest item in that inheritance. */
--help-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='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9.4'/><path d='M8.9 9.4a3.2 3.2 0 1 1 3.9 3.6v1.4'/><path d='M12.8 17.6h-.02'/></svg>");
position: absolute;
inset-inline-start: calc(var(--help-indent) * -1);
content: "\a0";
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: var(--fs-space-1);
background: var(--fs-accent);
mask-image: var(--help-icon);
mask-size: cover;
-webkit-mask-image: var(--help-icon);
-webkit-mask-size: cover;
}
/* tint (and the 6%-not-12% reasoning): theme/30-tables.css — absorbed */
.cbi-section-error {
border: 1px solid var(--fs-danger);
border-radius: var(--fs-radius);
padding: var(--fs-space-2);
margin-bottom: var(--fs-space-4);
}
.cbi-section-error ul { margin: 0 0 0 var(--fs-space-5); }
.cbi-section-error ul li {
/* colour: theme/35-alerts.css — absorbed */
font-weight: var(--fs-weight-bold);
}
.ifacebox {
border: var(--fs-hairline);
margin: 0 var(--fs-space-3);
text-align: center;
white-space: nowrap;
/* surface: theme/45-misc.css (flat) — absorbed */
border-radius: var(--fs-radius-sm);
display: inline-flex;
flex-direction: column;
/* a plate of single-line labels, not prose */
line-height: var(--fs-leading-tight);
min-width: 100px;
}
.ifacebox .ifacebox-head {
border-bottom: var(--fs-hairline);
/* the head sits INSIDE the box's 1px border, so its own radius is the box's less that
* border — restating a smaller literal is how the two drifted apart before. */
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
padding: var(--fs-space-0-5);
/* surface + ink: theme/45-misc.css — absorbed */
}
/* The !important fights luci-mod-network's inline zone-colour `style=`: a raw zone colour is too
* bright on a dark panel, so it is re-mixed at .4/.3.
*
* No fallback on --zone-color-rgb, and that is load-bearing: LuCI sets that var inline only
* where there IS a zone colour, so elsewhere the declaration is invalid at computed-value time
* and, being !important, resolves to `unset` and wipes the plain inline background — which is
* the desired outcome. A fallback grey makes the rule apply there and tints five interface
* boxes that correctly show the panel behind them (measured, computed-style diff). */
[data-darkmode="true"] .zonebadge[style],
[data-darkmode="true"] .ifacebox-head[style] {
background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
}
/* `.ifacebox` above is `white-space: nowrap` — right for a plate of one-word head labels, wrong
* for the body's own stat/address lines once they are long enough to need a break: measured
* three times under three different scopes before this rule (Network overview's
* `#cbi-network-interface`, the upstream plates' `.network-status-table`, and — pseudo-loc,
* gallery's plain Port-tile fixture with no `port_*.svg` icon — a 320px/Large stat line "1000
* Mbit/s / 1.2 GiB / 8.3 GiB" already 33px past a 118px box in PLAIN ENGLISH, no translation
* involved) — the base rule owns it once instead of a fourth scoped copy the next real page
* would need again. `overflow-wrap: anywhere`, not `break-word`: only `anywhere` lowers the
* box's own min-content, letting the flex item actually shrink to the tile (65-dropdown.css's
* own reasoning for the same two properties). */
.ifacebox .ifacebox-body {
padding: var(--fs-space-1);
white-space: normal;
overflow-wrap: anywhere;
}
#cbi-network-interface .ifacebox img {
width: 24px;
height: 24px;
}
.ifacebadge {
display: inline-block;
flex-direction: row;
border: var(--fs-hairline);
padding: var(--fs-space-0-5);
/* surface: theme/45-misc.css (flat) — absorbed */
color: var(--fs-text);
border-radius: var(--fs-radius-sm);
cursor: default;
/* a badge is one line of label — the tight step, like .ifacebox above */
line-height: var(--fs-leading-tight);
}
.ifacebadge img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.ifacebadge-active {
/* border-color: theme/45-misc.css (accent) — absorbed */
font-weight: var(--fs-weight-bold);
}
.network-status-table {
display: flex;
flex-wrap: wrap;
}
.network-status-table .ifacebox {
margin: var(--fs-space-2);
flex-grow: 1;
}
.network-status-table .ifacebox-body {
display: flex;
flex-direction: column;
height: 100%;
text-align: start;
/* the wrap fix (an uncompressed IPv6 is ~380px of unbreakable text, measured 45px over the
* card at 390px before it shipped) now lives on `.ifacebox .ifacebox-body` above, generalised
* to every plate rather than restated per scope — this rule keeps only what is unique to the
* upstream layout: full-height flex column, address-block start alignment. */
}
/* The head got the port tiles' `nowrap` (short one-word labels: "wan", "lan2") but sits in the
* SAME shrinking flex item as the body above, and RU's "IPv4 Upstream" caption is a sentence,
* not a word: "Подключение IPv4 (внешняя сеть)" measured 301px in a 288px column at 320px/
* Density Large, 41.3px past #view, with the .cbi-section clipped by 42px and no scroller — EN
* at the same width and density: 0. Same two declarations as the body, same reasoning. */
.network-status-table .ifacebox-head {
white-space: normal;
overflow-wrap: anywhere;
}
.network-status-table .ifacebox-body > * {
margin: var(--fs-space-1);
}
.network-status-table .ifacebox-body > span {
flex: 10 10 auto;
height: 100%;
}
.network-status-table .ifacebox-body > div {
margin: -.125em;
display: flex;
flex-wrap: wrap;
}
#dsl_status_table .ifacebox-body span > strong {
display: inline-block;
min-width: 35%;
}
.ifacebadge.large,
.network-status-table .ifacebox-body .ifacebadge {
display: flex;
flex: 1;
padding: var(--fs-space-1);
min-width: 220px;
margin: var(--fs-space-1);
}
.ifacebadge.large {
display: inline-flex;
}
.network-status-table .ifacebox-body .ifacebadge > span {
overflow: hidden;
text-overflow: ellipsis;
}
.ifacebadge > *,
.ifacebadge.large > * {
margin: 0 var(--fs-space-1);
}
.zonebadge {
padding: var(--fs-space-0-5);
border-radius: var(--fs-radius-sm);
display: inline-block;
white-space: nowrap;
color: var(--fs-text);
}
.zonebadge > em,
.zonebadge > strong {
margin: 0 var(--fs-space-1);
display: inline-block;
}
/* .zonebadge input width: theme/30-tables.css — absorbed with the generic box */
.zonebadge > .ifacebadge {
margin-inline-start: var(--fs-space-0-5);
}
.zonebadge-empty {
/* width/style only — colour + ink: theme/45-misc.css */
border: 1px dashed var(--fs-border);
font-style: italic;
font-size: smaller;
}
/* color: theme/60-inputs.css — absorbed. */
div.cbi-value var,
.td.cbi-value-field var {
font-style: italic;
}
div.cbi-value var[data-tooltip],
.td.cbi-value-field var[data-tooltip],
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container {
cursor: help;
border-bottom: 1px dotted var(--fs-accent);
}
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip {
font-style: normal;
white-space: normal;
color: var(--fs-text);
}
#modal_overlay > .modal.uci-dialog,
#modal_overlay > .modal.cbi-modal {
max-width: 900px;
}
#modal_overlay > .modal.login > button.important {
/* no !important needed: the ID makes this (1,3,1), which no button rule can reach */
/* Coverage vocabulary, not a live control: nothing in luci-base gives a modal the `login`
* class (ui.showModal takes classes and no call site passes one), and this theme's own
* login is a server-rendered form, not a modal. It is inherited from luci-theme-bootstrap,
* which carries the same selector, so an app that emits it gets a themed result — a step up
* the scale, where it was a bare 120% of whatever the button happened to be. */
font-size: var(--fs-type-lg);
margin-top: var(--fs-space-2);
width: 100%;
}
/* a list of changed settings, one per line: prose leading, not a hand 170%. */
.uci-change-list {
line-height: var(--fs-leading);
white-space: pre;
}
/* one changed-setting row. It carried `line-height: 15px` — a hand number under a 13px face;
* a row of single-line code takes the tight step like every other plate here. */
.uci-dialog div > del,
.uci-dialog div > ins,
.uci-dialog div > var {
margin-bottom: var(--fs-space-0-5);
border: var(--fs-hairline);
line-height: var(--fs-leading-tight);
overflow: hidden;
text-overflow: ellipsis;
padding: var(--fs-space-0-5);
position: relative;
background-color: var(--fs-panel2);
}
.uci-dialog div > ins {
background-color: var(--fs-good-fill);
border-color: var(--fs-good-line);
}
.uci-dialog div > del {
background-color: var(--fs-danger-fill);
border-color: var(--fs-danger-line);
}
.uci-dialog var > ins {
background-color: var(--fs-good-fill);
}
.uci-dialog var > del {
background-color: var(--fs-danger-fill);
}
.uci-change-list > var > * {
overflow: hidden;
text-overflow: ellipsis;
}
.uci-dialog del,
.uci-dialog ins,
.uci-dialog var {
text-decoration: none;
font-family: var(--fs-font-mono);
font-style: normal;
color: var(--fs-text);
display: block;
}
.uci-change-legend {
display: flex;
flex-wrap: wrap;
}
.uci-change-legend-label {
flex-basis: 150px;
margin: var(--fs-space-0-5);
display: flex;
align-items: center;
}
.uci-change-legend-label > ins,
.uci-change-legend-label > del,
.uci-change-legend-label > var {
margin-inline-end: var(--fs-space-1);
width: 16px;
height: 16px;
display: flex;
}
.uci-change-legend-label > * > * {
flex-basis: 100%;
padding: 1px;
}
#applyreboot-section {
line-height: 300%;
}
@keyframes flash {
0% { opacity: 1; }
50% { opacity: .5; }
100% { opacity: 1; }
}
.flash {
animation: flash var(--fs-dur-fade);
}
#view > div.spinning:first-child {
display: table;
margin: 15vh auto;
}
/* Rotate in CSS, NEVER with a SMIL <animateTransform> inside the data-URI: that runs in
* the SVG's own document, which the page's `prefers-reduced-motion` cannot reach — the
* one spinner a motion-sensitive user could not turn off. theme/95-a11y-media.css now
* stops it like everything else. (The circle under the arc is rotationally symmetric,
* so spinning the whole box looks identical.) */
/* ---- the glyph sits in the flow, which is the fix for a whole bug class ----
*
* Absolutely positioned, the host has to be told to leave room for it — a `padding-left` that
* must out-rank whatever else pads that element. That race cannot be won: every padding rule
* aimed at a button is a candidate and the loser is silent, the glyph simply drawing on top of
* the label. Twice reported from the field, each time by a more specific rule than the last:
* issue #15 (`.td .cbi-button`, 0,2,0) and issue #22, whose
* `.cbi-section .table[id] .td .cbi-button` is 0,5,0 — there the ladder had padding-left at
* 10px against the 32px the glyph needs.
*
* In the flow the glyph reserves its own space, so no host needs a padding and there is nothing
* to out-rank. `flex: 0 0 auto` because the buttons are `inline-flex` and a flex item with a
* mask is squeezable; `vertical-align: middle` for the non-flex hosts. The centring is the
* host's own align-items or baseline alignment. */
.spinning::before {
--spinner-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");
display: inline-block;
flex: 0 0 auto;
vertical-align: middle;
margin-inline-end: var(--fs-space-1);
width: var(--fs-spin-size);
height: var(--fs-spin-size);
content: " ";
background: var(--on-color, #000);
mask: var(--spinner-icon) center/cover no-repeat;
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
/* The two deliberate exceptions to the motion scale (02-tokens.css), both because this
* is a LOOP, not a state change: 1s is a spinner period, not one of the four durations;
* and `linear` is the one justified curve — `ease` would slow at each turn's seam, so an
* infinite rotation visibly stutters once a second instead of spinning. */
animation: fs-spin 1s linear infinite;
}
/* One user in this theme (`.spinning::before` above) — the rail's poll glyph is
* deliberately STATIC (theme/20-shell.css; the spin was what broke that click).
* Unscoped anyway: keyframe names resolve document-wide, so a luci-app-* may ride it. */
@keyframes fs-spin { to { transform: rotate(360deg); } }
[data-darkmode="true"] .spinning::before {
background: var(--on-color, #fff);
}
[data-tab-title] {
height: 0;
opacity: 0;
overflow: hidden;
}
/* the revealed state of both collapsibles — tab pane and file browser — in one rule */
[data-tab-active="true"],
.cbi-filebrowser.open {
opacity: 1;
height: auto;
overflow: visible;
transition: opacity var(--fs-dur-fade);
}
.cbi-filebrowser {
/* `min(210px, 100%)`, not a bare 210: the floor keeps the file list readable, and a floor
* wider than the field it sits in is not a floor but an overflow — measured at a 300px
* viewport, the panel held 210px inside a 203px field and its whole upload row (the input,
* Upload file, Cancel) stood 7px outside the card. Same shape as the dynlist's floor one file
* over, and the same fix. Nothing moves at any width where the field is wider than 210. */
min-width: min(210px, 100%);
max-width: 100%;
/* width/style only; radius: theme/60-inputs.css — absorbed */
border: var(--fs-hairline);
display: flex;
flex-direction: column;
opacity: 0;
height: 0;
overflow: hidden;
}
/* padding: theme/60-inputs.css — absorbed. The zero side padding here was what let a 36px
* themed button touch this element's own clip box and lose its corners to it. */
.cbi-filebrowser > * {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
margin: .25em .25em 0 .25em;
white-space: nowrap;
border-bottom: var(--fs-hairline);
}
.cbi-filebrowser .cbi-button-positive {
margin-inline-end: .25em;
}
/* `.cbi-filebrowser > *` above gives every row `nowrap` + ellipsis on the assumption that a row
* is ONE LINE OF TEXT — true for the path and each listing row, false for `.right`: its content
* is the upload strip (its own flex row) plus the Create/Cancel buttons, none of it text that an
* ellipsis can stand in for. `nowrap`, inherited by `.upload` and its Browse button, was blocking
* the wrap that button already had (`flex-wrap: wrap`, theme/60-inputs.css) from ever firing, and
* `overflow: hidden` had no ellipsis to show for a clipped BUTTON — pseudo-loc measured Cancel's
* label rendered 76px past this box with no "…" drawn, the severe case the gate's own docs call
* "text the reader cannot reach", not the accepted dropdown/tab shape. `normal` restores the
* ordinary wrap a button row gets everywhere else in the theme; `visible` undoes a clip a
* text-only rule has no business applying to a row of controls. */
.cbi-filebrowser > .right {
white-space: normal;
overflow: visible;
}
.cbi-filebrowser > div {
border-bottom: none;
}
.cbi-filebrowser > ul > li {
display: flex;
flex-direction: row;
}
/* row hover surface, and the row's whole flex sizing: theme/60-inputs.css — absorbed. The
* proportional split that lived here (name `flex: 10` / actions `flex: 3`) fitted base's ~20px
* buttons and not the theme's 36px ones, which is how a file row came to clip its own Delete. */
.cbi-filebrowser > ul > li > div:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.cbi-filebrowser > ul > li > div:last-child {
text-align: end;
}
.cbi-filebrowser > ul > li > div:last-child > button {
padding: .125em .25em;
}
.cbi-filebrowser .upload {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -.125em .25em -.125em;
padding: 0 0 .125em 0;
border-bottom: var(--fs-hairline);
}
.cbi-filebrowser .upload > * {
margin: .125em;
flex: 1;
}
.cbi-filebrowser .upload > .btn {
flex-basis: 60px;
}
.cbi-filebrowser .upload > div {
flex: 10;
min-width: 150px;
}
/* the upload input's width: theme/60-inputs.css — absorbed with the generic box */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
/* The `fade-in` keyframes above stay although nothing here rides them: it is an upstream
* animation NAME a luci-app-* may ride (coverage contract, docs/conventions.md). The theme's own
* rule on the `.fade-in` CLASS is gone on purpose — luci-mod-status re-marks with it on every
* poll tick, so anything hung there replays on every tick across the whole column.
*
* `fs-fade` is the theme's own and is one-shot: the rail flyout and the search overlay each
* play it once, when the surface opens, which is why its 4px rise is right there and wrong on
* a class the poll rewrites. Both keyframes live here because every keyframes in this theme
* does, and an unresolvable animation-name is simply dropped, silently. */
@keyframes fs-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.fade-out {
animation: fade-out var(--fs-dur-fade);
}
.assoclist .ifacebadge {
display: flex;
flex-direction: column;
align-items: center;
white-space: normal;
text-align: center;
}
.assoclist .ifacebadge > img {
margin: var(--fs-space-1);
}
.assoclist .td:nth-of-type(3),
.assoclist .td:nth-of-type(5) {
width: 25%;
}
.assoclist .td:nth-of-type(6) button {
word-break: normal;
}
/* Statistics renders its graphs as PNGs on a white ground, so dark mode inverts them. 180deg is
* arithmetic rather than taste: invert() maps every hue to h+180, so rotating 180 back restores
* the original hue while keeping the inverted lightness. Read back off collectd's own cpu
* series, at 150deg System (h=0) came back at h=301 and User (h=240) at h=180 — 59 and 60
* degrees out, i.e. the blue plot was drawn cyan. At 180deg all three primaries are exact.
*
* It is not exact for every colour and cannot be: CSS hue-rotate is a linear matrix
* approximation, so a non-primary survives only approximately (amber lands 13deg out). Do not
* tune the angle — moving off 180 trades an exact red/green/blue for a marginally better
* amber. */
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
filter: invert(100%) hue-rotate(180deg);
}
/* Realtime graphs (Status → Load/Traffic) inject resources/svg/*.svg into a div with
* an inline style. The <svg> root carries none itself, so the background needs no flag.
* Its grid <line>s do — `style="stroke:black"` in the .svg — and a black hairline is
* invisible on a dark panel; only an author !important beats an inline declaration. */
#view div[style] > svg {
background-color: var(--fs-panel);
}
#view div[style] > svg line[style] {
stroke: var(--fs-text) !important;
}
/* …and the axis labels beside them: every <text> in the .svg carries an inline fill of the
* file's own light grey plus a black halo, drawn for the black background the file assumes
* rather than for the panel this theme paints. On a light palette that grey lands on white at
* 1.16:1, legible only as the shadow around it, against 17.9:1 for --fs-text, and 14.7:1 on the
* dark panel, where it is the halo that has to go instead. Both come from an inline
* declaration, so both need the flag.
*
* The weight is not decoration: the SVG hardcodes a 9pt label, the smallest type this theme
* renders, and at the regular weight also the thinnest.
*
* A CHILD combinator, and the difference is a page: Channel Analysis derives a colour per BSSID
* and paints both the curve and its SSID label with it, and those labels are built inside a
* <g>, while the grey axis labels are direct children of the <svg>. A descendant selector
* recolours both and leaves the curves as they were; `> text[style]` reaches only the axis. */
#view div[style] > svg > text[style] {
fill: var(--fs-text) !important;
text-shadow: none !important;
font-weight: var(--fs-weight-bold);
}
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) +
* pages/20-overview.css (chevron variant) — absorbed. */
}