@layer theme { /* ---------- panels / cards (cbi) ---------- */ .cbi-section, .cbi-map > .cbi-section-node, fieldset.cbi-section, .panel { background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow); } .cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); } /* a bare ; the ones inside a .cbi-section are covered below. The ink is the heading * ramp's (45-misc.css) — legend is kept out of that group so this rule stays its one owner; * no line-height here: base's `fieldset legend` sets 1 and every real legend sits in a * fieldset, so stating leading here would silently repaint them all through the layer. */ legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); } /* overview (luci-mod-status) DOM: * .cbi-section > .cbi-title>h3 + div > table.table (.tr/.td) */ /* section title + collapse control */ .cbi-section > .cbi-title { margin-bottom: var(--fs-space-3); } .cbi-section > .cbi-title h3, .cbi-section > h3:first-child, .cbi-section > legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); margin: 0; padding: 0; border: 0; } /* A bare section title (no .cbi-title wrapper) sat flush against the content below it — * on System → System the "System Properties" h3 touched the tab strip. Give it the same * 12px the .cbi-title wrapper already carries above. Excludes .cbi-title h3: that variant * gets its gap from the wrapper's own margin-bottom, so flagging the inner h3 too would * double it. */ .cbi-section > h3:first-child, .cbi-section > legend { margin-bottom: var(--fs-space-3); } /* A second heading inside the same card — the one that follows a table rather than opening the * section — has no air at all: on Status -> Overview the stock includes stack `table + h3 + * table` inside one .cbi-section, and the heading reads as another row of the table above it. * The rule above cannot cover it, `:first-child` being what excludes the one that opens the * card. The two values are the ladder's own: a heading is separated from what came before by * more than from what it introduces. */ .cbi-section .table + h3 { margin-top: var(--fs-space-5); } .cbi-section h3 + .table { margin-top: var(--fs-space-3); } /* ---- tables in cards ---- * Discriminator: a key/value include (System, Memory) has NO id; a data table has one * (status_leases) or the JS tag .fs-dt. Key/value is not itself a card — no frame, no clip; * a data table keeps the `.table` frame below. Nested (`.table .table`, `.cbi-value .table`) * and the config-diff dialog (`.cbi-modal .table`) go with key/value. */ .cbi-section .table:not([id]):not(.fs-dt), .cbi-section-node .table:not([id]):not(.fs-dt), .cbi-modal .table, .table .table, .cbi-value .table { border: 0; border-radius: 0; overflow: visible; background: none; } .cbi-section .table { margin: 0; width: 100%; } /* A data table straight in #view (no section: package-manager, Startup) misses the width above, * so it sized to preferred width and overflowed the content column — which base already fixes * for EVERY `.table` (`width: 100%`, base/40-tables.css), and has for as long as both rules have * existed. Switched off against the 24 tables that match it across eight pages: no computed * style moved, so the rule only restated a value one layer down. Removed. */ /* Config-table fields fill their cell — moved from base with the generic field box in * theme/60-inputs.css (in base these beat its 210px in-layer; left behind they would lose * to it by layer), together with the numeric-cell refinement below them. */ .table.cbi-section-table input[type="password"], .table.cbi-section-table input[type="text"], .table.cbi-section-table textarea, .table.cbi-section-table select, .table.cbi-section-table .cbi-select { width: 100%; min-width: auto; } .table.cbi-section-table .td.cbi-section-table-cell select, .table.cbi-section-table .td.cbi-section-table-cell .cbi-select { width: inherit; } /* The filter row (form.js renderHeaderRows) is the one place a field cannot claim the width it * wants: it lives in a table HEADER cell, so the column is as wide as the DATA under it. The * rule above makes it fill that cell; what does not fit is the TEXT, LuCI writing the * placeholder as `Filter ` plus the column title, so a long translated title is sliced * mid-glyph (issue #12). The flag column is the worse half: form.js writes `width: 30px` inline * there, of which the theme's padding ate 24, leaving less than one glyph. * * A denser box and denser type buy back ~20px per column; the ellipsis changes the FAILURE, a * placeholder too long for its column now ending instead of being cut through a letter. Nothing * is lost to it — the column title says the same word directly above. No !important, so the * inline 30px still stands: it is upstream's sizing for a one-character field. */ .table.cbi-section-table .cbi-section-filter { padding: var(--fs-space-1) var(--fs-space-1-5); font-size: var(--fs-type-xs); text-overflow: ellipsis; } /* width:100% cannot shrink a table below min-content — one long unbreakable value holds the * floor. Breaking values reflows it into the column instead of scrolling, and that break is the * DEFAULT for every cell (base/40-tables.css), so a copy here would say nothing the cascade does * not. * * A rule on this property is back a few lines down, and it is the INVERSE rather than a copy: a * data table gets `break-word` while it is still a table, so its columns keep a floor and an * overflow can be seen. Read that block before touching either. DHCP leases need finer control * (only DUID/IPv6 may break) and get it from `white-space: nowrap`: pages/50-leases.css. */ .cbi-section .table .td, .cbi-section .table .th { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-2-5) 0; border: 0; border-bottom: var(--fs-hairline); line-height: 1.45; vertical-align: middle; text-align: start; } /* Row actions hug the far edge. Base says so too but loses to the `text-align: start` above (an * earlier layer), so restate it. `.cbi-section-table-cell` is the discriminator: * form.TableSection's action cells carry it (firewall zones, port forwards); the hand-built * ones of Wireless (`.td.middle`) and DHCP leases do not, and stay left. */ .cbi-section .table .td.cbi-section-table-cell.cbi-section-actions { text-align: end; } .cbi-section .table .tr:last-child .td { border-bottom: 0; } /* Case is the app's, not the theme's: `text-transform: uppercase` stood here and printed LuCI's * own `IPv4 address` as `IPV4 ADDRESS`, which reads as a typo in a column heading rather than as * a style (issue #39). Everything else that made these read as headings — the smaller size, the * weight, the faint ink, the tracking — stays. Four rules carry the same decision: the title row * below, the card label (mirrored into theme/65-dropdown.css) and the alert's table in * theme/35-alerts.css. */ .cbi-section .table .th { color: var(--fs-eyebrow-color); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); } /* --- DATA tables ([id] or JS-tagged .fs-dt): leases, wifi, processes, routes … --- * Symmetric padding so cells clear the table's own contour (below). * * `:is([id], .fs-dt)` is one discriminator at ONE specificity: written as two selector lists the * two names for "data table" land at different weights — `.cbi-section .table[id] .td` (0,4,0) * against `table.fs-dt .td` (0,2,1), the latter losing to the key/value default (0,3,0) — so a * table known only by the JS tag keeps the flush-left padding, with no mono face, no row hover * and no header rule. Status -> Routing emits exactly that shape. */ .cbi-section .table:is([id], .fs-dt) .td, .cbi-section .table:is([id], .fs-dt) .th, table.fs-dt .td, table.fs-dt .th { padding: var(--fs-space-2-5) var(--fs-space-3); } .cbi-section .table:is([id], .fs-dt) .td { font-family: var(--fs-font-mono); font-size: var(--fs-type); color: var(--fs-text); } /* ---- the floor, and it belongs to the one tier a measurement reads ---- * * `base/40-tables.css` breaks every value `anywhere`, the one break value that also lowers the * cell's min-content contribution — to a single character. That is what keeps a table nobody * measures inside its card, and it is also a LIE told to the engine: with no floor, auto table * layout is free to starve a column and the browser answers "it fits", truthfully and uselessly. * * So the floor goes back exactly where it is READ: a data table, while it is still a table. * `break-word` breaks the same values in the same places — the two differ only in whether the * break counts towards min-content (css-text-3 §5.4) — so nothing about the rendering changes; * what changes is that the column can no longer be starved below it, and an overflow becomes * visible to `fit.overflows()`. * * `:not(.fs-stacked)` is the whole guard, and it is the state the fitter measures in (it strips * the class first, fs-fit rule 1). Once carded, every cell has the row to itself. * * base/40-tables.css is deliberately NOT touched: `break-word` there was tried and recorded as a * failure (a table stayed 355px wide in a 346px column, and .fs-main clips), a tier with no card * and no scroll having nowhere to put the overflow. */ .table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; } /* …and the ONE column the fitter decided may be shredded rather than card the whole table. Written * by fs-select.js on the cells of a single column, stripped before every measurement. (0,5,0) — it * beats the floor above on specificity, not on source order. */ .table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; } /* ---- and the one flag, because the view writes its shredding inline ---- * * `luci-mod-status`'s processes.js emits `E('span', {'style': 'word-break: break-word'}, …)`. * `word-break: break-word` is the deprecated alias defined as "`word-break: normal` plus * `overflow-wrap: anywhere`" (css-text-3 §5.1), so it lowers the cell's min-content to a single * character from an INLINE declaration no cascade layer can outrank: measured at 720px, the * Command column sat at 126px against a widest token of 353px and forcing the column's * `overflow-wrap` any of three ways changed nothing, the floor being erased inside the span. * Neutralised, the same table asks for 963px in 688px of room, which is the truth. * * This re-times the view's intent rather than overruling it: the column may still shred, but * when the fitter marks it (`.fs-td-break`, one rung before carding) rather than always. Buttons * and inputs are excluded — base gives them `break-all` deliberately, and a control label is not * a value being read. * * The `!important` is the sanctioned kind: it fights an inline `style=`, the one adversary a * layer cannot reach. * * A CARD IS NOT EXEMPT, though `:not(.fs-stacked)` stood here. The inline alias damages the * MEASUREMENT, and a carded table is not measured — but it is not the only adversary. LuCI's * luci-mod-dashboard ships `.Dashboard … .devices-info .tr .td:first-child { word-break: * break-all }` in view/dashboard/css/custom.css with no layer around it, and `break-all` splits a * token that would have fitted: measured at 390px, `t3e-syp.home.iot` over six lines and the * caption `HOSTNAME` cut as `HOSTNA`/`ME`. A card's own breaking is unchanged — `.td` below keeps * `overflow-wrap: anywhere`, so a token too long for the line still breaks. */ .table.fs-dt .td, .table.fs-dt .td :not(button):not(.btn):not(input):not(select):not(textarea) { word-break: normal !important; } /* Whether a table cards, breaks one column or scrolls is MEASURED (fs-select.js), at any width. */ /* ---- the header row stays while the rows go past ---- * * Processes renders 114 rows and Connections a few hundred: past the first screenful the column * headings are gone and every value is an unlabelled string of digits. The card view solves that * by printing the heading above each value; a table that FITS had no equivalent. * * On the CELLS, not the row: `position: sticky` needs a box with a background, and in the * separated border model a row's background does not travel with a sticky cell — the heading * would float over the rows with the values showing through. So each `.th` sticks and carries * the header's own fill. * * What it sticks TO depends on which box scrolls. In the vertical sidebar `.fs-main` is its own * scroller and nothing overlaps its top edge, so the offset is 0. In the bar layouts the * DOCUMENT scrolls under a sticky bar whose height is not `--fs-bar-h` — it grows when the brand * wraps, when the menu takes a row or when the cluster does, all decided by measurement — so * fs-chrome.js publishes `--fs-bar-live` and this reads it, with the designed height as the * fallback for the frame before the chrome has been fitted. * * And a dialog is neither, which is why every one of these selectors names its root. The roots * ride in `:where()` so they cost nothing: spelled out they weigh (1,9,0) against the (1,7,0) * ceiling tools/css-metrics.mjs enforces, while the cell rule that has to win is (0,6,0). */ :where(:root[data-layout="sidebar"]:not([data-narrow]) #view, #modal_overlay) .table.fs-dt:not(.fs-stacked) .tr.table-titles .th { position: sticky; top: 0; z-index: 1; background: var(--fs-panel2); } :where(:root[data-layout="top"] #view, :root[data-narrow] #view) .table.fs-dt:not(.fs-stacked) .tr.table-titles .th { position: sticky; top: var(--fs-bar-live, var(--fs-bar-h)); z-index: 1; background: var(--fs-panel2); } /* ---- a measurement must not throw the reader ---- * * `fitTables()` decides a data table's tier by taking its marks OFF and asking the engine * (fs-fit rule 1), and the reading forces layout, so for that pass the document really is laid * out with a table several screens taller than the one on screen. * * Scroll anchoring turns that into a visible bug: the browser keeps the node under the reader * still by adjusting the offset, and re-picks that node whenever the offset changes for any * other reason — which, while the user scrolls, is every frame. The pick can land inside a table * in the un-stacked state the fitter is about to undo, and the next frame the engine faithfully * corrects the offset by the difference. Measured on the Overview at 390x844 with the poll * driving real re-renders: 18 movements nobody asked for in 1500 frames, the worst 1877px, * always a pair. * * So the tables the fitter re-stamps are declared ineligible as anchors — and only they, because * every other node staying eligible is what keeps the good half of anchoring working. NOT on the * scroll container, which is the answer that suggests itself and is worse: with * `html, body, #maincontent { overflow-anchor: none }` the same run still moved 12 times, up to * 788px, having thrown that compensation away as well. */ .table.fs-dt { overflow-anchor: none; } .cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); } .cbi-section .table:is([id], .fs-dt) .tr.table-titles .th { border-bottom-color: var(--fs-border); } /* Contour around the whole data table. A real needs border-collapse:separate or the * radius is silently dropped. Scoped to #view too: Startup renders a .table.fs-dt in a bare * #view wrapper with no section, and drew frameless under a section-scoped selector. */ .cbi-section .table[id], #view .table[id], .cbi-section .table.fs-dt, .cbi-section table.fs-dt, table.fs-dt { border-collapse: separate; border-spacing: 0; border: var(--fs-hairline); border-radius: var(--fs-radius-lg); /* EXPLICIT, not an omission: the generic `.table` rule below still says `overflow: hidden` * — which clipped the last column of a wide table — and not repeating it here does not * stop it applying, which is what kept the bug alive. The corner cells (below) round the * frame instead, and long values wrap so the table shrinks to fit. */ overflow: visible; } /* The frame is overflow:visible, so nothing clips the square corner cells to its radius and * they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill. * `#view .table[id]` is listed because the frame rule draws the border there too. */ .cbi-section .table[id] .tr.table-titles .th:first-child, #view .table[id] .tr.table-titles .th:first-child, table.fs-dt .tr.table-titles .th:first-child { border-top-left-radius: var(--fs-radius-lg); } .cbi-section .table[id] .tr.table-titles .th:last-child, #view .table[id] .tr.table-titles .th:last-child, table.fs-dt .tr.table-titles .th:last-child { border-top-right-radius: var(--fs-radius-lg); } .cbi-section .table[id] .tr:last-child .td:first-child, #view .table[id] .tr:last-child .td:first-child, table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); } .cbi-section .table[id] .tr:last-child .td:last-child, #view .table[id] .tr:last-child .td:last-child, table.fs-dt .tr:last-child .td:last-child { border-bottom-right-radius: var(--fs-radius-lg); } /* Empty table: L.ui.Table adds a single-cell .tr.placeholder. Spanning it across the table is * base's job (base/40-tables.css); what is left here is the FRAMED table's own flavour — the * plate reads as prose, left-aligned like the rows it stands in for, and carries the frame's * bottom corners so the rounding does not cut across it. */ .cbi-section .table[id] .tr.placeholder .td, #view .table[id] .tr.placeholder .td, table.fs-dt .tr.placeholder .td { /* the framed flavour pads its cells `--fs-space-2-5` a side (above), and that shorthand is in * THIS layer, so it overrides base's placeholder padding and made the plate 4px taller than * the row reserved for it — measured on Wireless as 67px inside 63px, printing below the * frame's bottom border. Put base's number back for this one cell. */ padding-block: var(--fs-space-2); text-align: start; color: var(--fs-dim); font-family: var(--fs-font-sans); border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg); } /* --- KEY/VALUE tables: System, Memory --- * "no id" is not enough: form.TableSection/GridSection also render `.table.cbi-section-table` * with no id, where a 40%/nowrap first column starves the data columns. So exclude it and .fs-dt * explicitly. * * Also exclude the realtime graph legend: `[style*="table-layout"]` is the one no-id `.table` * LuCI hands an inline `table-layout:fixed`, where a 40% first column would only starve the * other five. A DDNS-style multi-column status table is not excluded and keeps the key/value * first column. */ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child { color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap; } .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) { font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end; } /* --- the realtime graph legend (status/realtime/*) --- * The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and six cells * per row. `fixed` splits the row into six equal columns, so on a phone each is ~1/6 of the * content width and the labels overflow onto the value beside them. Stock luci-theme-bootstrap * folds the six cells into label/value PAIRS on a narrow screen; do the same. `display:flex` on * the row takes the cells out of the fixed table layout, so the inline `table-layout` no longer * bites. Keyed on that inline attribute so only this legend is touched. */ @media (max-width: 767px) { .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr { display: flex; flex-wrap: wrap; } .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .td { flex: 1 1 50%; min-width: 0; width: auto; } } /* remove double card when a section nests a table-only include */ .cbi-section .cbi-section { border: 0; box-shadow: none; padding: 0; margin: 0; background: transparent; } /* generic links/labels inside cards */ .cbi-section .ifacebox, .cbi-section .zonebadge { border-radius: var(--fs-radius); } /* moved from base with the generic field box (same layer arithmetic as the cell rules above) */ .zonebadge input { width: 6em; } /* The device/MAC badge inside the Network status box kept base's flat 4px, reading as square * against the card's --fs-radius corners (issue #5.4). Scoped to the status box: the small * firewall zone .ifacebadge chips want to stay tight. Follows the Rounding axis via the token. */ .network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); } /* …and the head has to round WITH the box: it sits inside the box's 1px border and carries the * zone colour as an inline background, so its own corners are what you see, and a 3px head * inside a 10px box cuts straight through the rounding with the zone colour spilling past the * corner (issue #7). Minus the 1px border it is inset by; max() because the Rounding axis goes * down to 0, where a bare subtraction is negative — an invalid declaration, silently dropped. */ .cbi-section .ifacebox > .ifacebox-head { --fs-head-radius: max(0px, calc(var(--fs-radius) - 1px)); border-radius: var(--fs-head-radius) var(--fs-head-radius) 0 0; } /* Inactive tab panes. Base's [data-tab-title] rule sets height:0/overflow:hidden, collapsing only * the CONTENT box — padding, border and margin still draw empty bars, and a pane left in flow * inflates scrollHeight (Interfaces scrolled ~585px past the footer, Network -> DNS another * ~750px). Zeroing the box does both jobs, and hiding the pane with `display: none` on top of * that buys nothing (Interfaces scrollHeight 1039 either way). * * It is `visibility`, not `display: none`, because a display:none pane has no width and a view * is entitled to measure one: stock channel_analysis.js builds its channel axis from * `graph.offsetWidth` once, in a rAF, so a pane that starts inactive reads 0 and the 5GHz graph * stays a collapsed blob for the life of the page. `visibility: hidden` keeps the pane laid out * at full width while still taking it out of the a11y tree and the tab order, and * height:0 + overflow:hidden keep it out of the scroll. * * `position: relative` is what makes that overflow hold: an absolutely positioned descendant is * clipped only by an overflow ancestor in its own containing-block chain, and a collapsed pane of * ordinary markup has none — the theme supplies them itself, the toggle switch parking its real * checkbox at `position: absolute` (theme/60-inputs.css). Measured: DHCP and DNS scrolled 1841px * against 1110px of content on 24.10 and 1925 vs 1439 on 25.12, i.e. ~500-700px of empty page * under the footer (issue #10). * * Hence any [data-tab-title], not just .cbi-section ones. */ [data-tab-title]:not([data-tab-active="true"]) { visibility: hidden; position: relative; height: 0; overflow: hidden; padding: 0; margin: 0; border: 0; } /* 6%, not 12%. The list inside is bold --fs-danger on a tint OF --fs-danger, so the tint * eats its own contrast: 12% measured 4.4:1 (axe-core). At 6% every palette clears 4.5 * (4.87 / 4.95 / 5.28 / 6.09) and the box still reads red. */ .cbi-section-error { border-color: var(--fs-danger); background: color-mix(in srgb, var(--fs-danger) 6%, var(--fs-panel)); color: var(--fs-text); } .cbi-section-error ul li { color: var(--fs-danger); } /* denser data tables (dhcp static leases, section-tables) */ .cbi-section-table .th, .cbi-section-table .td, /* The `::before` is the GridSection name CELL on 24.10, where form.js renders none and base * generates the column from `data-title` (base/95-luci.css). It is not a `.th`/`.td`, so every * cell rule below has to name it too, or the column it draws lines up with nothing. Listing * `.tr::before` unqualified is safe: base names exactly the rows that get one, and a * pseudo-element whose content is `none` generates no box. */ .table.cbi-section-table .th, .table.cbi-section-table .td, .table.cbi-section-table .tr::before { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; } /* The zero padding-left above is right only INSIDE a card, where the .cbi-section supplies that * gutter itself. Outside one it is wrong — Status -> Firewall renders `.table.cbi-section-table` * in a bare
, so the table draws its own frame and the header text ends up glued to that * frame's rounded corner (issue #7). A table carrying its own frame pads its own cells. */ .table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .th, .table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .td { padding: var(--fs-space-2-5) var(--fs-space-3); } .cbi-section-table .tr.cbi-section-table-titles .th, .cbi-section-table .tr.table-titles .th { font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); line-height: 1.25; } /* Standalone data tables (Startup, Processes, Routes) -> rounded card. Inside a CBI section * the section already IS the card, so the frame is stripped there (above): no double border. */ .table { border: var(--fs-hairline); border-radius: var(--fs-radius-lg); overflow: hidden; background: var(--fs-panel); } /* Separators go on the CELLS, not on the rows, and that is not a preference: the frame above needs * `border-collapse: separate` (a collapsed table silently drops the radius), and in the separated * model a row's border is not painted at all — on the .tr these rules paint nothing, and what * draws the lines is base's per-cell `border-top`, which also runs along the table's top edge * across the frame's rounded corners (issue #7). base no longer sets it, and the lines are * declared here, where they paint. Remove one and the other is not a fallback. * * `:not(:last-child)` lives IN the body-row selector: a trailing bottom border draws a straight * line the full width and pokes past the frame's rounded bottom corners. */ .table .tr.table-titles > .th, .table .tr.cbi-section-table-titles > .th, .table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); } .table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td, .table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); } /* descendant combinator (not >) so a real
's browser-inserted is covered * too — one rule themes every data table, div- or table-based. */ .table .tr.table-titles, .table .tr.cbi-section-table-titles { background: transparent; /* drop the base panel2 header bar — match the dashboard's faint-label + underline look */ } .table .tr.table-titles .th, .table .tr.cbi-section-table-titles .th, .table .tr.cbi-section-table-titles::before { color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); background: transparent; } /* The row's NAME, which must read the same on both releases: 25.12 renders a real * `.td.cbi-section-table-titles`, 24.10 base's `::before`. Both are a LABEL, not a value, so * both take the UI face — the surrounding value cells are mono, and base makes this one bold, so * with no 700 mono face shipped every rule name is drawn in synthetic bold mono, smearing the * grid on the one string in the row that is a name. Specificity (0,3,0), not source order, is * what beats the (0,1,0) mono rule. */ .table.cbi-section-table .td.cbi-section-table-titles, .table.cbi-section-table .tr.cbi-section-table-row::before { font-family: var(--fs-font-sans); font-weight: var(--fs-weight-bold); color: var(--fs-text); } .table .tr:not(.table-titles):not(.cbi-section-table-titles) { transition: background var(--fs-dur); } /* Row hover ONLY on real data/config tables. A plain layout `.table` — Diagnostics is one * of input+button spanning the whole card, with no header row, so fs-select never tags * it .fs-dt — lit the ENTIRE block on hover (issue #5.3), and a data table's placeholder row * ("no active leases") lit up too (issue #5.2, plus :not(.placeholder) above). Data tables * hover per-cell above; config tables (.cbi-section-table, never .fs-dt) hover data rows here. */ /* :not(.placeholder) — a config table's "This section contains no values yet" row carries * .cbi-section-table-row too, so it lit up under the pointer exactly as the data tables' did * before the rule above excluded theirs (issue #15: reported on UPnP, but every empty * GridSection had it — firewall's NAT rules, IP sets, static routes). A hover cue promises a * row you can act on; there is no row. */ .table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); } /* keep section/modal tables flush (no forced hover-card look) */ .cbi-section .table > .tr, .cbi-modal .table > .tr { border-radius: 0; } /* .fs-dt is set by fs-select.js on a #view .table that has a header row and is NOT a * .cbi-section-table — replacing a live `:has(.tr.table-titles):not(.cbi-section-table)` * the style engine re-evaluated on every mutation of these polled tables (processes, * routes, leases). */ /* ---- the card stack: one copy, every data table, no @container threshold ---- * * Three @container thresholds meant two copies of these declarations, CSS being unable to share * a block across thresholds — 55 duplicated declarations, free to drift. All three really asked * "does it OVERFLOW?", which the browser can compute: fs-select.js measures it and sets * .fs-stacked, so every table finds its own width, a third-party app's included. Only the * judgement a measurement cannot make survives, in fs-select.js: "too cramped to be a table". * * A config table (.cbi-section-table) is deliberately not here and keeps its @container * (theme/65-dropdown.css): its rows hold widgets that bake in a width from the layout they were * rendered in, so un-collapsing one to read it CHANGES what is read — the firewall zone table * then claimed 1747px where it needs 1190px. Do not "finish the job" by measuring it. * * Shape = stock LuCI's phone layout: label ABOVE its value, cell = half the row. Label:value on * one line with the value right-flushed costs a leases row 7 full-width lines and gives the * value ~40% of the width; half-width pairs fold it into 4. Values stay left-aligned. * * ONE CONTRACT — do not split it again. The stack is MEASURED and fires at ANY width, while * `display: block` and the `.hide-xs`/`.hide-sm` drops once lived only inside * `@media (max-width: 767px)` (theme/90-responsive.css). The window between the two is real: the * sidebar layout's content column is (viewport - 224 - 56), so from ~768 to 860px it is already * below CRAMPED while that query is off. Measured at 790/820/850px, status_leases stacked with * `display: table` still in force and wifi_assoclist_table rendered all five of its .hide-xs * cells; at 760px both were correct. So they key off the CLASS. */ /* ---- a table nobody has answered for yet takes no room ---- * * A poll tick REPLACES these tables, and a fresh one carries no marks: for the moment between * landing and being stamped it is laid out as a full-width table, several screens taller than * the card stack it is about to be. The engine re-anchors on that intermediate and throws the * reader 612px out and back, twice per tick. fs-select.js answers in a MutationObserver * microtask, i.e. before paint — but only if nothing forces layout in between, and an app that * reads a width right after rendering does exactly that. * * So the table waits outside the layout until it has an answer. `display: none`, not * `visibility: hidden`, because a hidden box still occupies its height. * * Guarded on the fitters being alive: `:root[data-fs-fit]` is written by fs-fit.js and by * nothing else, so a document where that file never ran shows every table exactly as before this * rule existed. A stylesheet that can hide content on its own is one that can hide it * forever. */ :root[data-fs-fit] #view .table.fs-dt:not(.fs-fitted), :root[data-fs-fit] #modal_overlay .table.fs-dt:not(.fs-fitted) { display: none; } .table.fs-stacked { display: block; width: 100%; } /* `.fs-drop-xs` is the same drop one rung earlier: fs-select.js writes it on a table that has * overflowed, before it decides to card, because the view author has already said which columns * are expendable and a table with two fewer columns often fits. Same declaration, so the two * selectors share the block, and the class is what makes the drop a MEASURED decision rather * than a viewport guess (theme/90-responsive.css keeps that copy for tables that never card). */ .table.fs-stacked .td.hide-sm, .table.fs-stacked .th.hide-sm, .table.fs-stacked .td.hide-xs, .table.fs-stacked .th.hide-xs, .table.fs-drop-xs .td.hide-sm, .table.fs-drop-xs .th.hide-sm, .table.fs-drop-xs .td.hide-xs, .table.fs-drop-xs .th.hide-xs { display: none; } .table.fs-stacked .tr.table-titles, .table.fs-stacked .thead, .table.fs-stacked .tr.cbi-section-table-titles, .table.fs-stacked .tr.cbi-section-table-descr { display: none; } /* align-items: stretch (the default), NOT flex-start: the cells carry a per-cell * border-bottom, so when one cell of a pair wraps (a 2-line hostname beside a 1-line MAC) * a flex-start neighbour stays short and its separator paints 52px higher — a broken line * mid-card (measured). Stretched, both cells take the flex line's height and the * separators meet; content still sits at the top (the cells are blocks). */ /* A `` with no `.tr` inside it IS the row — see the footer block below for which view * writes which shape. * * TWO rules, not one list: a selector list is not forgiving, so a browser without `:has()` * (Firefox before 121) throws away the WHOLE list, and `.table.fs-stacked .tr` — every card on * every stacked table — loses `display: flex` with it. Split, the `:has()` half degrades alone. * The duplicated body is under `css-dup`'s three-declaration floor, so it will not be flagged; * the two must change together. */ .table.fs-stacked .tr { display: flex; flex-wrap: wrap; } .table.fs-stacked tfoot:not(:has(> .tr)), .table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; } .table.fs-stacked .td { display: block; min-width: 0; width: auto; padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start; overflow-wrap: anywhere; white-space: normal; } /* The column label, lifted above its value (LuCI writes it per cell as data-title). * * Pinned: the config table (theme/65-dropdown.css) needs these exact declarations under its * @container guard, and CSS cannot share a block between a class and a container query — the two * guards do not OR. tools/mirror.mjs keeps the copies byte-identical; edit one and the build * fails until you edit the other, a duplicate detector going quiet once two copies differ. */ .table.fs-stacked .td[data-title]::before { /* @mirror table-card/label */ content: attr(data-title); display: block; margin-bottom: var(--fs-space-1); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color); /* @endmirror */ } /* A footer cell is the fourth member here, and it has to carry `[data-title]` in its own selector * to win: `.table.fs-stacked tfoot .td::before` is (0,3,1) against the caption rule's (0,4,0) and * loses wherever it is written. Measured on the wifi footer with the titles stamped: `content` * computed to "SSID" over `Total:`. With the attribute it is (0,4,1) and (0,5,0). */ .table.fs-stacked .td.cbi-section-actions::before, .table.fs-stacked .td:not([data-title])::before, .table.fs-stacked .td[data-title=""]::before, .table.fs-stacked tfoot .td[data-title]::before, .table.fs-stacked .tfoot .td[data-title]::before { content: none; } /* The row's buttons are not a label/value pair — full width below the pairs. * PINNED with the config table's copy — see above. */ .table.fs-stacked .td.cbi-section-actions { /* @mirror table-card/actions */ display: flex; flex: 1 1 100%; justify-content: flex-start; flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2); /* @endmirror */ } /* LuCI groups a row's buttons in ONE inner
(startup.js wraps all five), so the td's own * flex-wrap above never fires — the div is a single flex item, and .fs-main's overflow-x:clip * CUT the tail button (Startup at 390px, measured: the div needs 350px in a 324px cell and * "Stop" vanished). Same trap the config table hit on the interfaces row; PINNED to its copy * in theme/65-dropdown.css. */ .table.fs-stacked .td.cbi-section-actions > * { /* @mirror table-card/actions-inner */ flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2); /* @endmirror */ } /* Nothing in a half-width cell may force the row wider than the viewport: .fs-main is * overflow-x:clip, so an over-wide child is CUT, not scrolled to. */ .table.fs-stacked .td > *, .table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; } /* ---- the footer, in both shapes upstream writes it ---- * * A `
` is still a table-footer-group inside a `display: block` table, so in a card it drew * as a box beside the cards: measured at 390px on the station list, 186px wide against 354. * `20_lan.js` builds `tfoot > tr > td` and needs only the footer out of the way, the `.tr` above * already being a flex row; `30_wifi.js` appends the `` through E(), where * no parser runs and no row is implied, so there the footer has to become the row — that arm is * up with the `.tr` rule. `:has(> .tr)` tells them apart. * * An empty footer cell goes entirely: a card is a list of facts and an empty cell is not one. The * captions are suppressed up in the `content: none` group, for the specificity reason stated * there. */ .table.fs-stacked tfoot:has(> .tr), .table.fs-stacked .tfoot:has(> .tr) { display: block; } .table.fs-stacked tfoot .td:empty, .table.fs-stacked .tfoot .td:empty { display: none; } /* --- the DATA table's flavour (L.ui.Table, tagged .fs-dt) --- * Clip rows to the frame's corners: zebra paints its background on the .tr and the frame is * overflow:visible, so a striped row's square background pokes past the corners. Safe once * stacked — a flex-wrap row fits the width, so there is no horizontal column left to clip, which * is the only thing overflow:visible protected. `#view .table.fs-dt.fs-stacked` (1,3,0) outranks * the frame's `#view .table[id]` (1,2,0) on specificity, not on order. * * `#modal_overlay` as well, because the FRAME already reaches a dialog and this clip did not: a * data table inside a dialog's `.cbi-section` takes the frame from `.cbi-section .table[id]` * above and has the same corners with the same zebra painted square across them. */ #view .table.fs-dt.fs-stacked, #modal_overlay .table.fs-dt.fs-stacked { overflow: hidden; } /* `:not(.placeholder)` — the plate is out of flow (base/40-tables.css) and its row exists only to * reserve the height it needs. Padding is subtracted from that reservation (border-box), so the * card's row inset left 41px of the 63px the plate is: measured on Wireless as 10px of text * hanging below the frame once the table stacked. A row with no rows in it has nothing to inset. */ .table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); } /* ---- the pair width, and the adversary specificity cannot reach ---- * * Half the row is what makes the card a card. It was `.fs-stacked .td` at (0,2,0), deliberately * low so the unguarded `.table .td.col-N` below, at (0,3,0), could beat it on specificity rather * than source order — which holds against every rule in a LAYER and none outside one. * luci-mod-dashboard ships `@media (max-width: 640px) { .Dashboard … .tr .td { flex: 2 2 10% } }` * unlayered, and that beats every layered rule at any specificity: measured at 390px, four cells * of a station row at 81px on one line, `flex-basis` computing to 10% — the pairs never formed. * * Hence the flag, the sanctioned kind. Spent narrowly, and by SELECTOR because a flag would * outrank these two families as well: `.cbi-section-actions` is a full-width line of buttons, * `[class*="col-"]` is the view author's own weight. The third `:not()` is not one of those — it * is how this rule loses to the meter below, both being flagged: without it (0,5,0) beats (0,4,0) * and the meter silently keeps half a line. */ .table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) { flex: 1 1 50% !important; } /* A meter takes the whole line: `.cbi-progressbar::after` prints the bar's title above its right * edge (25-progressbar.css), which in a card is where the cell's own caption sits — measured at * 390px, `SIGNAL STRENGTH` and `RSSI: 65% (-57dBm)` drawn over each other. Beside the bar instead * would be paid out of the bar, too short at half a row to read as one. */ .table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; } /* ---- column weights: one unguarded block, unguarded on purpose ---- * A weight (1-10) is a share of a CARDED row — the JS says which cell is a narrow badge and * which the wide status text; without it every cell falls back to the flat 50% above. * * Do not copy them under a guard. `flex` applies only to a FLEX ITEM, and a `.td` becomes one * exactly when its `.tr` turns into a flex container — by either mechanism (`.fs-stacked`, * measured at any width, or the config table's @container, which can card on a 1200px desktop). * Anywhere else they are inert, so one copy serves all three tiers. Basis N*30px, as in stock, * so the weights compose the same way. */ .table .td.col-1, .table .th.col-1 { flex: 1 1 30px; } .table .td.col-2, .table .th.col-2 { flex: 2 2 60px; } .table .td.col-3, .table .th.col-3 { flex: 3 3 90px; } .table .td.col-4, .table .th.col-4 { flex: 4 4 120px; } .table .td.col-5, .table .th.col-5 { flex: 5 5 150px; } .table .td.col-6, .table .th.col-6 { flex: 6 6 180px; } .table .td.col-7, .table .th.col-7 { flex: 7 7 210px; } .table .td.col-8, .table .th.col-8 { flex: 8 8 240px; } .table .td.col-9, .table .th.col-9 { flex: 9 9 270px; } .table .td.col-10, .table .th.col-10 { flex: 10 10 300px; } /* general table cell padding — compact but readable (Startup/Processes/etc) */ .table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); } /* ---- the last resort: a foreign
`s to the `
the card stack cannot claim ---- * * fs-select.js tags any `#view table` that HAS a header row as `.table.fs-dt`, so it is measured * and cards like every other data table. What is left here is the table with no header at all: a * log, a statistics matrix, a layout table. Those must not be carded even if they overflow — a * card prints `data-title` above each value, and a matrix has no titles to print, so stacking one * produces a column of numbers with nothing saying what they are. Comparison is the point of * that shape; it scrolls instead. * * `display: block` plus `overflow-x: auto` on the table ITSELF, not on a wrapper: the theme * cannot add markup around someone else's element, and the block box is what gives it a scroll * container. The rows still lay out as a table, a `display: block`
wrapping its children * in an anonymous table box. * * Unguarded by a media query, because the viewport was never the fact that mattered: a narrow * panel or a split window overflows at any width, and a phone report is only where it was * noticed first. * * A dialog is the worst place to leave it out, hence the `#modal_overlay` half of the selector: * the overlay is the scroll container (base/60-modal.css) and the dialog is centred in it with * `margin: auto`, so an over-wide child scrolls the dialog's own edge off the screen instead of * scrolling itself. Measured at 390px with a four-column `nowrap` log table: in #view it scrolls * inside itself (358px shown of 636px, page scroll 0); in a dialog it rendered 636px wide in * 317px of room and gave the overlay 282px of horizontal scroll. */ /* `.fs-xscroll` is the same outcome reached the other way: fs-select.js writes it on a `.table` * with no header row that has been measured to overflow — the shape a bare `
` selector * cannot reach, a third-party app being free to render its log as `
`. A class * rather than a selector, and measured rather than declared, for a spec-level reason: * `overflow-x: auto` forces `overflow-y` to compute to `auto` too (css-overflow-3 §3.1), so a * table given this becomes a clip for every popup inside it, and ui.js then walks up from an open * dropdown, finds the TABLE as the scroll parent and sizes the list against it. The fitter * refuses the class to any table holding a widget, so this rule needs no `:has()` ladder. */ #view table:not(.table):not(.cbi-section-table):not(.fs-dt), #modal_overlay table:not(.table):not(.cbi-section-table):not(.fs-dt), #view .table.fs-xscroll, #modal_overlay .table.fs-xscroll { display: block; max-width: 100%; overflow-x: auto; /* the scroll gesture stops at this table instead of dragging the page sideways */ overscroll-behavior-x: contain; /* the scrollbar IS the affordance here. A gradient "there is more to the right" shadow is the * usual trick and does not work on these tables: it has to paint on the scroll container, * behind rows that carry their own zebra fill, so it shows through every second row only. */ scrollbar-width: thin; } /* ---- a table of controls stacks: it is a form laid out as a table ---- * * Written by fs-select.js on a header-less table that overflows and holds a control. Scrolling * one is not an option — the popup inside would be clipped by the scroll container it just * became — and doing nothing leaves it cut: at 320px of room the row needed 338 and the button * was past `.fs-main`'s clip with no way to reach it. * * `display: block` on all three levels, the one place this theme wants it: the rows are a form, * so there is nothing to compare across them and nothing to keep in columns. Each control takes * the width of its line and the table cannot exceed its parent by construction. Nothing here * creates a scroll container, so an open dropdown still escapes. */ .table.fs-rowstack, .table.fs-rowstack .tr, .table.fs-rowstack .td, .table.fs-rowstack .th { display: block; width: auto; } /* the cells were table-cells a moment ago and carried its side padding; as blocks they need the * vertical rhythm instead, or the controls sit flush against each other */ .table.fs-rowstack .td { padding-inline: 0; padding-block: var(--fs-space-1); } /* ---- what makes a scrolled table readable: the row keeps its name ---- * * Scrolling a table sideways takes the identity column away first, leaving a screenful of values * belonging to nothing. Pinning the first cell of every row is the one thing a card view gives * for free and a scrolled table does not. * * The cells need their own background (a row's does not travel with a sticky cell in the * separated model, as for the header above) and a hairline on the inline end, so the pinned * column reads as an edge rather than as text sliding over text. `z-index: 2` keeps it over the * sticky header's `1` where the two cross, at the corner cell. */ #view .table.fs-xscroll .tr > :first-child, #modal_overlay .table.fs-xscroll .tr > :first-child, #view .table.fs-xscroll tr > :first-child, #modal_overlay .table.fs-xscroll tr > :first-child { position: sticky; left: 0; z-index: 2; background: var(--fs-panel); border-inline-end: var(--fs-hairline); } /* a keyboard user reaches the scroll box by tabbing to it (fs-select.js gives it tabindex and a * name); it has to show that it has focus — outline, not box-shadow, so forced-colors keeps it */ #view .table.fs-xscroll:focus-visible, #modal_overlay .table.fs-xscroll:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 2px; } }