mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-05 01:44:24 +08:00
update 2026-10-29 07:49:11
This commit is contained in:
1 parent
4b8b360547
commit
bc85557d34
237 files changed
+76845
-197
No files matched your search
@@ -0,0 +1,107 @@
|
||||
@layer theme {
|
||||
/* Pins inherited-only props on [data-fs-chrome], closing what the fence cannot: inheritance
|
||||
* from html/body. ROOT-only selector and text-align: start (never left) are load-bearing —
|
||||
* third-party-apps.md, "Rule 7. The chrome is fenced off, in three places that must agree". */
|
||||
:where([data-fs-chrome]:not([data-fs-chrome] *)) {
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-transform: none;
|
||||
text-indent: 0;
|
||||
text-align: start;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* Brand/logo/wordmark/logout/indicators: one look for every layout — a layout file sets only
|
||||
* PLACEMENT (flex, order, rail collapse), never look. */
|
||||
|
||||
.fs-brand {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2-5);
|
||||
color: var(--fs-text); font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
.fs-logo {
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); border-radius: var(--fs-radius);
|
||||
border: var(--fs-hairline); /* glyph is baked-colour (header.ut), no currentColor use */
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fs-logo svg { width: 20px; height: 20px; }
|
||||
.fs-logo-ring { fill: #002b49; } /* flips with mode like the favicon SVG; cyan arcs stay baked */
|
||||
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
|
||||
|
||||
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
|
||||
|
||||
/* First tab stop — never display:none/visibility:hidden, that drops it from the tab order. */
|
||||
.fs-skip {
|
||||
position: fixed; top: 8px; inset-inline-start: 8px; z-index: var(--fs-z-dropdown);
|
||||
padding: var(--fs-space-2-5) var(--fs-space-4); border-radius: var(--fs-radius);
|
||||
background: var(--fs-accent); color: var(--fs-on-accent);
|
||||
font-weight: var(--fs-weight-bold); font-size: var(--fs-type); text-decoration: none;
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* clip-path, not a -68px translate: the translate still bled ~16px of drop shadow into the
|
||||
* viewport (--fs-shadow-pop is offset 12px/blur 34px); clip-path clips the shadow too. */
|
||||
.fs-skip:not(:focus), .fs-sr {
|
||||
clip-path: inset(50%);
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
.fs-skip:focus {
|
||||
outline: 2px solid var(--fs-panel); outline-offset: 2px;
|
||||
}
|
||||
/* Clipped, not display:none/visibility:hidden — both take the <h1> out of the a11y tree too. */
|
||||
.fs-sr { position: absolute; margin: -1px; }
|
||||
|
||||
/* tabindex="-1" on <main> makes Safari/Chrome move focus here rather than just scroll to it. */
|
||||
#maincontent:focus { outline: none; }
|
||||
|
||||
/* box and base colour: 20-shell.css (shared with .fs-themerow) */
|
||||
.fs-logout:hover { color: var(--fs-danger); }
|
||||
|
||||
/* fitTabStrips (fs-chrome.js) adds .fs-dense1/2 when the horizontal menu row would wrap, and
|
||||
* strips them in the vertical sidebar — no media guard needed. */
|
||||
.fs-sidebar ul.nav.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
.fs-sidebar ul.nav.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-1-5); }
|
||||
|
||||
/* One markup (`ul.nav > li > a`) for both layouts, so these three run once. */
|
||||
ul.nav { position: relative; margin: 0; list-style: none; }
|
||||
ul.nav > li { display: block; }
|
||||
ul.nav a { line-height: 19px; text-decoration: none; }
|
||||
|
||||
/* No emitter here, stays styled under the coverage contract — conventions.md, "Coverage is a
|
||||
* contract". */
|
||||
.dropdown-menu li { display: block; position: relative; }
|
||||
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; } /* denser than ul.nav a above */
|
||||
|
||||
.fs-sidebar ul.nav > li > a:hover,
|
||||
.fs-sidebar ul.nav ul a:hover,
|
||||
.fs-themerow:hover {
|
||||
background: var(--fs-panel2); color: var(--fs-text); opacity: 1;
|
||||
}
|
||||
.fs-sidebar ul.nav > li > a.active,
|
||||
.fs-sidebar ul.nav ul a.active,
|
||||
.fs-themerow[aria-expanded="true"],
|
||||
.fs-modemenu li.active a {
|
||||
background: var(--fs-accent-soft); color: var(--fs-accent);
|
||||
}
|
||||
/* Plate on the PAGE only; the open ANCESTOR keeps the ink alone — issue #37 (two identical
|
||||
* plates stacked on System -> System otherwise). chrome.md, "fs-chrome.js". */
|
||||
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
|
||||
|
||||
/* --fs-focus-ring-solo: none of these flips a border on focus, so the ring alone must clear
|
||||
* 3:1 — the tinted ring reads only 1.15-1.29:1 (two-ring contract, 02-tokens.css). */
|
||||
.fs-railtoggle:focus-visible,
|
||||
.fs-themerow:focus-visible,
|
||||
.fs-logout:focus-visible,
|
||||
.fs-sidebar ul.nav a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* accent-color reaches a foreign app's native checkbox/radio/progress — the theme's own are
|
||||
* appearance:none and never see it. */
|
||||
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@layer theme {
|
||||
/* .fs-pattern: the masked wallpaper element. Why a dedicated element, why z-index: -1 needs a
|
||||
* transparent body — design-system.md, "Wallpaper rendering: why the canvas moves to :root". */
|
||||
.fs-pattern { display: none; }
|
||||
|
||||
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
|
||||
/* transparent for both wallpapers: an opaque --fs-bg here would cover the shell's one background layer
|
||||
* (the photo on the file wallpaper, the .fs-pattern element on the pattern one). */
|
||||
:root[data-wallpaper="pattern"] body,
|
||||
:root:is([data-wallpaper="pattern"], [data-wallpaper="file"]) .fs-main { background-color: transparent; }
|
||||
:root:is([data-wallpaper="pattern"], [data-wallpaper="file"]) footer.fs-footer { background: transparent; }
|
||||
|
||||
/* --fs-pattern-url is `none` until an upload exists (head.ut/fs-prefs.js) — the axis picked
|
||||
* with no file is a bare canvas, not a broken tile. */
|
||||
:root[data-wallpaper="pattern"] .fs-pattern {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: var(--fs-pattern-strength);
|
||||
}
|
||||
/* Theme ink (default): file's own colours discarded, only alpha used through the mask —
|
||||
* design-system.md, "The appearance axes". */
|
||||
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
|
||||
background-color: var(--fs-text);
|
||||
-webkit-mask-image: var(--fs-pattern-url);
|
||||
mask-image: var(--fs-pattern-url);
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-size: var(--fs-pattern-size) auto;
|
||||
mask-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
/* original: the file's own palette, plain tiled background, no mask. */
|
||||
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
|
||||
background-image: var(--fs-pattern-url);
|
||||
background-repeat: repeat;
|
||||
background-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
|
||||
/* No wash layer behind #view: cards paint their own --fs-panel (opaque unless 17-glass.css
|
||||
* thins it), so nothing needs dimming globally. */
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@layer theme {
|
||||
/* The `file` wallpaper: scrim + photo on one element — design-system.md,
|
||||
* "Wallpaper rendering: why the canvas moves to :root". */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) {
|
||||
background-image: linear-gradient(var(--fs-photo-scrim), var(--fs-photo-scrim)), var(--fs-login-bg-url);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed; /* one calm backdrop the page scrolls under, not a sliding strip */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
@layer theme {
|
||||
/* Glass (issue #87): cards and the menu let the wallpaper through. design-system.md, "Transparency".
|
||||
*
|
||||
* No paint rule is rewritten. Every surface keeps reading --fs-panel / --fs-panel2 / --fs-track /
|
||||
* --fs-border, and this file re-scopes those TOKENS per subtree: a card takes a translucent
|
||||
* --fs-panel for itself and a transparent one for everything below it (a nested card, a table
|
||||
* frame, an ifacebox repaint the card colour and would double the alpha); below a card
|
||||
* --fs-panel2 is the material's inset fill, below an inset it is transparent, as an inset inside
|
||||
* an inset is invisible today. Floating surfaces take the solid palette back. Tokens computed on
|
||||
* :root (--fs-placeholder, the focus rings, --fs-bar-bg) keep their solid ingredients.
|
||||
*
|
||||
* [data-glass] / [data-glass-bar] are set by fs-axes.js and head.ut only below 100% and only when
|
||||
* the OS asks for neither less transparency nor more contrast; [data-wallpaper] is the gate here.
|
||||
* screen: paper gets the solid print palette. @supports: without color-mix every glass token is
|
||||
* invalid at use and a card would paint nothing, so an old engine keeps the solid theme. */
|
||||
@media screen {
|
||||
@supports (color: color-mix(in srgb, red 50%, transparent)) {
|
||||
|
||||
/* ---------- the glass tokens, resolved once on :root ---------- */
|
||||
:root[data-wallpaper]:is([data-glass], [data-glass-bar]) {
|
||||
/* solid copies for the floating surfaces to take back */
|
||||
--fs-glass-o-panel: var(--fs-panel);
|
||||
--fs-glass-o-panel2: var(--fs-panel2);
|
||||
--fs-glass-o-track: var(--fs-track);
|
||||
--fs-glass-o-border: var(--fs-border);
|
||||
|
||||
--fs-glass-card: color-mix(in srgb, var(--fs-panel) var(--fs-card-opacity), transparent);
|
||||
/* an inset or a track straight on the wallpaper is a small card of its own */
|
||||
--fs-glass-inset: color-mix(in srgb, var(--fs-panel2) var(--fs-card-opacity), transparent);
|
||||
--fs-glass-track: color-mix(in srgb, var(--fs-track) var(--fs-card-opacity), transparent);
|
||||
|
||||
/* Frosted and Acrylic: what sits on a card is a veil of the ink, lighter in dark mode and
|
||||
* darker in light, the direction panel2 takes from panel in every palette */
|
||||
--fs-glass-inset-on: color-mix(in srgb, var(--fs-text) 7%, transparent);
|
||||
--fs-glass-track-on: color-mix(in srgb, var(--fs-text) 13%, transparent);
|
||||
--fs-glass-line: color-mix(in srgb, var(--fs-text) 16%, transparent);
|
||||
--fs-glass-blur-base: 30px;
|
||||
--fs-glass-blur: calc(var(--fs-glass-blur-base) * var(--fs-blur));
|
||||
--fs-glass-sat: 1.4;
|
||||
--fs-glass-here: var(--fs-glass-card);
|
||||
/* a sticky cell's fill: under a blurred card its own blur reads nothing (Chromium blurs no
|
||||
* backdrop past a backdrop-filtered ancestor), so the fill alone keeps rows from showing */
|
||||
--fs-glass-stuck: color-mix(in srgb, var(--fs-glass-o-panel) 95%, transparent);
|
||||
|
||||
--fs-glass-bar: color-mix(in srgb, var(--fs-bar-bg) var(--fs-bar-opacity), transparent);
|
||||
/* the vertical sidebar paints --fs-panel, the bar --fs-bar-bg (20-shell.css) */
|
||||
--fs-glass-bar-panel: color-mix(in srgb, var(--fs-panel) var(--fs-bar-opacity), transparent);
|
||||
--fs-glass-bar-inset: var(--fs-glass-inset-on);
|
||||
}
|
||||
/* Flat: everything keeps its own colour at the same opacity, layers compound, and only what the
|
||||
* page scrolls under is blurred */
|
||||
:root[data-wallpaper][data-material="flat"]:is([data-glass], [data-glass-bar]) {
|
||||
--fs-glass-inset-on: var(--fs-glass-inset);
|
||||
--fs-glass-track-on: var(--fs-glass-track);
|
||||
--fs-glass-line: var(--fs-border);
|
||||
--fs-glass-stuck: var(--fs-glass-card);
|
||||
--fs-glass-bar-inset: color-mix(in srgb, var(--fs-panel2) var(--fs-bar-opacity), transparent);
|
||||
}
|
||||
:root[data-wallpaper][data-material="acrylic"]:is([data-glass], [data-glass-bar]) {
|
||||
--fs-glass-blur-base: 54px;
|
||||
--fs-glass-sat: 1.8;
|
||||
/* the inner url( is percent-encoded: tools/devkit-build.mjs refuses a url( that is not a data URI */
|
||||
--fs-glass-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url%28%23n%29'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ---------- cards ---------- */
|
||||
:root[data-wallpaper][data-glass] body {
|
||||
--fs-panel2: var(--fs-glass-inset);
|
||||
--fs-track: var(--fs-glass-track);
|
||||
--fs-border: var(--fs-glass-line);
|
||||
--fs-hairline: 1px solid var(--fs-glass-line);
|
||||
}
|
||||
/* everything that paints --fs-panel in flow */
|
||||
:root[data-wallpaper][data-glass] :where(.cbi-section, .cbi-map > .cbi-section-node, .panel, .fs-login,
|
||||
.alert-message:not(.btn), #tabmenu ul.tabs, .tabs, .cbi-tabmenu, .cbi-map > h2, #view > h2,
|
||||
h2 + .cbi-map-descr, .table, .ifacebox, .cbi-filebrowser, .cbi-dynlist > .item,
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] > div) > * {
|
||||
--fs-glass-here: transparent;
|
||||
--fs-panel: transparent;
|
||||
--fs-panel2: var(--fs-glass-inset-on);
|
||||
--fs-track: var(--fs-glass-track-on);
|
||||
}
|
||||
/* same specificity (:where), so source order decides: after the rule above, a card that is a
|
||||
* card's direct child reads its own --fs-glass-here, transparent */
|
||||
:root[data-wallpaper][data-glass] :where(.cbi-section, .cbi-map > .cbi-section-node, .panel, .fs-login,
|
||||
.alert-message:not(.btn), #tabmenu ul.tabs, .tabs, .cbi-tabmenu, .cbi-map > h2, #view > h2,
|
||||
h2 + .cbi-map-descr, .table, .ifacebox, .cbi-filebrowser, .cbi-dynlist > .item,
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] > div) {
|
||||
--fs-panel: var(--fs-glass-here);
|
||||
}
|
||||
/* insets: an inset inside an inset is invisible today and stays so */
|
||||
:root[data-wallpaper][data-glass] :where(pre, .actions, .cbi-rowstyle-2, .label, .ifacebadge,
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]),
|
||||
.cbi-section .tabs, .cbi-section .cbi-tabmenu, .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-dynlist > .add-item, .control-group:has(> .cbi-input-password), .fs-seg) > * {
|
||||
--fs-panel2: transparent;
|
||||
}
|
||||
/* the checked pill of a radio row (pages/80-appearance.css) is raised as a card, not a hole */
|
||||
:root[data-wallpaper][data-glass] .fs-seg input:checked + span { --fs-panel: var(--fs-glass-card); }
|
||||
@container fs-content (max-width: 992px) {
|
||||
:root[data-wallpaper][data-glass] .table.cbi-section-table .tr > * { --fs-panel2: transparent; }
|
||||
}
|
||||
|
||||
/* The sticky cells sit over the rows they scroll past: the card's own fill under the header's,
|
||||
* and a blur, so a stuck header never reads as text over text (a functional blur, Flat too). */
|
||||
:root[data-wallpaper][data-glass] #view .table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
|
||||
background-color: var(--fs-glass-stuck);
|
||||
background-image: linear-gradient(var(--fs-panel2), var(--fs-panel2));
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
:root[data-wallpaper][data-glass] #view .table.fs-xscroll :is(.tr, tr) > :first-child {
|
||||
--fs-panel: var(--fs-glass-stuck);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Realtime graphs: LuCI's wrapper carries an inline white ground the solid svg used to hide */
|
||||
:root[data-wallpaper][data-glass] #view div[style*="background"]:has(> svg) { background: transparent !important; }
|
||||
|
||||
/* third-party apps: the export tier as glass too, or an app's tile is the solid block on a glass
|
||||
* card that issue #87 reports for the port tiles (docs/third-party-apps.md) */
|
||||
:root[data-wallpaper][data-glass] {
|
||||
--background-color-high: var(--fs-glass-card);
|
||||
--background-color-medium: color-mix(in srgb, var(--fs-glass-card) 55%, var(--fs-glass-inset));
|
||||
--background-color-low: var(--fs-glass-inset);
|
||||
--border-color-high: color-mix(in srgb, var(--fs-glass-line) 60%, var(--fs-text));
|
||||
--border-color-medium: var(--fs-glass-line);
|
||||
--border-color-low: color-mix(in srgb, var(--fs-glass-line) 55%, var(--fs-glass-card));
|
||||
}
|
||||
|
||||
/* floating surfaces cover other text: the solid palette, for them and everything inside, glass
|
||||
* tokens included, so a card inside a modal is solid too */
|
||||
:root[data-wallpaper][data-glass] :where(#modal_overlay, .modal, .cbi-tooltip, .fs-search-box,
|
||||
.cbi-dropdown[open] > ul.dropdown, .dropdown-menu, .fs-sidebar ul.nav ul, .fs-sidebar) {
|
||||
--fs-panel: var(--fs-glass-o-panel);
|
||||
--fs-panel2: var(--fs-glass-o-panel2);
|
||||
--fs-track: var(--fs-glass-o-track);
|
||||
--fs-border: var(--fs-glass-o-border);
|
||||
--fs-hairline: 1px solid var(--fs-glass-o-border);
|
||||
--fs-glass-here: var(--fs-glass-o-panel);
|
||||
--fs-glass-inset-on: var(--fs-glass-o-panel2);
|
||||
--fs-glass-track-on: var(--fs-glass-o-track);
|
||||
}
|
||||
|
||||
/* ---------- Frosted and Acrylic: the wallpaper under each top-level card is blurred ---------- */
|
||||
:root[data-wallpaper][data-glass]:not([data-material="flat"])
|
||||
:is(.cbi-section, .cbi-map > .cbi-section-node, .panel, .fs-login, .alert-message:not(.btn), #tabmenu ul.tabs,
|
||||
.tabs, .cbi-tabmenu, .cbi-map > h2, #view > h2, h2 + .cbi-map-descr, .table):not(:is(.cbi-section,
|
||||
.cbi-section-node, .panel, .fs-login, .alert-message, .table, .tabs, .ifacebox, #modal_overlay) *) {
|
||||
-webkit-backdrop-filter: blur(var(--fs-glass-blur)) saturate(var(--fs-glass-sat));
|
||||
backdrop-filter: blur(var(--fs-glass-blur)) saturate(var(--fs-glass-sat));
|
||||
}
|
||||
/* a blurred card is a stacking context: the one in use is lifted, so its open list or tooltip is
|
||||
* not painted under the next card. backdrop-filter already made it the containing block, so the
|
||||
* position changes no descendant's box; a nested card is not blurred and is left alone. */
|
||||
:root[data-wallpaper][data-glass]:not([data-material="flat"])
|
||||
:is(.cbi-section, .cbi-map > .cbi-section-node, .panel, .fs-login, .alert-message:not(.btn), #tabmenu ul.tabs,
|
||||
.tabs, .cbi-tabmenu, .cbi-map > h2, #view > h2, h2 + .cbi-map-descr, .table):not(:is(.cbi-section,
|
||||
.cbi-section-node, .panel, .fs-login, .alert-message, .table, .tabs, .ifacebox, #modal_overlay) *):is(:hover, :focus-within) {
|
||||
position: relative;
|
||||
z-index: var(--fs-z-raise);
|
||||
}
|
||||
|
||||
/* ---------- Acrylic: grain, a lit edge and a shadow off the photo ---------- */
|
||||
:root[data-wallpaper][data-glass][data-material="acrylic"]
|
||||
:is(.cbi-section, .fs-login, .cbi-map > h2, #view > h2):not(:is(.cbi-section, .fs-login, #modal_overlay) *) {
|
||||
background-image: var(--fs-glass-noise), linear-gradient(160deg, color-mix(in srgb, #fff 7%, transparent), transparent 45%);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 10%, transparent), 0 14px 32px -18px rgb(0 0 0 / .55);
|
||||
}
|
||||
:root[data-wallpaper][data-glass][data-material="acrylic"]:not([data-darkmode="true"])
|
||||
:is(.cbi-section, .fs-login, .cbi-map > h2, #view > h2):not(:is(.cbi-section, .fs-login, #modal_overlay) *) {
|
||||
background-image: var(--fs-glass-noise), linear-gradient(160deg, color-mix(in srgb, #fff 45%, transparent), transparent 50%);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 70%, transparent), 0 14px 32px -20px rgb(0 0 0 / .3);
|
||||
}
|
||||
|
||||
/* ---------- the menu: the sidebar, the bar and its popups at their own opacity ---------- */
|
||||
:root[data-wallpaper][data-glass-bar] .fs-sidebar {
|
||||
--fs-panel: var(--fs-glass-bar-panel);
|
||||
--fs-bar-bg: var(--fs-glass-bar);
|
||||
--fs-panel2: var(--fs-glass-bar-inset);
|
||||
--fs-border: var(--fs-glass-line);
|
||||
--fs-hairline: 1px solid var(--fs-glass-line);
|
||||
}
|
||||
/* its popups (bar dropdowns, rail flyouts) are the menu too: the bar's glass, not the solid
|
||||
* palette the floating surfaces above take back */
|
||||
:root[data-wallpaper][data-glass-bar] .fs-sidebar ul.nav ul {
|
||||
--fs-panel: var(--fs-glass-bar-panel);
|
||||
--fs-panel2: var(--fs-glass-bar-inset);
|
||||
--fs-border: var(--fs-glass-line);
|
||||
--fs-hairline: 1px solid var(--fs-glass-line);
|
||||
}
|
||||
/* The blur sits on a layer under the menu, not on the menu: a backdrop-filtered bar is the backdrop
|
||||
* root of its own popups, which then blur nothing (Chromium), and it is a stacking context that
|
||||
* buries the rail's flyouts under the page. The bar's colour moves onto the layer, over the blur. */
|
||||
:root[data-wallpaper][data-glass-bar] .fs-sidebar::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
:root[data-wallpaper][data-glass-bar]:is([data-layout="top"], [data-narrow]) .fs-sidebar { background-color: transparent; }
|
||||
:root[data-wallpaper][data-glass-bar]:is([data-layout="top"], [data-narrow]) .fs-sidebar::before { background-color: var(--fs-bar-bg); }
|
||||
:root[data-wallpaper][data-glass-bar]:not([data-material="flat"]) .fs-sidebar::before,
|
||||
:root[data-wallpaper][data-glass-bar]:not([data-material="flat"]) .fs-sidebar ul.nav ul {
|
||||
-webkit-backdrop-filter: blur(var(--fs-glass-blur)) saturate(var(--fs-glass-sat));
|
||||
backdrop-filter: blur(var(--fs-glass-blur)) saturate(var(--fs-glass-sat));
|
||||
}
|
||||
/* what covers the page's text is blurred in Flat too, plainly and at the base radius, as Flat has
|
||||
* no Blur slider: the bar the page scrolls under, and every popup */
|
||||
:root[data-wallpaper][data-glass-bar][data-material="flat"]:is([data-layout="top"], [data-narrow]) .fs-sidebar::before,
|
||||
:root[data-wallpaper][data-glass-bar][data-material="flat"] .fs-sidebar ul.nav ul {
|
||||
-webkit-backdrop-filter: blur(var(--fs-glass-blur-base));
|
||||
backdrop-filter: blur(var(--fs-glass-blur-base));
|
||||
}
|
||||
/* the rail's name bubbles are labels over the page, not menus: solid */
|
||||
:root[data-wallpaper][data-glass-bar] .fs-sidebar ul.nav > li > a::after {
|
||||
--fs-panel: var(--fs-glass-o-panel);
|
||||
--fs-border: var(--fs-glass-o-border);
|
||||
--fs-hairline: 1px solid var(--fs-glass-o-border);
|
||||
}
|
||||
@media (min-width: 521px) {
|
||||
/* the rail is static: the layer needs it as its containing block, and no z-index, so the
|
||||
* flyouts still rise over the page on their own (20-shell.css, issue #7) */
|
||||
:root[data-wallpaper][data-glass-bar][data-layout="sidebar"][data-rail="true"]:not([data-narrow]) .fs-sidebar { position: relative; }
|
||||
/* the expanded sidebar scrolls, which a layer would scroll with, and opens no popup: the blur
|
||||
* goes back on the column itself, and its accordion is the panel, not a popup over it */
|
||||
:root[data-wallpaper][data-glass-bar][data-layout="sidebar"]:not([data-narrow]):not([data-rail="true"]) .fs-sidebar::before { content: none; }
|
||||
:root[data-wallpaper][data-glass-bar][data-layout="sidebar"]:not([data-narrow]):not([data-rail="true"]):not([data-material="flat"]) .fs-sidebar {
|
||||
-webkit-backdrop-filter: blur(var(--fs-glass-blur)) saturate(var(--fs-glass-sat));
|
||||
backdrop-filter: blur(var(--fs-glass-blur)) saturate(var(--fs-glass-sat));
|
||||
}
|
||||
:root[data-wallpaper][data-glass-bar][data-layout="sidebar"]:not([data-narrow]):not([data-rail="true"]) .fs-sidebar ul.nav ul {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
@layer theme {
|
||||
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
|
||||
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
|
||||
|
||||
body {
|
||||
background-color: var(--fs-bg);
|
||||
font-family: var(--fs-font-sans);
|
||||
}
|
||||
/* One stroke for every chrome icon (header.ut, svgIcon()): 5 attributes per <svg> were 300 B of each admin page */
|
||||
.fs-ico, .fs-chevron { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
/* placement only — the look lives in 10-chrome.css */
|
||||
.fs-brand { flex: 1 1 auto; min-width: 0; }
|
||||
/* Hostname handling, two rules for two failures — chrome.md, "Hostname handling". */
|
||||
.fs-wordmark {
|
||||
overflow-wrap: anywhere; min-width: 0;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
|
||||
}
|
||||
/* collapse the sidebar to an icon rail. The toggle only EXISTS in the vertical sidebar,
|
||||
* so its `display` is declared there, not here, or it would just be shadowed. */
|
||||
.fs-railtoggle {
|
||||
align-items: center; justify-content: center;
|
||||
width: calc(28px * var(--fs-density-box)); height: calc(28px * var(--fs-density-box));
|
||||
padding: 0; flex: 0 0 auto;
|
||||
border: 1px solid transparent; border-radius: var(--fs-radius-sm);
|
||||
background: transparent; color: var(--fs-faint); cursor: pointer;
|
||||
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
|
||||
}
|
||||
.fs-railtoggle:hover { color: var(--fs-text); background: var(--fs-panel2); border-color: var(--fs-border); }
|
||||
.fs-railtoggle .fs-ico { width: 16px; height: 16px; transition: rotate var(--fs-dur-move); }
|
||||
/* base `ul` gives a 24px inline-start margin and a disc; padding is already 0 (reset) */
|
||||
.fs-modemenu { list-style: none; margin: 0; }
|
||||
.fs-sidebar ul.nav > li > a .fs-label { flex: 1 1 auto; }
|
||||
/* collapse chevron for sections with children */
|
||||
.fs-chevron {
|
||||
width: 14px; height: 14px; flex: 0 0 auto;
|
||||
color: var(--fs-dim);
|
||||
transition: rotate var(--fs-dur-move);
|
||||
}
|
||||
/* `rotate`, not `transform: rotate()`: individual transform properties compose across rules,
|
||||
* so the RTL mirror below sets `scale` without either rule restating the other's value. */
|
||||
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
|
||||
/* The two chevrons are DIRECTION, not decoration, so with the document flipped (head.ut
|
||||
* stamps dir) they must be mirrored explicitly — logical properties flip the box sides
|
||||
* (margin, padding, inset, radius), but a glyph needs the transform. */
|
||||
:root[dir="rtl"] .fs-chevron,
|
||||
:root[dir="rtl"] .fs-railtoggle .fs-ico { scale: -1 1; }
|
||||
.fs-sidebar ul.nav > li.has-sub > a { cursor: pointer; }
|
||||
/* .fs-logout shares the .fs-themerow button rule below (display, colour, box); only its
|
||||
* LAYOUT (square button vs full-width row) differs per layout block. */
|
||||
|
||||
|
||||
/* The bar is the default chrome and the only copy of it, unguarded — chrome.md, "The bar:
|
||||
* fit steps, scroll model, a rail that must match its guard". The vertical sidebar below is
|
||||
* the single guarded override, winning on SPECIFICITY, never on source order. */
|
||||
.fs-shell {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 100svh; width: 100%; /* svh not dvh — chrome.md, same heading */
|
||||
}
|
||||
.fs-sidebar {
|
||||
display: flex;
|
||||
width: 100%; height: auto;
|
||||
position: sticky; top: 0; z-index: var(--fs-z-sticky);
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||
gap: var(--fs-space-2-5); padding: var(--fs-space-2-5) var(--fs-space-4);
|
||||
background: var(--fs-bar-bg);
|
||||
border-inline-end: 0; border-bottom: var(--fs-hairline);
|
||||
/* the submenu popups anchor to this bar and must escape it */
|
||||
overflow: visible;
|
||||
}
|
||||
/* brand may shrink (min-width: 0) but is capped at 30ch, tracking the text not the viewport;
|
||||
* the extra line grows the bar's own min-height (50-toplayout.css). */
|
||||
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
|
||||
/* the bar has no section caption, no column to collapse, and its height follows its
|
||||
* content — so the flex filler has nothing to fill */
|
||||
.fs-navlabel, .fs-spacer, .fs-railtoggle, .fs-modemenu { display: none; }
|
||||
/* the mode strip opts in at 2+ modes; empty (before renderModeMenu in fs-chrome.js runs: no gap, no brand
|
||||
* shift) or `.single` stays hidden by the line above. */
|
||||
.fs-modemenu:not(:empty):not(.single) { display: flex; }
|
||||
|
||||
/* the menu on its own full-width row beneath the brand. The DESKTOP bar lifts it back
|
||||
* onto the brand's row whenever it fits — MEASURED, not a breakpoint
|
||||
* (fs-chrome.js fitChrome adds .fs-bar-stack when it does NOT fit). */
|
||||
.fs-sidebar ul.nav {
|
||||
display: flex;
|
||||
order: 3; flex: 1 1 100%;
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
|
||||
}
|
||||
/* anchors the dropdown to its OWN item, not the bar — issue #19, chrome.md, "A bar dropdown
|
||||
* anchors to its own <li>". A phone never shows it: `hover: none` disables the rule. */
|
||||
.fs-sidebar ul.nav > li.has-sub { position: relative; }
|
||||
.fs-sidebar ul.nav > li > a {
|
||||
display: flex; align-items: center;
|
||||
padding: var(--fs-space-1-5) var(--fs-space-3); border-radius: var(--fs-radius);
|
||||
color: var(--fs-dim);
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
|
||||
}
|
||||
/* a bar item is its label: no icon, no chevron */
|
||||
.fs-sidebar ul.nav > li > a .fs-ico,
|
||||
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
|
||||
|
||||
/* popup card under its own item, sized to content and capped to the viewport; clampDropdown
|
||||
* (fs-chrome.js) nudges one back inside near the inline-end edge, every bar state. */
|
||||
.fs-sidebar ul.nav ul {
|
||||
display: none; list-style: none;
|
||||
position: absolute; inset-inline: 0 auto; top: calc(100% + 8px); z-index: var(--fs-z-flyout);
|
||||
width: max-content; min-width: 200px; max-width: calc(100vw - 32px);
|
||||
margin: 0; padding: var(--fs-space-2);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* an invisible bridge across the 8px gap, so travelling from the item down into the panel
|
||||
* does not drop the hover and close it. Unguarded: every state that can open a panel on
|
||||
* hover needs it, not just one layout. */
|
||||
.fs-sidebar ul.nav > li.has-sub > ul::before {
|
||||
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
|
||||
}
|
||||
/* hover only on a real pointer: a tap leaves :hover stuck on mobile browsers, which
|
||||
* would block tap-to-close */
|
||||
@media (hover: hover) {
|
||||
.fs-sidebar ul.nav > li.has-sub:hover > ul { display: block; }
|
||||
}
|
||||
.fs-sidebar ul.nav > li.has-sub.open > ul { display: block; }
|
||||
/* a submenu link is the same link in every layout, the accordion included. */
|
||||
.fs-sidebar ul.nav ul a {
|
||||
display: block; padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
}
|
||||
|
||||
/* right cluster: the poll pill, then Search and Log out as square icon buttons.
|
||||
* LuCI's #indicators (poll "Refreshing", unsaved changes) collapses when empty, so it
|
||||
* only shows when there is something to report. */
|
||||
.fs-sidebar #indicators {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1-5);
|
||||
order: 2; margin-inline-start: auto; padding: 0;
|
||||
}
|
||||
|
||||
/* the square icon button both far-edge controls are; .fs-themerow adds only what a <button> needs */
|
||||
.fs-themerow, .fs-logout {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto; order: 2;
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
background: transparent; color: var(--fs-dim);
|
||||
}
|
||||
.fs-themerow {
|
||||
margin-inline-start: auto;
|
||||
/* see the :not(:empty) rule below — two auto margins would SPLIT the free space between
|
||||
* them instead of pushing one cluster to the bar's far edge */
|
||||
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
|
||||
text-align: start; cursor: pointer;
|
||||
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
|
||||
}
|
||||
/* .fs-ind-compact then .fs-bar-actrow, both by fitCluster (fs-chrome.js) — chrome.md, "Right
|
||||
* cluster: two fit steps". max-width releases with the flex basis, or the 30ch cap leaves
|
||||
* the brand short of the row it was just given. */
|
||||
.fs-sidebar.fs-bar-actrow .fs-brandrow { flex: 1 1 100%; max-width: 100%; }
|
||||
|
||||
/* the poll pill: label off, masked glyph on. Static green arrows, grey when the poll is paused —
|
||||
* the same --fs-icon-refresh as the rail, and the same reason it does not spin (a permanently
|
||||
* spinning glyph makes an idle router look busy, and a moving target cannot be clicked). */
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"] {
|
||||
width: var(--fs-btn-size); padding: 0; font-size: 0; justify-content: center; position: relative;
|
||||
}
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
|
||||
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
|
||||
background: var(--fs-good);
|
||||
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
}
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
|
||||
background: var(--fs-dim);
|
||||
}
|
||||
/* every OTHER indicator collapses to the same box and shows the badge fs-chrome.js lifted out of
|
||||
* its label — the count, or a dot when there is no number — exactly as the collapsed rail does.
|
||||
* min-width rather than width: three digits must not be clipped, and here there is room to grow. */
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"]) {
|
||||
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
|
||||
}
|
||||
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
|
||||
content: attr(data-fs-badge);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
|
||||
* FIRST one actually rendered. #indicators is earlier in the DOM, so when the poll pill
|
||||
* shows IT opens the gap and .fs-themerow's auto must stand down — otherwise flexbox
|
||||
* SPLITS the free space between the two and the pill drifts away from the buttons.
|
||||
* .fs-themerow keeps its own auto for when the pill is empty and collapsed. */
|
||||
.fs-sidebar #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
|
||||
/* on a square button the BORDER carries the state — the fill and the ink come from the
|
||||
* shared hover/active groups in 10-chrome.css */
|
||||
.fs-themerow:hover,
|
||||
.fs-themerow[aria-expanded="true"] { border-color: var(--fs-accent); }
|
||||
.fs-logout:hover { border-color: var(--fs-danger); }
|
||||
.fs-themerow .fs-label, .fs-logout .fs-label { display: none; }
|
||||
.fs-themerow .fs-ico,
|
||||
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
|
||||
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
|
||||
|
||||
/* The vertical sidebar: the single override, guarded POSITIVELY (never `:not([data-layout=
|
||||
* …])`); everything the bar declared above must be answered here. The `data-narrow`
|
||||
* measurement and the 521px JS-disabled floor: chrome.md, "`data-narrow`". */
|
||||
@media (min-width: 521px) {
|
||||
/* Static, never sticky (issue #7) — chrome.md, "A static sidebar, never position:
|
||||
* sticky". Confined here; the top layout and the phone bar keep window scrolling. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
|
||||
flex-direction: row; height: 100svh; overflow: hidden;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
|
||||
height: 100svh; overflow-y: auto;
|
||||
/* Firefox-only overflow leak past the static sidebar (issue #12) — chrome.md,
|
||||
* "contain: paint … is the fix for issue #12". */
|
||||
contain: paint;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
|
||||
width: var(--fs-sidebar-w); height: 100svh;
|
||||
flex: 0 0 auto;
|
||||
position: static;
|
||||
flex-direction: column; flex-wrap: nowrap; align-items: normal;
|
||||
gap: normal; padding: var(--fs-space-5) var(--fs-space-3-5);
|
||||
background: var(--fs-panel);
|
||||
border-inline-end: var(--fs-hairline); border-bottom: 0;
|
||||
box-shadow: none;
|
||||
/* `overflow: auto`, not overflow-y alone: the bar sets `overflow: visible` for its
|
||||
* popups, and a lone overflow-y here would leave overflow-x visible — which the
|
||||
* spec forces back to auto anyway. Say both. */
|
||||
overflow: auto;
|
||||
z-index: auto;
|
||||
}
|
||||
/* the column is already 224px wide — that IS the cap, so the bar's 30ch one must go
|
||||
* or the brand is capped twice, at the wrong value */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-brandrow {
|
||||
display: flex; align-items: center; gap: var(--fs-space-1-5);
|
||||
flex: 0 0 auto; order: 0; min-width: auto; max-width: none; padding-block: 0 var(--fs-space-5); padding-inline: var(--fs-space-2) var(--fs-space-1);
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-navlabel {
|
||||
display: block;
|
||||
color: var(--fs-dim);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
padding: var(--fs-space-1-5) var(--fs-space-2-5) var(--fs-space-2);
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-spacer { display: block; flex: 1 1 auto; min-height: 12px; }
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-railtoggle { display: inline-flex; }
|
||||
/* spacing only: whether the strip shows is the bar's :not(:empty):not(.single) rule above,
|
||||
* and the rail's hide below outranks it (0,5,0 vs 0,3,0). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu { margin-top: var(--fs-space-1); }
|
||||
|
||||
/* vertical main nav */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #modemenu {
|
||||
order: 0; flex: 0 1 auto;
|
||||
flex-direction: column; flex-wrap: nowrap; align-items: normal; gap: var(--fs-space-0-5);
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li { position: relative; }
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
|
||||
gap: var(--fs-space-3);
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3);
|
||||
/* Bold is the root level's own signal (lightening children reads as hierarchy instead); only the accordion needs it. */
|
||||
font-weight: var(--fs-weight-bold); white-space: normal;
|
||||
line-height: 1.1;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-ico,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a .fs-chevron { display: block; }
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav .fs-ico,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-ico,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-ico { width: 17px; height: 17px; }
|
||||
|
||||
/* nested submenu -> an inline accordion, not a popup */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul {
|
||||
display: none;
|
||||
/* the bar anchors this panel under itself (absolute + top/left); an accordion is
|
||||
* just the next block in the column, so the offsets must go back to auto or they
|
||||
* linger in the computed style */
|
||||
position: static; top: auto; inset-inline: auto; z-index: auto;
|
||||
width: auto; min-width: 0; max-width: none;
|
||||
flex-direction: column; gap: var(--fs-space-0-5);
|
||||
margin-block: 0; margin-inline: var(--fs-space-7) 0; padding: 0;
|
||||
background: none;
|
||||
border: 0; border-radius: 0; box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* the bar's hover bridge does not follow: static here, its containing block would fall
|
||||
* back to the <li> and swallow clicks above it. The rail brings its own (::after). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
|
||||
/* An open section is a GROUP: an indent alone does not say where it ends, and after ten
|
||||
* children the next root category read as an eleventh (issue #37). No :not([data-rail])
|
||||
* guard needed — the rail's own rule below restates these at higher specificity. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
|
||||
display: flex;
|
||||
margin-block: var(--fs-space-1) var(--fs-space-3);
|
||||
margin-inline: var(--fs-space-7) 0;
|
||||
padding-inline-start: var(--fs-space-1);
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
|
||||
/* Search and Log out are full-width text rows down here */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout {
|
||||
order: 0; margin-inline-start: 0;
|
||||
width: 100%; height: auto;
|
||||
justify-content: normal;
|
||||
gap: var(--fs-space-3); padding: var(--fs-space-2-5) var(--fs-space-3);
|
||||
border: 0;
|
||||
/* no background here: at (0,4,0) it would out-rank the (0,2,0) hover and open plates in 10-chrome.css */
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout { font-weight: var(--fs-weight); font-size: var(--fs-type); }
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout:hover { background: var(--fs-panel2); }
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-logout .fs-label { display: block; flex: 1 1 auto; }
|
||||
|
||||
/* the poll pill sits in the sidebar footer, above Search */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
|
||||
order: 0; margin-inline-start: 0;
|
||||
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
|
||||
}
|
||||
/* In the column the indicator is one of a stack of ROWS, not a row of buttons —
|
||||
* Search and Log out below it run the sidebar's full width, so a pill at its own
|
||||
* content width read as a stray chip. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
|
||||
flex: 1 1 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Collapsed sidebar (icon rail): <html data-rail="true">, persisted in localStorage and
|
||||
* re-applied before paint by head.ut. Labels collapse and a section's submenu becomes a
|
||||
* flyout anchored at its icon's inline-end. Same block as the vertical sidebar, so the two guards
|
||||
* cannot drift — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
|
||||
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
|
||||
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
|
||||
* which would clip the flyouts; the rail is short enough not to scroll */
|
||||
overflow: visible;
|
||||
/* no z-index/sticky: static, like the vertical sidebar (issue #7) — its flyouts
|
||||
* still cover the content on their own --fs-z-flyout in the root stacking context. */
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-railtoggle .fs-ico { rotate: 180deg; }
|
||||
|
||||
/* only the rail's own labels collapse — a `.fs-label` also exists on every
|
||||
* submenu link, and those live inside the flyout where they must stay */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-wordmark,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-navlabel,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-modemenu,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
|
||||
/* a rail item has no accordion — its children fly out sideways — so the chevron says
|
||||
* nothing and crowds the icon. Selector kept this long because the vertical override
|
||||
* turns the chevron back on at (0,6,3), so a plainer form here would only be (0,6,0). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
|
||||
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout {
|
||||
justify-content: center; gap: 0; padding: var(--fs-space-2-5) 0;
|
||||
}
|
||||
|
||||
/* leaf sections have no flyout — show their name as a tooltip bubble */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a::after {
|
||||
content: attr(data-label);
|
||||
position: absolute; inset-inline-start: 100%; top: 50%; transform: translateY(-50%);
|
||||
margin-inline-start: var(--fs-space-2-5); z-index: var(--fs-z-flyout);
|
||||
padding: var(--fs-space-1-5) var(--fs-space-2-5); border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-panel); border: var(--fs-hairline);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
color: var(--fs-text); font-size: var(--fs-type); font-weight: var(--fs-weight); white-space: nowrap;
|
||||
opacity: 0; pointer-events: none; transition: opacity var(--fs-dur);
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a:hover::after { opacity: 1; }
|
||||
|
||||
/* the flyout: opened by hover on a mouse, or by .open which the menu JS toggles on tap
|
||||
* (exclusively — fs-chrome.js flyoutMode). Hover gated on a real pointer: mobile
|
||||
* browsers leave :hover stuck on the tapped element, which would block tap-to-close. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
|
||||
display: none;
|
||||
position: absolute; inset-inline-start: 100%; top: -8px; margin-block: 0; margin-inline: var(--fs-space-2-5) 0;
|
||||
min-width: 196px; padding: var(--fs-space-2); z-index: var(--fs-z-flyout);
|
||||
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
animation: fs-fade var(--fs-dur);
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
|
||||
@media (hover: hover) {
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub:hover > ul { display: flex; }
|
||||
}
|
||||
/* The flyout's heading reuses the bar's hover-bridge pseudo-element (issue #22) —
|
||||
* chrome.md, "A flyout's heading reuses the bar's hover-bridge pseudo-element". */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
|
||||
content: attr(data-title);
|
||||
position: static; inset: auto; height: auto;
|
||||
padding: var(--fs-space-1) var(--fs-space-3) var(--fs-space-2);
|
||||
color: var(--fs-eyebrow-color); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
|
||||
}
|
||||
/* bridge the 10px gap so the pointer can travel icon -> flyout without a hover drop */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub::after {
|
||||
content: ""; position: absolute; inset-inline-start: 100%; top: 0;
|
||||
width: 12px; height: 100%;
|
||||
}
|
||||
|
||||
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
|
||||
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
|
||||
* narrow phone would paint a bare glyph in the phone bar until the modules land.
|
||||
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
|
||||
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
|
||||
border: 0; box-shadow: none; background: var(--fs-good);
|
||||
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
/* Static, deliberately: this is a poll running quietly forever, not something being
|
||||
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
|
||||
}
|
||||
|
||||
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
|
||||
* poll was stopped, the one thing the indicator exists to report. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
|
||||
background: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
|
||||
* collapsed-rail indicator badges its count". `.fs-sidebar` is deliberately absent:
|
||||
* #indicators is an id. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
|
||||
/* min-width, not width: the count is unbounded (a big config can queue three digits)
|
||||
* and a fixed box would clip it. */
|
||||
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
|
||||
padding: 0 var(--fs-space-1); overflow: hidden;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
/* .fs-ind-compact above paints the same badge but cannot SHARE this
|
||||
* rule: it applies at every width, this one must stay inside the 521px floor. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
|
||||
content: attr(data-fs-badge);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- main column ---------- */
|
||||
.fs-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--fs-bg);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.fs-sidebar #indicators:empty { display: none; }
|
||||
.fs-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
|
||||
|
||||
/* fs-router.js builds the incoming page in a second #view here, then moves its children into
|
||||
* the live one, so the visible page is never emptied while the next one loads. `visibility:
|
||||
* hidden` (not display: none) keeps the box so a self-sizing view sees no zero width;
|
||||
* `height: 0` stops the page moving; `overflow: clip` (not hidden) skips a second scrollport. */
|
||||
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
|
||||
|
||||
/* Slow-navigation hairline, shown once a nav outlives 150ms (fs-router.js). Fixed, and a
|
||||
* child of <body>, not .fs-main (a column flex container shrinks its 2px to zero) or
|
||||
* .fs-shell (contain: paint would re-base and clip a fixed descendant). transform/opacity
|
||||
* only, both composited; the one style flush is on activation out of 'done', no layout. */
|
||||
#fs-nav-progress {
|
||||
position: fixed; inset-inline: 0; top: 0;
|
||||
height: 2px;
|
||||
background: var(--fs-accent);
|
||||
transform: scaleX(0); transform-origin: left center;
|
||||
opacity: 0; pointer-events: none;
|
||||
z-index: var(--fs-z-tooltip);
|
||||
}
|
||||
:root[dir="rtl"] #fs-nav-progress { transform-origin: right center; }
|
||||
#fs-nav-progress[data-state="active"] {
|
||||
opacity: 1;
|
||||
transform: scaleX(.8);
|
||||
transition: transform 2s cubic-bezier(.1, .7, .1, 1), opacity 120ms linear;
|
||||
}
|
||||
#fs-nav-progress[data-state="done"] {
|
||||
transform: scaleX(1);
|
||||
transition: transform 120ms ease-out, opacity 300ms linear 100ms;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#fs-nav-progress[data-state="active"],
|
||||
#fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
/* ---------- footer ----------
|
||||
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright, so a
|
||||
* third-party view emitting its own <footer> stays themed too. */
|
||||
footer {
|
||||
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: var(--fs-hairline);
|
||||
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
|
||||
/* centre the flex LINE (base declares no justify-content) so text-align has something
|
||||
* to centre rather than a <span> already shrunk to its content */
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
/* target-size floor for the two links this sentence carries (WCAG 2.2 SC 2.5.8) —
|
||||
* chrome.md, "A footer link pair needs 26px". */
|
||||
line-height: max(26px, calc(var(--fs-type) * 2));
|
||||
}
|
||||
footer a { color: var(--fs-dim); }
|
||||
footer a:hover { color: var(--fs-accent); }
|
||||
|
||||
/* Notifications — ui.addNotification prepends .alert-message as the first child of
|
||||
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
|
||||
.fs-main > .alert-message {
|
||||
order: 1; margin: var(--fs-space-4) auto 0;
|
||||
width: calc(100% - var(--fs-content-pad) * 2);
|
||||
max-width: calc(var(--fs-content-max) - var(--fs-content-pad) * 2);
|
||||
}
|
||||
.fs-main > .fs-content { order: 2; }
|
||||
.fs-main > .fs-footer { order: 3; }
|
||||
|
||||
/* phone tier: content gutter, alert-message width and wrap — merged, none override each
|
||||
* other (distinct selectors), must stay after the base rules above they narrow */
|
||||
@media (max-width: 767px) {
|
||||
.fs-content { padding: var(--fs-space-4); }
|
||||
/* width: auto, not the base width above: that calc subtracts the DESKTOP gutter while
|
||||
* .fs-content's own gutter is narrower here — auto plus the side margins tracks the
|
||||
* content column exactly at any width in this tier. */
|
||||
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
|
||||
/* a top-level alert's action buttons were nowrap, so Dismiss was shoved past the panel
|
||||
* and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main for #view's too. */
|
||||
.fs-main .alert-message { flex-wrap: wrap; }
|
||||
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
|
||||
/* the action row is a nested flex that stayed nowrap and clipped its first label */
|
||||
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
|
||||
.fs-main .alert-message .btn,
|
||||
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
|
||||
}
|
||||
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
|
||||
* overflowed a 320px button). */
|
||||
.fs-main .alert-message .btn,
|
||||
.fs-main .alert-message button { overflow-wrap: anywhere; }
|
||||
|
||||
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
|
||||
#indicators [data-indicator] {
|
||||
/* wears the row's own --fs-btn-size and --fs-radius, not a pill, or it reads as a
|
||||
* different kind of object beside Search and Log out */
|
||||
display: inline-flex; align-items: center;
|
||||
min-height: var(--fs-btn-size);
|
||||
text-transform: none;
|
||||
font-weight: var(--fs-weight); font-size: var(--fs-type);
|
||||
/* the bar's control surface; ui.js emits a bare `<span data-indicator=…>`, no class to key on */
|
||||
background: transparent;
|
||||
border: var(--fs-hairline);
|
||||
color: var(--fs-dim);
|
||||
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
|
||||
}
|
||||
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
|
||||
/* nothing else gives a clickable indicator a pointer cursor — base's rule carries geometry
|
||||
* only, and this theme emits no <header> at all — so "Refreshing" showed a text caret. */
|
||||
#indicators [data-indicator][data-clickable] { cursor: pointer; }
|
||||
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so it
|
||||
* takes the control ring the buttons beside it take (55-buttons.css). :focus-visible, not
|
||||
* :focus: a pointer press on the pill should not draw a ring. */
|
||||
#indicators [data-indicator][data-clickable]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@layer theme {
|
||||
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
|
||||
* the right edge instead of stuffed inside a fat pill. Default reserves that value's own line
|
||||
* in flow (bootstrap's margin:1.4em model) — an abspos value with zero reserve spilled 5-6px
|
||||
* UP across the divider of the row above, off the overview page (key/value .table, no header
|
||||
* row). Contexts that put the value BESIDE the bar or on a neighbour's line opt out with
|
||||
* margin-block-start: 0. */
|
||||
.cbi-progressbar {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
background: var(--fs-track);
|
||||
border: 0;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
overflow: visible;
|
||||
min-width: 120px;
|
||||
margin: 0;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
.cbi-progressbar > div {
|
||||
height: 100%;
|
||||
background: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
transition: width var(--fs-dur-fill);
|
||||
}
|
||||
/* Colour carries the reading too: data-fs-level set from JS (80/92% thresholds) every poll
|
||||
* tick. Solid tokens, no color-mix, nothing owed to 04-nocolormix.css. */
|
||||
.cbi-progressbar[data-fs-level="warn"] > div {
|
||||
background: var(--fs-warn);
|
||||
}
|
||||
.cbi-progressbar[data-fs-level="danger"] > div {
|
||||
background: var(--fs-danger);
|
||||
}
|
||||
.cbi-progressbar::after {
|
||||
content: attr(title);
|
||||
position: absolute; inset-inline: auto 0; bottom: calc(100% + var(--fs-space-1)); top: auto;
|
||||
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim); white-space: nowrap; text-shadow: none;
|
||||
}
|
||||
|
||||
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
|
||||
* meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead
|
||||
* of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the
|
||||
* whole rule, form row included. */
|
||||
.cbi-value-field > .cbi-progressbar {
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
margin-block-start: 0;
|
||||
--fs-meter-value: calc(var(--fs-space-10) * 2);
|
||||
}
|
||||
.cbi-value-field > .cbi-progressbar::after {
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
margin-block-start: 0;
|
||||
--fs-meter-value: var(--fs-space-10);
|
||||
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
|
||||
* the row). Back over the bar with a one-line top margin (was 6-9px overlap).
|
||||
* `:has(.tr.table-titles)` stays for SPECIFICITY, matching the beside-rule's (0,6,0). */
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
margin-inline-end: 0;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
inset-inline: auto 0;
|
||||
bottom: calc(100% + var(--fs-space-1));
|
||||
top: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
@layer theme {
|
||||
/* ---------- panels / cards (cbi) ---------- */
|
||||
.cbi-section, .cbi-map > .cbi-section-node, .panel {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); }
|
||||
/* bare <legend> only; ink from the heading ramp. No line-height — base already sets 1. */
|
||||
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 {
|
||||
margin: 0; padding: 0; border: 0;
|
||||
}
|
||||
/* bare title (no wrapper) sat flush below — same 12px; excludes .cbi-title h3, already gapped */
|
||||
.cbi-section > h3:first-child,
|
||||
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
|
||||
|
||||
/* a heading FOLLOWING a table had no air; separates more from what precedes than introduces */
|
||||
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
|
||||
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* [id]/.fs-dt = data table; no id = key/value, no frame/clip; nested tables too */
|
||||
.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%; }
|
||||
/* moved from base with the generic field box (theme/60-inputs.css) */
|
||||
.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; }
|
||||
/* filter placeholder sliced mid-glyph on a long title (issue #12); ellipsis changes the
|
||||
* failure instead. No !important — the inline 30px flag width still stands. */
|
||||
.table.cbi-section-table .cbi-section-filter {
|
||||
padding: var(--fs-space-1) var(--fs-space-1-5);
|
||||
font-size: var(--fs-type-field-xs);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* width:100% cannot shrink below min-content; the inverse rule further down restores the
|
||||
* floor on a data table — read that block before touching either. */
|
||||
.cbi-section .table .td,
|
||||
.cbi-section .table .th {
|
||||
padding-block: var(--fs-space-2-5);
|
||||
padding-inline: 0 var(--fs-space-4);
|
||||
border: 0; border-bottom: var(--fs-hairline);
|
||||
line-height: 1.45; vertical-align: middle;
|
||||
text-align: start;
|
||||
}
|
||||
/* hugs the far edge, beating text-align:start above (form.TableSection's own action cells;
|
||||
* hand-built ones 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: uppercase broke LuCI's own casing (issue #39); weight/size/ink alone
|
||||
* still read as a heading. */
|
||||
.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) --- `:is()` at ONE specificity: split, a table
|
||||
* known only by the tag (Status -> Routing) lost to key/value's default. The padding, frame, corner
|
||||
* and placeholder rules key on `:is(table, div).fs-dt` (0,1,1) as v0.14.14's `table.fs-dt`, plus
|
||||
* the bare div: `.table.fs-dt .td` (0,3,0) beat an app's `.table td:nth-child(1)` (0,2,1),
|
||||
* filemanager padding-left 5 -> 12px (docs/css.md). The rules that were `.table.fs-dt` stay so. */
|
||||
.cbi-section .table:is([id], .fs-dt) .td,
|
||||
.cbi-section .table:is([id], .fs-dt) .th,
|
||||
:is(table, div).fs-dt .td, :is(table, div).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 min-content FLOOR belongs here: break-word (unlike base's -anywhere) does not lower
|
||||
* min-content, so an overflow stays visible to fit.overflows(). :not(.fs-stacked) is the
|
||||
* fitter's own measuring state. */
|
||||
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
|
||||
/* the ONE column the fitter allows to shred rather than card the table (fs-select.js);
|
||||
* (0,5,0) beats the floor above on specificity, not source order */
|
||||
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
|
||||
|
||||
/* processes.js's inline style= cannot be outranked by a layer — !important is the sanctioned
|
||||
* fight against it. A CARD IS NOT EXEMPT: an unlayered app rule breaks a token that would fit. */
|
||||
.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. */
|
||||
|
||||
/* sticks each .th, not the row — a row's fill does not travel with a sticky cell here. 0 in
|
||||
* the sidebar (.fs-main scrolls itself), --fs-bar-live where the document does. */
|
||||
: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);
|
||||
}
|
||||
|
||||
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
|
||||
* (18 jumps/1500 frames measured). */
|
||||
.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 (border-collapse:separate, or the radius is silently dropped); #view too —
|
||||
* Startup renders .table.fs-dt with no section */
|
||||
.cbi-section .table[id], #view .table[id],
|
||||
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, :is(table, div).fs-dt {
|
||||
border-collapse: separate; border-spacing: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* EXPLICIT: the generic .table rule below still says overflow:hidden, clipped otherwise */
|
||||
overflow: visible;
|
||||
}
|
||||
/* frame is overflow:visible, so nothing clips the corner cells — round the four OUTER ones.
|
||||
* `:is(.tr.table-titles, .thead)` covers both header shapes; without .thead a real <thead>
|
||||
* kept square top corners. */
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
:is(table, div).fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-start-start-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:last-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:last-child,
|
||||
:is(table, div).fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-start-end-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] .tr:last-child .td:first-child,
|
||||
#view .table[id] .tr:last-child .td:first-child,
|
||||
:is(table, div).fs-dt .tr:last-child .td:first-child { border-end-start-radius: var(--fs-radius-lg); }
|
||||
.cbi-section .table[id] .tr:last-child .td:last-child,
|
||||
#view .table[id] .tr:last-child .td:last-child,
|
||||
:is(table, div).fs-dt .tr:last-child .td:last-child { border-end-end-radius: var(--fs-radius-lg); }
|
||||
/* empty table: L.ui.Table adds .tr.placeholder; spanning it is base's job, framed flavour left here */
|
||||
.cbi-section .table[id] .tr.placeholder .td,
|
||||
#view .table[id] .tr.placeholder .td,
|
||||
:is(table, div).fs-dt .tr.placeholder .td {
|
||||
padding-block: var(--fs-space-2); /* this layer's own cell padding made the plate 4px tall */
|
||||
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: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
|
||||
* excluded along with .fs-dt and the legend's inline table-layout. Shape, not a class, says
|
||||
* data table: any .th or a 3rd .td rules a table out — a real key/value row is exactly 2 .td
|
||||
* and no .th. docs/css.md, "Tables: the key/value discriminator". */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):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(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:not(:first-child) {
|
||||
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
|
||||
}
|
||||
/* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config
|
||||
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
|
||||
@container fs-content (max-width: 560px) {
|
||||
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr {
|
||||
display: block;
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
border: 0;
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child:first-child {
|
||||
display: block; width: auto; white-space: normal;
|
||||
padding: 0; border: 0;
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
|
||||
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
|
||||
display: block; width: auto; text-align: start;
|
||||
padding: 0; border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the realtime graph legend: the one no-id .table with inline table-layout:fixed, six
|
||||
* equal columns overflowing on a phone — folded into label/value pairs. */
|
||||
@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; the MAC badge keeps base's flat 4px, square against the card corners (issue #5.4) */
|
||||
.cbi-section .ifacebox, .cbi-section .zonebadge,
|
||||
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
|
||||
.zonebadge input { width: 6em; } /* moved from base with the generic field box */
|
||||
/* head rounds WITH the box (issue #7); max() since Rounding goes to 0, an invalid subtraction */
|
||||
.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 height:0/overflow:hidden still drew empty padding/border bars
|
||||
* (issue #10). visibility not display:none — stock JS reads a graph's width once in a rAF. */
|
||||
[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%: bold --fs-danger on a tint OF --fs-danger eats its own contrast (12% measured
|
||||
* 4.4:1); at 6% every palette clears 4.5 (4.87/4.95/5.28/6.09) */
|
||||
.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 GridSection name CELL on 24.10, a ::before from data-title where form.js has no real
|
||||
* cell — named here too or the column lines up with nothing */
|
||||
.table.cbi-section-table .th, .table.cbi-section-table .td,
|
||||
.table.cbi-section-table .tr::before { padding-block: var(--fs-space-2); padding-inline: 0 var(--fs-space-3); }
|
||||
/* zero inline-start padding is right only INSIDE a card; a self-framed table glued its header text
|
||||
* to its own corner without this (issue #7) */
|
||||
.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 -> rounded card; inside a CBI section the section already IS the
|
||||
* card, so the frame is stripped there (above) */
|
||||
.table {
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--fs-panel);
|
||||
}
|
||||
/* separators on the CELLS: the frame needs border-collapse:separate, a row's own border
|
||||
* paints nothing. :not(:last-child) keeps a trailing one off the rounded corner. */
|
||||
.table .tr.table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles::before,
|
||||
.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); }
|
||||
/* per-CELL border, so a foreign `position: relative; top:` (dashboard) drags one cell's
|
||||
* border out of line — `top` AND `bottom` cancels either without touching `relative` itself */
|
||||
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
|
||||
/* descendant combinator so a real <table>'s inserted <tbody> is covered too */
|
||||
.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;
|
||||
}
|
||||
/* row NAME reads the same on both releases (real cell / ::before) — a LABEL, sans face */
|
||||
.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 (an untagged plain .table lit its whole block,
|
||||
* issue #5.3; a placeholder row lit up too, issue #5.2/#15) */
|
||||
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
|
||||
|
||||
/* .fs-dt: fs-select.js on a header-row #view .table that is NOT .cbi-section-table —
|
||||
* replacing a live :has() selector re-evaluated on every mutation. */
|
||||
|
||||
/* One card-stack copy, no @container: fs-select.js already measures OVERFLOW and sets
|
||||
* .fs-stacked. The config table keeps its own @container — its widgets bake in a width. */
|
||||
/* a poll REPLACES a data table with a fresh, unmarked copy that briefly lays out full-width,
|
||||
* throwing the reader 612px out and back — waits OUTSIDE the layout until fs-select.js
|
||||
* answers, guarded on :root[data-fs-fit]. */
|
||||
: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, on an overflowed table before carding */
|
||||
.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,
|
||||
.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; }
|
||||
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
|
||||
* 1-line neighbour's (52px) */
|
||||
/* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list,
|
||||
* losing display:flex on every card. Keep both in step by hand. */
|
||||
.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. A copy sits in theme/65-dropdown.css: the config
|
||||
* table needs it under its own @container guard, which cannot share a block with a class. Keep
|
||||
* the two identical by hand. */
|
||||
.table.fs-stacked .td[data-title]::before {
|
||||
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);
|
||||
}
|
||||
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,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; }
|
||||
/* row buttons are not a label/value pair — full width below the pairs. Copy in theme/65-dropdown.css. */
|
||||
.table.fs-stacked .td.cbi-section-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);
|
||||
}
|
||||
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
|
||||
* the tail button. Copy in theme/65-dropdown.css. */
|
||||
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
}
|
||||
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
|
||||
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/65-dropdown.css. */
|
||||
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
|
||||
flex: 0 1 content; width: 0; min-width: 0;
|
||||
}
|
||||
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
|
||||
* it does not scroll to an over-wide child */
|
||||
.table.fs-stacked .td > *,
|
||||
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* a <tfoot> is still a table-footer-group in a display:block table, drawing beside the cards
|
||||
* instead of under them; `:has(> .tr)` tells the two shapes upstream writes it in apart. */
|
||||
.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; }
|
||||
|
||||
/* --- DATA table flavour (L.ui.Table, tagged .fs-dt) --- the frame stays overflow:visible so a
|
||||
* ComboButton's open list can leave the card; zebra and hover paint on the .tr, so the two outer
|
||||
* rows carry the frame's inner radius (its radius less the 1px border) instead of a clip. */
|
||||
.table.fs-dt.fs-stacked :is(.tr:first-child, :is(.tr.table-titles, .thead) + .tr) {
|
||||
border-start-start-radius: calc(var(--fs-radius-lg) - 1px);
|
||||
border-start-end-radius: calc(var(--fs-radius-lg) - 1px);
|
||||
}
|
||||
.table.fs-dt.fs-stacked .tr:last-child {
|
||||
border-end-start-radius: calc(var(--fs-radius-lg) - 1px);
|
||||
border-end-end-radius: calc(var(--fs-radius-lg) - 1px);
|
||||
}
|
||||
/* :not(.placeholder) — the empty-state plate is out of flow; its row only reserves height,
|
||||
* and padding subtracted from that left text hanging below the frame (Wireless) */
|
||||
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
|
||||
/* half the row makes the card; col-N cells are excluded by the second :not(), not outranked. Third :not() loses on purpose to the meter below. */
|
||||
.table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) {
|
||||
flex: 1 1 50% !important;
|
||||
}
|
||||
/* a first cell that is one token (fs-select.js): "/dev/mtdblock6" is 110px, the half-split left
|
||||
* 94-106px at 360-390 and `anywhere` cut it mid-word. fit-content of one token is the token,
|
||||
* capped at the row, so a 64-char hash still breaks inside the card; a phrase keeps its half. */
|
||||
.table.fs-stacked .tr > .td.fs-td-token:first-child:not(.cbi-section-actions):not([class*="col-"]) { min-width: fit-content; }
|
||||
/* a meter takes the whole line — its own title prints over the cell's caption otherwise */
|
||||
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
|
||||
|
||||
/* column weights (1-10): a share of a CARDED row, unguarded — `flex` only applies once a
|
||||
* `.td` becomes a flex item, so one copy serves every tier and is inert elsewhere. */
|
||||
.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); }
|
||||
|
||||
/* last resort: no header row, never carded (no data-title to print). .fs-xscroll: same,
|
||||
* MEASURED to overflow; refused to a table holding a widget (clips a popup inside). */
|
||||
#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;
|
||||
overscroll-behavior-x: contain; /* the gesture stops here instead of dragging the page */
|
||||
scrollbar-width: thin; /* the scrollbar IS the affordance; a shadow trick shows through zebra rows */
|
||||
}
|
||||
|
||||
/* a header-less table holding a control stacks instead of scrolling — a scroll container
|
||||
* would clip the popup inside; nothing here creates one, so a 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 (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); }
|
||||
|
||||
/* pins the row's NAME while scrolling — its own background (a row's fill does not travel
|
||||
* with a sticky cell) and a hairline so it reads as an edge, not text over text. */
|
||||
#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; inset-inline-start: 0; z-index: 2;
|
||||
background: var(--fs-panel);
|
||||
border-inline-end: var(--fs-hairline);
|
||||
}
|
||||
/* a keyboard user tabs to the scroll box (fs-select.js gives it tabindex+name) — 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@layer theme {
|
||||
/* alert panel: a left accent stripe on a panel card; variants recolour the frame */
|
||||
.alert-message {
|
||||
padding: var(--fs-space-3-5) var(--fs-space-4);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: var(--fs-hairline);
|
||||
/* logical: the stripe marks the READING start, swapping sides in RTL (LuCI ships ar/fa/he) */
|
||||
border-inline-start: 3px solid var(--fs-accent);
|
||||
/* --fs-panel stays the background-COLOUR; the tint is a background-IMAGE layer over it,
|
||||
* not a replacement — a `-fill` background alone is transparent to the wallpaper. */
|
||||
background-color: var(--fs-panel);
|
||||
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
|
||||
color: var(--fs-text);
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
/* status lives in stripe+surface, ring stays neutral. :not(.btn) is load-bearing: a confirm
|
||||
* control is a `<label class="btn alert-message danger">`, and without the guard the tint
|
||||
* out-ranked `.btn.danger`, fading its fill. */
|
||||
.alert-message.warning:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-warn);
|
||||
--fs-alert-tint: var(--fs-warn-fill);
|
||||
}
|
||||
.alert-message.error:not(.btn), .alert-message.danger:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-danger);
|
||||
--fs-alert-tint: var(--fs-danger-fill);
|
||||
}
|
||||
.alert-message.success:not(.btn) {
|
||||
border-inline-start-color: var(--fs-good);
|
||||
--fs-alert-tint: var(--fs-good-fill);
|
||||
}
|
||||
/* .notice groups HERE, not with .label.notice's accent: ui.js emits `notice spinning` for an
|
||||
* IN-PROGRESS banner, so it reads as informational rather than success. */
|
||||
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
|
||||
border-inline-start-color: var(--fs-accent);
|
||||
--fs-alert-tint: var(--fs-accent-fill);
|
||||
}
|
||||
|
||||
/* spinner ::before is inline-block for its usual inline hosts, but displayStatus() builds a
|
||||
* BLOCK host, opening its own line. GRID, not flex: the caller decides child count. */
|
||||
.alert-message.spinning {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center; /* centres the glyph against the FIRST row, not pinned to its top */
|
||||
/* the column gap is the glyph's own spacing, stated rather than inherited from the row `gap` */
|
||||
column-gap: var(--fs-space-2);
|
||||
}
|
||||
/* grid item now, so its inline margin would double the column gap; row 1 keeps it beside the
|
||||
* FIRST child however many follow */
|
||||
.alert-message.spinning::before { grid-row: 1; margin-inline-end: 0; }
|
||||
.alert-message.spinning > * { grid-column: 2; }
|
||||
|
||||
/* h2 is what the theme's own notices emit; h4 covers a third-party alert, h5/h6 joined so an
|
||||
* alert heading does not fall back to base's look past h4. */
|
||||
.alert-message :is(h2, h3, h4, h5, h6) { margin: 0 0 var(--fs-space-1); font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
/* moved from base with the heading ramp: line-height:inherit beat the ramp's leading
|
||||
* in-layer, left behind it loses by layer (21 -> 16.8px) */
|
||||
.alert-message h4,
|
||||
.alert-message h5,
|
||||
.alert-message pre,
|
||||
.alert-message ul,
|
||||
.alert-message li,
|
||||
.alert-message p {
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
/* --fs-text, not --fs-dim: the paragraph IS the message — dim measured 4.06:1 on the tint */
|
||||
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
|
||||
/* the body elements an alert may carry, absorbed from base/90-widgets.css (same values) */
|
||||
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
|
||||
/* inner table (firmware Current/Available) flattened — no nested-card look, row dividers only */
|
||||
.alert-message .table { margin: var(--fs-space-2-5) 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; }
|
||||
/* --fs-text, not --fs-eyebrow-color: the muted ink does not survive the status tint
|
||||
* (3.35-3.36:1 against AA's 4.5). */
|
||||
.alert-message .table .th,
|
||||
/* title-row arms (0,5,0) outrank 30-tables.css's `.table .tr.table-titles .th` (0,4,0) */
|
||||
.alert-message .table .tr.table-titles .th,
|
||||
.alert-message .table .tr.cbi-section-table-titles .th { color: var(--fs-text); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); }
|
||||
/* column gap --fs-space-3 like every other cell; row density --fs-space-2, a dense readout */
|
||||
.alert-message .table .th, .alert-message .table .td { padding-block: var(--fs-space-2); padding-inline: 0 var(--fs-space-3); background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
|
||||
.alert-message .table .td { color: var(--fs-text); }
|
||||
|
||||
/* validation tooltip: fill stays SOLID, unlike .label below — floats over arbitrary content,
|
||||
* so a border alone cannot carry the status. Shadow replaces base's raw 2px halo (1.08-1.86:1). */
|
||||
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
|
||||
/* base reveals it on :hover alone; no tabindex in anything LuCI ships today, but a third
|
||||
* party's own container may add one */
|
||||
.cbi-tooltip-container:focus-within .cbi-tooltip:not(:empty) { inset-inline-start: auto; opacity: 1; }
|
||||
.cbi-tooltip.info { background: var(--fs-accent); border-color: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.cbi-tooltip.success { background: var(--fs-good); border-color: var(--fs-good); color: var(--fs-on-good); }
|
||||
.cbi-tooltip.error { background: var(--fs-danger); border-color: var(--fs-danger); color: var(--fs-on-danger); }
|
||||
.cbi-tooltip.notice { background: var(--fs-panel2); border-color: var(--fs-border); color: var(--fs-text); }
|
||||
|
||||
/* .label: core barely uses it, any luci-app-* may. .important is bootstrap's accent name. */
|
||||
.label {
|
||||
display: inline-block; padding: var(--fs-space-1) var(--fs-space-2); border-radius: var(--fs-radius-pill);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
color: var(--fs-dim); text-shadow: none; text-transform: none;
|
||||
white-space: nowrap; margin-block: .125em; margin-inline: .4em 0; /* carried over from the absorbed base rule */
|
||||
}
|
||||
/* no background: a translucent tint under same-colour text eats contrast (3.4-4.4:1);
|
||||
* inherited opaque --fs-panel2 guarantees >=4.5. */
|
||||
.label.important, .label.notice { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.label.warning { border-color: var(--fs-warn); color: var(--fs-warn); }
|
||||
.label.success { border-color: var(--fs-good); color: var(--fs-good); }
|
||||
.label.danger, .label.error { border-color: var(--fs-danger); color: var(--fs-danger); }
|
||||
a.label:link, a.label:visited { color: var(--fs-dim); }
|
||||
a.label:hover { text-decoration: none; color: var(--fs-text); }
|
||||
|
||||
/* section-title toggles are .label pills, clickable — SINGLE owner */
|
||||
.cbi-title .label {
|
||||
padding: var(--fs-space-1) var(--fs-space-3);
|
||||
border: var(--fs-hairline);
|
||||
color: var(--fs-dim); cursor: pointer;
|
||||
letter-spacing: 0;
|
||||
margin: 0; min-width: 0; box-shadow: none;
|
||||
}
|
||||
.cbi-title .label:hover { color: var(--fs-text); border-color: var(--fs-dim); opacity: 1; }
|
||||
/* toggles section visibility — interactive, so keyboard focus must show */
|
||||
.cbi-title .label:focus-visible {
|
||||
outline: none; border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* opaque surface (--fs-panel2 inherited): accent text on --fs-accent-soft measured 4.21:1
|
||||
* (theme/65-dropdown.css) — the border carries the accent instead */
|
||||
.cbi-title .label.notice { opacity: 1; color: var(--fs-accent); border-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
@layer theme {
|
||||
/* one component, three LuCI selectors, no markup difference worth honouring: #tabmenu ul.tabs
|
||||
* (page tabs), .tabs (view tabs), .cbi-tabmenu (CBI form tabs, the mode switcher included).
|
||||
* Active marker: .active on ul.tabs, .cbi-tab on every .cbi-tabmenu flavour. */
|
||||
#tabmenu { margin-bottom: 0; }
|
||||
|
||||
/* the card */
|
||||
#tabmenu ul.tabs,
|
||||
.tabs,
|
||||
.cbi-tabmenu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-1);
|
||||
list-style: none;
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: var(--fs-space-2);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* zeroes the folder-tab baseline gradient (1px x 29px) instead of restating it, so a
|
||||
* third-party gradient the strip carries is sized away too */
|
||||
background-size: 0 0;
|
||||
}
|
||||
|
||||
/* a strip nested in .cbi-section -> soft inset row, no frame (a card inside a card reads as a bug) */
|
||||
.cbi-section .tabs,
|
||||
.cbi-section .cbi-tabmenu:not(.mode):not(.map) {
|
||||
background: var(--fs-panel2);
|
||||
padding: var(--fs-space-1-5);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li,
|
||||
.tabs > li,
|
||||
.cbi-tabmenu > li {
|
||||
height: auto;
|
||||
margin: 0; padding: 0;
|
||||
border: 0; background: none;
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* page tabs and the mode switcher never carry a sentence-length label; ul.cbi-tabmenu.mode > li
|
||||
* wins specificity over .cbi-tabmenu > li below, so mode pills stay none regardless of order */
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* CBI form tabs only, not the mode switcher: a long label overflowed its column by 28.6px at
|
||||
* 320px/Large, EN measured 0 */
|
||||
.cbi-tabmenu > li {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li > a,
|
||||
.tabs > li > a,
|
||||
.cbi-tabmenu > li > a {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
border: 0;
|
||||
border-radius: var(--fs-radius);
|
||||
line-height: 1.3;
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight);
|
||||
color: var(--fs-dim);
|
||||
transition: background var(--fs-dur), color var(--fs-dur);
|
||||
}
|
||||
|
||||
#tabmenu ul.tabs > li > a:hover,
|
||||
.tabs > li > a:hover,
|
||||
.cbi-tabmenu > li > a:hover {
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
/* active pill */
|
||||
#tabmenu ul.tabs > li.active,
|
||||
.tabs > li.active,
|
||||
.cbi-tabmenu > li.cbi-tab {
|
||||
color: var(--fs-on-accent);
|
||||
}
|
||||
#tabmenu ul.tabs > li.active > a,
|
||||
.tabs > li.active > a,
|
||||
.cbi-tabmenu > li.cbi-tab > a {
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-on-accent);
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
/* one ring for both states, on --fs-focus-ring-solo: base's outline is invisible on the active
|
||||
* pill, whose background is the same accent. box-shadow, not outline, because the strip clips
|
||||
* and a shadow is not */
|
||||
#tabmenu ul.tabs > li > a:focus-visible,
|
||||
.tabs > li > a:focus-visible,
|
||||
.cbi-tabmenu > li > a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* fitTabStrips (fs-chrome.js) escalates dense1 -> dense2 on wrap; for tabs both are the same
|
||||
* pill padding (gap and font-size already equal the base), so one list serves both */
|
||||
#tabmenu ul.tabs:is(.fs-dense1, .fs-dense2) > li > a,
|
||||
.tabs:is(.fs-dense1, .fs-dense2) > li > a,
|
||||
.cbi-tabmenu:is(.fs-dense1, .fs-dense2) > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
|
||||
/* map-level tabs keep the body font size instead of base's 16.5px/bold */
|
||||
.cbi-tabmenu.map > li { font-size: var(--fs-type); font-weight: var(--fs-weight); }
|
||||
/* the little "n errors" badge on a tab */
|
||||
.cbi-tab-disabled[data-errors]::after { background: var(--fs-danger); }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
@layer theme {
|
||||
/* realtime graphs size their canvas from #view, clipping 34px off the newest samples — docs/css.md "Misc: the realtime graph bleed" */
|
||||
#view .cbi-section > div[style]:has(> svg) {
|
||||
width: auto !important;
|
||||
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
|
||||
}
|
||||
|
||||
/* same box's frame, painted BLACK inline — only colour moves; wider than the bleed above, channel_analysis.js's box must not be bled but is just as black */
|
||||
#view div[style]:has(> svg) {
|
||||
border-color: var(--fs-border) !important;
|
||||
}
|
||||
|
||||
/* WCAG 1.4.1: --fs-accent on --fs-text measures 1.4:1 in dark, needs a non-colour cue in
|
||||
* prose. Nav links/buttons/pills are identified by position and shape instead. */
|
||||
p a, dd a,
|
||||
.cbi-value-description a,
|
||||
.alert-message a:not(.btn):not(.cbi-button) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* a label must set sans, not merely be excluded — excluded still inherits mono (227 elements/
|
||||
* 20 KB of bold mono for nothing); exempt in pre/code/#syslog/textarea, monospaced content.
|
||||
* `.ifacebadge` is a badge (chrome, not a value) in a mono cell; wins on specificity (0,2,0)
|
||||
* over theme/60-inputs.css's (0,1,0) mono rule, not source order */
|
||||
:is(strong, b):not(pre *, code *, #syslog *, textarea *),
|
||||
.ifacebadge,
|
||||
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
|
||||
code, pre { font-weight: var(--fs-weight-normal); }
|
||||
|
||||
.cbi-title-ref { color: var(--fs-accent); }
|
||||
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
|
||||
.close:hover { opacity: 1; }
|
||||
.ifacebox { background: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.ifacebox .ifacebox-head { background: var(--fs-panel2); color: var(--fs-text); }
|
||||
/* lives in THIS layer, next to the head it overrides: the same rule in base was dead, a layer beats specificity */
|
||||
.ifacebox .ifacebox-head.active { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.ifacebadge { background: var(--fs-panel2); border-color: var(--fs-border); }
|
||||
.ifacebadge-active { border-color: var(--fs-accent); }
|
||||
.zonebadge-empty { border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* @container PLUMBING only, card rules live with the tables. Named: an unnamed query binds to the nearest container-type ancestor, silently re-targeted by a new one */
|
||||
.cbi-map { container-type: inline-size; container-name: fs-content; }
|
||||
#view { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* ui.js appends #modal_overlay outside #view, so the names above must repeat here or no dialog query resolves.
|
||||
* box-shadow merged in from theme/70-modal.css: base's elevation = --fs-bg's hex in footstrap
|
||||
* light, a glow not a shadow; others take --fs-shadow-pop */
|
||||
.modal { container-type: inline-size; container-name: fs-view fs-content; box-shadow: var(--fs-shadow-pop); }
|
||||
|
||||
/* pager (« prev · text · next ») — generic, not page-scoped */
|
||||
.pager.center { display: flex; align-items: center; justify-content: center; gap: var(--fs-space-2-5); padding: var(--fs-space-3-5) 0; }
|
||||
.pager .text { flex: 0 0 auto; font-size: var(--fs-type); color: var(--fs-dim); }
|
||||
|
||||
/* absorbed from base; stock LuCI only renders h2/h3, the rest is third-party coverage.
|
||||
* `legend` excluded: theme/30-tables.css owns it. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--fs-weight-bold);
|
||||
color: var(--fs-text);
|
||||
/* 1.5 is prose leading; a heading is a label and looks slack at it */
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
h1 { margin-bottom: var(--fs-space-4); font-size: var(--fs-type-2xl); }
|
||||
h2 { font-size: var(--fs-type-xl); }
|
||||
h3 { font-size: var(--fs-type-lg); }
|
||||
h4, h5 { font-size: var(--fs-type); }
|
||||
h6 { font-size: var(--fs-type-xs); color: var(--fs-dim); text-transform: uppercase; }
|
||||
|
||||
/* heading card: h2 + .cbi-map-descr get a .cbi-section card's frame, so the page reads as a
|
||||
* stack of panels rather than loose text over the background */
|
||||
.cbi-map > h2,
|
||||
#view > h2 {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: var(--fs-space-4);
|
||||
}
|
||||
/* split the card across the two siblings — no wrapper element exists to hold both */
|
||||
.cbi-map > h2:has(+ .cbi-map-descr),
|
||||
#view > h2:has(+ .cbi-map-descr) {
|
||||
border-bottom: 0;
|
||||
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
|
||||
padding-bottom: var(--fs-space-1);
|
||||
margin-bottom: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cbi-map > h2 + .cbi-map-descr,
|
||||
#view > h2 + .cbi-map-descr {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
padding: 0 var(--fs-space-4) var(--fs-space-4);
|
||||
color: var(--fs-dim);
|
||||
font-size: var(--fs-type);
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
|
||||
/* .right holding Add/Save directly under a field had no gap: pinned the button to the textarea */
|
||||
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
|
||||
margin-top: var(--fs-space-2-5);
|
||||
}
|
||||
|
||||
/* button row belonging to the PAGE TITLE, not a section — same 16px as .cbi-section, half above */
|
||||
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
|
||||
|
||||
/* Delete button luci-base renders for every NAMED section sat flush against what follows — one class, not one app */
|
||||
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
|
||||
|
||||
/* a view may hand the map a bare widget with no `.cbi-section`; exclusions are the children luci-base already spaces itself */
|
||||
.cbi-map > *:not(.cbi-section):not(.cbi-map-descr):not(.cbi-page-actions):not(h2):not(legend):not(.cbi-tabmenu):not(.cbi-map-tabbed):not(:empty) {
|
||||
margin-bottom: var(--fs-card-gap);
|
||||
}
|
||||
|
||||
/* the disclosure chevron of the overview card toggle (a stock element, keyed by attribute).
|
||||
* pages/20-overview.css sizes the pill around it. */
|
||||
.cbi-title .label[data-indicator="poll-status"]::after {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
border-radius: 1px;
|
||||
transform: rotate(45deg); /* closed: chevron down */
|
||||
margin-top: -2px; /* optical centring of the down chevron */
|
||||
transition: transform var(--fs-dur-move);
|
||||
}
|
||||
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after {
|
||||
transform: rotate(-135deg); /* open: chevron up */
|
||||
margin-top: var(--fs-space-0-5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@layer theme {
|
||||
/* a DELTA on top of the bar's unguarded base (20-shell.css), every width — no @media floor,
|
||||
* fitChrome measures instead — docs/css.md "Toplayout: measured, not media-queried". */
|
||||
:root[data-layout="top"] .fs-sidebar {
|
||||
/* min-height not height: a long hostname wraps the capped brand onto a second line (20-shell.css) */
|
||||
min-height: var(--fs-bar-h);
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
/* the stacked bar (.fs-bar-stack, set by fitChrome in fs-chrome.js, not a media query) IS the
|
||||
* base bar, so the delta below skips it instead of restating the base values */
|
||||
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) {
|
||||
/* nowrap or flexbox wraps the BAR itself instead of the menu's own pills — the signal fitChrome measures */
|
||||
flex-wrap: nowrap;
|
||||
/* aligns the bar to .fs-content so the brand/menu line up with the headings below */
|
||||
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
|
||||
}
|
||||
|
||||
/* flex:1 opens the gap pushing the right cluster to the column's edge */
|
||||
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) ul.nav {
|
||||
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
|
||||
}
|
||||
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) #indicators,
|
||||
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) .fs-themerow,
|
||||
:root[data-layout="top"] .fs-sidebar:not(.fs-bar-stack) .fs-logout { order: 3; margin-inline-start: 0; }
|
||||
|
||||
/* dropdown anchor/bridge/clamp live unguarded in 20-shell.css, not here (issue #19) */
|
||||
|
||||
/* placement only: the strip's own show/hide (empty, single) lives in 20-shell.css */
|
||||
:root[data-layout="top"] .fs-modemenu { order: 4; flex: 1 1 100%; gap: var(--fs-space-1); }
|
||||
|
||||
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
@layer theme {
|
||||
/* buttons straight in a field (Enable/Disable, Start/Restart/Stop) only get whitespace
|
||||
* spacing inline-block, collapsing rows on wrap — wrapping flex with a real gap instead */
|
||||
.cbi-value-field:has(> .cbi-button) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* native input types listed on purpose: LuCI also emits <input type=submit|reset|button>
|
||||
* (sysauth, luci-compat cbi/*.htm), mostly with .cbi-button; luci-app-splash emits bare ones.
|
||||
* Base only gives those width/height: auto — coverage is a contract (docs/gallery.html) */
|
||||
.cbi-button, .btn,
|
||||
input[type="button"], input[type="submit"], input[type="reset"] {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
/* auto, stated: theme/60-inputs.css's generic input box says 210x32px, a base reset would lose to it by layer */
|
||||
width: auto; height: auto;
|
||||
min-height: var(--fs-ctl-h);
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
font-family: var(--fs-font-sans); font-weight: var(--fs-weight); line-height: 1.1;
|
||||
color: var(--fs-text);
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
box-shadow: none; text-shadow: none;
|
||||
/* border-COLOR not the shorthand, which also animates width — a layout animation on every hover */
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* bare `button` rides along: harmless on a border:0 box, meaningful once an app gives it one */
|
||||
.cbi-button:hover, .btn:hover, button:hover,
|
||||
input[type="button"]:hover, input[type="submit"]:hover, input[type="reset"]:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* keyboard focus only: box-shadow:none above kills any stray glow, plain mouse :focus shows
|
||||
* nothing on purpose — the pressed cue below already answers a click */
|
||||
.cbi-button:focus-visible, .btn:focus-visible,
|
||||
input[type="button"]:focus-visible, input[type="submit"]:focus-visible, input[type="reset"]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* solid variants sit ON --fs-accent/-good/-danger, an accent-tinted ring is invisible against
|
||||
* them — ring in the surface colour with a solid accent halo (--fs-focus-ring-solo) instead */
|
||||
.cbi-button-positive:focus-visible, .cbi-button-action:focus-visible,
|
||||
.cbi-button-negative:focus-visible, .cbi-button-remove:focus-visible,
|
||||
.cbi-button-apply:focus-visible, .cbi-button-save:focus-visible,
|
||||
.btn.primary:focus-visible, .btn.danger:focus-visible,
|
||||
.btn.error:focus-visible, .btn.success:focus-visible, .btn.info:focus-visible {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* the sole copy of the pressed inset shadow (base's identical one was absorbed) */
|
||||
.cbi-button:active, .btn:active, .btn.active,
|
||||
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active {
|
||||
box-shadow: inset 0 2px 4px color-mix(in srgb, var(--fs-border) 40%, transparent);
|
||||
}
|
||||
|
||||
/* three fills, each shared by `.cbi-button-*` (LuCI's own views) and `.btn.<colour-word>` (third-party
|
||||
* apps), or a recolour lands on one half and not the other.
|
||||
* The `.important` twins are needed: at 0,2,0 they beat the neutral `input[type=button]` paint (0,1,1)
|
||||
* for dockerman's `<input type=button class="cbi-button-action important">`. */
|
||||
.cbi-button-positive, .btn.success {
|
||||
background: var(--fs-good); border-color: transparent; color: var(--fs-on-good);
|
||||
}
|
||||
.cbi-button-action, .cbi-button-action.important, .btn.primary, .btn.info {
|
||||
background: var(--fs-accent); border-color: transparent; color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-button-negative, .cbi-button-negative.important, .cbi-button-remove,
|
||||
.btn.danger, .btn.error {
|
||||
background: var(--fs-danger); border-color: transparent; color: var(--fs-on-danger);
|
||||
}
|
||||
/* the lift is for the fills above plus the page-action Save & Apply: brightness deepens a solid
|
||||
* fill and washes out an outline or a tint, so neutral Apply/Save keep their hairline.
|
||||
* `.td .cbi-button-apply` is the one outline that does lift. */
|
||||
.cbi-button-positive:hover, .btn.success:hover,
|
||||
.cbi-button-action:hover, .btn.primary:hover, .btn.info:hover,
|
||||
.cbi-button-negative:hover, .cbi-button-remove:hover, .btn.danger:hover, .btn.error:hover,
|
||||
.cbi-page-actions .cbi-button-apply:hover, .td .cbi-button-apply:hover {
|
||||
filter: var(--fs-hover-lift); border-color: transparent;
|
||||
}
|
||||
.cbi-button-reset, .cbi-button-link { background: transparent; border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* .btn owns size words in full now: the plain rule above re-sets padding in a later
|
||||
* layer, so a size word carrying only font-size (base's old copy) got default padding */
|
||||
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); }
|
||||
.btn.small { font-size: var(--fs-type-xs); min-height: var(--fs-ctl-h-sm); padding: var(--fs-space-1) var(--fs-space-2-5); border-radius: var(--fs-radius-sm); }
|
||||
|
||||
.cbi-section .table[id] .td .btn,
|
||||
.cbi-section .table[id] .td .cbi-button { padding: var(--fs-space-1) var(--fs-space-2-5); font-size: var(--fs-type); }
|
||||
/* size only: ui.changes puts .alert-message on the apply/rollback modal itself, so a paint here
|
||||
* (0,2,0, after the fills) turned 'Apply unchecked' into the same panel2 grey as 'Cancel' */
|
||||
.alert-message .btn, .alert-message .cbi-button {
|
||||
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3);
|
||||
}
|
||||
.alert-message .btn:not(.cbi-button-positive, .cbi-button-action, .cbi-button-negative, .cbi-button-remove,
|
||||
.primary, .info, .success, .danger, .error):hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Save/Apply footer: base renders a tall half-empty plate with bottom-only radius, made a compact rounded bar */
|
||||
.cbi-page-actions {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: var(--fs-space-2) 0;
|
||||
margin: 0 0 var(--fs-card-gap);
|
||||
text-align: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--fs-space-2);
|
||||
/* WRAP the bar rather than let items shrink into it: a no-wrap row put the whole squeeze on
|
||||
* the Save & Apply split control's caption (measured up to 119px cut at 320px/Large) instead
|
||||
* of sharing it — docs/css.md "Buttons: the page-action bar wraps, not shrinks" has the
|
||||
* before/after numbers. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cbi-page-actions .cbi-button,
|
||||
.cbi-page-actions .btn {
|
||||
min-height: var(--fs-ctl-h);
|
||||
padding: 0 var(--fs-space-5);
|
||||
border-radius: var(--fs-radius);
|
||||
font-weight: var(--fs-weight);
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Reset takes Save's same ladder step (-soft/-fill) off the DANGER role, since it discards
|
||||
* unsaved changes; label is --fs-text for the same over-tint reason as Save's below. */
|
||||
.cbi-page-actions .cbi-button-reset {
|
||||
background: var(--fs-danger-soft);
|
||||
border: 1px solid var(--fs-danger-line);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
.cbi-page-actions .cbi-button-reset:hover {
|
||||
background: var(--fs-danger-fill);
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* no `.spinning` padding ladder here, and it must not come back — base/95-luci.css puts the
|
||||
* glyph IN THE FLOW instead, reserving its own space (the ladder lost twice: issues #15, #22) */
|
||||
|
||||
input[type="file"]::file-selector-button {
|
||||
margin-inline-end: var(--fs-space-3); padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
font-family: var(--fs-font-sans); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
color: var(--fs-text); background: var(--fs-panel2);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-sm); cursor: pointer;
|
||||
}
|
||||
input[type="file"]::file-selector-button:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* row-action buttons (Edit/Delete/Start-Stop): neutral outline, variants colour the ones that
|
||||
* carry meaning. Neutral :hover comes FIRST — after it, it would grey the coloured variants
|
||||
* back out on hover at equal specificity. 32px clears WCAG 2.5.8's 24px target/spacing rule;
|
||||
* 28px did not (adjacent 24px circles overlapped). */
|
||||
.cbi-section-actions .cbi-button, .cbi-section-actions .btn,
|
||||
.cbi-section-table .cbi-button {
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); font-size: var(--fs-type);
|
||||
/* flex-basis: auto, not a definite basis: with one, the automatic minimum resolves to 0 in
|
||||
* every row and flex-grow divides by count instead of by need — docs/css.md "Buttons:
|
||||
* `flex-basis: auto`, not the definite basis base gave every row action" has the
|
||||
* regression this fixed and the measured widths before/after. */
|
||||
flex-basis: auto;
|
||||
}
|
||||
/* a ComboButton is a widget: its open list is a child of the box, and its label ellipsizes in its own <li> */
|
||||
:is(.cbi-section-actions .cbi-button, .cbi-section-actions .btn, .cbi-section-table .cbi-button):not(.cbi-dropdown) {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
|
||||
background: transparent;
|
||||
border: var(--fs-hairline); color: var(--fs-dim);
|
||||
}
|
||||
.td .cbi-button:hover { border-color: var(--fs-dim); color: var(--fs-text); }
|
||||
|
||||
.td .cbi-button-action, .td .cbi-button-edit, .td .cbi-button-apply,
|
||||
.cbi-section-actions .cbi-button-edit, .cbi-section-actions .cbi-button-action {
|
||||
color: var(--fs-accent); border-color: var(--fs-accent-line); background: transparent;
|
||||
}
|
||||
/* hover ink is --fs-text, not the role colour: text of colour C on a tint of C eats its own
|
||||
* contrast (Save: accent on accent-soft = 4.25:1, AA fail) — role reads from fill + border */
|
||||
.td .cbi-button-action:hover, .td .cbi-button-edit:hover, .td .cbi-button-apply:hover,
|
||||
.cbi-section-actions .cbi-button-action:hover {
|
||||
background: var(--fs-accent-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
}
|
||||
/* an enabled/positive toggle in a table cell (System -> Startup) rendered blue text on a green fill = unreadable; green outline instead */
|
||||
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
|
||||
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
|
||||
}
|
||||
/* without this border-colour the positive toggle read less responsive than its hover neighbours (which brighten to -line-hi) */
|
||||
.td .cbi-button-positive:hover, .cbi-section-actions .cbi-button-positive:hover {
|
||||
background: var(--fs-good-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-good-line-hi);
|
||||
}
|
||||
.td .cbi-button-negative, .td .cbi-button-remove,
|
||||
.cbi-section-actions .cbi-button-remove, .cbi-section-actions .cbi-button-negative {
|
||||
color: var(--fs-danger); border-color: var(--fs-danger-line); background: transparent;
|
||||
}
|
||||
.td .cbi-button-negative:hover, .td .cbi-button-remove:hover,
|
||||
.cbi-section-actions .cbi-button-negative:hover {
|
||||
background: var(--fs-danger-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* Save & Apply is THE primary action, Save demoted so only one reads primary */
|
||||
.cbi-page-actions .cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
|
||||
background: var(--fs-accent);
|
||||
border: 1px solid transparent;
|
||||
color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
/* a TINT at rest, not a bare outline, or Save reads disabled beside the solid Save & Apply;
|
||||
* -soft/-fill off the role ladder. Label --fs-text, not --fs-accent: accent on accent-soft
|
||||
* measures 4.25:1, an AA failure (docs/design-system.md). */
|
||||
.cbi-page-actions .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
|
||||
background: var(--fs-accent-soft);
|
||||
border: 1px solid var(--fs-accent-line);
|
||||
color: var(--fs-text);
|
||||
text-shadow: none;
|
||||
}
|
||||
.cbi-page-actions .cbi-button-save:hover {
|
||||
background: var(--fs-accent-fill);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
}
|
||||
|
||||
/* buttons that are part of a compound field widget */
|
||||
.cbi-dynlist > .add-item > button,
|
||||
.cbi-dynlist > .add-item > .cbi-button-add {
|
||||
/* min-width was a bare 38px, the number --fs-ctl-h already states — a restated value is the drift this ladder exists to prevent */
|
||||
flex: 0 0 auto; width: auto; min-width: var(--fs-ctl-h);
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); margin: 0;
|
||||
border: 0; border-inline-start: var(--fs-hairline);
|
||||
border-radius: 0;
|
||||
background: transparent; color: var(--fs-dim);
|
||||
}
|
||||
.cbi-dynlist > .add-item > button:hover,
|
||||
.cbi-dynlist > .add-item > .cbi-button-add:hover { color: var(--fs-accent); }
|
||||
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral {
|
||||
border: 0; border-radius: 0; margin: 0;
|
||||
background: transparent; color: var(--fs-dim);
|
||||
padding: 0 var(--fs-space-3); min-height: var(--fs-ctl-h);
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral:hover { color: var(--fs-accent); }
|
||||
/* SQUARE, ON THE LADDER: the only buttons once set in bare pixels (40x34, 34 being a height in
|
||||
* no scale) and opted out of the Density axis; square because the glyph is an arrow, not a label */
|
||||
.pager .btn.prev, .pager .btn.next {
|
||||
flex: 0 0 auto; width: var(--fs-ctl-h); min-width: var(--fs-ctl-h); height: var(--fs-ctl-h); padding: 0;
|
||||
border: var(--fs-hairline);
|
||||
}
|
||||
.pager .btn.prev:hover, .pager .btn.next:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
@layer theme {
|
||||
/* baseline, not center: a taller field (help sentence under the control) would push a
|
||||
* centered label off the control's first line */
|
||||
.cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: var(--fs-hairline); }
|
||||
/* stacked controls need a gap, a raw <br> collapses it; password-reveal excluded, must stay seamless */
|
||||
.cbi-value-field > .control-group { margin-top: var(--fs-space-2); }
|
||||
.cbi-value-field > .control-group:not(:has(.cbi-input-password)) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center;
|
||||
}
|
||||
/* hairline separates a row from the NEXT one, so it drops whenever none follows, not only at the section's end */
|
||||
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
|
||||
/* .cbi-checkbox has no text baseline (synthesised at its bottom edge, floats ~5px above the label) — centred, except with a help sentence under it (see the baseline note above) */
|
||||
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
|
||||
align-items: center;
|
||||
}
|
||||
/* ONLY the transition here, shared by every field box below (`textarea` included); typography stays in base/30-forms.css. border-COLOR not the shorthand, which also animates width */
|
||||
input, button, textarea {
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
|
||||
.cbi-value-title {
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
/* stated not inherited: this is TEXT, the control rule hands labels line-height: normal (measured 18px -> normal without this) */
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
/* mono is for VALUES: buttons, the help sentence and `<strong>` LABELS excluded — a label
|
||||
* fed to mono also goes bold, 227 elements/20 KB of face for nothing. theme/45-misc.css's
|
||||
* label rule must ASSIGN sans; excluding `<strong>` here alone still inherits mono */
|
||||
.cbi-value-field,
|
||||
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
|
||||
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
|
||||
|
||||
/* not the generic input's fixed 210px (above): mono + padded --fs-space-3, 210px clipped a URL mid-domain (issue #5); elastic, capped to the dynlist's ceiling */
|
||||
.cbi-value-field input[type="text"],
|
||||
.cbi-value-field input[type="url"],
|
||||
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
|
||||
/* base assumes ONE title+field pair; stack full-width on a SECOND title (not a second field — a runtime-appended field would re-stack under the user's finger, measured: a button moved 31px) */
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
|
||||
flex: 1 1 100%;
|
||||
margin-inline-start: 0;
|
||||
text-align: start;
|
||||
}
|
||||
.td > input[type="text"],
|
||||
.td > input[type="password"],
|
||||
.td > select,
|
||||
.td > .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-dynlist > .add-item > .cbi-dropdown,
|
||||
.cbi-section-create > * > input { width: 100%; }
|
||||
|
||||
/* --- live components that bypassed the theme --- */
|
||||
/* a CBI row is <div class="cbi-value"> on a form, <td class="cbi-value-field"> in a table — same <var>, theme had claimed only the table half */
|
||||
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
|
||||
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
|
||||
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
|
||||
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
|
||||
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
|
||||
|
||||
/* base sizes a row by proportion for its own ~20px buttons; this theme's 36px button (3 per
|
||||
* row) does not fit at 23% and `overflow: hidden` clips the corners — size actions to content,
|
||||
* let the name ellipsize, wrap when that is not enough */
|
||||
.cbi-filebrowser > * { border-bottom-color: var(--fs-border); padding: var(--fs-space-1-5) var(--fs-space-2); }
|
||||
.cbi-filebrowser > ul { display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||
.cbi-filebrowser > ul > li { align-items: center; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.cbi-filebrowser > ul > li > div:first-child { flex: 1 1 auto; min-width: 0; }
|
||||
/* `0 1 auto`: may wrap but not shrink below min-content, or it sticks out (measured 23px past the box at 320px before min-width: 0) */
|
||||
.cbi-filebrowser > ul > li > div:last-child {
|
||||
flex: 0 1 auto; min-width: 0;
|
||||
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
|
||||
}
|
||||
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
|
||||
.cbi-filebrowser .upload > div > input { width: 100%; }
|
||||
|
||||
/* Form widgets — toggles, file input. Applies to every CBI form. */
|
||||
|
||||
/* the shared checkbox rule below gives the input a -4px margin (16px box in a 24px hit area, Normal
|
||||
* density), so inside a .btn label the mark sat flush against the text; .5em restores the gap.
|
||||
* The toggle switch is unaffected: its own margin:0 wins on a different ancestor */
|
||||
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
|
||||
|
||||
/* checkbox -> toggle switch, drawn on the INPUT — docs/css.md "Inputs: the toggle switch,
|
||||
* drawn on the input" has the forum regression this replaced. density-scaled local tokens. */
|
||||
/* overflow-wrap: anywhere for the one caption word too long even at full width (pseudo-loc: a
|
||||
* 14-letter Russian compound grown to 20 pushed the row 24.6px past the field at 320px/Large) */
|
||||
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
|
||||
/* label[for] stays in the DOM but draws nothing: out of flow, out of the flex item count, so gap opens only on the switch input + an optional caption */
|
||||
.cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||
.cbi-checkbox > input[type="checkbox"] {
|
||||
--sw-w: calc(40px * var(--fs-density-box));
|
||||
--sw-h: calc(22px * var(--fs-density-box));
|
||||
--sw-knob: calc(16px * var(--fs-density-box));
|
||||
--sw-pad: calc(2px * var(--fs-density-box));
|
||||
--sw-travel: calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px);
|
||||
/* appearance/--bd-color/--fg-color come from the shared input[type=checkbox] rule below;
|
||||
* flex-shrink: 0 keeps the pill at its width beside a caption (docs/css.md "Inputs: the toggle switch, drawn on the input, not the label") */
|
||||
display: inline-block; position: relative; margin: 0; flex-shrink: 0;
|
||||
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
|
||||
background: var(--fs-track); border: var(--fs-hairline);
|
||||
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur);
|
||||
}
|
||||
/* hit-target overlay to 44x30 (WCAG 2.5.8 floor 24px, pill is 22px); inset stays literal, not
|
||||
* density-scaled — a floor that shrinks with Density is not one */
|
||||
.cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; }
|
||||
.cbi-checkbox > input[type="checkbox"]::after {
|
||||
top: var(--sw-pad); inset-inline-start: var(--sw-pad);
|
||||
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
|
||||
background: var(--fs-dim);
|
||||
/* transform not `left`: left re-lays out the row every frame, translateX is composited */
|
||||
transform: translateX(0);
|
||||
transition: transform var(--fs-dur-move), background var(--fs-dur);
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
|
||||
/* derived so it cannot drift from the sizes above; -2px is the pill's two 1px borders (the knob sits against the padding box, so it has 2px less room — travel is 18px not 20) */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after {
|
||||
transform: translateX(var(--sw-travel));
|
||||
background: var(--fs-on-accent);
|
||||
}
|
||||
/* the knob starts at the inline-start edge, so RTL travels the other way (translateX is physical) */
|
||||
:root[dir="rtl"] .cbi-checkbox > input[type="checkbox"]:checked::after { transform: translateX(calc(var(--sw-travel) * -1)); }
|
||||
/* the input IS the switch, ring goes here; -solo: checked switch already spends its border channel on accent, only the tinted shadow shows */
|
||||
.cbi-checkbox > input[type="checkbox"]:focus-visible {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]:disabled { cursor: not-allowed; } /* opacity comes from base/30-forms.css input[disabled] */
|
||||
|
||||
/* raw checkbox/radio, absorbed from base: undoes the generic input box below. 16px box on the
|
||||
* grid, mark inset FROM it. `.cbi-checkbox > input` also matches the switch's own input
|
||||
* (unscoped, it wants this rule's appearance:none) — selectors that WOULD paint the switch
|
||||
* exclude it individually below instead. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
--bd-color: var(--fs-border);
|
||||
--fg-color: var(--fs-text);
|
||||
/* box tier not space tier: --fs-space-4 gave 16px at Normal but 10.4px at Compact (smaller than any control) and never grew at Large */
|
||||
--box: calc(16px * var(--fs-density-box));
|
||||
--mark-inset: 3px;
|
||||
/* WCAG 2.5.8's 24px spacing floor holds only while the theme owns the layout; ELEMENT is
|
||||
* 24px (drawing stays 16px, centred), max() so it never shrinks under --box at Large */
|
||||
--hit: max(24px, var(--box));
|
||||
appearance: none;
|
||||
width: var(--hit);
|
||||
height: var(--hit);
|
||||
/* negative margin pulls the extra hit area back out of flow (without it, rows measured 42px -> 51px) */
|
||||
margin: calc((var(--box) - var(--hit)) / 2);
|
||||
color: var(--fg-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
/* padding/border-radius not reset: land on an appearance-none box whose pseudo-elements are absolute, invisible either way */
|
||||
}
|
||||
input[type="checkbox"]::before,
|
||||
input[type="checkbox"]::after,
|
||||
input[type="radio"]::before,
|
||||
input[type="radio"]::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
|
||||
input[type="radio"]::before {
|
||||
/* inset:0; margin:auto centres a fixed box the same in RTL as LTR; a translate pair would not */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: var(--box);
|
||||
height: var(--box);
|
||||
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
|
||||
border: 1px solid var(--bd-color);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
input[type="checkbox"]::before { border-radius: 2px; }
|
||||
input[type="radio"],
|
||||
input[type="radio"]::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* keyboard/click focus ring on the DRAWN box, the theme's one ring token */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
|
||||
input[type="radio"]::after {
|
||||
/* mark inset from the DRAWING, which is centred in the target, not from the target's own edge */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: calc(var(--box) - var(--mark-inset) * 2);
|
||||
height: calc(var(--box) - var(--mark-inset) * 2);
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
|
||||
input[type="radio"]:checked::after {
|
||||
/* OURS: stroked path (this theme's icon language), replacing a fill byte-identical to luci-theme-bootstrap's; mask keys on alpha so a stroke masks like a fill did */
|
||||
--checkmark-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='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");
|
||||
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
background: var(--fg-color);
|
||||
}
|
||||
input[type="radio"]:checked:after {
|
||||
/* radio dot filled, not ringed — a ring reads as "not selected" */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>");
|
||||
}
|
||||
input[type="checkbox"].cbi-input-invalid,
|
||||
input[type="radio"].cbi-input-invalid {
|
||||
--bd-color: var(--fs-danger);
|
||||
--fg-color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* generic field box, absorbed from base; bare input keeps the historical 210px fallback for types the list below does not name */
|
||||
input {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
height: var(--fs-control-h);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
background: var(--fs-panel);
|
||||
color: var(--fs-text);
|
||||
font-size: var(--fs-type-field);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
}
|
||||
/* base's old #localtime{width:300px} media rule not moved here: the elastic field width above already out-layers it (measured 440px, not 300) */
|
||||
|
||||
input[type="file"] {
|
||||
color: var(--fs-dim); font-size: var(--fs-type);
|
||||
padding: initial;
|
||||
border: initial;
|
||||
line-height: initial;
|
||||
box-shadow: none;
|
||||
/* no !important: width:auto out-specifies the generic input{width:210px} alone */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* native <select> chevron + theme; the chevron is --fs-select-chevron (two gradient bands), the RTL rule below mirrors its position */
|
||||
select, select.cbi-input-select, .cbi-value-field select, .cbi-select:not(select) {
|
||||
/* unprefixed only, floor clears it everywhere — docs/css.md "The browser floor" */
|
||||
appearance: none;
|
||||
background-color: var(--fs-panel2);
|
||||
background-image: var(--fs-select-chevron);
|
||||
/* right inset is the scale step, not a literal 12; the .cbi-select shell shares this rule */
|
||||
background-repeat: no-repeat; background-size: 6px 6px;
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
padding-block: var(--fs-space-2); padding-inline: var(--fs-space-3) var(--fs-space-8);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type-field);
|
||||
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
|
||||
width: 210px;
|
||||
display: inline-block;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* background-position has no logical keyword, so it is mirrored by hand; the two bands swap offsets
|
||||
* to keep the chevron's left-leaning half on the left. The padding above is logical and follows. */
|
||||
:root[dir="rtl"] :is(select, .cbi-select:not(select)) {
|
||||
background-position: left calc(var(--fs-space-3) + 5px) center, left calc(var(--fs-space-3) - 1px) center;
|
||||
}
|
||||
/* height:inherit so the multiple/listbox variant does not take the closed control's one-line height */
|
||||
select[multiple] { height: inherit; }
|
||||
option { font-size: 100%; line-height: 2em; }
|
||||
select option { background: var(--fs-panel); color: var(--fs-text); }
|
||||
|
||||
/* the OS renders the native list, so `select option` above is mostly ignored; Chromium's
|
||||
* base-select renders it in the DOM instead, themeable like .cbi-dropdown; @supports-gated,
|
||||
* scoped to .cbi-value-field so a third-party app's raw <select> stays native */
|
||||
@supports (appearance: base-select) {
|
||||
.cbi-value-field select { appearance: base-select; }
|
||||
.cbi-value-field select::picker-icon { display: none; }
|
||||
::picker(select) {
|
||||
appearance: base-select;
|
||||
background: var(--fs-panel); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
|
||||
padding: var(--fs-space-1-5); margin-top: var(--fs-space-1-5);
|
||||
}
|
||||
.cbi-value-field select option {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm); font-size: var(--fs-type);
|
||||
/* background:transparent undoes `select option`'s --fs-panel so the row's own hover shows */
|
||||
background: transparent; min-height: 0;
|
||||
}
|
||||
.cbi-value-field select option::checkmark { display: none; }
|
||||
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
|
||||
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
|
||||
.cbi-value-field select option:checked {
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
}
|
||||
}
|
||||
|
||||
/* .cbi-select — legacy luci-compat shell around a native <select>; it takes the themed select rule above and
|
||||
* keeps only its box. :not(select): the class also appears on a bare <select>, which cannot draw the ::before chevron */
|
||||
.cbi-select:not(select) {
|
||||
/* height not min-height: the child's height:100% falls back to content height against an auto-height parent (measured collapse 30px -> 15px in a 38px shell) */
|
||||
height: var(--fs-ctl-h);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
/* the shell draws no text; the shared rule's field size belongs to its select child */
|
||||
font-size: var(--fs-type);
|
||||
}
|
||||
/* base/30-forms.css only dims the old ▾ plate; with the plate gone the disabled state had no visual, so the shell carries it now */
|
||||
.cbi-select[disabled]:not(select) {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* invisible, filling the shell, clearing the plate by the same --fs-space-8 inset the themed select uses */
|
||||
.cbi-select select,
|
||||
.cbi-select select:focus {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding-block: 0; padding-inline: var(--fs-space-3) var(--fs-space-8);
|
||||
font-size: var(--fs-type-field);
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* :where() keeps this at 0,1,0: a bare :not() would be 0,2,0 and beat `.alert-message p` (0,1,1).
|
||||
* A button keeps its own label ink while it spins; dim on a solid fill measured 1.00-2.24:1. */
|
||||
.spinning:where(:not(.btn, .cbi-button)), .cbi-value em, em.spinning { color: var(--fs-dim); }
|
||||
|
||||
/* type list must be EXHAUSTIVE: an omission falls back to the generic `input` box above, beside a themed one, not to "unstyled" */
|
||||
.cbi-input-text, .cbi-input-password, .cbi-input-textarea,
|
||||
.cbi-section-create-name, .cbi-value-field input.create-item-input,
|
||||
input[type="text"], input[type="password"], input[type="number"],
|
||||
input[type="email"], input[type="url"], input[type="search"],
|
||||
input[type="tel"], input[type="date"], input[type="time"],
|
||||
input[type="datetime-local"], input[type="month"], input[type="week"],
|
||||
input[type="color"],
|
||||
textarea {
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-text);
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
min-height: var(--fs-ctl-h);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* max-width:100% beside the fixed 210px: a textarea's intrinsic width comes from `cols` (default 20), which put two textareas 19px outside their card at 300px before this */
|
||||
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type-field); }
|
||||
/* a square native scrollbar is not clipped by a rounded corner; self-rounded thumb + transparent track fixes it. no scrollbar-width/-color: either switches Chromium to the square standard bar */
|
||||
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
|
||||
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: var(--fs-dim); border-radius: var(--fs-radius-pill);
|
||||
border: 5px solid transparent; background-clip: padding-box;
|
||||
}
|
||||
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
|
||||
textarea::-webkit-scrollbar-corner { background: transparent; }
|
||||
/* accent arc traces the frame's own radius (two borders shrunk to 16px - radius); mirrored by
|
||||
* hand below, ::-webkit-resizer ignores logical properties */
|
||||
textarea::-webkit-resizer {
|
||||
background: transparent;
|
||||
border-right: 2px solid var(--fs-accent);
|
||||
border-bottom: 2px solid var(--fs-accent);
|
||||
border-bottom-right-radius: var(--fs-radius);
|
||||
margin: calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius));
|
||||
}
|
||||
:root[dir="rtl"] textarea::-webkit-resizer {
|
||||
border-right: 0; border-left: 2px solid var(--fs-accent);
|
||||
border-bottom-right-radius: 0; border-bottom-left-radius: var(--fs-radius);
|
||||
margin: calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0;
|
||||
}
|
||||
|
||||
.cbi-input-text:hover, .cbi-input-password:hover, .cbi-input-textarea:hover,
|
||||
input[type="text"]:hover, input[type="password"]:hover, input[type="number"]:hover,
|
||||
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
|
||||
textarea:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* fallback ring for untyped inputs; excludes the toggle switch, whose :focus-visible-only ring must not double up */
|
||||
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
|
||||
.cbi-section-create-name:focus,
|
||||
input:focus:not(:where(.cbi-checkbox > input)),
|
||||
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
|
||||
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
|
||||
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.cbi-dynlist > .item:focus {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* same ring, shared by 4 selectors that cannot join the list above; :is() around :has() keeps the list forgiving — unwrapped, a :has() compound sharing a list fails the WHOLE rule below the floor */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
|
||||
.cbi-select.focus,
|
||||
.cbi-dynlist > .add-item:focus-within,
|
||||
.control-group:is(:has(> .cbi-input-password)):focus-within {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* .cbi-value-error input (Lua-CBI) must live here: base's red border-color is a longhand and
|
||||
* this rule's border shorthand wipes it by layer regardless of specificity.
|
||||
* The same rejected value shown by a ui.Dropdown: base carried the widget and its closed <li>, theme only the input half.
|
||||
* The enhanced select's validator fails on the hidden <select>, so its widget (the next sibling) is matched through it;
|
||||
* (0,5,1) beats 65-dropdown's border shorthand (0,3,0), its hover (0,4,0) and its focus ring (0,2,0); the closed <li> takes the border only, the ring sits on the widget */
|
||||
input[type="checkbox"].cbi-input-invalid:focus::before,
|
||||
input[type="radio"].cbi-input-invalid:focus::before,
|
||||
.cbi-select.cbi-input-invalid.focus,
|
||||
.cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid,
|
||||
.cbi-value-error input, .cbi-value-error textarea,
|
||||
.cbi-input-invalid:focus,
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
select.cbi-input-invalid[data-fs-select="1"] + .cbi-dropdown:not(.btn):not(.cbi-button) {
|
||||
border-color: var(--fs-danger);
|
||||
box-shadow: var(--fs-focus-ring-invalid);
|
||||
}
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
|
||||
|
||||
/* doubled [readonly] states intent at (0,2,1): at (0,1,1) it only tied the typed input rules above and silently lost */
|
||||
:is(input, select, textarea)[readonly][readonly] {
|
||||
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
|
||||
pointer-events: auto;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* keep base's layout (inline-flex chips + add-item row); only colours, rounding, a compact + button, narrow cap so a chip's × sits near its text */
|
||||
.cbi-dynlist {
|
||||
gap: var(--fs-space-1-5);
|
||||
background: transparent; border: 0; padding: 0;
|
||||
/* min(240px,100%) not a bare 240: a floor that exceeds the room is an overflow (measured 29px outside a 210px field at 320px) */
|
||||
min-height: 0; min-width: min(240px, 100%); max-width: var(--fs-field-max); width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cbi-dynlist > .item {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text);
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.item::after {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(var(--fs-panel), var(--fs-panel) 25%, var(--fs-panel2));
|
||||
padding: 0 var(--fs-space-3);
|
||||
color: var(--fs-text);
|
||||
line-height: 2em;
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.item:hover::after { filter: var(--fs-hover-lift); }
|
||||
.cbi-dynlist > .item::after {
|
||||
display: inline-flex;
|
||||
padding: 0 var(--fs-space-2);
|
||||
color: var(--fs-danger);
|
||||
/* logical: base pins the × with inset-inline-end, so in RTL the rounded corners must follow it */
|
||||
border-radius: 0;
|
||||
border-start-end-radius: var(--fs-radius-sm);
|
||||
border-end-end-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.cbi-dynlist > .add-item {
|
||||
align-items: stretch;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm); overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.cbi-dynlist > .add-item > input,
|
||||
/* width:auto keeps the flex basis in charge — this input sits inside .cbi-value-field, the elastic width above would otherwise hand it 100% on top of flex:1 1 auto */
|
||||
.cbi-dynlist > .add-item > .cbi-input-text {
|
||||
flex: 1 1 auto; min-width: 0; width: auto;
|
||||
border: 0; background: transparent;
|
||||
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
|
||||
}
|
||||
/* UIDynamicList drop indicator (ui.js puts .drag-over on the dragenter target): (0,4,0) out-ranks the border
|
||||
* shorthands above and in 55-buttons; [draggable] (ui.js sets it on every .item) lifts the item past .item:focus (0,3,0) */
|
||||
.cbi-dynlist > .item[draggable].drag-over,
|
||||
.cbi-dynlist > .add-item > :is(.cbi-input-text, .cbi-button-add).drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
/* input + button as ONE seamless group; gap:0 is load-bearing — base gives every .control-group a gap, this is the one whose members must touch */
|
||||
.control-group:has(> .cbi-input-password) {
|
||||
display: inline-flex; align-items: stretch; gap: 0;
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
.control-group:has(> .cbi-input-password) > .cbi-input-password {
|
||||
border: 0; border-radius: 0;
|
||||
background: transparent; box-shadow: none;
|
||||
min-height: var(--fs-ctl-h);
|
||||
}
|
||||
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* `.hidden.hidden` beats component rules up to (0,3,0) that come earlier in source, without
|
||||
* !important (which would invert layer order); it also covers the Enter-key submit legacy Lua
|
||||
* pages carry (issue #12). The @container table rules and fs-stacked actions outrank it.
|
||||
* :not([class*=":"]) excludes a Tailwind/UnoCSS class fs-sheets.js re-hosts into this layer —
|
||||
* docs/css.md "Inputs: `.hidden.hidden`, and the Tailwind/UnoCSS exception". */
|
||||
.hidden.hidden:not([class*=":"]) { display: none; }
|
||||
/* the WCAG 2.5.8 target (24px min) split from the 6px track — docs/css.md "Inputs: the range
|
||||
* slider's hit target and its line-box" has the two-part fix and its numbers */
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
width: 100%; height: 24px; min-height: 0; padding: 0;
|
||||
margin-block: -9px;
|
||||
background: transparent; border: 0; border-radius: 0;
|
||||
outline: none; box-shadow: none; cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--fs-accent); border: 2px solid var(--fs-panel);
|
||||
cursor: pointer;
|
||||
/* WebKit centres on the INPUT's box not the track: (6-16)/2 = -5px */
|
||||
margin-top: -5px;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
|
||||
}
|
||||
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
|
||||
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
|
||||
/* same ceiling as the elastic text inputs; vertical-align:middle decouples the line-box from the input's own edge, which moved 6px -> 24px above */
|
||||
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
|
||||
.cbi-range-slider-value {
|
||||
background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
|
||||
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);
|
||||
/* not 600: no bold mono face bundled, drags in 20 KB or fakes it with synthetic emboldening */
|
||||
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
|
||||
}
|
||||
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
@layer theme {
|
||||
/* generic .dropdown-menu popup: nothing in luci-base emits it — coverage vocabulary, since
|
||||
* stock luci-theme-bootstrap styles it and a third-party app may emit it */
|
||||
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus-visible { background: var(--fs-panel2); color: var(--fs-accent); }
|
||||
|
||||
/* the outer div IS the button, class = ui.js's SELECTED mode; unguarded this erased that
|
||||
* variant's fill while its ink survived — measured page-on-page at 1.06-1.13:1. */
|
||||
.cbi-page-actions .cbi-dropdown:not(.btn):not(.cbi-button) {
|
||||
border: 0; border-radius: var(--fs-radius); background: transparent;
|
||||
}
|
||||
/* Apply is the one variant with no fill of its own (theme/55-buttons.css gives the rest theirs); painted by specificity, not source order */
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button {
|
||||
background: var(--fs-accent);
|
||||
border: 0;
|
||||
color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button:hover { filter: var(--fs-hover-lift); }
|
||||
|
||||
/* ---- cbi-dropdown widget: the JS select used across all forms ---- */
|
||||
/* machinery absorbed from base, driven by ui.js attributes; the state machine below is
|
||||
* UNFLAGGED, stating openly what upstream's !important flags resolved — docs/css.md
|
||||
* "Dropdown: the display state machine, unflagged" has the specificity table. */
|
||||
.cbi-dropdown {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
.cbi-dropdown img {
|
||||
/* constrain a vector icon of any intrinsic size to the text line */
|
||||
max-height: 1.6em;
|
||||
width: auto;
|
||||
}
|
||||
.cbi-dropdown > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
/* clears the button's own side inset so the label starts on its text, unflagged (0,3,1) over the rule above's (0,1,1) */
|
||||
.cbi-dropdown.btn > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
|
||||
margin-block: 0;
|
||||
margin-inline: var(--fs-space-3);
|
||||
}
|
||||
.cbi-dropdown.btn.spinning > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) {
|
||||
margin: 0;
|
||||
}
|
||||
.cbi-dropdown > ul.preview {
|
||||
display: none;
|
||||
}
|
||||
/* no `display` here: `.open` states its own below, `.more` is none until the showers flip it — one declaration per state or the audit's same-selector shadow check fires */
|
||||
.cbi-dropdown > .open,
|
||||
.cbi-dropdown > .more {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
/* currentColor: the variant's own ink, 4.6-5.4:1 on every button fill measured; --fs-border is 1.45:1 */
|
||||
.cbi-dropdown.btn > .open,
|
||||
.cbi-dropdown.cbi-button > .open {
|
||||
border-inline-start: 1px solid currentColor;
|
||||
}
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
|
||||
display: none;
|
||||
}
|
||||
.cbi-dropdown > ul > li {
|
||||
display: none;
|
||||
font-size: var(--fs-type-field);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* ellipsis did nothing until min-width: 0 — a flex item's automatic minimum floors it at its
|
||||
* own content width — docs/css.md "Dropdown: ellipsis needs `min-width: 0` first". */
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
color: inherit;
|
||||
}
|
||||
.cbi-dropdown > ul > li .hide-open { display: initial; }
|
||||
.cbi-dropdown > ul > li .hide-close { display: none; }
|
||||
/* a button dropdown's label is a button label, not a form field's 16px value; block, not the
|
||||
* state machine's flex, since text-overflow only draws its "…" on a block container */
|
||||
.cbi-dropdown:is(.btn, .cbi-button) > ul:not(.dropdown) > li[display] { display: block; font-size: inherit; }
|
||||
/* the chip divider on a BUTTON dropdown's preview; the form dropdown zeroes it below */
|
||||
.cbi-dropdown > ul > li[display]:not([display="0"]) {
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
.cbi-dropdown[empty] > ul {
|
||||
max-width: 1px;
|
||||
}
|
||||
.cbi-dropdown > ul > li > form {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cbi-dropdown > ul > li img {
|
||||
vertical-align: middle;
|
||||
margin-inline-end: .25em;
|
||||
}
|
||||
.cbi-dropdown > ul > li > form > input[type="checkbox"] {
|
||||
margin-block: 0;
|
||||
margin-inline: 0 var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* ---- the display state machine, on specificity (docs/css.md has the per-selector table) ---- */
|
||||
/* a closed optional dropdown with an empty value renders EMPTY unflagged too: the placeholder
|
||||
* hider out-specifies the generic shower. :not([open]) keeps this out of the open list. */
|
||||
.cbi-dropdown > ul > li[display],
|
||||
.cbi-dropdown[open] > ul.preview,
|
||||
.cbi-dropdown[open] > ul.dropdown > li,
|
||||
.cbi-dropdown[multiple] > ul > li > label,
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li,
|
||||
.cbi-dropdown[multiple][more] > .more,
|
||||
.cbi-dropdown[multiple][empty] > .more,
|
||||
.cbi-dropdown:not([open]):not(.btn):not(.cbi-button) > ul > li[placeholder][display] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[empty] > ul > li,
|
||||
.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] {
|
||||
display: block;
|
||||
}
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li > form {
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
|
||||
/* min-width: a CHILD with an inline width overflows, wrapping the row cannot reach it —
|
||||
* form.js's RichListValue assumes an unbounded list (issue #15). */
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: initial; min-width: 0 !important; }
|
||||
.cbi-dropdown[open] > ul.dropdown > li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li[unselectable] {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) {
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-text);
|
||||
min-height: var(--fs-ctl-h);
|
||||
/* fixed height: without it a closed dropdown grows to its tallest child (Save & Apply's
|
||||
* 40px button, measured 38 -> 42px on the gallery); --fs-ctl-h, since the min-height floor always won */
|
||||
height: var(--fs-ctl-h);
|
||||
font-size: var(--fs-type);
|
||||
transition: var(--fs-field-transition);
|
||||
/* absorbed from base/30-forms.css; NOT the dynlist's numbers (240/440) — one value vs a column of them */
|
||||
min-width: 210px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
}
|
||||
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { padding: 0; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button):hover { border-color: var(--fs-dim); }
|
||||
.cbi-dropdown:focus, .cbi-dropdown[open] {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* `.cbi-dropdown > ul.dropdown > li` is the same row on a BUTTON dropdown, excluded by
|
||||
* :not(.btn):not(.cbi-button) below — one menu, one row, but two selectors. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li,
|
||||
.cbi-dropdown > ul.dropdown > li {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); color: var(--fs-text);
|
||||
font-family: var(--fs-font-sans);
|
||||
/* a menu row is not a button label: the open list sits inside the button and would inherit its 600 and 1.1 line (a plain dropdown's rows: 400, 1.5) */
|
||||
font-weight: var(--fs-weight-normal); line-height: var(--fs-leading);
|
||||
/* a grid step, not calc(--fs-type * --fs-leading) (19.5px) — a half-pixel box edge is what the scale exists to stop */
|
||||
min-height: var(--fs-space-5);
|
||||
}
|
||||
/* a MULTI-select wraps items in <form><label>…</label>, which theme/60-inputs.css's `.cbi-value-field *` would hand mono — inherit keeps it the row's sans, a LABEL not a value */
|
||||
.cbi-dropdown > ul > li > form,
|
||||
.cbi-dropdown > ul > li label { font-family: inherit; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
|
||||
/* same ink base/30-forms.css gives `placeholder`; repeats the row rule's two selectors rather
|
||||
* than a shorter form, which LOSES to the row's own color and rendered in the value's ink. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder],
|
||||
.cbi-dropdown > ul.dropdown > li[placeholder] { color: var(--fs-placeholder); }
|
||||
/* the multiselect's inline filter/create inputs, moved from base with the generic input box (theme/60-inputs.css) */
|
||||
.cbi-dropdown > ul > li input[type="text"] { height: var(--fs-space-5); }
|
||||
.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { width: 100%; }
|
||||
|
||||
/* `.open` is the only hit target that opens the list (a click elsewhere on a button dropdown
|
||||
* RUNS the button). `font-size: 0` collapsed the stock line-height to 28x0, putting the
|
||||
* chevron outside its own box — a real box fixes that. */
|
||||
.cbi-dropdown > .open {
|
||||
display: flex;
|
||||
font-size: 0; line-height: 0; border: 0;
|
||||
position: relative; padding: 0;
|
||||
align-self: stretch;
|
||||
min-width: var(--fs-btn-size); min-height: var(--fs-ctl-h-sm);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* `.more` was one base rule with `.open`, split when the theme took `.open` for the chevron */
|
||||
.cbi-dropdown > .more { line-height: 2em; padding: 0 .3em; color: var(--fs-dim); display: none; }
|
||||
.cbi-dropdown > .open > * { display: none; }
|
||||
.cbi-dropdown > .open::after {
|
||||
content: ""; position: absolute; inset: 0; margin: auto;
|
||||
width: 7px; height: 7px;
|
||||
border-right: 2px solid var(--fs-dim); border-bottom: 2px solid var(--fs-dim);
|
||||
transform: translateY(-2px) rotate(45deg); /* optical centre of a "v" */
|
||||
}
|
||||
/* --fs-dim reads as a smudge on the accent-filled Save & Apply; currentColor uses the variant's own ink */
|
||||
.cbi-dropdown.btn > .open::after,
|
||||
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
|
||||
|
||||
/* open list -> design popover */
|
||||
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-dropdown);
|
||||
transition: max-height var(--fs-dur-move);
|
||||
overflow-y: auto;
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
padding: var(--fs-space-1-5);
|
||||
margin-top: var(--fs-space-1-5);
|
||||
/* anchored to its widget: ui.js's touch branch assumes the viewport as containing block; !important beats the inline style */
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: auto !important;
|
||||
min-width: 100%;
|
||||
/* desktop only: min(92vw,420px) ignores trigger's left offset, grew past viewport under 767px (113 findings) */
|
||||
width: max-content;
|
||||
max-width: min(92vw, 420px);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li {
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-inline-start: 0;
|
||||
position: relative;
|
||||
/* an OPEN row wraps; only the closed preview ellipsizes — the menu is capped at
|
||||
* min(92vw, 420px), so a verbose option would otherwise decide the width of every menu */
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
/* no border-bottom: it curls at the corners of a rounded box; the ::after inset strip below is the divider instead */
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* The row's own wrap above never reaches a `.zonebadge` (base/95-luci.css): `white-space: nowrap`
|
||||
* there, right for a one-line pill in a table cell, wrong here where a multi-homed interface
|
||||
* (RichListValue) nests two `.ifacebadge` chips in one badge with no space between them — an
|
||||
* unbreakable 254-258px at 320px (`R` past the 320px viewport), overflowing
|
||||
* regardless of the popup's own width. Scoped to the open list only: every other `.zonebadge`
|
||||
* keeps `nowrap`. */
|
||||
.cbi-dropdown[open] > ul.dropdown > li .zonebadge {
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:not(:last-child)::after {
|
||||
content: ""; position: absolute; inset-inline: 11px; bottom: 0;
|
||||
height: 1px; background: var(--fs-border);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:hover,
|
||||
.cbi-dropdown[open] > ul.dropdown > li.focus { background: var(--fs-panel2); color: var(--fs-accent); }
|
||||
/* after hover on purpose: hovering the selected row must still read as selected — docs/css.md
|
||||
* "Dropdown: the open list's rail and the selected-row's contrast". */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected] {
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
}
|
||||
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
|
||||
* INSIDE it so a focused+selected row keeps both */
|
||||
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; the same declarations as
|
||||
* `select option:checked` in theme/60-inputs.css, which has no checkbox (kept identical by hand) */
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: var(--fs-hairline); margin-top: var(--fs-space-1); }
|
||||
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, wrapper stays transparent: no double frame */
|
||||
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
|
||||
background: none; border: 0; overflow: visible; box-shadow: none;
|
||||
}
|
||||
|
||||
.cbi-section-table .td .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-section-table .td .cbi-input-select,
|
||||
.cbi-section-table .td select,
|
||||
.cbi-section-table .td input[type="text"] {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* below ~960px a multi-dropdown row no longer fits, so each row cards, same shape as the
|
||||
* data-table stack (theme/30-tables.css); a @container, not the tables' own measurement, since
|
||||
* un-collapsing a row to measure it CHANGES what its widgets read. 992, not 960 — docs/css.md
|
||||
* "Dropdown: 992px, not 960" has the padding subtraction and the regression it fixed. */
|
||||
@container fs-content (max-width: 992px) {
|
||||
.table.cbi-section-table,
|
||||
.table.cbi-section-table .thead,
|
||||
.table.cbi-section-table .tbody,
|
||||
.table.cbi-section-table .td { display: block; width: auto; }
|
||||
/* a card repeats every label, so the header goes in BOTH markups (form.js's .thead children,
|
||||
* the Lua CBI's bare titles/descr rows) — except the opt-in Table Filters row (25.12 tags it
|
||||
* -titles too): no card repeats it, and hidden its 4 gallery controls rendered 0x0 */
|
||||
.table.cbi-section-table .thead > :not(.cbi-section-table-filter),
|
||||
.table.cbi-section-table .tr.cbi-section-table-titles:not(.cbi-section-table-filter),
|
||||
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
|
||||
/* stretch (not flex-start) keeps a pair's cells the same height when one wraps */
|
||||
.table.cbi-section-table .tr {
|
||||
display: flex; flex-wrap: wrap; width: auto;
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-panel2);
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5) var(--fs-space-3);
|
||||
margin-bottom: var(--fs-space-3);
|
||||
}
|
||||
.table.cbi-section-table .tr:hover { background: var(--fs-panel2); }
|
||||
/* margin: 0 load-bearing: the Lua CBI gives a table CELL the .cbi-value-field class a form
|
||||
* field carries, base indents it 20px — without the reset two cells no longer fit a line */
|
||||
.table.cbi-section-table .td {
|
||||
flex: 1 1 100%; min-width: 0; margin: 0;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
/* overflow-wrap: anywhere for opaque tokens (a real IPv6 GUA has no break point) — one
|
||||
* ran 156px past a 360px viewport before this */
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* the filter cells are .th, so the .td rule above never reaches them: one field per card
|
||||
* line, and no row divider (.cbi-section .table .th's border-bottom) between fields */
|
||||
.table.cbi-section-table .tr.cbi-section-table-filter > .th {
|
||||
flex: 1 1 100%; min-width: 0; border: 0;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5);
|
||||
}
|
||||
/* a cell that only READS a value is short, two fit a line; a real widget keeps full width (a dropdown at half a phone's width is unusable) */
|
||||
.table.cbi-section-table .td[data-widget="CBI.DummyValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.FlagValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.Button"],
|
||||
.table.cbi-section-table .td[data-widget="dvalue"],
|
||||
.table.cbi-section-table .td[data-widget="fvalue"],
|
||||
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
|
||||
/* a copy of the data table's rule (theme/30-tables.css), needed under a CLASS there and
|
||||
* under this @container here, and CSS cannot OR the two guards. Keep them identical by hand. */
|
||||
.table.cbi-section-table .td[data-title]::before {
|
||||
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);
|
||||
}
|
||||
/* the row's buttons are not a label/value pair: full width, sharing the line. Copy in theme/30-tables.css. */
|
||||
.table.cbi-section-table .td.cbi-section-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);
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
|
||||
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
|
||||
* div is a single flex item. Copy in theme/30-tables.css; same trap on Startup. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
}
|
||||
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
|
||||
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/30-tables.css. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
|
||||
flex: 0 1 content; width: 0; min-width: 0;
|
||||
}
|
||||
.table.cbi-section-table .td .zone-forwards { width: 100%; }
|
||||
}
|
||||
|
||||
/* the table card (theme/30-tables.css) clips with overflow: hidden, but a .cbi-dropdown's open
|
||||
* list is absolutely positioned — drop the clip, restore corners via separated borders instead */
|
||||
.table:has(.cbi-dropdown),
|
||||
.table:has(.cbi-dynlist) {
|
||||
overflow: visible;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@layer theme {
|
||||
/* beats the heading ramp's tight leading in-layer for a dialog's h4 (45-misc.css) */
|
||||
.modal > * {
|
||||
line-height: normal;
|
||||
margin-bottom: .5em;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* wins on specificity, not base's file-order tie */
|
||||
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
|
||||
/* moved from base: width:100% loses to the 210px generic box by layer */
|
||||
.modal > pre,
|
||||
.modal > textarea {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 600px is cramped for a table form (.cbi-map cards on a big screen); vw widens toward 1100 on desktop only */
|
||||
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
|
||||
|
||||
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
|
||||
#modal_overlay .modal label.btn {
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#modal_overlay .modal li { overflow-wrap: anywhere; }
|
||||
/* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */
|
||||
#modal_overlay .uci-dialog div > ins,
|
||||
#modal_overlay .uci-dialog div > del,
|
||||
#modal_overlay .uci-dialog div > var,
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); }
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
#modal_overlay .uci-change-legend-label > var { overflow: hidden; }
|
||||
|
||||
/* fill child sits 3px inset, overflow:hidden never reaches the corner — max() floors at 0 (issue #14) */
|
||||
#modal_overlay .uci-dialog var > ins,
|
||||
#modal_overlay .uci-dialog var > del {
|
||||
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
@layer theme {
|
||||
/* page-search palette. fs-search.js parents it to <body>: .fs-sidebar is overflow:auto, which
|
||||
* would clip a child of the trigger, and the overlay covers the whole viewport anyway. */
|
||||
|
||||
/* --fs-scrim is the theme's one colour literal (plain black at .7) on purpose: absence of light behind a dialog, not a tint of any palette token */
|
||||
.fs-search-ov {
|
||||
position: fixed; inset: 0; z-index: var(--fs-z-popover);
|
||||
/* what the `hidden` attribute has to beat — base/10-reset.css covers every element now */
|
||||
display: flex; justify-content: center;
|
||||
/* not centred vertically: the box grows DOWNWARD as results come in, centred would slide the input out from under the user's eyes on every keystroke */
|
||||
align-items: flex-start;
|
||||
padding: max(8vh, var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);
|
||||
background: var(--fs-scrim);
|
||||
animation: fs-fade var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
/* a SCROLL CONTAINER, not decoration: uncapped the box grows past a short viewport (phone
|
||||
* landscape ~380px), and a position:fixed popup cannot be scrolled by the page */
|
||||
.fs-search-box {
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
width: 100%; max-width: 560px; max-height: min(70dvh, 560px);
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-search-row {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2-5); flex: 0 0 auto;
|
||||
padding: var(--fs-space-3) var(--fs-space-3-5);
|
||||
border-bottom: var(--fs-hairline);
|
||||
}
|
||||
.fs-search-ico { display: inline-flex; color: var(--fs-faint); }
|
||||
.fs-search-ico .fs-ico { width: 18px; height: 18px; }
|
||||
/* the ROW is the field, the input carries no frame of its own — two classes (0,2,0) needed to
|
||||
* outrank 60-inputs.css's generic field box (0,1,1) in the same layer regardless of source
|
||||
* order. --fs-type-floor at every width, not the body size: below 16px iOS Safari zooms the page
|
||||
* on focus, and this field focuses as the palette opens. */
|
||||
.fs-search-row .fs-search-input {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
margin: 0; padding: 0; border: 0; min-height: 0; background: transparent;
|
||||
color: var(--fs-text);
|
||||
font-family: inherit; font-size: var(--fs-type-floor); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
}
|
||||
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
|
||||
/* ink comes from base ::placeholder (one for the whole theme); only the weight is restated */
|
||||
.fs-search-row .fs-search-input::placeholder { font-weight: var(--fs-weight); }
|
||||
|
||||
.fs-search-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--fs-space-1-5); }
|
||||
|
||||
.fs-search-opt {
|
||||
display: flex; align-items: baseline; gap: var(--fs-space-2-5);
|
||||
padding: var(--fs-space-2) var(--fs-space-2-5); border-radius: var(--fs-radius);
|
||||
color: var(--fs-text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
/* one highlight for pointer AND keyboard: .active is set on both aria-activedescendant and pointermove, so mouse and arrow keys can never disagree which row Enter opens */
|
||||
.fs-search-opt.active { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||
.fs-search-opt-title { flex: 0 1 auto; font-weight: var(--fs-weight-bold); font-size: var(--fs-type); }
|
||||
.fs-search-opt-path {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
color: var(--fs-faint); font-size: var(--fs-type); text-align: end;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fs-search-opt.active .fs-search-opt-path { color: inherit; }
|
||||
|
||||
.fs-search-note {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3-5) var(--fs-space-0-5);
|
||||
color: var(--fs-eyebrow-color);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* empty state ("No pages found") reads as a sentence, not an eyebrow label. :has(), not a
|
||||
* sibling combinator: the note sits ABOVE the list it describes, `+` cannot reach back to it. */
|
||||
.fs-search-box:has(.fs-search-list:empty) .fs-search-note {
|
||||
padding: var(--fs-space-6) var(--fs-space-3-5) var(--fs-space-7);
|
||||
text-align: center; text-transform: none; letter-spacing: 0;
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@layer theme {
|
||||
/* phone overflow safety net (<=767px): .fs-main is overflow-x: clip, cutting anything wider silently; tablets were clean */
|
||||
/* split-button labels wrap under 360px — docs/css.md "Responsive: a split button's label must wrap under 360px" */
|
||||
@container fs-view (max-width: 360px) {
|
||||
#view .cbi-button, #view .btn,
|
||||
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* foreign-<table> fallback unguarded now in theme/30-tables.css: fit is content/column, never viewport */
|
||||
|
||||
/* button dropdowns: no floor, capped to the column */
|
||||
.cbi-dropdown { min-width: 0; max-width: 100%; }
|
||||
/* form dropdowns: 65-dropdown.css's (0,3,0) 210px/400px floor outranks the rule above, a @media adds no
|
||||
* specificity; the doubled class lifts this one to (0,4,0), min() so a 320px modal field is not overflowed */
|
||||
.cbi-dropdown.cbi-dropdown:not(.btn):not(.cbi-button) { min-width: min(210px, 100%); max-width: 100%; }
|
||||
/* phone tier over 65-dropdown.css's `width: max-content` (0,3,1): the doubled class makes this (0,4,1); trigger already capped to field width, `auto` keeps the popup on-screen */
|
||||
.cbi-dropdown[open] > ul.dropdown.dropdown { width: auto; }
|
||||
|
||||
/* package-manager.js injects an unlayered `.controls > * > label { min-width: 250px }` (overflows ~360px) and `.controls { display: flex }`; !important beats that sheet, and the two pager rows' inline display:none until the first display() */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
|
||||
/* :not(.pager): blocking it stacked « / text / » onto three lines */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager {
|
||||
display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .text { flex: 0 1 auto; min-width: 0; }
|
||||
/* disk gauge value moved BELOW the bar — docs/css.md "Responsive: the disk gauge value moves below the bar" */
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
|
||||
width: 100%; margin-bottom: 3em;
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar::after {
|
||||
top: calc(100% + 5px); inset-inline: 0 auto; bottom: auto;
|
||||
text-align: start; white-space: normal; max-width: 100%;
|
||||
}
|
||||
/* `> div > label`: only the TITLE label, a bare `.controls label` also caught nested radio labels */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > label { display: block; min-width: 0; white-space: normal; margin-bottom: var(--fs-space-1); }
|
||||
/* radio row: stock's text nodes don't stop a mid-choice split, a real gap does */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > div:has(> label > input[type="radio"]) {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1) var(--fs-space-5);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group { display: flex; flex-wrap: wrap; min-width: 0; gap: var(--fs-space-2); }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group input { flex: 1 1 140px; min-width: 0; max-width: 100%; }
|
||||
|
||||
/* Diagnostics stack not here: fs-select.js writes .fs-rowstack at any width, theme/30-tables.css carries it */
|
||||
|
||||
/* no `table.fs-dt { display: block }`: discards the table context — docs/css.md "Responsive: no `display: block` on `table.fs-dt`" */
|
||||
|
||||
/* generic caps: a field never exceeds its column */
|
||||
.cbi-value-field input, .cbi-value-field select { max-width: 100%; }
|
||||
/* content-sized leaves half the row empty; checkbox/radio/file keep intrinsic size */
|
||||
.cbi-value-field select,
|
||||
.cbi-value-field input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
|
||||
.cbi-value-field .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-value-field .cbi-dynlist,
|
||||
.cbi-value-field .cbi-select { width: 100%; }
|
||||
/* min-width: 0 only, base/40-tables.css already breaks a long value */
|
||||
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
|
||||
|
||||
/* side-by-side squeezes a field to ~120px; not #view-scoped, ui.js appends modals to <body> */
|
||||
.cbi-value > .cbi-value-title { flex: 1 1 100%; text-align: start; padding-top: 0; }
|
||||
.cbi-value > .cbi-value-field { flex: 1 1 100%; min-width: 0; }
|
||||
|
||||
/* stock phone contract; `.td.hide-xs` not redundant, stacking rules (0,2,1)/(0,2,0) outrank a bare `.hide-xs` (0,1,0) */
|
||||
.hide-sm, .hide-xs,
|
||||
.table .td.hide-sm, .table .td.hide-xs,
|
||||
.table .th.hide-sm, .table .th.hide-xs { display: none; }
|
||||
/* .col-N weights UNGUARDED now, theme/30-tables.css: flex is inert on a non-flex item */
|
||||
|
||||
/* no field-size rule here: the 16px iOS floor is the --fs-type-field token, set under this query in 02-tokens.css */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@layer theme {
|
||||
/* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules,
|
||||
* and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below
|
||||
* .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* re-states the tokens so every rule reading them follows — docs/css.md "A11y: prefers-contrast token shifts, and the numbers behind them" has why each number */
|
||||
@media (prefers-contrast: more) {
|
||||
/* theme layer outranks the tokens-layer twins (04-nocolormix.css): without color-mix an unguarded
|
||||
* re-set leaves --fs-hairline, the focus ring and the placeholder ink invalid at use */
|
||||
@supports (color: color-mix(in srgb, red 50%, transparent)) {
|
||||
:root {
|
||||
--fs-border: color-mix(in srgb, var(--fs-text) 45%, var(--fs-bg));
|
||||
--fs-dim: color-mix(in srgb, var(--fs-text) 85%, var(--fs-bg));
|
||||
--fs-faint: color-mix(in srgb, var(--fs-text) 82%, var(--fs-bg));
|
||||
--fs-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fs-accent) 60%, transparent);
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--fs-accent-line: var(--fs-accent); --fs-accent-line-hi: var(--fs-accent);
|
||||
--fs-good-line: var(--fs-good); --fs-good-line-hi: var(--fs-good);
|
||||
--fs-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
|
||||
/* the one token shipped under AA at rest; both modes buy the ink back here */
|
||||
--fs-placeholder-mix: 62%;
|
||||
}
|
||||
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
|
||||
}
|
||||
|
||||
/* forced-colors: background-color is forced to the system canvas, so state carried by
|
||||
* BACKGROUND alone vanishes (active tab, checked toggle, open-list selected row) — re-stated with a
|
||||
* border/outline in system keywords, which forced-colors keeps — docs/css.md "A11y:
|
||||
* forced-colors state repairs". */
|
||||
@media (forced-colors: active) {
|
||||
#tabmenu ul.tabs > li.active > a,
|
||||
.tabs > li.active > a,
|
||||
.cbi-tabmenu > li.cbi-tab > a,
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected],
|
||||
.ifacebox .ifacebox-head.active,
|
||||
.cbi-page-actions .cbi-button-apply {
|
||||
outline: 2px solid Highlight;
|
||||
outline-offset: -2px;
|
||||
color: HighlightText;
|
||||
background: Highlight;
|
||||
forced-color-adjust: none; /* keep the fill: it IS the state */
|
||||
}
|
||||
/* active #topmenu state was only a 10%-accent background + box-shadow rail, both dropped */
|
||||
#topmenu li.active > a {
|
||||
border-inline-start: 3px solid Highlight;
|
||||
padding-inline-start: calc(var(--fs-space-3) - 3px);
|
||||
}
|
||||
/* input[type=checkbox] (60-inputs.css draws the switch there) cannot reach ui.Checkbox's tooltip label */
|
||||
.cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked {
|
||||
background: Highlight;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]::after { background: ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
|
||||
/* a glyph drawn as a background fill cut out by mask is Canvas on Canvas here: the poll glyph keeps 0 of the
|
||||
* 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked
|
||||
* one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused
|
||||
* keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the
|
||||
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at (1,7,0) and still win by file order. */
|
||||
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"],
|
||||
input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
|
||||
input[type="radio"][type="radio"]:checked::after,
|
||||
:is(.btn, .cbi-button).spinning::before {
|
||||
background: ButtonText;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
|
||||
background: GrayText;
|
||||
}
|
||||
.cbi-value-description:not(:empty)::before,
|
||||
.spinning::before {
|
||||
background: CanvasText;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
/* icon-only buttons (rail toggle, search) lose their hover fill */
|
||||
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
|
||||
/* box-shadow is dropped entirely in forced-colors, and themed rules set outline: none above (0,1,0)
|
||||
* or in the page layer, so only !important reaches them. #maincontent keeps its deliberate none:
|
||||
* the router and the skip link focus <main>. */
|
||||
:focus-visible:not(#maincontent) { outline: 2px solid Highlight !important; outline-offset: 1px; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@layer theme {
|
||||
/* without this a page prints the dark chrome and only ONE page (shell is 100svh,
|
||||
* overflow:hidden). No !important: each arm is at least one class over the
|
||||
* strongest chrome rule it meets. */
|
||||
@media print {
|
||||
/* theme layer outranks every tokens-layer palette block without naming data-darkmode */
|
||||
:root {
|
||||
--fs-bg: #fff; --fs-panel: #fff; --fs-panel2: #f4f4f4;
|
||||
--fs-text: #000; --fs-dim: #333; --fs-faint: #555;
|
||||
--fs-border: #bbb; --fs-track: #ddd;
|
||||
--fs-shadow-pop: none;
|
||||
--fs-bar-bg: #fff;
|
||||
}
|
||||
/* nav/search/actions unusable on paper; Appearance is a PAGE now, stays and prints */
|
||||
.fs-sidebar.fs-sidebar.fs-sidebar.fs-sidebar,
|
||||
.fs-skip.fs-skip,
|
||||
.fs-search-ov.fs-search-ov,
|
||||
.cbi-page-actions.cbi-page-actions,
|
||||
.cbi-tabmenu.cbi-tabmenu,
|
||||
footer { display: none; }
|
||||
/* undoes the sidebar layout's scroll frame, the ONE-page bug above */
|
||||
.fs-shell.fs-shell.fs-shell.fs-shell.fs-shell,
|
||||
.fs-main.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
|
||||
.fs-content.fs-content.fs-content.fs-content { max-width: none; padding: 0; }
|
||||
/* (0,4,0) over 15-wallpaper.css's :root[data-wallpaper="pattern"] .fs-pattern (0,3,0) */
|
||||
.fs-pattern.fs-pattern.fs-pattern.fs-pattern { display: none; }
|
||||
/* the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) { background-image: none; }
|
||||
.cbi-section { break-inside: avoid; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user