@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,"); 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 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,"); 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 root carries none itself, so the background needs no flag. * Its grid 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 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 * , while the grey axis labels are direct children of the . 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. */ }